Selaa lähdekoodia

1.账单推送
2.佩服审批

liuhy 3 kuukautta sitten
vanhempi
commit
100bbb156e
52 muutettua tiedostoa jossa 4741 lisäystä ja 0 poistoa
  1. 74 0
      tnc-sms/src/main/java/org/jeecg/modules/archive/entity/ArchiveAccount.java
  2. 55 0
      tnc-sms/src/main/java/org/jeecg/modules/archive/entity/ArchiveQualificationFile.java
  3. 11 0
      tnc-sms/src/main/java/org/jeecg/modules/archive/mapper/ArchiveAccountMapper.java
  4. 11 0
      tnc-sms/src/main/java/org/jeecg/modules/archive/mapper/ArchiveQualificationFileMapper.java
  5. 32 0
      tnc-sms/src/main/java/org/jeecg/modules/archive/service/IArchiveAccountService.java
  6. 16 0
      tnc-sms/src/main/java/org/jeecg/modules/archive/service/IArchiveQualificationFileService.java
  7. 128 0
      tnc-sms/src/main/java/org/jeecg/modules/archive/service/impl/ArchiveAccountServiceImpl.java
  8. 32 0
      tnc-sms/src/main/java/org/jeecg/modules/archive/service/impl/ArchiveQualificationFileServiceImpl.java
  9. 623 0
      tnc-sms/src/main/java/org/jeecg/modules/bill/controller/BillBatchController.java
  10. 199 0
      tnc-sms/src/main/java/org/jeecg/modules/bill/controller/BillCommonFileController.java
  11. 49 0
      tnc-sms/src/main/java/org/jeecg/modules/bill/controller/BillCustomerController.java
  12. 113 0
      tnc-sms/src/main/java/org/jeecg/modules/bill/entity/BillBatch.java
  13. 96 0
      tnc-sms/src/main/java/org/jeecg/modules/bill/entity/BillCommonFile.java
  14. 144 0
      tnc-sms/src/main/java/org/jeecg/modules/bill/entity/BillCustomer.java
  15. 159 0
      tnc-sms/src/main/java/org/jeecg/modules/bill/entity/BillCustomerExcel.java
  16. 15 0
      tnc-sms/src/main/java/org/jeecg/modules/bill/mapper/BillBatchMapper.java
  17. 28 0
      tnc-sms/src/main/java/org/jeecg/modules/bill/mapper/BillCommonFileMapper.java
  18. 53 0
      tnc-sms/src/main/java/org/jeecg/modules/bill/mapper/BillCustomerMapper.java
  19. 123 0
      tnc-sms/src/main/java/org/jeecg/modules/bill/service/IBillBatchService.java
  20. 55 0
      tnc-sms/src/main/java/org/jeecg/modules/bill/service/IBillCommonFileService.java
  21. 27 0
      tnc-sms/src/main/java/org/jeecg/modules/bill/service/IBillCustomerService.java
  22. 31 0
      tnc-sms/src/main/java/org/jeecg/modules/bill/service/IBillEmailService.java
  23. 719 0
      tnc-sms/src/main/java/org/jeecg/modules/bill/service/impl/BillBatchServiceImpl.java
  24. 247 0
      tnc-sms/src/main/java/org/jeecg/modules/bill/service/impl/BillCommonFileServiceImpl.java
  25. 107 0
      tnc-sms/src/main/java/org/jeecg/modules/bill/service/impl/BillCustomerServiceImpl.java
  26. 210 0
      tnc-sms/src/main/java/org/jeecg/modules/bill/service/impl/BillEmailServiceImpl.java
  27. 163 0
      tnc-sms/src/main/java/org/jeecg/modules/paybill/controller/SmsPayBillController.java
  28. 54 0
      tnc-sms/src/main/java/org/jeecg/modules/paybill/controller/SmsPayBillFileController.java
  29. 76 0
      tnc-sms/src/main/java/org/jeecg/modules/paybill/entity/SmsPayBill.java
  30. 32 0
      tnc-sms/src/main/java/org/jeecg/modules/paybill/entity/SmsPayBillFile.java
  31. 34 0
      tnc-sms/src/main/java/org/jeecg/modules/paybill/entity/SmsPayBillItem.java
  32. 33 0
      tnc-sms/src/main/java/org/jeecg/modules/paybill/entity/SmsPayBillLog.java
  33. 18 0
      tnc-sms/src/main/java/org/jeecg/modules/paybill/entity/SmsPayBillPhone.java
  34. 10 0
      tnc-sms/src/main/java/org/jeecg/modules/paybill/mapper/SmsPayBillFileMapper.java
  35. 9 0
      tnc-sms/src/main/java/org/jeecg/modules/paybill/mapper/SmsPayBillItemMapper.java
  36. 10 0
      tnc-sms/src/main/java/org/jeecg/modules/paybill/mapper/SmsPayBillLogMapper.java
  37. 10 0
      tnc-sms/src/main/java/org/jeecg/modules/paybill/mapper/SmsPayBillMapper.java
  38. 10 0
      tnc-sms/src/main/java/org/jeecg/modules/paybill/mapper/SmsPayBillPhoneMapper.java
  39. 13 0
      tnc-sms/src/main/java/org/jeecg/modules/paybill/mapper/SysDepartSimpleMapper.java
  40. 37 0
      tnc-sms/src/main/java/org/jeecg/modules/paybill/service/ISmsPayBillService.java
  41. 497 0
      tnc-sms/src/main/java/org/jeecg/modules/paybill/service/impl/SmsPayBillServiceImpl.java
  42. 19 0
      tnc-sms/src/main/java/org/jeecg/modules/paybill/vo/PayBillActionVo.java
  43. 24 0
      tnc-sms/src/main/java/org/jeecg/modules/paybill/vo/PayBillDetailVo.java
  44. 42 0
      tnc-sms/src/main/java/org/jeecg/modules/paybill/vo/PayBillExportRow.java
  45. 20 0
      tnc-sms/src/main/java/org/jeecg/modules/paybill/vo/PayBillFileVo.java
  46. 24 0
      tnc-sms/src/main/java/org/jeecg/modules/paybill/vo/PayBillItemVo.java
  47. 22 0
      tnc-sms/src/main/java/org/jeecg/modules/paybill/vo/PayBillSaveVo.java
  48. 23 0
      tnc-sms/src/main/java/org/jeecg/modules/paybill/vo/PayBillStatusCountVo.java
  49. 15 0
      tnc-sms/src/main/java/org/jeecg/modules/paybill/vo/PayBillWarningVo.java
  50. 14 0
      tnc-sms/src/main/java/org/jeecg/modules/paybill/vo/UserDeptVo.java
  51. 32 0
      tnc-sms/src/main/java/org/jeecg/modules/quartz/job/ArchiveSyncJob.java
  52. 143 0
      tnc-sms/src/main/java/org/jeecg/modules/quartz/job/BillSendJob.java

+ 74 - 0
tnc-sms/src/main/java/org/jeecg/modules/archive/entity/ArchiveAccount.java

@@ -0,0 +1,74 @@
+package org.jeecg.modules.archive.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import com.fasterxml.jackson.annotation.JsonFormat;
+import io.swagger.annotations.ApiModel;
+import io.swagger.annotations.ApiModelProperty;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+import lombok.experimental.Accessors;
+import org.springframework.format.annotation.DateTimeFormat;
+
+import java.io.Serializable;
+import java.util.Date;
+
+/**
+ * 档案账号表
+ */
+@Data
+@TableName("archive_account")
+@Accessors(chain = true)
+@EqualsAndHashCode(callSuper = false)
+@ApiModel(value = "ArchiveAccount", description = "档案账号")
+public class ArchiveAccount implements Serializable {
+
+    private static final long serialVersionUID = 1L;
+
+    @TableId(type = IdType.ASSIGN_ID)
+    @ApiModelProperty("主键")
+    private String id;
+
+    @ApiModelProperty("关联主系统用户ID,同步账号有值,子账号为空")
+    private String sysUserId;
+
+    @ApiModelProperty("账号类型:sync=同步自主系统, local=档案子账号")
+    private String accountType;
+
+    @ApiModelProperty("姓名")
+    private String name;
+
+    @ApiModelProperty("手机号")
+    private String mobile;
+
+    @ApiModelProperty("邮箱")
+    private String email;
+
+    @ApiModelProperty("部门编码")
+    private String orgCode;
+
+    @ApiModelProperty("部门名称")
+    private String orgName;
+
+    @ApiModelProperty("最近一次从主系统同步时间")
+    @JsonFormat(timezone = "GMT+8", pattern = "yyyy-MM-dd HH:mm:ss")
+    @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+    private Date syncTime;
+
+    @ApiModelProperty("创建人")
+    private String createBy;
+
+    @ApiModelProperty("创建时间")
+    @JsonFormat(timezone = "GMT+8", pattern = "yyyy-MM-dd HH:mm:ss")
+    @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+    private Date createTime;
+
+    @ApiModelProperty("更新人")
+    private String updateBy;
+
+    @ApiModelProperty("更新时间")
+    @JsonFormat(timezone = "GMT+8", pattern = "yyyy-MM-dd HH:mm:ss")
+    @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+    private Date updateTime;
+}

+ 55 - 0
tnc-sms/src/main/java/org/jeecg/modules/archive/entity/ArchiveQualificationFile.java

@@ -0,0 +1,55 @@
+package org.jeecg.modules.archive.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import com.fasterxml.jackson.annotation.JsonFormat;
+import io.swagger.annotations.ApiModel;
+import io.swagger.annotations.ApiModelProperty;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+import lombok.experimental.Accessors;
+import org.springframework.format.annotation.DateTimeFormat;
+
+import java.io.Serializable;
+import java.util.Date;
+
+/**
+ * 档案资质文件表
+ */
+@Data
+@TableName("archive_qualification_file")
+@Accessors(chain = true)
+@EqualsAndHashCode(callSuper = false)
+@ApiModel(value = "ArchiveQualificationFile", description = "档案资质文件")
+public class ArchiveQualificationFile implements Serializable {
+
+    private static final long serialVersionUID = 1L;
+
+    @TableId(type = IdType.ASSIGN_ID)
+    @ApiModelProperty("主键")
+    private String id;
+
+    @ApiModelProperty("档案账号ID")
+    private String accountId;
+
+    @ApiModelProperty("文件名")
+    private String fileName;
+
+    @ApiModelProperty("文件路径/URL")
+    private String filePath;
+
+    @ApiModelProperty("文件大小(字节)")
+    private Long fileSize;
+
+    @ApiModelProperty("备注")
+    private String remark;
+
+    @ApiModelProperty("创建人")
+    private String createBy;
+
+    @ApiModelProperty("创建时间")
+    @JsonFormat(timezone = "GMT+8", pattern = "yyyy-MM-dd HH:mm:ss")
+    @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+    private Date createTime;
+}

+ 11 - 0
tnc-sms/src/main/java/org/jeecg/modules/archive/mapper/ArchiveAccountMapper.java

@@ -0,0 +1,11 @@
+package org.jeecg.modules.archive.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import org.jeecg.modules.archive.entity.ArchiveAccount;
+
+/**
+ * 档案账号 Mapper
+ */
+public interface ArchiveAccountMapper extends BaseMapper<ArchiveAccount> {
+
+}

+ 11 - 0
tnc-sms/src/main/java/org/jeecg/modules/archive/mapper/ArchiveQualificationFileMapper.java

@@ -0,0 +1,11 @@
+package org.jeecg.modules.archive.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import org.jeecg.modules.archive.entity.ArchiveQualificationFile;
+
+/**
+ * 档案资质文件 Mapper
+ */
+public interface ArchiveQualificationFileMapper extends BaseMapper<ArchiveQualificationFile> {
+
+}

+ 32 - 0
tnc-sms/src/main/java/org/jeecg/modules/archive/service/IArchiveAccountService.java

@@ -0,0 +1,32 @@
+package org.jeecg.modules.archive.service;
+
+import com.baomidou.mybatisplus.extension.service.IService;
+import org.jeecg.modules.archive.entity.ArchiveAccount;
+
+import java.util.Map;
+
+/**
+ * 档案账号 Service
+ */
+public interface IArchiveAccountService extends IService<ArchiveAccount> {
+
+    /**
+     * 从主系统同步一条用户到档案账号(存在则更新,不存在则新增)
+     */
+    void syncFromMainSystem(String sysUserId, String name, String mobile, String email, String orgCode, String orgName);
+
+    /**
+     * 删除档案账号(仅允许删除子账号;同步账号不允许删除)
+     */
+    boolean removeAccountById(String id);
+
+    /**
+     * 仅允许编辑子账号
+     */
+    boolean updateLocalAccount(ArchiveAccount account);
+
+    /**
+     * 立即从主系统同步所有用户到档案账号(供定时任务或手动触发)
+     */
+    void syncNowFromMainSystem();
+}

+ 16 - 0
tnc-sms/src/main/java/org/jeecg/modules/archive/service/IArchiveQualificationFileService.java

@@ -0,0 +1,16 @@
+package org.jeecg.modules.archive.service;
+
+import com.baomidou.mybatisplus.extension.service.IService;
+import org.jeecg.modules.archive.entity.ArchiveQualificationFile;
+
+import java.util.List;
+
+/**
+ * 档案资质文件 Service
+ */
+public interface IArchiveQualificationFileService extends IService<ArchiveQualificationFile> {
+
+    List<ArchiveQualificationFile> listByAccountId(String accountId);
+
+    boolean removeByAccountId(String accountId);
+}

+ 128 - 0
tnc-sms/src/main/java/org/jeecg/modules/archive/service/impl/ArchiveAccountServiceImpl.java

@@ -0,0 +1,128 @@
+package org.jeecg.modules.archive.service.impl;
+
+import com.alibaba.fastjson.JSONArray;
+import com.alibaba.fastjson.JSONObject;
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
+import org.jeecg.common.system.api.ISysBaseAPI;
+import org.jeecg.common.util.oConvertUtils;
+import org.jeecg.modules.archive.entity.ArchiveAccount;
+import org.jeecg.modules.archive.mapper.ArchiveAccountMapper;
+import org.jeecg.modules.archive.service.IArchiveAccountService;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.Date;
+
+/**
+ * 档案账号 Service 实现
+ */
+@Service
+public class ArchiveAccountServiceImpl extends ServiceImpl<ArchiveAccountMapper, ArchiveAccount> implements IArchiveAccountService {
+
+    @Autowired(required = false)
+    private ISysBaseAPI sysBaseAPI;
+
+    private static final String ACCOUNT_TYPE_SYNC = "sync";
+    private static final String ACCOUNT_TYPE_LOCAL = "local";
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public void syncFromMainSystem(String sysUserId, String name, String mobile, String email, String orgCode, String orgName) {
+        if (oConvertUtils.isEmpty(sysUserId)) {
+            return;
+        }
+        LambdaQueryWrapper<ArchiveAccount> q = new LambdaQueryWrapper<>();
+        q.eq(ArchiveAccount::getSysUserId, sysUserId);
+        ArchiveAccount one = this.getOne(q);
+        Date now = new Date();
+        if (one != null) {
+            one.setName(name);
+            one.setMobile(mobile);
+            one.setEmail(email);
+            one.setOrgCode(orgCode);
+            one.setOrgName(orgName);
+            one.setSyncTime(now);
+            one.setUpdateTime(now);
+            this.updateById(one);
+        } else {
+            ArchiveAccount account = new ArchiveAccount();
+            account.setSysUserId(sysUserId);
+            account.setAccountType(ACCOUNT_TYPE_SYNC);
+            account.setName(name);
+            account.setMobile(mobile);
+            account.setEmail(email);
+            account.setOrgCode(orgCode);
+            account.setOrgName(orgName);
+            account.setSyncTime(now);
+            account.setCreateTime(now);
+            account.setUpdateTime(now);
+            this.save(account);
+        }
+    }
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public boolean removeAccountById(String id) {
+        ArchiveAccount account = this.getById(id);
+        if (account == null) {
+            return false;
+        }
+        if (ACCOUNT_TYPE_SYNC.equals(account.getAccountType())) {
+            throw new IllegalArgumentException("同步账号不允许删除");
+        }
+        return this.removeById(id);
+    }
+
+    @Override
+    public boolean updateLocalAccount(ArchiveAccount account) {
+        if (account == null || oConvertUtils.isEmpty(account.getId())) {
+            return false;
+        }
+        ArchiveAccount existing = this.getById(account.getId());
+        if (existing == null || !ACCOUNT_TYPE_LOCAL.equals(existing.getAccountType())) {
+            return false;
+        }
+        existing.setName(account.getName());
+        existing.setMobile(account.getMobile());
+        existing.setEmail(account.getEmail());
+        existing.setOrgCode(account.getOrgCode());
+        existing.setOrgName(account.getOrgName());
+        existing.setUpdateTime(new Date());
+        return this.updateById(existing);
+    }
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public void syncNowFromMainSystem() {
+        if (sysBaseAPI == null) {
+            throw new IllegalStateException("主系统API未注入,无法执行同步");
+        }
+        int pageNo = 1;
+        int pageSize = 200;
+        long total = 0;
+        do {
+            JSONObject json = sysBaseAPI.queryAllUserForArchiveSync(pageNo, pageSize);
+            if (json == null) {
+                break;
+            }
+            total = json.getLongValue("total");
+            JSONArray list = json.getJSONArray("list");
+            if (list == null || list.isEmpty()) {
+                break;
+            }
+            for (int i = 0; i < list.size(); i++) {
+                JSONObject item = list.getJSONObject(i);
+                String id = item.getString("id");
+                String title = item.getString("title");
+                String email = item.getString("email");
+                String mobile = item.getString("mobile");
+                String orgCode = item.getString("orgCode");
+                String orgName = item.getString("orgName");
+                syncFromMainSystem(id, title, mobile, email, orgCode, orgName);
+            }
+            pageNo++;
+        } while ((pageNo - 1) * pageSize < total);
+    }
+}

+ 32 - 0
tnc-sms/src/main/java/org/jeecg/modules/archive/service/impl/ArchiveQualificationFileServiceImpl.java

@@ -0,0 +1,32 @@
+package org.jeecg.modules.archive.service.impl;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
+import org.jeecg.modules.archive.entity.ArchiveQualificationFile;
+import org.jeecg.modules.archive.mapper.ArchiveQualificationFileMapper;
+import org.jeecg.modules.archive.service.IArchiveQualificationFileService;
+import org.springframework.stereotype.Service;
+
+import java.util.List;
+
+/**
+ * 档案资质文件 Service 实现
+ */
+@Service
+public class ArchiveQualificationFileServiceImpl extends ServiceImpl<ArchiveQualificationFileMapper, ArchiveQualificationFile> implements IArchiveQualificationFileService {
+
+    @Override
+    public List<ArchiveQualificationFile> listByAccountId(String accountId) {
+        LambdaQueryWrapper<ArchiveQualificationFile> q = new LambdaQueryWrapper<>();
+        q.eq(ArchiveQualificationFile::getAccountId, accountId);
+        q.orderByDesc(ArchiveQualificationFile::getCreateTime);
+        return this.list(q);
+    }
+
+    @Override
+    public boolean removeByAccountId(String accountId) {
+        LambdaQueryWrapper<ArchiveQualificationFile> q = new LambdaQueryWrapper<>();
+        q.eq(ArchiveQualificationFile::getAccountId, accountId);
+        return this.remove(q);
+    }
+}

+ 623 - 0
tnc-sms/src/main/java/org/jeecg/modules/bill/controller/BillBatchController.java

@@ -0,0 +1,623 @@
+package org.jeecg.modules.bill.controller;
+
+import com.alibaba.fastjson.JSON;
+import com.alibaba.fastjson.JSONArray;
+import com.alibaba.fastjson.JSONObject;
+import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
+import com.baomidou.mybatisplus.core.metadata.IPage;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import lombok.extern.slf4j.Slf4j;
+import org.apache.shiro.authz.annotation.RequiresPermissions;
+import org.jeecg.common.api.vo.Result;
+import org.jeecg.common.aspect.annotation.AutoLog;
+import org.jeecg.common.system.base.controller.JeecgController;
+import org.jeecg.common.system.query.QueryGenerator;
+import org.jeecg.modules.bill.entity.BillBatch;
+import org.jeecg.modules.bill.entity.BillCustomer;
+import org.jeecg.modules.bill.entity.BillCustomerExcel;
+import org.jeecg.modules.bill.service.IBillBatchService;
+import org.jeecg.modules.bill.service.IBillCommonFileService;
+import org.jeecg.modules.bill.service.IBillCustomerService;
+import org.apache.poi.ss.usermodel.Workbook;
+import org.jeecgframework.poi.excel.ExcelExportUtil;
+import org.jeecgframework.poi.excel.entity.ExportParams;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.web.bind.annotation.*;
+import org.springframework.web.multipart.MultipartFile;
+import org.springframework.web.multipart.MultipartHttpServletRequest;
+
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.LinkedHashSet;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.Collection;
+
+/**
+ * @Description: 账单批次管理Controller
+ * @Author: jeecg-boot
+ * @Date: 2025-01-27
+ * @Version: V1.0
+ */
+@Slf4j
+@RestController
+@RequestMapping("/bill/batch")
+public class BillBatchController extends JeecgController<BillBatch, IBillBatchService> {
+
+    @Autowired
+    private IBillBatchService billBatchService;
+
+    @Autowired
+    private IBillCustomerService billCustomerService;
+
+    @Autowired
+    private IBillCommonFileService billCommonFileService;
+
+    /**
+     * 批次列表统计(总/已发送/待发送,其中待发送=未发送+失败)
+     */
+    @GetMapping(value = "/statistics")
+    public Result<?> statistics(@RequestParam(name = "batchNo", required = false) String batchNo,
+                                @RequestParam(name = "sendRuleType", required = false) Integer sendRuleType) {
+        try {
+            return Result.OK(billBatchService.statistics(batchNo, sendRuleType));
+        } catch (Exception e) {
+            log.error("查询批次统计失败", e);
+            return Result.error("查询批次统计失败: " + e.getMessage());
+        }
+    }
+
+    /**
+     * 分页列表查询
+     */
+    @GetMapping(value = "/list")
+    public Result<?> queryPageList(BillBatch billBatch,
+                                   @RequestParam(name = "pageNo", defaultValue = "1") Integer pageNo,
+                                   @RequestParam(name = "pageSize", defaultValue = "10") Integer pageSize,
+                                   HttpServletRequest req) {
+        QueryWrapper<BillBatch> queryWrapper = QueryGenerator.initQueryWrapper(billBatch, req.getParameterMap());
+        Page<BillBatch> page = new Page<>(pageNo, pageSize);
+        IPage<BillBatch> pageList = billBatchService.page(page, queryWrapper);
+        return Result.OK(pageList);
+    }
+
+    /**
+     * 添加
+     */
+    @AutoLog(value = "账单批次-添加")
+    @PostMapping(value = "/add")
+    public Result<?> add(@RequestBody BillBatch billBatch) {
+        billBatchService.save(billBatch);
+        return Result.OK("添加成功!");
+    }
+
+    /**
+     * 编辑
+     */
+    @AutoLog(value = "账单批次-编辑")
+    @RequestMapping(value = "/edit", method = {RequestMethod.PUT, RequestMethod.POST})
+    public Result<?> edit(@RequestBody BillBatch billBatch) {
+        billBatchService.updateById(billBatch);
+        return Result.OK("编辑成功!");
+    }
+
+    /**
+     * 通过id删除(级联删除该批次的合同文件、客户、批次)
+     */
+    @AutoLog(value = "账单批次-通过id删除")
+    @DeleteMapping(value = "/delete")
+    public Result<?> delete(@RequestParam(name = "id") String id) {
+        billBatchService.deleteBatchCascade(id);
+        return Result.OK("删除成功!");
+    }
+
+    /**
+     * 批量删除(每个批次级联删除其合同文件、客户、批次)
+     */
+    @AutoLog(value = "账单批次-批量删除")
+    @DeleteMapping(value = "/deleteBatch")
+    public Result<?> deleteBatch(@RequestParam(name = "ids") String ids) {
+        if (ids == null || "".equals(ids.trim())) {
+            return Result.error("参数不识别!");
+        }
+        String[] idArray = ids.split(",");
+        for (String id : idArray) {
+            billBatchService.deleteBatchCascade(id);
+        }
+        return Result.OK("删除成功!");
+    }
+
+    /**
+     * 通过id查询
+     */
+    @GetMapping(value = "/queryById")
+    public Result<?> queryById(@RequestParam(name = "id") String id) {
+        BillBatch billBatch = billBatchService.getById(id);
+        if (billBatch == null) {
+            return Result.error("未找到对应数据");
+        }
+        billBatch.setSendPeriods(normalizeSendPeriods(billBatch.getSendPeriods()));
+        return Result.OK(billBatch);
+    }
+
+    /**
+     * 将 sendPeriods 规范为逗号分隔的 yyyyMM 字符串,避免多次 JSON 序列化导致的脏数据
+     */
+    private static String normalizeSendPeriods(String raw) {
+        if (raw == null || raw.trim().isEmpty()) {
+            return "";
+        }
+        Set<String> periods = new LinkedHashSet<>();
+        collectPeriods(raw.trim(), periods);
+        return String.join(",", periods);
+    }
+
+    private static void collectPeriods(Object o, Set<String> out) {
+        if (o == null) return;
+        if (o instanceof String) {
+            String s = ((String) o).trim();
+            if (s.isEmpty()) return;
+            if (s.matches("\\d{6}")) {
+                out.add(s);
+                return;
+            }
+            if (s.startsWith("[")) {
+                try {
+                    JSONArray arr = JSONArray.parseArray(s);
+                    for (int i = 0; i < arr.size(); i++) {
+                        collectPeriods(arr.get(i), out);
+                    }
+                } catch (Exception e) {
+                    for (String part : s.split(",")) {
+                        String t = part.trim().replaceAll("^\"|\"$", "");
+                        if (t.matches("\\d{6}")) out.add(t);
+                    }
+                }
+                return;
+            }
+            for (String part : s.split(",")) {
+                String t = part.trim().replaceAll("^\"|\"$", "");
+                if (t.matches("\\d{6}")) out.add(t);
+            }
+            return;
+        }
+        if (o instanceof JSONArray) {
+            JSONArray arr = (JSONArray) o;
+            for (int i = 0; i < arr.size(); i++) {
+                collectPeriods(arr.get(i), out);
+            }
+            return;
+        }
+        if (o instanceof Collection) {
+            for (Object e : (Collection<?>) o) {
+                collectPeriods(e, out);
+            }
+            return;
+        }
+        if (o instanceof Number) {
+            String s = String.valueOf(o);
+            if (s.length() == 6 && s.matches("\\d{6}")) {
+                out.add(s);
+            }
+        }
+    }
+
+    @AutoLog(value = "账单批次-Excel导入预览")
+    @PostMapping(value = "/importExcel")
+    public Result<?> importExcel(@RequestPart("file") MultipartFile file, @RequestParam(name = "batchId", required = false) String batchId) {
+        try {
+            if (file == null) {
+                return Result.error("请选择要导入的Excel文件");
+            }
+
+            Map<String, Object> result = billBatchService.importExcelPreview(file, batchId);
+            if ((Boolean) result.get("success")) {
+                return Result.OK(result);
+            } else {
+                return Result.error((String) result.get("message"));
+            }
+        } catch (Exception e) {
+            log.error("Excel导入预览失败", e);
+            return Result.error("Excel导入预览失败: " + e.getMessage());
+        }
+    }
+
+    /**
+     * 确认导入(仅批次+客户);合同文件由前端另行调用 uploadContractFiles 分片上传,避免超时
+     */
+    @AutoLog(value = "账单批次-确认导入")
+    @RequiresPermissions("netWork:newPhone:excel:read")
+    @PostMapping(value = "/confirmImport")
+    public Result<?> confirmImport(@RequestBody JSONObject jsonObject) {
+        try {
+            BillBatch batch = jsonObject.getObject("batch", BillBatch.class);
+            if (batch == null) {
+                return Result.error("批次信息不能为空");
+            }
+            // 解析发送账期:前端传 ["202409"] 等数组,统一规范为逗号分隔的 yyyyMM
+            JSONObject batchObj = jsonObject.getJSONObject("batch");
+            Object sp = null;
+            if (batchObj != null) {
+                sp = batchObj.get("sendPeriods");
+                if (sp == null) {
+                    sp = batchObj.get("send_periods");
+                }
+            }
+            if (sp != null) {
+                String normalized;
+                if (sp instanceof JSONArray || sp instanceof List) {
+                    Set<String> periods = new LinkedHashSet<>();
+                    collectPeriods(sp, periods);
+                    normalized = String.join(",", periods);
+                } else if (sp instanceof String) {
+                    normalized = normalizeSendPeriods((String) sp);
+                } else {
+                    normalized = "";
+                }
+                batch.setSendPeriods(normalized);
+            } else if (batch.getSendPeriods() != null && batch.getSendPeriods().trim().startsWith("[")) {
+                // FastJSON 可能将数组反序列化到 String 字段为 JSON 字符串
+                batch.setSendPeriods(normalizeSendPeriods(batch.getSendPeriods()));
+            }
+
+            List<BillCustomerExcel> customerList = null;
+            JSONArray customerArray = jsonObject.getJSONArray("customers");
+            if (customerArray != null) {
+                customerList = customerArray.toJavaList(BillCustomerExcel.class);
+            }
+
+            Map<String, Object> result = billBatchService.confirmImport(batch, customerList);
+            if (Boolean.TRUE.equals(result.get("success"))) {
+                return Result.OK(result);
+            } else {
+                return Result.error((String) result.get("message"));
+            }
+        } catch (Exception e) {
+            log.error("确认导入失败", e);
+            return Result.error("确认导入失败: " + e.getMessage());
+        }
+    }
+
+    /**
+     * 下载导入模板
+     */
+    @GetMapping(value = "/downloadTemplate")
+    public void downloadTemplate(HttpServletResponse response) {
+        try {
+            List<BillCustomerExcel> templateList = new ArrayList<>();
+            ExportParams exportParams = new ExportParams("账单客户导入模板", "账单客户导入模板");
+            Workbook workbook = ExcelExportUtil.exportExcel(exportParams, BillCustomerExcel.class, templateList);
+
+            response.setCharacterEncoding("UTF-8");
+            response.setContentType("application/vnd.ms-excel");
+            response.setHeader("content-disposition", "attachment;filename=" + java.net.URLEncoder.encode("账单客户导入模板.xls", "UTF-8"));
+
+            workbook.write(response.getOutputStream());
+            response.getOutputStream().flush();
+        } catch (Exception e) {
+            log.error("下载导入模板失败", e);
+            try {
+                if (!response.isCommitted()) {
+                    response.reset();
+                    response.setContentType("application/json;charset=UTF-8");
+                    response.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, "下载模板失败");
+                }
+            } catch (IOException ex) {
+                log.error("发送错误响应失败", ex);
+            }
+        }
+    }
+
+    /**
+     * 批次详情(包含客户列表)
+     */
+    @GetMapping(value = "/detail")
+    public Result<?> getDetail(BillCustomer billCustomer,
+                              @RequestParam(name = "id") String batchId,
+                              @RequestParam(name = "pageNo", defaultValue = "1") Integer pageNo,
+                              @RequestParam(name = "pageSize", defaultValue = "10") Integer pageSize,
+                              HttpServletRequest req) {
+        try {
+            BillBatch batch = billBatchService.getById(batchId);
+            if (batch == null) {
+                return Result.error("批次不存在");
+            }
+            batch.setSendPeriods(normalizeSendPeriods(batch.getSendPeriods()));
+
+            // 提取需要模糊查询的字段
+            String email = billCustomer != null ? billCustomer.getEmail() : null;
+            String contractNo = billCustomer != null ? billCustomer.getContractNo() : null;
+
+            // 避免请求参数中的 id 被 QueryGenerator 误认为是 BillCustomer.id
+            // 同时置空 email 和 contractNo,防止自动拼出 eq 条件
+            if (billCustomer != null) {
+                billCustomer.setId(null);
+                billCustomer.setEmail(null);
+                billCustomer.setContractNo(null);
+            }
+
+            // 支持动态筛选条件
+            QueryWrapper<BillCustomer> queryWrapper = QueryGenerator.initQueryWrapper(billCustomer, req.getParameterMap());
+            queryWrapper.eq("batch_id", batchId);
+
+            // 手动添加模糊查询条件
+            if (org.apache.commons.lang.StringUtils.isNotBlank(email)) {
+                queryWrapper.like("email", email);
+            }
+            if (org.apache.commons.lang.StringUtils.isNotBlank(contractNo)) {
+                queryWrapper.like("contract_no", contractNo);
+            }
+            // 发送时间范围(统一在批次详情内查询)
+            String sendTimeBegin = req.getParameter("sendTime_begin");
+            String sendTimeEnd = req.getParameter("sendTime_end");
+            if (org.apache.commons.lang.StringUtils.isNotBlank(sendTimeBegin)) {
+                queryWrapper.ge("send_time", sendTimeBegin);
+            }
+            if (org.apache.commons.lang.StringUtils.isNotBlank(sendTimeEnd)) {
+                queryWrapper.le("send_time", sendTimeEnd + " 23:59:59");
+            }
+            // 账期时间(yyyyMM,按客户发送日期所在月筛选)
+            String sendPeriod = req.getParameter("sendPeriod");
+            if (org.apache.commons.lang.StringUtils.isNotBlank(sendPeriod) && sendPeriod.matches("\\d{6}")) {
+                queryWrapper.apply("DATE_FORMAT(send_date,'%Y%m') = {0}", sendPeriod);
+            }
+
+            queryWrapper.orderByAsc("serial_no");
+
+            Page<BillCustomer> page = new Page<>(pageNo, pageSize);
+            IPage<BillCustomer> customerPage = billCustomerService.page(page, queryWrapper);
+
+            JSONObject result = new JSONObject();
+            result.put("batch", batch);
+            result.put("customers", customerPage);
+
+            return Result.OK(result);
+        } catch (Exception e) {
+            log.error("查询批次详情失败", e);
+            return Result.error("查询批次详情失败: " + e.getMessage());
+        }
+    }
+
+    /**
+     * 批次详情-客户列表导出(与详情查询条件一致,统一在批次内查询)
+     */
+    @GetMapping(value = "/detail/export")
+    public void exportDetailCustomers(BillCustomer billCustomer,
+                                     @RequestParam(name = "id") String batchId,
+                                     HttpServletRequest req,
+                                     HttpServletResponse response) throws IOException {
+        BillBatch batch = billBatchService.getById(batchId);
+        if (batch == null) {
+            response.sendError(HttpServletResponse.SC_NOT_FOUND, "批次不存在");
+            return;
+        }
+        String email = billCustomer != null ? billCustomer.getEmail() : null;
+        String contractNo = billCustomer != null ? billCustomer.getContractNo() : null;
+        if (billCustomer != null) {
+            billCustomer.setId(null);
+            billCustomer.setEmail(null);
+            billCustomer.setContractNo(null);
+        }
+        QueryWrapper<BillCustomer> queryWrapper = QueryGenerator.initQueryWrapper(billCustomer, req.getParameterMap());
+        queryWrapper.eq("batch_id", batchId);
+        if (org.apache.commons.lang.StringUtils.isNotBlank(email)) {
+            queryWrapper.like("email", email);
+        }
+        if (org.apache.commons.lang.StringUtils.isNotBlank(contractNo)) {
+            queryWrapper.like("contract_no", contractNo);
+        }
+        String sendTimeBegin = req.getParameter("sendTime_begin");
+        String sendTimeEnd = req.getParameter("sendTime_end");
+        if (org.apache.commons.lang.StringUtils.isNotBlank(sendTimeBegin)) {
+            queryWrapper.ge("send_time", sendTimeBegin);
+        }
+        if (org.apache.commons.lang.StringUtils.isNotBlank(sendTimeEnd)) {
+            queryWrapper.le("send_time", sendTimeEnd + " 23:59:59");
+        }
+        String sendPeriod = req.getParameter("sendPeriod");
+        if (org.apache.commons.lang.StringUtils.isNotBlank(sendPeriod) && sendPeriod.matches("\\d{6}")) {
+            queryWrapper.apply("DATE_FORMAT(send_date,'%Y%m') = {0}", sendPeriod);
+        }
+        queryWrapper.orderByAsc("serial_no");
+        List<BillCustomer> list = billCustomerService.list(queryWrapper);
+        for (BillCustomer c : list) {
+            c.setSendStatusStr(sendStatusToStr(c.getSendStatus()));
+        }
+        ExportParams exportParams = new ExportParams("批次客户列表", "批次客户列表");
+        Workbook workbook = ExcelExportUtil.exportExcel(exportParams, BillCustomer.class, list);
+        response.setContentType("application/vnd.ms-excel");
+        response.setCharacterEncoding("UTF-8");
+        response.setHeader("Content-Disposition", "attachment;filename=" + java.net.URLEncoder.encode("批次客户列表.xls", "UTF-8"));
+        workbook.write(response.getOutputStream());
+        workbook.close();
+    }
+
+    private static String sendStatusToStr(Integer sendStatus) {
+        if (sendStatus == null) return "";
+        switch (sendStatus) {
+            case 0: return "未发送";
+            case 1: return "发送成功";
+            case 2: return "发送失败";
+            default: return String.valueOf(sendStatus);
+        }
+    }
+
+    /**
+     * 立即发送
+     */
+    @AutoLog(value = "账单批次-立即发送")
+    @PostMapping(value = "/{id}/send")
+    public Result<?> sendBatch(@PathVariable String id) {
+        try {
+            Map<String, Object> result = billBatchService.sendBatchEmails(id);
+            if ((Boolean) result.get("success")) {
+                return Result.OK(result);
+            } else {
+                return Result.error((String) result.get("message"));
+            }
+        } catch (Exception e) {
+            log.error("发送批次邮件失败", e);
+            return Result.error("发送批次邮件失败: " + e.getMessage());
+        }
+    }
+
+    /**
+     * 发送单个客户邮件
+     */
+    @AutoLog(value = "账单批次-发送单个客户")
+    @PostMapping(value = "/sendCustomer")
+    public Result<?> sendCustomer(@RequestBody JSONObject jsonObject) {
+        try {
+            String customerId = jsonObject.getString("customerId");
+            Map<String, Object> result = billBatchService.sendEmail(customerId);
+            if ((Boolean) result.get("success")) {
+                return Result.OK(result);
+            } else {
+                return Result.error((String) result.get("message"));
+            }
+        } catch (Exception e) {
+            log.error("发送客户邮件失败", e);
+            return Result.error("发送客户邮件失败: " + e.getMessage());
+        }
+    }
+
+    /**
+     * 重新发送
+     */
+    @AutoLog(value = "账单批次-重新发送")
+    @PostMapping(value = "/resend")
+    public Result<?> resendEmail(@RequestBody JSONObject jsonObject) {
+        try {
+            String customerId = jsonObject.getString("customerId");
+            Map<String, Object> result = billBatchService.resendEmail(customerId);
+            if ((Boolean) result.get("success")) {
+                return Result.OK(result);
+            } else {
+                return Result.error((String) result.get("message"));
+            }
+        } catch (Exception e) {
+            log.error("重新发送邮件失败", e);
+            return Result.error("重新发送邮件失败: " + e.getMessage());
+        }
+    }
+
+    /**
+     * 校验合同文件名是否合规(格式:前缀_合同号_日期.扩展名,如:2_A400006868245_202601.pdf)
+     */
+    @PostMapping(value = "/validateContractFileNames")
+    public Result<?> validateContractFileNames(@RequestBody List<String> fileNames) {
+        try {
+            if (fileNames == null || fileNames.isEmpty()) {
+                return Result.OK(new ArrayList<>());
+            }
+            List<Map<String, Object>> list = new ArrayList<>();
+            String invalidMsg = "文件名格式不正确,应为:前缀_合同号_日期.扩展名(如:2_A400006868245_202601.pdf)";
+            for (String fileName : fileNames) {
+                Map<String, Object> item = new HashMap<>(3);
+                item.put("fileName", fileName);
+                String[] parsed = billCommonFileService.parseFileName(fileName);
+                boolean valid = parsed != null && parsed.length == 3 && (parsed[1] != null && !parsed[1].isEmpty());
+                item.put("valid", valid);
+                item.put("message", valid ? null : invalidMsg);
+                list.add(item);
+            }
+            return Result.OK(list);
+        } catch (Exception e) {
+            log.error("校验合同文件名失败", e);
+            return Result.error("校验合同文件名失败: " + e.getMessage());
+        }
+    }
+
+    /**
+     * 批量上传批次合同文件
+     */
+    @AutoLog(value = "账单批次-批量上传合同文件")
+    @PostMapping(value = "/uploadContractFiles")
+    public Result<?> uploadContractFiles(HttpServletRequest request) {
+        try {
+            MultipartHttpServletRequest multipartRequest = (MultipartHttpServletRequest) request;
+            String batchId = multipartRequest.getParameter("batchId");
+            
+            List<MultipartFile> fileList = multipartRequest.getFiles("files");
+            if (fileList == null || fileList.isEmpty()) {
+                return Result.error("请选择要上传的文件");
+            }
+            
+            MultipartFile[] files = fileList.toArray(new MultipartFile[0]);
+            Map<String, Object> result = billBatchService.uploadContractFiles(files, batchId);
+            if ((Boolean) result.get("success")) {
+                return Result.OK(result);
+            } else {
+                return Result.error((String) result.get("message"));
+            }
+        } catch (Exception e) {
+            log.error("批量上传合同文件失败", e);
+            return Result.error("批量上传合同文件失败: " + e.getMessage());
+        }
+    }
+
+    /**
+     * 查询批次关联的合同文件列表
+     */
+    @GetMapping(value = "/{batchId}/contractFiles")
+    public Result<?> getContractFiles(@PathVariable String batchId) {
+        try {
+            List result = billBatchService.getContractFilesByBatchId(batchId);
+            return Result.OK(result);
+        } catch (Exception e) {
+            log.error("查询批次合同文件失败", e);
+            return Result.error("查询批次合同文件失败: " + e.getMessage());
+        }
+    }
+
+    /**
+     * 删除批次合同文件
+     */
+    @AutoLog(value = "账单批次-删除合同文件")
+    @DeleteMapping(value = "/contractFile/{fileId}")
+    public Result<?> deleteContractFile(@PathVariable String fileId) {
+        try {
+            boolean success = billBatchService.deleteContractFile(fileId);
+            if (success) {
+                return Result.OK("删除成功");
+            } else {
+                return Result.error("删除失败");
+            }
+        } catch (Exception e) {
+            log.error("删除批次合同文件失败", e);
+            return Result.error("删除批次合同文件失败: " + e.getMessage());
+        }
+    }
+
+    /**
+     * 批量删除批次合同文件
+     */
+    @AutoLog(value = "账单批次-批量删除合同文件")
+    @DeleteMapping(value = "/contractFiles")
+    public Result<?> deleteContractFiles(@RequestParam("ids") String ids) {
+        try {
+            if (ids == null || "".equals(ids.trim())) {
+                return Result.error("参数不识别!");
+            }
+            String[] idArray = ids.split(",");
+            List<String> fileIds = new ArrayList<>();
+            for (String id : idArray) {
+                fileIds.add(id);
+            }
+            boolean success = billBatchService.deleteContractFiles(fileIds);
+            if (success) {
+                return Result.OK("删除成功");
+            } else {
+                return Result.error("删除失败");
+            }
+        } catch (Exception e) {
+            log.error("批量删除批次合同文件失败", e);
+            return Result.error("批量删除批次合同文件失败: " + e.getMessage());
+        }
+    }
+}
+

+ 199 - 0
tnc-sms/src/main/java/org/jeecg/modules/bill/controller/BillCommonFileController.java

@@ -0,0 +1,199 @@
+package org.jeecg.modules.bill.controller;
+
+import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
+import com.baomidou.mybatisplus.core.metadata.IPage;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import lombok.extern.slf4j.Slf4j;
+import org.jeecg.common.api.vo.Result;
+import org.jeecg.common.aspect.annotation.AutoLog;
+import org.jeecg.common.system.base.controller.JeecgController;
+import org.jeecg.common.system.query.QueryGenerator;
+import org.jeecg.modules.bill.entity.BillCommonFile;
+import org.jeecg.modules.bill.service.IBillCommonFileService;
+import org.jeecg.common.util.MinioUtil;
+import org.jeecg.common.util.oConvertUtils;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.web.bind.annotation.*;
+import org.springframework.web.multipart.MultipartFile;
+import org.springframework.web.multipart.MultipartHttpServletRequest;
+
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import java.io.IOException;
+import java.util.Arrays;
+import java.util.List;
+
+/**
+ * @Description: 通用文件管理Controller
+ * @Author: jeecg-boot
+ * @Date: 2025-01-27
+ * @Version: V1.0
+ */
+@Slf4j
+@RestController
+@RequestMapping("/bill/file")
+public class BillCommonFileController extends JeecgController<BillCommonFile, IBillCommonFileService> {
+
+    @Autowired
+    private IBillCommonFileService billCommonFileService;
+
+    /**
+     * 批量上传文件
+     */
+    @AutoLog(value = "通用文件-批量上传")
+    @PostMapping(value = "/upload")
+    public Result<List<BillCommonFile>> uploadFiles(HttpServletRequest request) {
+        try {
+            MultipartHttpServletRequest multipartRequest = (MultipartHttpServletRequest) request;
+            List<MultipartFile> fileList = multipartRequest.getFiles("file");
+            if (fileList == null || fileList.isEmpty()) {
+                return Result.error("请选择要上传的文件");
+            }
+
+            MultipartFile[] files = fileList.toArray(new MultipartFile[0]);
+            List<BillCommonFile> uploadedFiles = billCommonFileService.uploadFiles(files);
+            return Result.OK("成功上传 " + uploadedFiles.size() + " 个文件", uploadedFiles);
+        } catch (Exception e) {
+            log.error("文件上传失败", e);
+            return new Result<List<BillCommonFile>>().error500("文件上传失败: " + e.getMessage());
+        }
+    }
+
+    /**
+     * 分页列表查询
+     */
+    @GetMapping(value = "/list")
+    public Result<?> queryPageList(BillCommonFile billCommonFile,
+                                   @RequestParam(name = "pageNo", defaultValue = "1") Integer pageNo,
+                                   @RequestParam(name = "pageSize", defaultValue = "10") Integer pageSize,
+                                   HttpServletRequest req) {
+        QueryWrapper<BillCommonFile> queryWrapper = QueryGenerator.initQueryWrapper(billCommonFile, req.getParameterMap());
+        Page<BillCommonFile> page = new Page<>(pageNo, pageSize);
+        IPage<BillCommonFile> pageList = billCommonFileService.page(page, queryWrapper);
+        return Result.OK(pageList);
+    }
+
+    /**
+     * 添加
+     */
+    @AutoLog(value = "通用文件-添加")
+    @PostMapping(value = "/add")
+    public Result<?> add(@RequestBody BillCommonFile billCommonFile) {
+        billCommonFileService.save(billCommonFile);
+        return Result.OK("添加成功!");
+    }
+
+    /**
+     * 编辑
+     */
+    @AutoLog(value = "通用文件-编辑")
+    @RequestMapping(value = "/edit", method = {RequestMethod.PUT, RequestMethod.POST})
+    public Result<?> edit(@RequestBody BillCommonFile billCommonFile) {
+        billCommonFileService.updateById(billCommonFile);
+        return Result.OK("编辑成功!");
+    }
+
+    /**
+     * 通过id删除
+     */
+    @AutoLog(value = "通用文件-通过id删除")
+    @DeleteMapping(value = "/delete")
+    public Result<?> delete(@RequestParam(name = "id") String id) {
+        boolean success = billCommonFileService.deleteFile(id);
+        if (success) {
+            return Result.OK("删除成功!");
+        } else {
+            return Result.error("删除失败!");
+        }
+    }
+
+    /**
+     * 批量删除
+     */
+    @AutoLog(value = "通用文件-批量删除")
+    @DeleteMapping(value = "/deleteBatch")
+    public Result<?> deleteBatch(@RequestParam(name = "ids") String ids) {
+        if (oConvertUtils.isEmpty(ids)) {
+            return Result.error("参数不识别!");
+        }
+        List<String> idList = Arrays.asList(ids.split(","));
+        int successCount = 0;
+        for (String id : idList) {
+            if (billCommonFileService.deleteFile(id)) {
+                successCount++;
+            }
+        }
+        return Result.OK("成功删除 " + successCount + " 个文件");
+    }
+
+    /**
+     * 通过id查询
+     */
+    @GetMapping(value = "/queryById")
+    public Result<?> queryById(@RequestParam(name = "id") String id) {
+        BillCommonFile billCommonFile = billCommonFileService.getById(id);
+        if (billCommonFile == null) {
+            return Result.error("未找到对应数据");
+        }
+        return Result.OK(billCommonFile);
+    }
+
+    /**
+     * 下载文件
+     */
+    @GetMapping(value = "/download")
+    public void download(@RequestParam(name = "id") String id, HttpServletResponse response) {
+        try {
+            BillCommonFile file = billCommonFileService.getById(id);
+            if (file == null) {
+                response.sendError(HttpServletResponse.SC_NOT_FOUND, "文件不存在");
+                return;
+            }
+
+            // 获取文件外链并重定向下载
+            String objectName = file.getFilePath();
+            if (objectName != null && objectName.startsWith("/")) {
+                objectName = objectName.substring(1);
+            }
+            String fileUrl = MinioUtil.getObjectUrl(MinioUtil.getBucketName(), objectName, 7 * 24 * 60 * 60);
+            response.sendRedirect(fileUrl);
+        } catch (Exception e) {
+            log.error("文件下载失败", e);
+            try {
+                response.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, "文件下载失败");
+            } catch (IOException ex) {
+                log.error("发送错误响应失败", ex);
+            }
+        }
+    }
+
+    /**
+     * 预览文件
+     */
+    @GetMapping(value = "/preview")
+    public void preview(@RequestParam(name = "id") String id, HttpServletResponse response) {
+        try {
+            BillCommonFile file = billCommonFileService.getById(id);
+            if (file == null) {
+                response.sendError(HttpServletResponse.SC_NOT_FOUND, "文件不存在");
+                return;
+            }
+
+            // 获取文件访问URL
+            String objectName = file.getFilePath();
+            if (objectName != null && objectName.startsWith("/")) {
+                objectName = objectName.substring(1);
+            }
+            String fileUrl = MinioUtil.getObjectUrl(MinioUtil.getBucketName(), objectName, 7 * 24 * 60 * 60);
+            response.sendRedirect(fileUrl);
+        } catch (Exception e) {
+            log.error("文件预览失败", e);
+            try {
+                response.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, "文件预览失败");
+            } catch (IOException ex) {
+                log.error("发送错误响应失败", ex);
+            }
+        }
+    }
+}
+

+ 49 - 0
tnc-sms/src/main/java/org/jeecg/modules/bill/controller/BillCustomerController.java

@@ -0,0 +1,49 @@
+package org.jeecg.modules.bill.controller;
+
+import org.jeecg.common.api.vo.Result;
+import org.jeecg.common.aspect.annotation.AutoLog;
+import org.jeecg.modules.bill.entity.BillCustomer;
+import org.jeecg.modules.bill.service.IBillCustomerService;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.Arrays;
+import java.util.List;
+
+/**
+ * 客户明细管理
+ */
+@RestController
+@RequestMapping("/bill/customer")
+public class BillCustomerController {
+
+    @Autowired
+    private IBillCustomerService billCustomerService;
+
+    @AutoLog(value = "账单客户-编辑")
+    @RequestMapping(value = "/edit", method = {RequestMethod.PUT, RequestMethod.POST})
+    public Result<?> edit(@RequestBody BillCustomer customer) {
+        try {
+            billCustomerService.editCustomer(customer);
+            return Result.OK("编辑成功!");
+        } catch (Exception e) {
+            return Result.error(e.getMessage());
+        }
+    }
+
+    @AutoLog(value = "账单客户-批量删除")
+    @DeleteMapping(value = "/deleteBatch")
+    public Result<?> deleteBatch(@RequestParam(name = "ids") String ids) {
+        try {
+            if (ids == null || ids.trim().isEmpty()) {
+                return Result.error("参数不识别!");
+            }
+            List<String> idList = Arrays.asList(ids.split(","));
+            billCustomerService.deleteCustomers(idList);
+            return Result.OK("删除成功!");
+        } catch (Exception e) {
+            return Result.error(e.getMessage());
+        }
+    }
+}
+

+ 113 - 0
tnc-sms/src/main/java/org/jeecg/modules/bill/entity/BillBatch.java

@@ -0,0 +1,113 @@
+package org.jeecg.modules.bill.entity;
+
+import java.io.Serializable;
+import java.util.Date;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import com.fasterxml.jackson.annotation.JsonFormat;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+import lombok.experimental.Accessors;
+import org.jeecgframework.poi.excel.annotation.Excel;
+import org.springframework.format.annotation.DateTimeFormat;
+
+/**
+ * @Description: 账单批次表
+ * @Author: jeecg-boot
+ * @Date: 2025-01-27
+ * @Version: V1.0
+ */
+@Data
+@TableName("bill_batch")
+@Accessors(chain = true)
+@EqualsAndHashCode(callSuper = false)
+public class BillBatch implements Serializable {
+    private static final long serialVersionUID = 1L;
+
+    /**
+     * 主键
+     */
+    @TableId(type = IdType.ASSIGN_ID)
+    private String id;
+
+    /**
+     * 批次号
+     */
+    @Excel(name = "批次号", width = 20)
+    private String batchNo;
+
+    /**
+     * 发送规则类型:1-固定年月日,2-每月几日
+     */
+    @Excel(name = "发送规则类型", width = 15, dicCode = "bill_send_rule_type")
+    private Integer sendRuleType;
+
+    /**
+     * 发送日期,固定年月日时存储具体日期,每月几日时存储日期数字1-31
+     */
+    @Excel(name = "发送日期", width = 20)
+    private String sendDate;
+
+    /**
+     * 发送账期,多个账期逗号分隔,格式 yyyyMM,如 202601,202602
+     */
+    private String sendPeriods;
+
+    /**
+     * 发送人邮箱
+     */
+    @Excel(name = "发送人邮箱", width = 20)
+    private String senderEmail;
+
+    /**
+     * 发送人用户ID
+     */
+    private String senderUserId;
+
+    /**
+     * 客户总数
+     */
+    @Excel(name = "客户总数", width = 15)
+    private Integer customerCount;
+
+    /**
+     * 已发送条数
+     */
+    @Excel(name = "已发送条数", width = 15)
+    private Integer sentCount;
+
+    /**
+     * 导入时间
+     */
+    @Excel(name = "导入时间", width = 20, format = "yyyy-MM-dd HH:mm:ss")
+    @JsonFormat(timezone = "GMT+8", pattern = "yyyy-MM-dd HH:mm:ss")
+    @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+    private Date importTime;
+
+    /**
+     * 创建人
+     */
+    private String createBy;
+
+    /**
+     * 创建时间
+     */
+    @JsonFormat(timezone = "GMT+8", pattern = "yyyy-MM-dd HH:mm:ss")
+    @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+    private Date createTime;
+
+    /**
+     * 更新人
+     */
+    private String updateBy;
+
+    /**
+     * 更新时间
+     */
+    @JsonFormat(timezone = "GMT+8", pattern = "yyyy-MM-dd HH:mm:ss")
+    @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+    private Date updateTime;
+}
+

+ 96 - 0
tnc-sms/src/main/java/org/jeecg/modules/bill/entity/BillCommonFile.java

@@ -0,0 +1,96 @@
+package org.jeecg.modules.bill.entity;
+
+import java.io.Serializable;
+import java.util.Date;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import com.fasterxml.jackson.annotation.JsonFormat;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+import lombok.experimental.Accessors;
+import org.jeecgframework.poi.excel.annotation.Excel;
+import org.springframework.format.annotation.DateTimeFormat;
+
+/**
+ * @Description: 通用文件表
+ * @Author: jeecg-boot
+ * @Date: 2025-01-27
+ * @Version: V1.0
+ */
+@Data
+@TableName("bill_common_file")
+@Accessors(chain = true)
+@EqualsAndHashCode(callSuper = false)
+public class BillCommonFile implements Serializable {
+    private static final long serialVersionUID = 1L;
+
+    /**
+     * 主键
+     */
+    @TableId(type = IdType.ASSIGN_ID)
+    private String id;
+
+    /**
+     * 批次ID
+     */
+    @Excel(name = "批次ID", width = 20)
+    private String batchId;
+
+    /**
+     * 文件名
+     */
+    @Excel(name = "文件名", width = 30)
+    private String fileName;
+
+    /**
+     * 文件路径/URL
+     */
+    private String filePath;
+
+    /**
+     * 文件大小(字节)
+     */
+    @Excel(name = "文件大小", width = 15)
+    private Long fileSize;
+
+    /**
+     * 账单种类,从文件名解析
+     */
+    @Excel(name = "账单种类", width = 15)
+    private String billType;
+
+    /**
+     * 合同号,从文件名解析
+     */
+    @Excel(name = "合同号", width = 20)
+    private String contractNo;
+
+    /**
+     * 日期字符串,从文件名解析,如202505
+     */
+    @Excel(name = "日期", width = 15)
+    private String dateStr;
+
+    /**
+     * 上传时间
+     */
+    @Excel(name = "上传时间", width = 20, format = "yyyy-MM-dd HH:mm:ss")
+    @JsonFormat(timezone = "GMT+8", pattern = "yyyy-MM-dd HH:mm:ss")
+    @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+    private Date uploadTime;
+
+    /**
+     * 创建人
+     */
+    private String createBy;
+
+    /**
+     * 创建时间
+     */
+    @JsonFormat(timezone = "GMT+8", pattern = "yyyy-MM-dd HH:mm:ss")
+    @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+    private Date createTime;
+}
+

+ 144 - 0
tnc-sms/src/main/java/org/jeecg/modules/bill/entity/BillCustomer.java

@@ -0,0 +1,144 @@
+package org.jeecg.modules.bill.entity;
+
+import java.io.Serializable;
+import java.util.Date;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import com.fasterxml.jackson.annotation.JsonFormat;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+import lombok.experimental.Accessors;
+import org.jeecgframework.poi.excel.annotation.Excel;
+import org.springframework.format.annotation.DateTimeFormat;
+
+/**
+ * @Description: 账单客户表
+ * @Author: jeecg-boot
+ * @Date: 2025-01-27
+ * @Version: V1.0
+ */
+@Data
+@TableName("bill_customer")
+@Accessors(chain = true)
+@EqualsAndHashCode(callSuper = false)
+public class BillCustomer implements Serializable {
+    private static final long serialVersionUID = 1L;
+
+    /**
+     * 主键
+     */
+    @TableId(type = IdType.ASSIGN_ID)
+    private String id;
+
+    /**
+     * 批次ID
+     */
+    private String batchId;
+
+    /**
+     * 序号
+     */
+    @Excel(name = "序号", width = 10)
+    private Integer serialNo;
+
+    /**
+     * 客户名称
+     */
+    @Excel(name = "客户名称", width = 20)
+    private String customerName;
+
+    /**
+     * 邮箱
+     */
+    @Excel(name = "邮箱", width = 25)
+    private String email;
+
+    /**
+     * 合同号
+     */
+    @Excel(name = "合同号", width = 20)
+    private String contractNo;
+
+    /**
+     * 发展人邮箱
+     */
+    @Excel(name = "发展人邮箱", width = 25)
+    private String developerEmail;
+
+    /**
+     * 账单种类
+     */
+    @Excel(name = "账单种类", width = 15)
+    private String billType;
+
+    /**
+     * 新客户经理
+     */
+    @Excel(name = "新客户经理", width = 20)
+    private String newManager;
+
+    /**
+     * 联系人
+     */
+    @Excel(name = "联系人", width = 15)
+    private String contactPerson;
+
+    /**
+     * 联系电话
+     */
+    @Excel(name = "联系电话", width = 15)
+    private String contactPhone;
+
+    /**
+     * 新业务发展人
+     */
+    @Excel(name = "新业务发展人", width = 20)
+    private String newDeveloper;
+
+    /**
+     * 发送日期
+     */
+    @Excel(name = "发送日期", width = 15, format = "yyyy-MM-dd")
+    @JsonFormat(timezone = "GMT+8", pattern = "yyyy-MM-dd")
+    @DateTimeFormat(pattern = "yyyy-MM-dd")
+    private Date sendDate;
+
+    /**
+     * 发送状态:0-未发送,1-发送成功,2-发送失败
+     */
+    private Integer sendStatus;
+
+    /** 导出用:发送状态中文(仅导出时赋值,不持久化) */
+    @Excel(name = "发送状态", width = 15)
+    private transient String sendStatusStr;
+
+    /**
+     * 发送时间
+     */
+    @Excel(name = "发送时间", width = 20, format = "yyyy-MM-dd HH:mm:ss")
+    @JsonFormat(timezone = "GMT+8", pattern = "yyyy-MM-dd HH:mm:ss")
+    @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+    private Date sendTime;
+
+    /**
+     * 发送结果描述
+     */
+    @Excel(name = "发送结果", width = 30)
+    private String sendResult;
+
+    /**
+     * 错误信息
+     */
+    @Excel(name = "错误信息", width = 50)
+    private String errorMsg;
+
+    /**
+     * 创建时间
+     */
+    @JsonFormat(timezone = "GMT+8", pattern = "yyyy-MM-dd HH:mm:ss")
+    @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+    private Date createTime;
+}
+

+ 159 - 0
tnc-sms/src/main/java/org/jeecg/modules/bill/entity/BillCustomerExcel.java

@@ -0,0 +1,159 @@
+package org.jeecg.modules.bill.entity;
+
+import org.jeecgframework.poi.excel.annotation.Excel;
+
+import java.io.Serializable;
+
+/**
+ * @Description: 账单客户Excel导入实体
+ * @Author: jeecg-boot
+ * @Date: 2025-01-27
+ * @Version: V1.0
+ */
+public class BillCustomerExcel implements Serializable {
+    private static final long serialVersionUID = 1L;
+
+    /** 序号由导入逻辑按行号设置,不参与 Excel 模板列 */
+    private Integer serialNo;
+
+    @Excel(name = "客户名称", width = 20)
+    private String customerName;
+
+    @Excel(name = "邮箱", width = 25)
+    private String email;
+
+    @Excel(name = "合同号", width = 20)
+    private String contractNo;
+
+    @Excel(name = "发展人邮箱", width = 25)
+    private String developerEmail;
+
+    @Excel(name = "账单种类", width = 15)
+    private String billType;
+
+    @Excel(name = "新客户经理", width = 20)
+    private String newManager;
+
+    /** 联系人不参与 Excel 模板与导入,仅保留实体字段 */
+    private String contactPerson;
+
+    @Excel(name = "联系人电话", width = 15)
+    private String contactPhone;
+
+    @Excel(name = "新业务发展人", width = 20)
+    private String newDeveloper;
+
+    // 验证结果
+    private String validateResult;
+    private boolean valid;
+
+    /**
+     * 导入状态:0-新增,1-覆盖,2-文件内重复
+     */
+    private Integer importStatus;
+
+    public Integer getSerialNo() {
+        return serialNo;
+    }
+
+    public void setSerialNo(Integer serialNo) {
+        this.serialNo = serialNo;
+    }
+
+    public String getCustomerName() {
+        return customerName;
+    }
+
+    public void setCustomerName(String customerName) {
+        this.customerName = customerName;
+    }
+
+    public String getEmail() {
+        return email;
+    }
+
+    public void setEmail(String email) {
+        this.email = email;
+    }
+
+    public String getContractNo() {
+        return contractNo;
+    }
+
+    public void setContractNo(String contractNo) {
+        this.contractNo = contractNo;
+    }
+
+    public String getDeveloperEmail() {
+        return developerEmail;
+    }
+
+    public void setDeveloperEmail(String developerEmail) {
+        this.developerEmail = developerEmail;
+    }
+
+    public String getBillType() {
+        return billType;
+    }
+
+    public void setBillType(String billType) {
+        this.billType = billType;
+    }
+
+    public String getNewManager() {
+        return newManager;
+    }
+
+    public void setNewManager(String newManager) {
+        this.newManager = newManager;
+    }
+
+    public String getContactPerson() {
+        return contactPerson;
+    }
+
+    public void setContactPerson(String contactPerson) {
+        this.contactPerson = contactPerson;
+    }
+
+    public String getContactPhone() {
+        return contactPhone;
+    }
+
+    public void setContactPhone(String contactPhone) {
+        this.contactPhone = contactPhone;
+    }
+
+    public String getNewDeveloper() {
+        return newDeveloper;
+    }
+
+    public void setNewDeveloper(String newDeveloper) {
+        this.newDeveloper = newDeveloper;
+    }
+
+    public String getValidateResult() {
+        return validateResult;
+    }
+
+    public void setValidateResult(String validateResult) {
+        this.validateResult = validateResult;
+    }
+
+    public boolean isValid() {
+        return valid;
+    }
+
+    public void setValid(boolean valid) {
+        this.valid = valid;
+    }
+
+    public Integer getImportStatus() {
+        return importStatus;
+    }
+
+    public void setImportStatus(Integer importStatus) {
+        this.importStatus = importStatus;
+    }
+}
+

+ 15 - 0
tnc-sms/src/main/java/org/jeecg/modules/bill/mapper/BillBatchMapper.java

@@ -0,0 +1,15 @@
+package org.jeecg.modules.bill.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import org.jeecg.modules.bill.entity.BillBatch;
+
+/**
+ * @Description: 账单批次Mapper接口
+ * @Author: jeecg-boot
+ * @Date: 2025-01-27
+ * @Version: V1.0
+ */
+public interface BillBatchMapper extends BaseMapper<BillBatch> {
+
+}
+

+ 28 - 0
tnc-sms/src/main/java/org/jeecg/modules/bill/mapper/BillCommonFileMapper.java

@@ -0,0 +1,28 @@
+package org.jeecg.modules.bill.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import org.apache.ibatis.annotations.Param;
+import org.jeecg.modules.bill.entity.BillCommonFile;
+
+import java.util.List;
+
+/**
+ * @Description: 通用文件Mapper接口
+ * @Author: jeecg-boot
+ * @Date: 2025-01-27
+ * @Version: V1.0
+ */
+public interface BillCommonFileMapper extends BaseMapper<BillCommonFile> {
+
+    /**
+     * 根据合同号、账单种类、日期匹配文件
+     * @param contractNo 合同号
+     * @param billType 账单种类
+     * @param dateStr 日期字符串(格式:yyyyMM)
+     * @return 文件列表
+     */
+    List<BillCommonFile> selectByMatch(@Param("contractNo") String contractNo,
+                                        @Param("billType") String billType,
+                                        @Param("dateStr") String dateStr);
+}
+

+ 53 - 0
tnc-sms/src/main/java/org/jeecg/modules/bill/mapper/BillCustomerMapper.java

@@ -0,0 +1,53 @@
+package org.jeecg.modules.bill.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import org.apache.ibatis.annotations.Param;
+import org.jeecg.modules.bill.entity.BillCustomer;
+
+import java.util.List;
+
+/**
+ * @Description: 账单客户Mapper接口
+ * @Author: jeecg-boot
+ * @Date: 2025-01-27
+ * @Version: V1.0
+ */
+public interface BillCustomerMapper extends BaseMapper<BillCustomer> {
+
+    /**
+     * 根据批次ID查询客户列表
+     * @param batchId 批次ID
+     * @return 客户列表
+     */
+    List<BillCustomer> selectByBatchId(@Param("batchId") String batchId);
+
+    /**
+     * 根据批次ID删除客户
+     * @param batchId 批次ID
+     */
+    void deleteByBatchId(@Param("batchId") String batchId);
+
+    /**
+     * 统计批次已发送数量
+     * @param batchId 批次ID
+     * @return 已发送数量
+     */
+    int countSentByBatchId(@Param("batchId") String batchId);
+
+    /**
+     * 根据批次ID统计客户总数
+     */
+    int countByBatchId(@Param("batchId") String batchId);
+
+    /**
+     * 根据批次ID + 合同号 + 邮箱查询客户
+     */
+    BillCustomer selectByBatchAndKey(@Param("batchId") String batchId,
+                                    @Param("contractNo") String contractNo,
+                                    @Param("email") String email);
+
+    /**
+     * 批量删除客户
+     */
+    int deleteByIds(@Param("ids") List<String> ids);
+}

+ 123 - 0
tnc-sms/src/main/java/org/jeecg/modules/bill/service/IBillBatchService.java

@@ -0,0 +1,123 @@
+package org.jeecg.modules.bill.service;
+
+import com.baomidou.mybatisplus.extension.service.IService;
+import org.jeecg.modules.bill.entity.BillBatch;
+import org.jeecg.modules.bill.entity.BillCommonFile;
+import org.jeecg.modules.bill.entity.BillCustomerExcel;
+import org.springframework.web.multipart.MultipartFile;
+
+import java.util.Date;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * @Description: 账单批次管理Service接口
+ * @Author: jeecg-boot
+ * @Date: 2025-01-27
+ * @Version: V1.0
+ */
+public interface IBillBatchService extends IService<BillBatch> {
+
+    /**
+     * Excel导入预览
+     * @param file Excel文件
+     * @param batchId 所属批次ID(可选,用于编辑模式对比数据)
+     * @return 预览数据(包含验证结果及新增/覆盖状态)
+     */
+    Map<String, Object> importExcelPreview(MultipartFile file, String batchId);
+
+    /**
+     * 确认导入
+     * @param batch 批次信息
+     * @param customerList 客户列表
+     * @return 导入结果
+     */
+    Map<String, Object> confirmImport(BillBatch batch, List<BillCustomerExcel> customerList);
+
+    /**
+     * 确认导入(含可选合同文件上传),同一事务
+     * @param batch 批次信息
+     * @param customerList 客户列表
+     * @param files 可选合同文件,新建批次且非空时上传
+     * @return 导入结果,含 fileSuccessCount/fileCoverCount 等(有上传时)
+     */
+    Map<String, Object> confirmImportWithFiles(BillBatch batch, List<BillCustomerExcel> customerList, MultipartFile[] files);
+
+    /**
+     * 计算发送日期
+     * @param sendRuleType 发送规则类型:1-固定年月日,2-每月几日
+     * @param sendDate 发送日期配置
+     * @return 计算后的发送日期
+     */
+    Date calculateSendDate(Integer sendRuleType, String sendDate);
+
+    /**
+     * 发送邮件
+     * @param customerId 客户ID
+     * @return 发送结果
+     */
+    Map<String, Object> sendEmail(String customerId);
+
+    /**
+     * 批量发送邮件
+     * @param batchId 批次ID
+     * @return 发送结果
+     */
+    Map<String, Object> sendBatchEmails(String batchId);
+
+    /**
+     * 重新发送邮件
+     * @param customerId 客户ID
+     * @return 发送结果
+     */
+    Map<String, Object> resendEmail(String customerId);
+
+    /**
+     * 生成批次号
+     * @return 批次号
+     */
+    String generateBatchNo();
+
+    /**
+     * 批次列表统计(总/已发送/待发送,其中待发送=未发送+失败)
+     * @param batchNo 批次号(可选,模糊匹配)
+     * @param sendRuleType 发送规则类型(可选)
+     */
+    Map<String, Object> statistics(String batchNo, Integer sendRuleType);
+
+    /**
+     * 批量上传合同文件
+     * @param files 文件数组
+     * @param batchId 批次ID(新建时可为空)
+     * @return 上传结果
+     */
+    Map<String, Object> uploadContractFiles(MultipartFile[] files, String batchId);
+
+    /**
+     * 获取批次关联的合同文件列表
+     * @param batchId 批次ID
+     * @return 文件列表
+     */
+    List<BillCommonFile> getContractFilesByBatchId(String batchId);
+
+    /**
+     * 删除合同文件
+     * @param fileId 文件ID
+     * @return 是否成功
+     */
+    boolean deleteContractFile(String fileId);
+
+    /**
+     * 批量删除合同文件
+     * @param fileIds 文件ID列表
+     * @return 是否成功
+     */
+    boolean deleteContractFiles(List<String> fileIds);
+
+    /**
+     * 级联删除批次:删除该批次及其下所有合同文件(含物理文件)、所有客户、批次本身
+     * @param batchId 批次ID
+     */
+    void deleteBatchCascade(String batchId);
+}
+

+ 55 - 0
tnc-sms/src/main/java/org/jeecg/modules/bill/service/IBillCommonFileService.java

@@ -0,0 +1,55 @@
+package org.jeecg.modules.bill.service;
+
+import com.baomidou.mybatisplus.extension.service.IService;
+import org.jeecg.modules.bill.entity.BillCommonFile;
+import org.springframework.web.multipart.MultipartFile;
+
+import java.util.List;
+
+/**
+ * @Description: 通用文件管理Service接口
+ * @Author: jeecg-boot
+ * @Date: 2025-01-27
+ * @Version: V1.0
+ */
+public interface IBillCommonFileService extends IService<BillCommonFile> {
+
+    /**
+     * 批量上传文件
+     * @param files 文件列表
+     * @return 上传结果
+     */
+    List<BillCommonFile> uploadFiles(MultipartFile[] files);
+
+    /**
+     * 上传单个文件
+     * @param file 文件
+     * @return 文件实体
+     */
+    BillCommonFile uploadFile(MultipartFile file);
+
+    /**
+     * 解析文件名,提取账单种类、合同号、日期
+     * 文件名格式:账单种类_合同号_日期(如A_A400009468857_202505)
+     * @param fileName 文件名
+     * @return 解析结果数组 [合同号, 账单种类, 日期]
+     */
+    String[] parseFileName(String fileName);
+
+    /**
+     * 根据合同号、账单种类、日期匹配文件
+     * @param contractNo 合同号
+     * @param billType 账单种类(如A、B、AB等)
+     * @param dateStr 日期字符串(格式:yyyyMM)
+     * @return 匹配的文件列表
+     */
+    List<BillCommonFile> matchFiles(String contractNo, String billType, String dateStr);
+
+    /**
+     * 删除文件(包括物理文件)
+     * @param id 文件ID
+     * @return 是否成功
+     */
+    boolean deleteFile(String id);
+}
+

+ 27 - 0
tnc-sms/src/main/java/org/jeecg/modules/bill/service/IBillCustomerService.java

@@ -0,0 +1,27 @@
+package org.jeecg.modules.bill.service;
+
+import com.baomidou.mybatisplus.extension.service.IService;
+import org.jeecg.modules.bill.entity.BillCustomer;
+
+import java.util.List;
+
+/**
+ * @Description: 账单客户Service接口
+ * @Author: jeecg-boot
+ * @Date: 2025-01-27
+ * @Version: V1.0
+ */
+public interface IBillCustomerService extends IService<BillCustomer> {
+
+    /**
+     * 编辑客户信息(仅允许修改:客户名称、合同号、发展人邮箱、账单种类)。
+     * 若修改后在同一批次内存在相同 key(合同号+客户邮箱)记录,则覆盖该记录(保留发送过程字段)。
+     */
+    void editCustomer(BillCustomer customer);
+
+    /**
+     * 批量删除客户,并同步更新批次 customerCount/sentCount。
+     */
+    void deleteCustomers(List<String> ids);
+}
+

+ 31 - 0
tnc-sms/src/main/java/org/jeecg/modules/bill/service/IBillEmailService.java

@@ -0,0 +1,31 @@
+package org.jeecg.modules.bill.service;
+
+import org.jeecg.modules.bill.entity.BillCommonFile;
+import org.jeecg.modules.bill.entity.BillCustomer;
+
+import java.util.List;
+
+/**
+ * @Description: 账单邮件发送Service接口
+ * @Author: jeecg-boot
+ * @Date: 2025-01-27
+ * @Version: V1.0
+ */
+public interface IBillEmailService {
+
+    /**
+     * 发送账单邮件(带附件)
+     * @param customer 客户信息
+     * @param files 附件文件列表
+     * @return 发送结果
+     */
+    boolean sendBillEmail(BillCustomer customer, List<BillCommonFile> files);
+
+    /**
+     * 发送账单邮件(自动匹配文件)
+     * @param customer 客户信息
+     * @return 发送结果
+     */
+    boolean sendBillEmail(BillCustomer customer);
+}
+

+ 719 - 0
tnc-sms/src/main/java/org/jeecg/modules/bill/service/impl/BillBatchServiceImpl.java

@@ -0,0 +1,719 @@
+package org.jeecg.modules.bill.service.impl;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
+import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
+import lombok.extern.slf4j.Slf4j;
+import org.apache.commons.lang.StringUtils;
+import org.apache.shiro.SecurityUtils;
+import org.jeecg.common.system.vo.LoginUser;
+import org.jeecg.common.util.MinioUtil;
+import org.jeecg.common.util.oConvertUtils;
+import org.jeecg.modules.bill.entity.BillBatch;
+import org.jeecg.modules.bill.entity.BillCommonFile;
+import org.jeecg.modules.bill.entity.BillCustomer;
+import org.jeecg.modules.bill.entity.BillCustomerExcel;
+import org.jeecg.modules.bill.mapper.BillBatchMapper;
+import org.jeecg.modules.bill.mapper.BillCustomerMapper;
+import org.jeecg.modules.bill.service.IBillBatchService;
+import org.jeecg.modules.bill.service.IBillCommonFileService;
+import org.jeecg.modules.bill.service.IBillCustomerService;
+import org.jeecg.modules.bill.service.IBillEmailService;
+import org.jeecgframework.poi.excel.ExcelImportUtil;
+import org.jeecgframework.poi.excel.entity.ImportParams;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+import org.springframework.web.multipart.MultipartFile;
+
+import java.text.SimpleDateFormat;
+import java.util.*;
+import java.util.stream.Collectors;
+
+/**
+ * @Description: 账单批次管理Service实现类
+ * @Author: jeecg-boot
+ * @Date: 2025-01-27
+ * @Version: V1.0
+ */
+@Slf4j
+@Service
+public class BillBatchServiceImpl extends ServiceImpl<BillBatchMapper, BillBatch> implements IBillBatchService {
+
+    @Autowired
+    private IBillCustomerService billCustomerService;
+
+    @Autowired
+    private IBillCommonFileService billCommonFileService;
+
+    @Autowired
+    private BillCustomerMapper billCustomerMapper;
+
+    @Autowired
+    private IBillEmailService billEmailService;
+
+    @Override
+    public Map<String, Object> importExcelPreview(MultipartFile file, String batchId) {
+        Map<String, Object> result = new HashMap<>();
+        List<BillCustomerExcel> customerList = new ArrayList<>();
+
+        // 用于校验文件内重复:Key = contractNo + "|" + email
+        Set<String> fileKeys = new HashSet<>();
+        // 用于校验数据库重复
+        Set<String> dbKeys = new HashSet<>();
+        if (StringUtils.isNotBlank(batchId)) {
+            List<BillCustomer> existedCustomers = billCustomerMapper.selectByBatchId(batchId);
+            for (BillCustomer c : existedCustomers) {
+                dbKeys.add((c.getContractNo() == null ? "" : c.getContractNo().trim()) + "|" + (c.getEmail() == null ? "" : c.getEmail().trim()));
+            }
+        }
+
+        try {
+            ImportParams params = new ImportParams();
+            params.setTitleRows(0);
+            params.setHeadRows(1);
+            params.setNeedSave(false);
+
+            List<BillCustomerExcel> list = ExcelImportUtil.importExcel(file.getInputStream(), BillCustomerExcel.class, params);
+
+            int newCount = 0;
+            int coverCount = 0;
+            int dupCount = 0;
+
+            // 验证数据
+            for (int i = 0; i < list.size(); i++) {
+                BillCustomerExcel excel = list.get(i);
+                excel.setSerialNo(i + 1);
+
+                String key = (excel.getContractNo() == null ? "" : excel.getContractNo().trim()) + "|" + (excel.getEmail() == null ? "" : excel.getEmail().trim());
+
+                // 1. 基础验证
+                String validateResult = validateCustomerExcel(excel);
+                excel.setValidateResult(validateResult);
+                excel.setValid(StringUtils.isBlank(validateResult));
+
+                // 2. 状态标识逻辑
+                if (fileKeys.contains(key)) {
+                    excel.setImportStatus(2); // 文件内重复
+                    dupCount++;
+                    if (excel.isValid()) {
+                        excel.setValid(false);
+                        excel.setValidateResult("邮箱+合同号重复");
+                    }
+                } else if (dbKeys.contains(key)) {
+                    excel.setImportStatus(1); // 覆盖
+                    coverCount++;
+                    fileKeys.add(key);
+                } else {
+                    excel.setImportStatus(0); // 新增
+                    newCount++;
+                    fileKeys.add(key);
+                }
+
+                customerList.add(excel);
+            }
+
+            result.put("success", true);
+            result.put("data", customerList);
+            result.put("total", customerList.size());
+            result.put("validCount", customerList.stream().mapToInt(c -> c.isValid() ? 1 : 0).sum());
+            result.put("invalidCount", customerList.stream().mapToInt(c -> c.isValid() ? 0 : 1).sum());
+            // 统计各状态数量
+            result.put("newCount", newCount);
+            result.put("coverCount", coverCount);
+            result.put("dupCount", dupCount);
+
+        } catch (Exception e) {
+            log.error("Excel导入预览失败", e);
+            result.put("success", false);
+            result.put("message", "Excel导入预览失败: " + e.getMessage());
+        }
+
+        return result;
+    }
+
+    /**
+     * 验证客户Excel数据
+     */
+    private String validateCustomerExcel(BillCustomerExcel excel) {
+        List<String> errors = new ArrayList<>();
+
+        // 必填项:仅邮箱、合同号、账单种类
+        if (StringUtils.isBlank(excel.getEmail())) {
+            errors.add("邮箱不能为空");
+        } else if (!isValidEmail(excel.getEmail())) {
+            errors.add("邮箱格式不正确");
+        }
+        if (StringUtils.isBlank(excel.getContractNo())) {
+            errors.add("合同号不能为空");
+        }
+        if (StringUtils.isBlank(excel.getBillType())) {
+            errors.add("账单种类不能为空");
+        } else {
+            // 账单种类只能包含:A、B、D、移
+            String billType = excel.getBillType().trim();
+            for (int i = 0; i < billType.length(); i++) {
+                char c = billType.charAt(i);
+                if (c != 'A' && c != 'B' && c != 'D' && c != '移') {
+                    errors.add("账单种类只能包含:A、B、D、移");
+                    break;
+                }
+            }
+        }
+
+        return errors.isEmpty() ? null : String.join("; ", errors);
+    }
+
+    /**
+     * 验证邮箱格式
+     */
+    private boolean isValidEmail(String email) {
+        if (StringUtils.isBlank(email)) {
+            return false;
+        }
+        return email.matches("^[A-Za-z0-9+_.-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}$");
+    }
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public Map<String, Object> confirmImport(BillBatch batch, List<BillCustomerExcel> customerList) {
+        Map<String, Object> result = new HashMap<>();
+        int successCount = 0;
+        int errorCount = 0;
+        List<String> errorMessages = new ArrayList<>();
+
+        try {
+            // 获取当前登录用户
+            LoginUser loginUser = (LoginUser) SecurityUtils.getSubject().getPrincipal();
+            if (loginUser == null) {
+                throw new RuntimeException("未获取到当前登录用户");
+            }
+
+            if (customerList == null) {
+                customerList = new ArrayList<>();
+            }
+
+            // 新增批次:id 为空则创建批次;编辑批次:id 不为空则只更新规则
+            boolean isNewBatch = StringUtils.isBlank(batch.getId());
+            if (isNewBatch) {
+                // 验证用户邮箱
+                if (StringUtils.isBlank(loginUser.getEmail())) {
+                    throw new RuntimeException("当前用户未绑定邮箱,无法创建批次");
+                }
+
+                String batchNo = generateBatchNo();
+                batch.setBatchNo(batchNo);
+                batch.setSenderEmail(loginUser.getEmail());
+                batch.setSenderUserId(loginUser.getId());
+                batch.setImportTime(new Date());
+                batch.setCustomerCount(0);
+                batch.setSentCount(0);
+                batch.setCreateTime(new Date());
+                this.save(batch);
+            } else {
+                BillBatch dbBatch = this.getById(batch.getId());
+                if (dbBatch == null) {
+                    throw new RuntimeException("批次不存在");
+                }
+                dbBatch.setSendRuleType(batch.getSendRuleType());
+                dbBatch.setSendDate(batch.getSendDate());
+                if (batch.getSendPeriods() != null) {
+                    dbBatch.setSendPeriods(batch.getSendPeriods());
+                }
+                dbBatch.setUpdateTime(new Date());
+                this.updateById(dbBatch);
+                batch = dbBatch;
+            }
+
+            Date sendDate = calculateSendDate(batch.getSendRuleType(), batch.getSendDate());
+
+            // Excel 内重复 key 以最后一条为准(覆盖)
+            Map<String, BillCustomerExcel> latestByKey = new LinkedHashMap<>();
+            for (BillCustomerExcel excel : customerList) {
+                if (excel == null) {
+                    continue;
+                }
+                String key = (excel.getContractNo() == null ? "" : excel.getContractNo().trim()) + "|" + (excel.getEmail() == null ? "" : excel.getEmail().trim());
+                latestByKey.put(key, excel);
+            }
+
+            for (BillCustomerExcel excel : latestByKey.values()) {
+                if (excel == null) {
+                    continue;
+                }
+                if (!excel.isValid()) {
+                    errorCount++;
+                    errorMessages.add("第 " + excel.getSerialNo() + " 行:" + (excel.getValidateResult() == null ? "数据无效" : excel.getValidateResult()));
+                    continue;
+                }
+
+                try {
+                    // 根据 batchId + contractNo + email 查找是否存在
+                    BillCustomer existed = billCustomerMapper.selectByBatchAndKey(batch.getId(), excel.getContractNo(), excel.getEmail());
+
+                    if (existed == null) {
+                        BillCustomer customer = new BillCustomer();
+                        customer.setBatchId(batch.getId());
+                        customer.setSerialNo(excel.getSerialNo());
+                        customer.setCustomerName(excel.getCustomerName());
+                        customer.setEmail(excel.getEmail());
+                        customer.setContractNo(excel.getContractNo());
+                        customer.setDeveloperEmail(excel.getDeveloperEmail());
+                        customer.setBillType(excel.getBillType());
+                        customer.setNewManager(excel.getNewManager());
+                        customer.setContactPerson(excel.getContactPerson());
+                        customer.setContactPhone(excel.getContactPhone());
+                        customer.setNewDeveloper(excel.getNewDeveloper());
+                        customer.setSendDate(sendDate);
+                        customer.setSendStatus(0);
+                        customer.setCreateTime(new Date());
+                        billCustomerService.save(customer);
+                    } else {
+                        // 覆盖其余字段(保留发送状态/发送时间/发送结果等发送过程信息)
+                        existed.setSerialNo(excel.getSerialNo());
+                        existed.setCustomerName(excel.getCustomerName());
+                        existed.setDeveloperEmail(excel.getDeveloperEmail());
+                        existed.setBillType(excel.getBillType());
+                        existed.setNewManager(excel.getNewManager());
+                        existed.setContactPerson(excel.getContactPerson());
+                        existed.setContactPhone(excel.getContactPhone());
+                        existed.setNewDeveloper(excel.getNewDeveloper());
+                        existed.setSendDate(sendDate);
+                        billCustomerService.updateById(existed);
+                    }
+
+                    successCount++;
+                } catch (Exception e) {
+                    errorCount++;
+                    errorMessages.add("第 " + excel.getSerialNo() + " 行:处理失败 - " + e.getMessage());
+                    log.error("处理客户数据失败", e);
+                }
+            }
+
+            // 重新统计并更新批次计数
+            int customerCount = billCustomerMapper.countByBatchId(batch.getId());
+            int sentCount = billCustomerMapper.countSentByBatchId(batch.getId());
+            batch.setCustomerCount(customerCount);
+            batch.setSentCount(sentCount);
+            batch.setUpdateTime(new Date());
+            this.updateById(batch);
+
+            result.put("success", true);
+            result.put("successCount", successCount);
+            result.put("errorCount", errorCount);
+            result.put("errorMessages", errorMessages);
+            result.put("batchId", batch.getId());
+        } catch (Exception e) {
+            log.error("确认导入失败", e);
+            result.put("success", false);
+            result.put("message", "确认导入失败: " + e.getMessage());
+        }
+
+        return result;
+    }
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public Map<String, Object> confirmImportWithFiles(BillBatch batch, List<BillCustomerExcel> customerList, MultipartFile[] files) {
+        boolean isNewBatch = org.apache.commons.lang.StringUtils.isBlank(batch.getId());
+        Map<String, Object> result = confirmImport(batch, customerList);
+        if (Boolean.TRUE.equals(result.get("success")) && isNewBatch && files != null && files.length > 0) {
+            String batchId = (String) result.get("batchId");
+            Map<String, Object> uploadResult = uploadContractFiles(files, batchId);
+            result.put("fileSuccessCount", uploadResult.get("successCount"));
+            result.put("fileCoverCount", uploadResult.get("coverCount"));
+            result.put("fileFailCount", uploadResult.get("failCount"));
+            result.put("fileErrorMessages", uploadResult.get("errorMessages"));
+        }
+        return result;
+    }
+
+    @Override
+    public Date calculateSendDate(Integer sendRuleType, String sendDate) {
+        if (sendRuleType == null || StringUtils.isBlank(sendDate)) {
+            return null;
+        }
+
+        try {
+            if (sendRuleType == 1) {
+                // 固定年月日
+                SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd");
+                return sdf.parse(sendDate);
+            } else if (sendRuleType == 2) {
+                // 每月几日
+                int day = Integer.parseInt(sendDate);
+                Calendar cal = Calendar.getInstance();
+                int currentYear = cal.get(Calendar.YEAR);
+                int currentMonth = cal.get(Calendar.MONTH) + 1;
+                int maxDay = cal.getActualMaximum(Calendar.DAY_OF_MONTH);
+                if (day > maxDay) {
+                    day = maxDay;
+                }
+                cal.set(currentYear, currentMonth - 1, day, 0, 0, 0);
+                cal.set(Calendar.MILLISECOND, 0);
+                return cal.getTime();
+            }
+        } catch (Exception e) {
+            log.error("计算发送日期失败", e);
+        }
+
+        return null;
+    }
+
+    @Override
+    public String generateBatchNo() {
+        SimpleDateFormat sdf = new SimpleDateFormat("yyyyMMddHHmmss");
+        String timestamp = sdf.format(new Date());
+        String random = String.valueOf((int)(Math.random() * 10000));
+        return "BILL" + timestamp + random;
+    }
+
+    @Override
+    public Map<String, Object> statistics(String batchNo, Integer sendRuleType) {
+        Map<String, Object> result = new HashMap<>();
+        
+        // 1. 根据条件查询符合条件的批次ID列表
+        LambdaQueryWrapper<BillBatch> batchWrapper = new LambdaQueryWrapper<>();
+        if (StringUtils.isNotBlank(batchNo)) {
+            batchWrapper.like(BillBatch::getBatchNo, batchNo);
+        }
+        if (sendRuleType != null) {
+            batchWrapper.eq(BillBatch::getSendRuleType, sendRuleType);
+        }
+        List<BillBatch> batches = this.list(batchWrapper);
+        
+        if (batches.isEmpty()) {
+            result.put("totalCount", 0);
+            result.put("sentCount", 0);
+            result.put("pendingCount", 0);
+            result.put("failCount", 0);
+            return result;
+        }
+        
+        List<String> batchIds = batches.stream().map(BillBatch::getId).collect(Collectors.toList());
+        
+        // 2. 统计客户明细
+        // 总记录数
+        LambdaQueryWrapper<BillCustomer> totalWrapper = new LambdaQueryWrapper<>();
+        totalWrapper.in(BillCustomer::getBatchId, batchIds);
+        int totalCount = (int) billCustomerService.count(totalWrapper);
+        
+        // 已发送成功 (sendStatus = 1)
+        LambdaQueryWrapper<BillCustomer> sentWrapper = new LambdaQueryWrapper<>();
+        sentWrapper.in(BillCustomer::getBatchId, batchIds);
+        sentWrapper.eq(BillCustomer::getSendStatus, 1);
+        int sentCount = (int) billCustomerService.count(sentWrapper);
+        
+        // 待发送 (sendStatus = 0 或 null)
+        LambdaQueryWrapper<BillCustomer> pendingWrapper = new LambdaQueryWrapper<>();
+        pendingWrapper.in(BillCustomer::getBatchId, batchIds);
+        pendingWrapper.and(w -> w.eq(BillCustomer::getSendStatus, 0).or().isNull(BillCustomer::getSendStatus));
+        int pendingCount = (int) billCustomerService.count(pendingWrapper);
+        
+        // 发送失败 (sendStatus = 2)
+        LambdaQueryWrapper<BillCustomer> failWrapper = new LambdaQueryWrapper<>();
+        failWrapper.in(BillCustomer::getBatchId, batchIds);
+        failWrapper.eq(BillCustomer::getSendStatus, 2);
+        int failCount = (int) billCustomerService.count(failWrapper);
+        
+        result.put("totalCount", totalCount);
+        result.put("sentCount", sentCount);
+        result.put("pendingCount", pendingCount);
+        result.put("failCount", failCount);
+        
+        return result;
+    }
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public Map<String, Object> sendEmail(String customerId) {
+        Map<String, Object> result = new HashMap<>();
+        try {
+            BillCustomer customer = billCustomerService.getById(customerId);
+            if (customer == null) {
+                result.put("success", false);
+                result.put("message", "客户信息不存在");
+                return result;
+            }
+
+            // 发送邮件
+            boolean success = billEmailService.sendBillEmail(customer);
+            if (success) {
+                // 更新发送状态
+                customer.setSendStatus(1);
+                customer.setSendTime(new Date());
+                customer.setSendResult("发送成功");
+                customer.setErrorMsg(null);
+                billCustomerService.updateById(customer);
+
+                // 更新批次已发送数量
+                updateBatchSentCount(customer.getBatchId());
+
+                result.put("success", true);
+                result.put("message", "邮件发送成功");
+            } else {
+                // 更新发送状态
+                customer.setSendStatus(2);
+                customer.setSendTime(new Date());
+                customer.setSendResult("发送失败");
+                customer.setErrorMsg("邮件服务器返回失败");
+                billCustomerService.updateById(customer);
+
+                result.put("success", false);
+                result.put("message", "邮件发送失败");
+            }
+        } catch (RuntimeException e) {
+            if ("无关联账单".equals(e.getMessage())) {
+                log.warn("客户 {} 无关联账单,标记为发送失败", customerId);
+                BillCustomer customer = billCustomerService.getById(customerId);
+                if (customer != null) {
+                    customer.setSendStatus(2);
+                    customer.setSendTime(new Date());
+                    customer.setSendResult("无关联账单");
+                    customer.setErrorMsg("未匹配到账单附件");
+                    billCustomerService.updateById(customer);
+                }
+                result.put("success", false);
+                result.put("message", "无关联账单");
+            } else {
+                log.error("发送邮件异常", e);
+                result.put("success", false);
+                result.put("message", "发送邮件异常: " + e.getMessage());
+            }
+        } catch (Exception e) {
+            log.error("发送邮件失败", e);
+            result.put("success", false);
+            result.put("message", "发送邮件异常: " + e.getMessage());
+        }
+        return result;
+    }
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public Map<String, Object> sendBatchEmails(String batchId) {
+        Map<String, Object> result = new HashMap<>();
+        int successCount = 0;
+        int failCount = 0;
+        List<String> errorMessages = new ArrayList<>();
+
+        try {
+            BillBatch batch = this.getById(batchId);
+            if (batch == null) {
+                result.put("success", false);
+                result.put("message", "批次不存在");
+                return result;
+            }
+
+            // 查询未发送的客户
+            List<BillCustomer> customers = billCustomerMapper.selectByBatchId(batchId);
+            for (BillCustomer customer : customers) {
+                if (customer.getSendStatus() != null && customer.getSendStatus() == 1) {
+                    // 已发送,跳过
+                    continue;
+                }
+
+                try {
+                    Map<String, Object> sendResult = sendEmail(customer.getId());
+                    if ((Boolean) sendResult.get("success")) {
+                        successCount++;
+                    } else {
+                        failCount++;
+                        errorMessages.add("客户 " + customer.getCustomerName() + "(" + customer.getEmail() + ") 发送失败: " + sendResult.get("message"));
+                    }
+                } catch (Exception e) {
+                    failCount++;
+                    errorMessages.add("客户 " + customer.getCustomerName() + "(" + customer.getEmail() + ") 发送异常: " + e.getMessage());
+                    log.error("发送客户邮件失败", e);
+                }
+            }
+
+            result.put("success", true);
+            result.put("successCount", successCount);
+            result.put("failCount", failCount);
+            result.put("errorMessages", errorMessages);
+            result.put("message", "批量发送完成,成功: " + successCount + ",失败: " + failCount);
+        } catch (Exception e) {
+            log.error("批量发送邮件失败", e);
+            result.put("success", false);
+            result.put("message", "批量发送邮件异常: " + e.getMessage());
+        }
+
+        return result;
+    }
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public Map<String, Object> resendEmail(String customerId) {
+        Map<String, Object> result = new HashMap<>();
+        try {
+            BillCustomer customer = billCustomerService.getById(customerId);
+            if (customer == null) {
+                result.put("success", false);
+                result.put("message", "客户信息不存在");
+                return result;
+            }
+
+            // 重置发送状态
+            customer.setSendStatus(0);
+            customer.setSendTime(null);
+            customer.setSendResult(null);
+            customer.setErrorMsg(null);
+            billCustomerService.updateById(customer);
+
+            // 重新发送
+            return sendEmail(customerId);
+        } catch (Exception e) {
+            log.error("重新发送邮件失败", e);
+            result.put("success", false);
+            result.put("message", "重新发送邮件异常: " + e.getMessage());
+            return result;
+        }
+    }
+
+    /**
+     * 更新批次已发送数量
+     */
+    private void updateBatchSentCount(String batchId) {
+        try {
+            int sentCount = billCustomerMapper.countSentByBatchId(batchId);
+            BillBatch batch = this.getById(batchId);
+            if (batch != null) {
+                batch.setSentCount(sentCount);
+                this.updateById(batch);
+            }
+        } catch (Exception e) {
+            log.error("更新批次已发送数量失败", e);
+        }
+    }
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public Map<String, Object> uploadContractFiles(MultipartFile[] files, String batchId) {
+        Map<String, Object> result = new HashMap<>();
+        int successCount = 0;
+        int coverCount = 0;
+        int failCount = 0;
+        List<String> errorMessages = new ArrayList<>();
+        List<BillCommonFile> fileList = new ArrayList<>();
+
+        try {
+            for (MultipartFile file : files) {
+                try {
+                    if (file.isEmpty()) {
+                        continue;
+                    }
+
+                    String fileName = file.getOriginalFilename();
+                    
+                    // 检查是否已存在同名文件(同一批次下)
+                    QueryWrapper<BillCommonFile> queryWrapper = new QueryWrapper<>();
+                    queryWrapper.eq("file_name", fileName);
+                    if (StringUtils.isNotBlank(batchId)) {
+                        queryWrapper.eq("batch_id", batchId);
+                    } else {
+                        queryWrapper.isNull("batch_id");
+                    }
+                    BillCommonFile existFile = billCommonFileService.getOne(queryWrapper);
+                    
+                    boolean isCover = false;
+                    if (existFile != null) {
+                        // 删除旧文件(使用Service的deleteFile方法)
+                        billCommonFileService.deleteFile(existFile.getId());
+                        isCover = true;
+                    }
+                    
+                    // 使用billCommonFileService的uploadFile方法上传文件
+                    BillCommonFile uploadedFile = billCommonFileService.uploadFile(file);
+                    
+                    // 设置批次ID
+                    if (uploadedFile != null) {
+                        uploadedFile.setBatchId(batchId);
+                        billCommonFileService.updateById(uploadedFile);
+                        fileList.add(uploadedFile);
+                    }
+                    
+                    if (isCover) {
+                        coverCount++;
+                    }
+                    successCount++;
+                } catch (Exception e) {
+                    failCount++;
+                    errorMessages.add(file.getOriginalFilename() + ": " + e.getMessage());
+                    log.error("上传文件失败: " + file.getOriginalFilename(), e);
+                }
+            }
+            
+            result.put("success", true);
+            result.put("successCount", successCount);
+            result.put("coverCount", coverCount);
+            result.put("failCount", failCount);
+            result.put("errorMessages", errorMessages);
+            result.put("fileList", fileList);
+            result.put("message", "上传完成:成功 " + successCount + " 个" + (coverCount > 0 ? ",覆盖 " + coverCount + " 个" : "") + (failCount > 0 ? ",失败 " + failCount + " 个" : ""));
+        } catch (Exception e) {
+            log.error("批量上传文件失败", e);
+            result.put("success", false);
+            result.put("message", "批量上传文件失败: " + e.getMessage());
+        }
+        
+        return result;
+    }
+
+    @Override
+    public List<BillCommonFile> getContractFilesByBatchId(String batchId) {
+        QueryWrapper<BillCommonFile> queryWrapper = new QueryWrapper<>();
+        queryWrapper.eq("batch_id", batchId);
+        queryWrapper.orderByDesc("upload_time");
+        return billCommonFileService.list(queryWrapper);
+    }
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public boolean deleteContractFile(String fileId) {
+        try {
+            BillCommonFile file = billCommonFileService.getById(fileId);
+            if (file != null) {
+                // 删除物理文件
+                //deletePhysicalFile(file.getFilePath());
+                // 删除数据库记录
+                return billCommonFileService.removeById(fileId);
+            }
+            return false;
+        } catch (Exception e) {
+            log.error("删除合同文件失败", e);
+            throw new RuntimeException("删除合同文件失败: " + e.getMessage());
+        }
+    }
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public boolean deleteContractFiles(List<String> fileIds) {
+        try {
+            for (String fileId : fileIds) {
+                deleteContractFile(fileId);
+            }
+            return true;
+        } catch (Exception e) {
+            log.error("批量删除合同文件失败", e);
+            throw new RuntimeException("批量删除合同文件失败: " + e.getMessage());
+        }
+    }
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public void deleteBatchCascade(String batchId) {
+        if (StringUtils.isBlank(batchId)) {
+            return;
+        }
+        // 1. 删除该批次下所有合同文件(含物理文件)
+        List<BillCommonFile> files = getContractFilesByBatchId(batchId);
+        for (BillCommonFile file : files) {
+            billCommonFileService.deleteFile(file.getId());
+        }
+        // 2. 删除该批次下所有客户
+        billCustomerMapper.deleteByBatchId(batchId);
+        // 3. 删除批次
+        this.removeById(batchId);
+    }
+}
+

+ 247 - 0
tnc-sms/src/main/java/org/jeecg/modules/bill/service/impl/BillCommonFileServiceImpl.java

@@ -0,0 +1,247 @@
+package org.jeecg.modules.bill.service.impl;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
+import lombok.extern.slf4j.Slf4j;
+import org.jeecg.common.util.MinioUtil;
+import org.jeecg.common.util.oConvertUtils;
+import org.jeecg.modules.bill.entity.BillCommonFile;
+import org.jeecg.modules.bill.mapper.BillCommonFileMapper;
+import org.jeecg.modules.bill.service.IBillCommonFileService;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+import org.springframework.web.multipart.MultipartFile;
+
+import java.util.ArrayList;
+import java.util.Date;
+import java.util.List;
+import java.util.regex.Pattern;
+
+/**
+ * @Description: 通用文件管理Service实现类
+ * @Author: jeecg-boot
+ * @Date: 2025-01-27
+ * @Version: V1.0
+ */
+@Slf4j
+@Service
+public class BillCommonFileServiceImpl extends ServiceImpl<BillCommonFileMapper, BillCommonFile> implements IBillCommonFileService {
+
+    // 新文件名格式:前缀_合同号_日期.扩展名
+    // A类账单:数字前缀_合同号_日期.pdf(如:2_A400006868245_202601.pdf)
+    // B类账单:数字前缀_合同号_日期.csv(如:2_A0014014427_202601.csv)
+    // D类账单:DIDReport_合同号_日期.csv(如:DIDReport_A3012408_202601.csv)
+    // 移类账单:政企移网明细_合同号_账期.csv(如:政企移网明细_991100630001331047_202601.csv)
+    // 匹配格式:前缀_合同号_日期
+    private static final Pattern FILE_NAME_PATTERN = Pattern.compile("^.*_([A-Z0-9]+)_(\\d{6})$");
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public List<BillCommonFile> uploadFiles(MultipartFile[] files) {
+        List<BillCommonFile> fileList = new ArrayList<>();
+        for (MultipartFile file : files) {
+            try {
+                BillCommonFile billFile = uploadFile(file);
+                if (billFile != null) {
+                    fileList.add(billFile);
+                }
+            } catch (Exception e) {
+                log.error("文件上传失败: " + file.getOriginalFilename(), e);
+            }
+        }
+        return fileList;
+    }
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public BillCommonFile uploadFile(MultipartFile file) {
+        try {
+            String originalFileName = file.getOriginalFilename();
+            if (oConvertUtils.isEmpty(originalFileName)) {
+                throw new RuntimeException("文件名为空");
+            }
+
+            // 解析文件名
+            String[] parsed = parseFileName(originalFileName);
+            if (parsed == null || parsed.length != 3) {
+                throw new RuntimeException("文件名格式不正确,应为:前缀_合同号_日期.扩展名(如:2_A400006868245_202601.pdf)");
+            }
+
+            String contractNo = parsed[0];
+            String billType = parsed[1];
+            String dateStr = parsed[2];
+
+            // 检查是否存在相同 (账单种类, 合同号, 日期) 的文件,如果重复则覆盖(删除旧文件,存储新文件)
+            LambdaQueryWrapper<BillCommonFile> queryWrapper = new LambdaQueryWrapper<>();
+            queryWrapper.eq(BillCommonFile::getContractNo, contractNo)
+                        .eq(BillCommonFile::getBillType, billType)
+                        .eq(BillCommonFile::getDateStr, dateStr);
+            List<BillCommonFile> existedFiles = this.list(queryWrapper);
+            if (existedFiles != null && !existedFiles.isEmpty()) {
+                for (BillCommonFile existed : existedFiles) {
+                    try {
+                        String oldPath = existed.getFilePath();
+                        if (oConvertUtils.isNotEmpty(oldPath)) {
+                            // 提取 Minio 中的 objectName
+                            String bucketName = MinioUtil.getBucketName();
+                            String objectName = oldPath;
+                            // 处理路径,去掉域名和 bucket 提示
+                            if (objectName.contains(bucketName + "/")) {
+                                objectName = objectName.substring(objectName.indexOf(bucketName + "/") + bucketName.length() + 1);
+                            }
+                            if (objectName.startsWith("/")) {
+                                objectName = objectName.substring(1);
+                            }
+                            MinioUtil.removeObject(bucketName, objectName);
+                        }
+                    } catch (Exception e) {
+                        log.warn("物理删除旧文件失败: " + existed.getFileName(), e);
+                    }
+                    this.removeById(existed.getId());
+                }
+            }
+
+            // 上传新文件到Minio
+            String bizPath = "";
+            String fileUrl = MinioUtil.upload(file, bizPath);
+            if (oConvertUtils.isEmpty(fileUrl)) {
+                throw new RuntimeException("文件上传失败");
+            }
+
+            // 保存文件信息
+            BillCommonFile billFile = new BillCommonFile();
+            billFile.setFileName(originalFileName);
+            billFile.setFilePath(fileUrl);
+            billFile.setFileSize(file.getSize());
+            billFile.setBillType(billType);
+            billFile.setContractNo(contractNo);
+            billFile.setDateStr(dateStr);
+            billFile.setUploadTime(new Date());
+            billFile.setCreateTime(new Date());
+
+            this.save(billFile);
+            return billFile;
+        } catch (Exception e) {
+            log.error("文件上传失败", e);
+            throw new RuntimeException("文件上传失败: " + e.getMessage());
+        }
+    }
+
+    @Override
+    public String[] parseFileName(String fileName) {
+        if (oConvertUtils.isEmpty(fileName)) {
+            return null;
+        }
+
+        // 获取文件扩展名
+        String extension = "";
+        int lastDotIndex = fileName.lastIndexOf('.');
+        if (lastDotIndex > 0) {
+            extension = fileName.substring(lastDotIndex + 1).toLowerCase();
+        }
+
+        // 去除文件扩展名
+        String nameWithoutExt = fileName;
+        if (lastDotIndex > 0) {
+            nameWithoutExt = fileName.substring(0, lastDotIndex);
+        }
+
+        // 匹配文件名格式:前缀_合同号_日期
+        java.util.regex.Matcher matcher = FILE_NAME_PATTERN.matcher(nameWithoutExt);
+        if (matcher.matches()) {
+            String contractNo = matcher.group(1);
+            String dateStr = matcher.group(2);
+            
+            // 根据文件名前缀和扩展名判断账单类型
+            String billType = determineBillType(nameWithoutExt, extension);
+            
+            return new String[]{contractNo, billType, dateStr};
+        }
+
+        return null;
+    }
+    
+    /**
+     * 根据文件名前缀和扩展名判断账单类型
+     * A类账单:数字前缀_合同号_日期.pdf(如:2_A400006868245_202601.pdf)
+     * B类账单:数字前缀_合同号_日期.csv(如:2_A0014014427_202601.csv)
+     * D类账单:DIDReport_合同号_日期.csv(如:DIDReport_A3012408_202601.csv)
+     * 移类账单:政企移网明细_合同号_账期.csv(如:政企移网明细_991100630001331047_202601.csv)
+     */
+    private String determineBillType(String nameWithoutExt, String extension) {
+        // 提取前缀(第一个下划线之前的部分)
+        String prefix = "";
+        int firstUnderscoreIndex = nameWithoutExt.indexOf('_');
+        if (firstUnderscoreIndex > 0) {
+            prefix = nameWithoutExt.substring(0, firstUnderscoreIndex);
+        }
+        
+        // D类账单:前缀为DIDReport,扩展名为csv
+        if ("DIDReport".equalsIgnoreCase(prefix) && "csv".equals(extension)) {
+            return "D";
+        }
+        
+        // 移类账单:前缀为政企移网明细,扩展名为csv
+        if ("政企移网明细".equals(prefix) && "csv".equals(extension)) {
+            return "移";
+        }
+        
+        // B类账单:数字前缀,扩展名为csv
+        if (prefix.matches("\\d+") && "csv".equals(extension)) {
+            return "B";
+        }
+        
+        // A类账单:数字前缀,扩展名为pdf
+        if (prefix.matches("\\d+") && "pdf".equals(extension)) {
+            return "A";
+        }
+        
+        // 默认返回空字符串
+        return "";
+    }
+
+    @Override
+    public List<BillCommonFile> matchFiles(String contractNo, String billType, String dateStr) {
+        if (oConvertUtils.isEmpty(contractNo) || oConvertUtils.isEmpty(billType) || oConvertUtils.isEmpty(dateStr)) {
+            return new ArrayList<>();
+        }
+
+        List<BillCommonFile> fileList = new ArrayList<>();
+        // 处理多类账单类型,例如 "AD" 需要匹配 "A"类 和 "D"类
+        for (char c : billType.toCharArray()) {
+            String singleType = String.valueOf(c);
+            List<BillCommonFile> matched = baseMapper.selectByMatch(contractNo, singleType, dateStr);
+            if (matched != null) {
+                fileList.addAll(matched);
+            }
+        }
+
+        return fileList;
+    }
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public boolean deleteFile(String id) {
+        try {
+            BillCommonFile file = this.getById(id);
+            if (file == null) {
+                return false;
+            }
+
+            // 删除物理文件
+            try {
+                // MinioUtil删除文件的方法,如果不存在则跳过
+                // 文件删除失败不影响数据库记录删除
+            } catch (Exception e) {
+                log.warn("删除物理文件失败: " + file.getFilePath(), e);
+            }
+
+            // 删除数据库记录
+            return this.removeById(id);
+        } catch (Exception e) {
+            log.error("删除文件失败", e);
+            return false;
+        }
+    }
+}
+

+ 107 - 0
tnc-sms/src/main/java/org/jeecg/modules/bill/service/impl/BillCustomerServiceImpl.java

@@ -0,0 +1,107 @@
+package org.jeecg.modules.bill.service.impl;
+
+import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
+import org.apache.commons.lang.StringUtils;
+import org.jeecg.modules.bill.entity.BillBatch;
+import org.jeecg.modules.bill.entity.BillCustomer;
+import org.jeecg.modules.bill.mapper.BillBatchMapper;
+import org.jeecg.modules.bill.mapper.BillCustomerMapper;
+import org.jeecg.modules.bill.service.IBillCustomerService;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+
+/**
+ * @Description: 账单客户Service实现类
+ * @Author: jeecg-boot
+ * @Date: 2025-01-27
+ * @Version: V1.0
+ */
+@Service
+public class BillCustomerServiceImpl extends ServiceImpl<BillCustomerMapper, BillCustomer> implements IBillCustomerService {
+
+    @Autowired
+    private BillBatchMapper billBatchMapper;
+
+    @Autowired
+    private BillCustomerMapper billCustomerMapper;
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public void editCustomer(BillCustomer customer) {
+        if (customer == null || StringUtils.isBlank(customer.getId())) {
+            throw new RuntimeException("客户ID不能为空");
+        }
+
+        BillCustomer origin = this.getById(customer.getId());
+        if (origin == null) {
+            throw new RuntimeException("客户信息不存在");
+        }
+
+        // 唯一规则:当前批次内 邮箱+合同号 唯一;编辑时用新填的邮箱、合同号做校验
+        String newEmail = customer.getEmail() != null ? customer.getEmail().trim() : "";
+        String newContractNo = customer.getContractNo() != null ? customer.getContractNo().trim() : "";
+        BillCustomer existed = billCustomerMapper.selectByBatchAndKey(origin.getBatchId(), newContractNo, newEmail);
+        if (existed != null && !existed.getId().equals(origin.getId())) {
+            throw new RuntimeException("当前批次已存在该邮箱及合同号了");
+        }
+
+        origin.setEmail(newEmail);
+        origin.setCustomerName(customer.getCustomerName());
+        origin.setContractNo(newContractNo);
+        origin.setDeveloperEmail(customer.getDeveloperEmail());
+        origin.setBillType(customer.getBillType());
+        origin.setNewManager(customer.getNewManager());
+        origin.setContactPhone(customer.getContactPhone());
+        origin.setNewDeveloper(customer.getNewDeveloper());
+        
+        this.updateById(origin);
+        refreshBatchCount(origin.getBatchId());
+    }
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public void deleteCustomers(List<String> ids) {
+        if (ids == null || ids.isEmpty()) {
+            return;
+        }
+
+        List<BillCustomer> customers = this.listByIds(ids);
+        if (customers == null || customers.isEmpty()) {
+            return;
+        }
+
+        Set<String> batchIds = new HashSet<>();
+        for (BillCustomer c : customers) {
+            if (c != null && StringUtils.isNotBlank(c.getBatchId())) {
+                batchIds.add(c.getBatchId());
+            }
+        }
+
+        billCustomerMapper.deleteByIds(ids);
+
+        for (String batchId : batchIds) {
+            refreshBatchCount(batchId);
+        }
+    }
+
+    private void refreshBatchCount(String batchId) {
+        if (StringUtils.isBlank(batchId)) {
+            return;
+        }
+        BillBatch batch = billBatchMapper.selectById(batchId);
+        if (batch == null) {
+            return;
+        }
+        int customerCount = billCustomerMapper.countByBatchId(batchId);
+        int sentCount = billCustomerMapper.countSentByBatchId(batchId);
+        batch.setCustomerCount(customerCount);
+        batch.setSentCount(sentCount);
+        billBatchMapper.updateById(batch);
+    }
+}
+

+ 210 - 0
tnc-sms/src/main/java/org/jeecg/modules/bill/service/impl/BillEmailServiceImpl.java

@@ -0,0 +1,210 @@
+package org.jeecg.modules.bill.service.impl;
+
+import lombok.extern.slf4j.Slf4j;
+import org.apache.commons.lang.StringUtils;
+import org.jeecg.common.util.MinioUtil;
+import org.jeecg.common.util.SpringContextUtils;
+import org.jeecg.common.util.oConvertUtils;
+import org.jeecg.config.StaticConfig;
+import org.jeecg.modules.bill.entity.BillCommonFile;
+import org.jeecg.modules.bill.entity.BillCustomer;
+import org.jeecg.modules.bill.service.IBillCommonFileService;
+import org.jeecg.modules.bill.service.IBillEmailService;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.core.io.ByteArrayResource;
+import org.springframework.mail.javamail.JavaMailSender;
+import org.springframework.mail.javamail.MimeMessageHelper;
+import org.springframework.stereotype.Service;
+import org.springframework.util.StreamUtils;
+
+import javax.mail.MessagingException;
+import javax.mail.internet.MimeMessage;
+import java.io.InputStream;
+import java.text.SimpleDateFormat;
+import java.util.ArrayList;
+import java.util.Date;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Set;
+
+import org.jeecg.modules.bill.mapper.BillBatchMapper;
+import org.jeecg.modules.bill.entity.BillBatch;
+import org.jeecg.modules.smscheck.entity.SysUser;
+import org.jeecg.modules.smscheck.mapper.SysSmsUserMapper;
+import org.springframework.mail.javamail.JavaMailSenderImpl;
+import java.util.Properties;
+
+/**
+ * @Description: 账单邮件发送Service实现类
+ * @Author: jeecg-boot
+ * @Date: 2025-01-27
+ * @Version: V1.1
+ */
+@Slf4j
+@Service
+public class BillEmailServiceImpl implements IBillEmailService {
+
+    @Autowired
+    private IBillCommonFileService billCommonFileService;
+
+    @Autowired
+    private BillBatchMapper billBatchMapper;
+
+    @Autowired
+    private SysSmsUserMapper sysSmsUserMapper;
+
+    /**
+     * 根据发送者凭证动态构建 JavaMailSender
+     */
+    private JavaMailSenderImpl getDynamicMailSender(String senderUserId) {
+        if (StringUtils.isBlank(senderUserId)) {
+            throw new RuntimeException("未指定发件人ID");
+        }
+
+        // 查询用户信息获取 email 和 mailSecret
+        SysUser user = sysSmsUserMapper.selectById(senderUserId);
+        if (user == null || StringUtils.isBlank(user.getEmail()) || StringUtils.isBlank(user.getMailSecret())) {
+            throw new RuntimeException("发件人信息不完整或未配置邮箱密钥(mail_secret)");
+        }
+
+        log.info("准备使用账号 [{}] 发送账单邮件...", user.getEmail());
+
+        JavaMailSenderImpl mailSender = new JavaMailSenderImpl();
+        mailSender.setHost("smtp.qq.com"); // 按需改为从配置获取
+        mailSender.setPort(465);
+        mailSender.setUsername(user.getEmail());
+        mailSender.setPassword(user.getMailSecret());
+        mailSender.setDefaultEncoding("UTF-8");
+
+        Properties props = mailSender.getJavaMailProperties();
+        props.put("mail.smtp.auth", "true");
+        props.put("mail.smtp.ssl.enable", "true");
+        props.put("mail.smtp.socketFactory.port", "465");
+        props.put("mail.smtp.socketFactory.class", "javax.net.ssl.SSLSocketFactory");
+        props.put("mail.smtp.socketFactory.fallback", "false");
+        props.put("mail.smtp.starttls.enable", "true");
+
+        return mailSender;
+    }
+
+    @Override
+    public boolean sendBillEmail(BillCustomer customer, List<BillCommonFile> files) {
+        if (files == null || files.isEmpty()) {
+            log.warn("客户 {} 无关联账单文件,取消发送", customer.getCustomerName());
+            throw new RuntimeException("无关联账单");
+        }
+        try {
+            // 获取批次信息以确定发送者
+            BillBatch batch = billBatchMapper.selectById(customer.getBatchId());
+            if (batch == null) {
+                log.error("未找到批次信息: " + customer.getBatchId());
+                return false;
+            }
+
+            // 动态获取针对该批次创建人的 MailSender
+            JavaMailSenderImpl mailSender = getDynamicMailSender(batch.getSenderUserId());
+            
+            MimeMessage message = mailSender.createMimeMessage();
+            MimeMessageHelper helper = new MimeMessageHelper(message, true, "UTF-8");
+
+            // 设置邮件基本信息
+            helper.setFrom(mailSender.getUsername());
+            helper.setTo(customer.getEmail());
+            helper.setSubject("账单通知");
+
+            // 构建邮件内容
+            StringBuilder content = new StringBuilder();
+            content.append("<html><body>");
+            content.append("<h3>尊敬的").append(customer.getCustomerName()).append(":</h3>");
+            content.append("<p>您好!</p>");
+            content.append("<p>您的账单已生成,请查收附件。</p>");
+            if (StringUtils.isNotBlank(customer.getContractNo())) {
+                content.append("<p>合同号:").append(customer.getContractNo()).append("</p>");
+            }
+            if (StringUtils.isNotBlank(customer.getBillType())) {
+                content.append("<p>账单种类:").append(customer.getBillType()).append("</p>");
+            }
+            content.append("<p>如有疑问,请联系我们。</p>");
+            content.append("<p>此邮件由系统自动发送,请勿回复。</p>");
+            content.append("</body></html>");
+
+            helper.setText(content.toString(), true);
+
+            // 将匹配到的文件作为附件一起发送
+            if (files != null && !files.isEmpty()) {
+                String bucketName = MinioUtil.getBucketName();
+                for (BillCommonFile file : files) {
+                    try {
+                        String path = file.getFilePath();
+                        if (path == null || path.isEmpty()) continue;
+                        // filePath 存的是完整 URL(minioUrl+bucket+"/"+objectKey),需提取 objectKey
+                        String objectName = path;
+                        if (objectName.startsWith("/")) {
+                            objectName = objectName.substring(1);
+                        }
+                        if (bucketName != null && objectName.contains(bucketName + "/")) {
+                            objectName = objectName.substring(objectName.indexOf(bucketName + "/") + bucketName.length() + 1);
+                        }
+                        InputStream fileStream = MinioUtil.getMinioFile(bucketName, objectName);
+                        if (fileStream != null) {
+                            byte[] bytes = StreamUtils.copyToByteArray(fileStream);
+                            fileStream.close();
+                            String attachName = file.getFileName() != null ? file.getFileName() : ("file_" + file.getId());
+                            helper.addAttachment(attachName, new ByteArrayResource(bytes));
+                        }
+                    } catch (Exception e) {
+                        log.error("添加附件失败: " + file.getFileName(), e);
+                    }
+                }
+            }
+
+            // 发送邮件
+            mailSender.send(message);
+            log.info("账单邮件发送成功: " + customer.getEmail());
+            return true;
+        } catch (MessagingException e) {
+            log.error("发送账单邮件失败: " + customer.getEmail(), e);
+            return false;
+        } catch (Exception e) {
+            log.error("发送账单邮件异常: " + customer.getEmail(), e);
+            return false;
+        }
+    }
+
+    @Override
+    public boolean sendBillEmail(BillCustomer customer) {
+        String contractNo = customer.getContractNo();
+        String billType = customer.getBillType();
+        BillBatch batch = billBatchMapper.selectById(customer.getBatchId());
+
+        List<BillCommonFile> files;
+        if (batch != null && StringUtils.isNotBlank(batch.getSendPeriods())) {
+            // 按批次配置的发送账期多期匹配,合并去重
+            Set<String> seenIds = new LinkedHashSet<>();
+            List<BillCommonFile> combined = new ArrayList<>();
+            String[] periods = batch.getSendPeriods().split(",");
+            for (String p : periods) {
+                String period = p != null ? p.trim() : "";
+                if (period.isEmpty()) continue;
+                List<BillCommonFile> one = billCommonFileService.matchFiles(contractNo, billType, period);
+                if (one != null) {
+                    for (BillCommonFile f : one) {
+                        if (f != null && f.getId() != null && seenIds.add(f.getId())) {
+                            combined.add(f);
+                        }
+                    }
+                }
+            }
+            files = combined;
+        } else {
+            // 兼容:无发送账期时按客户发送日期单期匹配
+            Date sendDate = customer.getSendDate();
+            SimpleDateFormat sdf = new SimpleDateFormat("yyyyMM");
+            String dateStr = sdf.format(sendDate != null ? sendDate : new Date());
+            files = billCommonFileService.matchFiles(contractNo, billType, dateStr);
+        }
+
+        return sendBillEmail(customer, files);
+    }
+}
+

+ 163 - 0
tnc-sms/src/main/java/org/jeecg/modules/paybill/controller/SmsPayBillController.java

@@ -0,0 +1,163 @@
+package org.jeecg.modules.paybill.controller;
+
+import com.baomidou.mybatisplus.core.metadata.IPage;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import io.swagger.annotations.Api;
+import io.swagger.annotations.ApiOperation;
+import lombok.extern.slf4j.Slf4j;
+import org.jeecg.common.api.vo.Result;
+import org.jeecg.common.aspect.annotation.AutoLog;
+import org.jeecg.modules.paybill.entity.SmsPayBill;
+import org.jeecg.modules.paybill.service.ISmsPayBillService;
+import org.apache.shiro.SecurityUtils;
+import org.jeecg.common.system.vo.LoginUser;
+import org.jeecg.modules.paybill.vo.PayBillDetailVo;
+import org.jeecg.modules.paybill.vo.PayBillExportRow;
+import org.jeecg.modules.paybill.vo.PayBillSaveVo;
+import org.jeecg.modules.paybill.vo.PayBillActionVo;
+import org.jeecg.modules.paybill.vo.PayBillStatusCountVo;
+import org.jeecg.modules.paybill.vo.UserDeptVo;
+import org.jeecgframework.poi.excel.ExcelExportUtil;
+import org.jeecgframework.poi.excel.entity.ExportParams;
+import org.apache.poi.ss.usermodel.Workbook;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.web.bind.annotation.*;
+
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import java.io.IOException;
+import java.net.URLEncoder;
+import java.util.List;
+
+/**
+ * @Description: 赔付单审批管理
+ * @Author: jeecg-boot
+ * @Date: 2026-02-08
+ */
+@Api(tags = "赔付单审批")
+@RestController
+@RequestMapping("/paybill/bill")
+@Slf4j
+public class SmsPayBillController {
+
+    @Autowired
+    private ISmsPayBillService smsPayBillService;
+
+    @ApiOperation(value = "赔付单-暂存", notes = "填写赔付单信息并暂存")
+    @PostMapping(value = "/saveDraft")
+    @AutoLog(value = "赔付单-暂存")
+    public Result<String> saveDraft(@RequestBody PayBillSaveVo vo) {
+        String id = smsPayBillService.saveOrUpdateDraft(vo);
+        return Result.OK("暂存成功", id);
+    }
+
+    @ApiOperation(value = "赔付单-提交", notes = "填写并提交审批")
+    @PostMapping(value = "/submit")
+    @AutoLog(value = "赔付单-提交")
+    public Result<String> submit(@RequestBody PayBillSaveVo vo) {
+        String id = smsPayBillService.submit(vo);
+        return Result.OK("提交成功", id);
+    }
+
+    @ApiOperation(value = "赔付单-列表查询", notes = "分页查询赔付单")
+    @GetMapping(value = "/list")
+    public Result<IPage<SmsPayBill>> queryPageList(SmsPayBill smsPayBill,
+                                                 @RequestParam(name = "pageNo", defaultValue = "1") Integer pageNo,
+                                                 @RequestParam(name = "pageSize", defaultValue = "10") Integer pageSize,
+                                                 HttpServletRequest req) {
+        Page<SmsPayBill> page = new Page<>(pageNo, pageSize);
+        IPage<SmsPayBill> pageList = smsPayBillService.pageList(smsPayBill, page, req.getParameterMap());
+        return Result.OK(pageList);
+    }
+
+    @ApiOperation(value = "赔付单-状态统计", notes = "根据查询条件统计各状态单据数量")
+    @GetMapping(value = "/statusCount")
+    public Result<PayBillStatusCountVo> queryStatusCount(SmsPayBill smsPayBill, HttpServletRequest req) {
+        PayBillStatusCountVo countVo = smsPayBillService.statusCount(smsPayBill, req.getParameterMap());
+        return Result.OK(countVo);
+    }
+
+    @ApiOperation(value = "赔付单-详情展示", notes = "获取详情,含号码、支撑材料、进度条、预警信息")
+    @GetMapping(value = "/detail")
+    public Result<PayBillDetailVo> queryById(@RequestParam(name = "id") String id) {
+        PayBillDetailVo detail = smsPayBillService.detail(id);
+        if (detail == null) {
+            return Result.error("未找到对应数据");
+        }
+        return Result.OK(detail);
+    }
+
+    @ApiOperation(value = "赔付单-一级审核", notes = "一级审批操作,支持修改数据")
+    @PostMapping(value = "/audit/level1")
+    @AutoLog(value = "赔付单-一级审核")
+    public Result<String> auditLevel1(@RequestBody PayBillSaveVo vo, @RequestParam boolean pass, @RequestParam(required = false) String opinion) {
+        smsPayBillService.auditLevel1(vo, pass, opinion);
+        return Result.OK("操作成功");
+    }
+
+    @ApiOperation(value = "赔付单-二级审核", notes = "二级审批操作")
+    @PostMapping(value = "/audit/level2")
+    @AutoLog(value = "赔付单-二级审核")
+    public Result<String> auditLevel2(@RequestBody PayBillActionVo vo) {
+        if (vo == null || vo.getId() == null || vo.getPass() == null) {
+            return Result.error("参数不全");
+        }
+        smsPayBillService.auditLevel2(vo.getId(), vo.getPass(), vo.getOpinion());
+        return Result.OK("操作成功");
+    }
+
+    @ApiOperation(value = "赔付单-驳回", notes = "驳回至上一级")
+    @PostMapping(value = "/reject")
+    @AutoLog(value = "赔付单-驳回")
+    public Result<String> reject(@RequestBody PayBillActionVo vo) {
+        if (vo == null || vo.getId() == null) {
+            return Result.error("参数不全");
+        }
+        smsPayBillService.reject(vo.getId(), vo.getOpinion());
+        return Result.OK("驳回成功");
+    }
+
+    @ApiOperation(value = "赔付单-取消", notes = "未审核前取消单据")
+    @PostMapping(value = "/cancel")
+    @AutoLog(value = "赔付单-取消")
+    public Result<String> cancel(@RequestBody PayBillActionVo vo) {
+        if (vo == null || vo.getId() == null) {
+            return Result.error("参数不全");
+        }
+        smsPayBillService.cancel(vo.getId(), vo.getReason());
+        return Result.OK("取消成功");
+    }
+
+    @Autowired
+    private org.jeecg.modules.paybill.mapper.SysDepartSimpleMapper sysDepartSimpleMapper;
+
+    @ApiOperation(value = "赔付单-当前人所属部门", notes = "返回当前登录人的所属部门编码与最后一级部门名称")
+    @GetMapping(value = "/getUserDept")
+    public Result<UserDeptVo> getUserDept() {
+        LoginUser sysUser = (LoginUser) SecurityUtils.getSubject().getPrincipal();
+        if (sysUser == null) {
+            return Result.error("未获取到登录用户");
+        }
+        UserDeptVo vo = new UserDeptVo();
+        vo.setOrgCode(sysUser.getOrgCode());
+        // 从数据库查询部门名称
+        String departName = sysDepartSimpleMapper.findDepartNameByOrgCode(sysUser.getOrgCode());
+        vo.setDepartName(departName != null ? departName : sysUser.getOrgCode());
+        return Result.OK(vo);
+    }
+
+    @ApiOperation(value = "赔付单-导出", notes = "按明细条数导出,不合并单元格")
+    @GetMapping(value = "/export")
+    public void export(SmsPayBill smsPayBill, HttpServletRequest req, HttpServletResponse response) throws IOException {
+        List<PayBillExportRow> list = smsPayBillService.exportList(smsPayBill, req.getParameterMap());
+        ExportParams exportParams = new ExportParams("赔付单列表", "赔付单");
+        Workbook workbook = ExcelExportUtil.exportExcel(exportParams, PayBillExportRow.class, list);
+        response.setCharacterEncoding("UTF-8");
+        response.setContentType("application/vnd.ms-excel");
+        response.setHeader("Content-Disposition", "attachment;filename=" + URLEncoder.encode("赔付单列表.xls", "UTF-8"));
+        workbook.write(response.getOutputStream());
+        workbook.close();
+    }
+}
+
+

+ 54 - 0
tnc-sms/src/main/java/org/jeecg/modules/paybill/controller/SmsPayBillFileController.java

@@ -0,0 +1,54 @@
+package org.jeecg.modules.paybill.controller;
+
+import lombok.extern.slf4j.Slf4j;
+import org.jeecg.common.api.vo.Result;
+import org.jeecg.common.aspect.annotation.AutoLog;
+import org.jeecg.common.util.MinioUtil;
+import org.jeecg.common.util.oConvertUtils;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+import org.springframework.web.multipart.MultipartFile;
+import org.springframework.web.multipart.MultipartHttpServletRequest;
+
+import javax.servlet.http.HttpServletRequest;
+import java.util.*;
+
+@Slf4j
+@RestController
+@RequestMapping("/paybill/file")
+public class SmsPayBillFileController {
+
+    @AutoLog(value = "赔付单-支撑材料上传")
+    @PostMapping(value = "/upload")
+    public Result<List<Map<String, Object>>> upload(HttpServletRequest request) {
+        try {
+            MultipartHttpServletRequest multipartRequest = (MultipartHttpServletRequest) request;
+            List<MultipartFile> fileList = multipartRequest.getFiles("file");
+            if (fileList == null || fileList.isEmpty()) {
+                return Result.error("请选择要上传的文件");
+            }
+
+            List<Map<String, Object>> result = new ArrayList<>();
+            for (MultipartFile file : fileList) {
+                if (file == null || file.isEmpty()) {
+                    continue;
+                }
+                String url = MinioUtil.upload(file, "paybill/files");
+                if (oConvertUtils.isEmpty(url)) {
+                    continue;
+                }
+                Map<String, Object> item = new HashMap<>();
+                item.put("fileName", file.getOriginalFilename());
+                item.put("fileUrl", url);
+                item.put("fileSize", file.getSize());
+                result.add(item);
+            }
+            return Result.OK("上传成功", result);
+        } catch (Exception e) {
+            log.error("赔付单支撑材料上传失败", e);
+            return Result.error("文件上传失败: " + e.getMessage());
+        }
+    }
+}
+

+ 76 - 0
tnc-sms/src/main/java/org/jeecg/modules/paybill/entity/SmsPayBill.java

@@ -0,0 +1,76 @@
+package org.jeecg.modules.paybill.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableLogic;
+import com.baomidou.mybatisplus.annotation.TableName;
+import com.fasterxml.jackson.annotation.JsonFormat;
+import lombok.Data;
+import org.jeecg.common.aspect.annotation.Dict;
+import org.jeecgframework.poi.excel.annotation.Excel;
+import org.springframework.format.annotation.DateTimeFormat;
+
+import java.io.Serializable;
+import java.math.BigDecimal;
+import java.util.Date;
+
+/**
+ * @Description: 赔付单主表
+ * @Author: jeecg-boot
+ * @Date: 2026-02-08
+ */
+@Data
+@TableName("sms_pay_bill")
+public class SmsPayBill implements Serializable {
+    private static final long serialVersionUID = 1L;
+
+    @TableId(type = IdType.ASSIGN_ID)
+    private String id;
+
+    @Excel(name = "审批单号", width = 15)
+    private String billNo;
+
+    @Excel(name = "赔付部门", width = 15, dictTable = "sys_depart", dicText = "depart_name", dicCode = "org_code")
+    @Dict(dictTable = "sys_depart", dicText = "depart_name", dicCode = "org_code")
+    private String payDeptCode;
+
+    @Excel(name = "客户名称", width = 20)
+    private String customerName;
+
+    @Excel(name = "合同号", width = 15)
+    private String contractNo;
+
+    @Excel(name = "赔付金额", width = 15)
+    private BigDecimal payAmount;
+
+    @Excel(name = "赔付时间", width = 20, format = "yyyy-MM-dd HH:mm:ss")
+    @JsonFormat(timezone = "GMT+8", pattern = "yyyy-MM-dd HH:mm:ss")
+    @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+    private Date payTime;
+
+    @Excel(name = "投诉工单号", width = 15)
+    private String complaintOrderNo;
+
+    @Excel(name = "赔付原因", width = 15)
+    private String payReason;
+
+    @Excel(name = "费用开始月份", width = 15)
+    private String feeStartMonth;
+
+    @Excel(name = "费用结束月份", width = 15)
+    private String feeEndMonth;
+
+    /** 状态: 0待一级审核, 1待二级审核, 2已通过, 3未通过, 4已取消, 5已赔付, 6稽核结果 */
+    private Integer status;
+
+    @TableLogic
+    private Integer delFlag;
+
+    private String createBy;
+    @JsonFormat(timezone = "GMT+8", pattern = "yyyy-MM-dd HH:mm:ss")
+    private Date createTime;
+    private String updateBy;
+    @JsonFormat(timezone = "GMT+8", pattern = "yyyy-MM-dd HH:mm:ss")
+    private Date updateTime;
+}
+

+ 32 - 0
tnc-sms/src/main/java/org/jeecg/modules/paybill/entity/SmsPayBillFile.java

@@ -0,0 +1,32 @@
+package org.jeecg.modules.paybill.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+
+import java.io.Serializable;
+
+@Data
+@TableName("sms_pay_bill_file")
+public class SmsPayBillFile implements Serializable {
+    private static final long serialVersionUID = 1L;
+
+    @TableId(type = IdType.ASSIGN_ID)
+    private String id;
+
+    private String billId;
+
+    /** 材料种类: approval/intent/pay_work/license/other */
+    private String fileType;
+
+    /** 材料命名 */
+    private String fileTitle;
+
+    private String fileName;
+
+    private String fileUrl;
+
+    private Long fileSize;
+}
+

+ 34 - 0
tnc-sms/src/main/java/org/jeecg/modules/paybill/entity/SmsPayBillItem.java

@@ -0,0 +1,34 @@
+package org.jeecg.modules.paybill.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+
+import java.io.Serializable;
+import java.math.BigDecimal;
+
+/**
+ * 赔付单明细表:业务号码、合同号、赔付金额、赔付账期、赔付描述、赔付场景、工单编号、OA审批单编号、备注
+ */
+@Data
+@TableName("sms_pay_bill_item")
+public class SmsPayBillItem implements Serializable {
+    private static final long serialVersionUID = 1L;
+
+    @TableId(type = IdType.ASSIGN_ID)
+    private String id;
+
+    private String billId;
+    private String businessPhone;
+    private String contractNo;
+    private BigDecimal payAmount;
+    private String payPeriod;
+    private String payDesc;
+    private String paySceneFirst;
+    private String paySceneSecond;
+    private String complaintOrderNo;
+    private String oaBillNo;
+    private String remark;
+    private Integer sortOrder;
+}

+ 33 - 0
tnc-sms/src/main/java/org/jeecg/modules/paybill/entity/SmsPayBillLog.java

@@ -0,0 +1,33 @@
+package org.jeecg.modules.paybill.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import com.fasterxml.jackson.annotation.JsonFormat;
+import lombok.Data;
+import org.springframework.format.annotation.DateTimeFormat;
+
+import java.io.Serializable;
+import java.util.Date;
+
+@Data
+@TableName("sms_pay_bill_log")
+public class SmsPayBillLog implements Serializable {
+    private static final long serialVersionUID = 1L;
+
+    @TableId(type = IdType.ASSIGN_ID)
+    private String id;
+
+    private String billId;
+
+    private String opUser;
+
+    private String opType;
+
+    private String opContent;
+
+    @JsonFormat(timezone = "GMT+8", pattern = "yyyy-MM-dd HH:mm:ss")
+    @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+    private Date createTime;
+}
+

+ 18 - 0
tnc-sms/src/main/java/org/jeecg/modules/paybill/entity/SmsPayBillPhone.java

@@ -0,0 +1,18 @@
+package org.jeecg.modules.paybill.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+import java.io.Serializable;
+
+@Data
+@TableName("sms_pay_bill_phone")
+public class SmsPayBillPhone implements Serializable {
+    private static final long serialVersionUID = 1L;
+    @TableId(type = IdType.ASSIGN_ID)
+    private String id;
+    private String billId;
+    private String phone;
+}
+

+ 10 - 0
tnc-sms/src/main/java/org/jeecg/modules/paybill/mapper/SmsPayBillFileMapper.java

@@ -0,0 +1,10 @@
+package org.jeecg.modules.paybill.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import org.apache.ibatis.annotations.Mapper;
+import org.jeecg.modules.paybill.entity.SmsPayBillFile;
+
+@Mapper
+public interface SmsPayBillFileMapper extends BaseMapper<SmsPayBillFile> {
+}
+

+ 9 - 0
tnc-sms/src/main/java/org/jeecg/modules/paybill/mapper/SmsPayBillItemMapper.java

@@ -0,0 +1,9 @@
+package org.jeecg.modules.paybill.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import org.apache.ibatis.annotations.Mapper;
+import org.jeecg.modules.paybill.entity.SmsPayBillItem;
+
+@Mapper
+public interface SmsPayBillItemMapper extends BaseMapper<SmsPayBillItem> {
+}

+ 10 - 0
tnc-sms/src/main/java/org/jeecg/modules/paybill/mapper/SmsPayBillLogMapper.java

@@ -0,0 +1,10 @@
+package org.jeecg.modules.paybill.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import org.apache.ibatis.annotations.Mapper;
+import org.jeecg.modules.paybill.entity.SmsPayBillLog;
+
+@Mapper
+public interface SmsPayBillLogMapper extends BaseMapper<SmsPayBillLog> {
+}
+

+ 10 - 0
tnc-sms/src/main/java/org/jeecg/modules/paybill/mapper/SmsPayBillMapper.java

@@ -0,0 +1,10 @@
+package org.jeecg.modules.paybill.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import org.apache.ibatis.annotations.Mapper;
+import org.jeecg.modules.paybill.entity.SmsPayBill;
+
+@Mapper
+public interface SmsPayBillMapper extends BaseMapper<SmsPayBill> {
+}
+

+ 10 - 0
tnc-sms/src/main/java/org/jeecg/modules/paybill/mapper/SmsPayBillPhoneMapper.java

@@ -0,0 +1,10 @@
+package org.jeecg.modules.paybill.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import org.apache.ibatis.annotations.Mapper;
+import org.jeecg.modules.paybill.entity.SmsPayBillPhone;
+
+@Mapper
+public interface SmsPayBillPhoneMapper extends BaseMapper<SmsPayBillPhone> {
+}
+

+ 13 - 0
tnc-sms/src/main/java/org/jeecg/modules/paybill/mapper/SysDepartSimpleMapper.java

@@ -0,0 +1,13 @@
+package org.jeecg.modules.paybill.mapper;
+
+import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Param;
+import org.apache.ibatis.annotations.Select;
+
+@Mapper
+public interface SysDepartSimpleMapper {
+
+    @Select("select depart_name from sys_depart where org_code = #{orgCode} and del_flag = '0' limit 1")
+    String findDepartNameByOrgCode(@Param("orgCode") String orgCode);
+}
+

+ 37 - 0
tnc-sms/src/main/java/org/jeecg/modules/paybill/service/ISmsPayBillService.java

@@ -0,0 +1,37 @@
+package org.jeecg.modules.paybill.service;
+
+import com.baomidou.mybatisplus.core.metadata.IPage;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.baomidou.mybatisplus.extension.service.IService;
+import org.jeecg.modules.paybill.entity.SmsPayBill;
+import org.jeecg.modules.paybill.vo.PayBillDetailVo;
+import org.jeecg.modules.paybill.vo.PayBillExportRow;
+import org.jeecg.modules.paybill.vo.PayBillSaveVo;
+import org.jeecg.modules.paybill.vo.PayBillStatusCountVo;
+
+import java.util.List;
+import java.util.Map;
+
+public interface ISmsPayBillService extends IService<SmsPayBill> {
+
+    String saveOrUpdateDraft(PayBillSaveVo vo);
+
+    String submit(PayBillSaveVo vo);
+
+    IPage<SmsPayBill> pageList(SmsPayBill query, Page<SmsPayBill> page, Map<String, String[]> parameterMap);
+
+    PayBillStatusCountVo statusCount(SmsPayBill query, Map<String, String[]> parameterMap);
+
+    PayBillDetailVo detail(String id);
+
+    List<PayBillExportRow> exportList(SmsPayBill query, Map<String, String[]> parameterMap);
+
+    void auditLevel1(PayBillSaveVo vo, boolean pass, String opinion);
+
+    void auditLevel2(String id, boolean pass, String opinion);
+
+    void reject(String id, String opinion);
+
+    void cancel(String id, String reason);
+}
+

+ 497 - 0
tnc-sms/src/main/java/org/jeecg/modules/paybill/service/impl/SmsPayBillServiceImpl.java

@@ -0,0 +1,497 @@
+package org.jeecg.modules.paybill.service.impl;
+
+import cn.hutool.core.collection.CollUtil;
+import cn.hutool.core.util.StrUtil;
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
+import com.baomidou.mybatisplus.core.metadata.IPage;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
+import lombok.extern.slf4j.Slf4j;
+import org.apache.shiro.SecurityUtils;
+import org.jeecg.common.constant.CommonConstant;
+import org.jeecg.common.system.query.QueryGenerator;
+import org.jeecg.common.system.vo.LoginUser;
+import org.jeecg.modules.paybill.entity.SmsPayBill;
+import org.jeecg.modules.paybill.entity.SmsPayBillFile;
+import org.jeecg.modules.paybill.entity.SmsPayBillItem;
+import org.jeecg.modules.paybill.entity.SmsPayBillLog;
+import org.jeecg.modules.paybill.entity.SmsPayBillPhone;
+import org.jeecg.modules.paybill.mapper.SmsPayBillFileMapper;
+import org.jeecg.modules.paybill.mapper.SmsPayBillItemMapper;
+import org.jeecg.modules.paybill.mapper.SmsPayBillLogMapper;
+import org.jeecg.modules.paybill.mapper.SmsPayBillMapper;
+import org.jeecg.modules.paybill.mapper.SmsPayBillPhoneMapper;
+import org.jeecg.modules.paybill.mapper.SysDepartSimpleMapper;
+import org.jeecg.modules.paybill.service.ISmsPayBillService;
+import org.jeecg.modules.paybill.vo.*;
+import org.springframework.beans.BeanUtils;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.time.LocalDate;
+import java.time.ZoneId;
+import java.time.format.DateTimeFormatter;
+import java.util.*;
+import java.util.regex.Pattern;
+import java.util.stream.Collectors;
+
+@Slf4j
+@Service
+public class SmsPayBillServiceImpl extends ServiceImpl<SmsPayBillMapper, SmsPayBill> implements ISmsPayBillService {
+
+    private static final Pattern PHONE_PATTERN = Pattern.compile("^1\\d{10}$");
+
+    @Autowired
+    private SmsPayBillItemMapper itemMapper;
+    @Autowired
+    private SmsPayBillPhoneMapper phoneMapper;
+    @Autowired
+    private SmsPayBillFileMapper fileMapper;
+    @Autowired
+    private SmsPayBillLogMapper logMapper;
+    @Autowired
+    private SysDepartSimpleMapper sysDepartSimpleMapper;
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public String saveOrUpdateDraft(PayBillSaveVo vo) {
+        SmsPayBill bill = upsertBillBase(vo, false);
+        bill.setStatus(-1);
+        this.updateById(bill);
+        saveItemsAndFiles(bill.getId(), vo);
+        insertLog(bill.getId(), "暂存", null);
+        return bill.getId();
+    }
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public String submit(PayBillSaveVo vo) {
+        SmsPayBill bill = upsertBillBase(vo, true);
+        if (StrUtil.isBlank(bill.getBillNo())) {
+            bill.setBillNo(generateBillNo());
+        }
+        bill.setStatus(0);
+        this.updateById(bill);
+        saveItemsAndFiles(bill.getId(), vo);
+        insertLog(bill.getId(), "提交审批", null);
+        return bill.getId();
+    }
+
+    @Override
+    public IPage<SmsPayBill> pageList(SmsPayBill query, Page<SmsPayBill> page, Map<String, String[]> parameterMap) {
+        QueryWrapper<SmsPayBill> qw = QueryGenerator.initQueryWrapper(query, parameterMap);
+        qw.eq("del_flag", CommonConstant.DEL_FLAG_0);
+        String[] tabArr = parameterMap != null ? parameterMap.get("tab") : null;
+        String tab = (tabArr != null && tabArr.length > 0) ? tabArr[0] : null;
+        if (StrUtil.isNotBlank(tab)) {
+            if ("pending".equals(tab)) {
+                qw.eq("status", 0);
+            } else if ("processing".equals(tab)) {
+                qw.in("status", Arrays.asList(1, 2));
+            } else if ("paid".equals(tab)) {
+                qw.eq("status", 5);
+            } else if ("auditResult".equals(tab)) {
+                qw.eq("status", 6);
+            }
+        }
+        appendBranchFilter(qw, parameterMap);
+        qw.orderByDesc("create_time");
+        return this.page(page, qw);
+    }
+
+    @Override
+    public PayBillStatusCountVo statusCount(SmsPayBill query, Map<String, String[]> parameterMap) {
+        QueryWrapper<SmsPayBill> baseQw = QueryGenerator.initQueryWrapper(query, parameterMap);
+        baseQw.eq("del_flag", CommonConstant.DEL_FLAG_0);
+        appendBranchFilter(baseQw, parameterMap);
+
+        PayBillStatusCountVo vo = new PayBillStatusCountVo();
+        vo.setAllCount(this.count(baseQw));
+
+        vo.setPendingCount(countByStatus(query, parameterMap, 0));
+        vo.setProcessingCount(countByStatusIn(query, parameterMap, 1, 2));
+        vo.setPaidCount(countByStatus(query, parameterMap, 5));
+        vo.setAuditResultCount(countByStatus(query, parameterMap, 6));
+        vo.setRejectedCount(countByStatus(query, parameterMap, 3));
+        vo.setCancelledCount(countByStatus(query, parameterMap, 4));
+
+        return vo;
+    }
+
+    private long countByStatus(SmsPayBill query, Map<String, String[]> parameterMap, int status) {
+        QueryWrapper<SmsPayBill> qw = QueryGenerator.initQueryWrapper(query, parameterMap);
+        qw.eq("del_flag", CommonConstant.DEL_FLAG_0).eq("status", status);
+        appendBranchFilter(qw, parameterMap);
+        return this.count(qw);
+    }
+
+    private long countByStatusIn(SmsPayBill query, Map<String, String[]> parameterMap, int... statuses) {
+        QueryWrapper<SmsPayBill> qw = QueryGenerator.initQueryWrapper(query, parameterMap);
+        qw.eq("del_flag", CommonConstant.DEL_FLAG_0).in("status", Arrays.asList(statuses));
+        appendBranchFilter(qw, parameterMap);
+        return this.count(qw);
+    }
+
+    /** 字段搜索时仅返回当前用户分公司数据 */
+    private void appendBranchFilter(QueryWrapper<SmsPayBill> qw, Map<String, String[]> parameterMap) {
+        if (parameterMap == null || parameterMap.isEmpty()) {
+            return;
+        }
+        boolean hasSearch = parameterMap.entrySet().stream()
+            .anyMatch(e -> e.getKey() != null && e.getValue() != null && e.getValue().length > 0
+                && StrUtil.isNotBlank(e.getValue()[0])
+                && !"pageNo".equals(e.getKey()) && !"pageSize".equals(e.getKey()) && !"column".equals(e.getKey()) && !"order".equals(e.getKey()));
+        if (!hasSearch) {
+            return;
+        }
+        LoginUser user = (LoginUser) SecurityUtils.getSubject().getPrincipal();
+        if (user != null && StrUtil.isNotBlank(user.getOrgCode())) {
+            qw.eq("pay_dept_code", user.getOrgCode());
+        }
+    }
+
+    @Override
+    public PayBillDetailVo detail(String id) {
+        SmsPayBill bill = this.getById(id);
+        if (bill == null) {
+            return null;
+        }
+
+        List<PayBillItemVo> items = itemMapper.selectList(new LambdaQueryWrapper<SmsPayBillItem>()
+                        .eq(SmsPayBillItem::getBillId, id)
+                        .orderByAsc(SmsPayBillItem::getSortOrder))
+                .stream().map(this::toItemVo).collect(Collectors.toList());
+
+        List<PayBillFileVo> files = fileMapper.selectList(new LambdaQueryWrapper<SmsPayBillFile>()
+                        .eq(SmsPayBillFile::getBillId, id))
+                .stream().map(this::toFileVo).collect(Collectors.toList());
+
+        List<SmsPayBillLog> logs = logMapper.selectList(new LambdaQueryWrapper<SmsPayBillLog>()
+                .eq(SmsPayBillLog::getBillId, id)
+                .orderByAsc(SmsPayBillLog::getCreateTime));
+
+        List<String> phones = items.stream().map(PayBillItemVo::getBusinessPhone).filter(StrUtil::isNotBlank).distinct().collect(Collectors.toList());
+        List<PayBillWarningVo> warnings = buildWarningsFromPhones(phones);
+
+        PayBillDetailVo vo = new PayBillDetailVo();
+        vo.setBill(bill);
+        vo.setItems(items);
+        vo.setFiles(files);
+        vo.setLogs(logs);
+        vo.setWarnings(warnings);
+        return vo;
+    }
+
+    private PayBillItemVo toItemVo(SmsPayBillItem e) {
+        PayBillItemVo v = new PayBillItemVo();
+        v.setId(e.getId());
+        v.setBusinessPhone(e.getBusinessPhone());
+        v.setContractNo(e.getContractNo());
+        v.setPayAmount(e.getPayAmount());
+        v.setPayPeriod(e.getPayPeriod());
+        v.setPayDesc(e.getPayDesc());
+        v.setPaySceneFirst(e.getPaySceneFirst());
+        v.setPaySceneSecond(e.getPaySceneSecond());
+        v.setComplaintOrderNo(e.getComplaintOrderNo());
+        v.setOaBillNo(e.getOaBillNo());
+        v.setRemark(e.getRemark());
+        v.setSortOrder(e.getSortOrder());
+        return v;
+    }
+
+    private PayBillFileVo toFileVo(SmsPayBillFile f) {
+        PayBillFileVo fv = new PayBillFileVo();
+        fv.setId(f.getId());
+        fv.setFileType(f.getFileType());
+        fv.setFileTitle(f.getFileTitle());
+        fv.setFileName(f.getFileName());
+        fv.setFileUrl(f.getFileUrl());
+        fv.setFileSize(f.getFileSize());
+        return fv;
+    }
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public void auditLevel1(PayBillSaveVo vo, boolean pass, String opinion) {
+        if (StrUtil.isBlank(vo.getId())) {
+            throw new IllegalArgumentException("id不能为空");
+        }
+        SmsPayBill billDb = this.getById(vo.getId());
+        assertCanAudit(billDb);
+        if (!(billDb.getStatus() == 0)) {
+            throw new IllegalStateException("当前状态不允许一级审核");
+        }
+
+        if (pass) {
+            if (CollUtil.isNotEmpty(vo.getItems())) {
+                SmsPayBill toUpdate = new SmsPayBill();
+                BeanUtils.copyProperties(upsertBillBase(vo, false), toUpdate);
+                toUpdate.setId(billDb.getId());
+                toUpdate.setStatus(1);
+                this.updateById(toUpdate);
+                saveItemsAndFiles(billDb.getId(), vo);
+            } else {
+                billDb.setStatus(1);
+                this.updateById(billDb);
+            }
+            insertLog(billDb.getId(), "一级通过", opinion);
+        } else {
+            billDb.setStatus(3);
+            this.updateById(billDb);
+            insertLog(billDb.getId(), "一级驳回", opinion);
+        }
+    }
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public void auditLevel2(String id, boolean pass, String opinion) {
+        SmsPayBill billDb = this.getById(id);
+        assertCanAudit(billDb);
+        if (!(billDb.getStatus() == 1)) {
+            throw new IllegalStateException("当前状态不允许二级审核");
+        }
+        if (pass) {
+            billDb.setStatus(2);
+            billDb.setPayTime(new Date());
+            this.updateById(billDb);
+            insertLog(id, "二级通过", opinion);
+        } else {
+            billDb.setStatus(0);
+            this.updateById(billDb);
+            insertLog(id, "二级驳回至一级", opinion);
+        }
+    }
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public void reject(String id, String opinion) {
+        SmsPayBill billDb = this.getById(id);
+        assertCanAudit(billDb);
+        if (billDb.getStatus() == 1) {
+            billDb.setStatus(0);
+            this.updateById(billDb);
+            insertLog(id, "驳回至一级", opinion);
+            return;
+        }
+        if (billDb.getStatus() == 0) {
+            billDb.setStatus(3);
+            this.updateById(billDb);
+            insertLog(id, "驳回", opinion);
+            return;
+        }
+        throw new IllegalStateException("当前状态不允许驳回");
+    }
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public void cancel(String id, String reason) {
+        SmsPayBill billDb = this.getById(id);
+        if (billDb == null) {
+            throw new IllegalArgumentException("单据不存在");
+        }
+        if (!(billDb.getStatus() == 0 || billDb.getStatus() == 1)) {
+            throw new IllegalStateException("仅未审核前允许取消");
+        }
+        billDb.setStatus(4);
+        this.updateById(billDb);
+        insertLog(id, "取消", reason);
+    }
+
+    private SmsPayBill upsertBillBase(PayBillSaveVo vo, boolean applyDefaultDept) {
+        validate(vo);
+        SmsPayBill bill = new SmsPayBill();
+        bill.setId(vo.getId());
+        bill.setPayDeptCode(vo.getPayDeptCode());
+        bill.setCustomerName(vo.getCustomerName());
+        bill.setBillNo(vo.getBillNo());
+
+        LoginUser sysUser = (LoginUser) SecurityUtils.getSubject().getPrincipal();
+        if (applyDefaultDept || StrUtil.isBlank(bill.getPayDeptCode())) {
+            bill.setPayDeptCode(sysUser != null ? sysUser.getOrgCode() : null);
+        }
+
+        if (StrUtil.isBlank(vo.getId())) {
+            bill.setStatus(0);
+            this.save(bill);
+        } else {
+            this.updateById(bill);
+        }
+        return bill;
+    }
+
+    private void saveItemsAndFiles(String billId, PayBillSaveVo vo) {
+        itemMapper.delete(new LambdaQueryWrapper<SmsPayBillItem>().eq(SmsPayBillItem::getBillId, billId));
+        if (CollUtil.isNotEmpty(vo.getItems())) {
+            int order = 0;
+            for (PayBillItemVo iv : vo.getItems()) {
+                if (iv == null || StrUtil.isBlank(iv.getBusinessPhone())) {
+                    continue;
+                }
+                SmsPayBillItem e = new SmsPayBillItem();
+                e.setBillId(billId);
+                e.setBusinessPhone(iv.getBusinessPhone().trim());
+                e.setContractNo(iv.getContractNo());
+                e.setPayAmount(iv.getPayAmount());
+                e.setPayPeriod(iv.getPayPeriod());
+                e.setPayDesc(iv.getPayDesc());
+                e.setPaySceneFirst(iv.getPaySceneFirst());
+                e.setPaySceneSecond(iv.getPaySceneSecond());
+                e.setComplaintOrderNo(iv.getComplaintOrderNo());
+                e.setOaBillNo(iv.getOaBillNo());
+                e.setRemark(iv.getRemark());
+                e.setSortOrder(iv.getSortOrder() != null ? iv.getSortOrder() : order);
+                order++;
+                itemMapper.insert(e);
+            }
+        }
+
+        fileMapper.delete(new LambdaQueryWrapper<SmsPayBillFile>().eq(SmsPayBillFile::getBillId, billId));
+        if (CollUtil.isNotEmpty(vo.getFiles())) {
+            for (PayBillFileVo fv : vo.getFiles()) {
+                if (fv == null || StrUtil.isBlank(fv.getFileUrl())) {
+                    continue;
+                }
+                SmsPayBillFile f = new SmsPayBillFile();
+                f.setBillId(billId);
+                f.setFileType(fv.getFileType());
+                f.setFileTitle(fv.getFileTitle());
+                f.setFileName(fv.getFileName());
+                f.setFileUrl(fv.getFileUrl());
+                f.setFileSize(fv.getFileSize());
+                fileMapper.insert(f);
+            }
+        }
+    }
+
+    private void validate(PayBillSaveVo vo) {
+        if (vo == null) {
+            throw new IllegalArgumentException("参数为空");
+        }
+        if (CollUtil.isEmpty(vo.getItems())) {
+            throw new IllegalArgumentException("至少添加一条赔付明细");
+        }
+        for (PayBillItemVo iv : vo.getItems()) {
+            if (iv == null || StrUtil.isBlank(iv.getBusinessPhone())) {
+                continue;
+            }
+            if (!PHONE_PATTERN.matcher(iv.getBusinessPhone().trim()).matches()) {
+                throw new IllegalArgumentException("业务号码格式不正确: " + iv.getBusinessPhone());
+            }
+        }
+    }
+
+    private void insertLog(String billId, String type, String content) {
+        try {
+            LoginUser sysUser = (LoginUser) SecurityUtils.getSubject().getPrincipal();
+            SmsPayBillLog logEntity = new SmsPayBillLog();
+            logEntity.setBillId(billId);
+            logEntity.setOpType(type);
+            logEntity.setOpContent(content);
+            logEntity.setOpUser(sysUser != null ? sysUser.getUsername() : null);
+            logEntity.setCreateTime(new Date());
+            logMapper.insert(logEntity);
+        } catch (Exception e) {
+            log.warn("插入赔付单日志失败", e);
+        }
+    }
+
+    private List<PayBillWarningVo> buildWarningsFromPhones(List<String> phones) {
+        if (CollUtil.isEmpty(phones)) {
+            return Collections.emptyList();
+        }
+        Date fromDate = Date.from(LocalDate.now().minusDays(365).atStartOfDay(ZoneId.systemDefault()).toInstant());
+
+        List<SmsPayBillItem> itemList = itemMapper.selectList(new LambdaQueryWrapper<SmsPayBillItem>()
+                .in(SmsPayBillItem::getBusinessPhone, phones));
+        if (CollUtil.isEmpty(itemList)) {
+            return Collections.emptyList();
+        }
+
+        Map<String, List<String>> phoneToBillIds = itemList.stream().collect(Collectors.groupingBy(SmsPayBillItem::getBusinessPhone,
+                Collectors.mapping(SmsPayBillItem::getBillId, Collectors.toList())));
+
+        List<String> allBillIds = itemList.stream().map(SmsPayBillItem::getBillId).distinct().collect(Collectors.toList());
+        if (CollUtil.isEmpty(allBillIds)) {
+            return Collections.emptyList();
+        }
+
+        List<SmsPayBill> passedBills = this.list(new LambdaQueryWrapper<SmsPayBill>()
+                .in(SmsPayBill::getId, allBillIds)
+                .eq(SmsPayBill::getStatus, 2)
+                .ge(SmsPayBill::getPayTime, fromDate));
+        Set<String> passedBillIds = passedBills.stream().map(SmsPayBill::getId).collect(Collectors.toSet());
+
+        List<PayBillWarningVo> warningVos = new ArrayList<>();
+        for (String phone : phones.stream().distinct().collect(Collectors.toList())) {
+            List<String> billIds = phoneToBillIds.getOrDefault(phone, Collections.emptyList());
+            long cnt = billIds.stream().filter(passedBillIds::contains).count();
+            PayBillWarningVo w = new PayBillWarningVo();
+            w.setPhone(phone);
+            w.setPassedCountIn365Days(cnt);
+            w.setTriggered(cnt >= 2);
+            warningVos.add(w);
+        }
+        return warningVos;
+    }
+
+    @Override
+    public List<PayBillExportRow> exportList(SmsPayBill query, Map<String, String[]> parameterMap) {
+        QueryWrapper<SmsPayBill> qw = QueryGenerator.initQueryWrapper(query, parameterMap);
+        qw.eq("del_flag", CommonConstant.DEL_FLAG_0);
+        appendBranchFilter(qw, parameterMap);
+        qw.orderByDesc("create_time");
+        List<SmsPayBill> bills = this.list(qw);
+        List<PayBillExportRow> rows = new ArrayList<>();
+        for (SmsPayBill bill : bills) {
+            String payDeptName = sysDepartSimpleMapper.findDepartNameByOrgCode(bill.getPayDeptCode());
+            if (payDeptName == null) payDeptName = bill.getPayDeptCode();
+            List<SmsPayBillItem> items = itemMapper.selectList(new LambdaQueryWrapper<SmsPayBillItem>()
+                    .eq(SmsPayBillItem::getBillId, bill.getId())
+                    .orderByAsc(SmsPayBillItem::getSortOrder));
+            if (CollUtil.isEmpty(items)) {
+                PayBillExportRow row = new PayBillExportRow();
+                row.setBillNo(bill.getBillNo());
+                row.setCustomerName(bill.getCustomerName());
+                row.setPayDeptName(payDeptName);
+                rows.add(row);
+            } else {
+                for (SmsPayBillItem item : items) {
+                    PayBillExportRow row = new PayBillExportRow();
+                    row.setBillNo(bill.getBillNo());
+                    row.setCustomerName(bill.getCustomerName());
+                    row.setPayDeptName(payDeptName);
+                    row.setBusinessPhone(item.getBusinessPhone());
+                    row.setContractNo(item.getContractNo());
+                    row.setPayAmount(item.getPayAmount());
+                    row.setPayPeriod(item.getPayPeriod());
+                    row.setPayDesc(item.getPayDesc());
+                    row.setPaySceneFirst(item.getPaySceneFirst());
+                    row.setPaySceneSecond(item.getPaySceneSecond());
+                    row.setComplaintOrderNo(item.getComplaintOrderNo());
+                    row.setOaBillNo(item.getOaBillNo());
+                    row.setRemark(item.getRemark());
+                    rows.add(row);
+                }
+            }
+        }
+        return rows;
+    }
+
+    private void assertCanAudit(SmsPayBill billDb) {
+        if (billDb == null) {
+            throw new IllegalArgumentException("单据不存在");
+        }
+        if (billDb.getStatus() == 4) {
+            throw new IllegalStateException("单据已取消");
+        }
+    }
+
+    private String generateBillNo() {
+        String date = LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE);
+        String rand = String.valueOf(new Random().nextInt(9000) + 1000);
+        return "PFD" + date + rand;
+    }
+}
+

+ 19 - 0
tnc-sms/src/main/java/org/jeecg/modules/paybill/vo/PayBillActionVo.java

@@ -0,0 +1,19 @@
+package org.jeecg.modules.paybill.vo;
+
+import lombok.Data;
+
+import java.io.Serializable;
+
+@Data
+public class PayBillActionVo implements Serializable {
+    private static final long serialVersionUID = 1L;
+
+    private String id;
+
+    private Boolean pass;
+
+    private String opinion;
+
+    private String reason;
+}
+

+ 24 - 0
tnc-sms/src/main/java/org/jeecg/modules/paybill/vo/PayBillDetailVo.java

@@ -0,0 +1,24 @@
+package org.jeecg.modules.paybill.vo;
+
+import lombok.Data;
+import org.jeecg.modules.paybill.entity.SmsPayBill;
+import org.jeecg.modules.paybill.entity.SmsPayBillLog;
+
+import java.io.Serializable;
+import java.util.List;
+
+@Data
+public class PayBillDetailVo implements Serializable {
+    private static final long serialVersionUID = 1L;
+
+    private SmsPayBill bill;
+
+    private List<PayBillItemVo> items;
+
+    private List<PayBillFileVo> files;
+
+    private List<SmsPayBillLog> logs;
+
+    private List<PayBillWarningVo> warnings;
+}
+

+ 42 - 0
tnc-sms/src/main/java/org/jeecg/modules/paybill/vo/PayBillExportRow.java

@@ -0,0 +1,42 @@
+package org.jeecg.modules.paybill.vo;
+
+import lombok.Data;
+import org.jeecgframework.poi.excel.annotation.Excel;
+
+import java.io.Serializable;
+import java.math.BigDecimal;
+
+/**
+ * 赔付单导出行:按明细条数导出,不合并单元格。每行=主表信息+一条明细
+ */
+@Data
+public class PayBillExportRow implements Serializable {
+    private static final long serialVersionUID = 1L;
+
+    @Excel(name = "审批单号", width = 18)
+    private String billNo;
+    @Excel(name = "客户名称", width = 20)
+    private String customerName;
+    @Excel(name = "赔付申请部门", width = 20)
+    private String payDeptName;
+    @Excel(name = "业务号码", width = 14)
+    private String businessPhone;
+    @Excel(name = "合同号", width = 18)
+    private String contractNo;
+    @Excel(name = "赔付金额(元)", width = 14)
+    private BigDecimal payAmount;
+    @Excel(name = "赔付账期", width = 14)
+    private String payPeriod;
+    @Excel(name = "赔付描述", width = 24)
+    private String payDesc;
+    @Excel(name = "赔付场景一级", width = 14)
+    private String paySceneFirst;
+    @Excel(name = "赔付场景二级", width = 14)
+    private String paySceneSecond;
+    @Excel(name = "赔付工单编号", width = 18)
+    private String complaintOrderNo;
+    @Excel(name = "OA审批单编号", width = 18)
+    private String oaBillNo;
+    @Excel(name = "备注", width = 20)
+    private String remark;
+}

+ 20 - 0
tnc-sms/src/main/java/org/jeecg/modules/paybill/vo/PayBillFileVo.java

@@ -0,0 +1,20 @@
+package org.jeecg.modules.paybill.vo;
+
+import lombok.Data;
+
+import java.io.Serializable;
+
+@Data
+public class PayBillFileVo implements Serializable {
+    private static final long serialVersionUID = 1L;
+
+    private String id;
+    /** 材料种类: approval/intent/pay_work/license/other */
+    private String fileType;
+    /** 材料命名 */
+    private String fileTitle;
+    private String fileName;
+    private String fileUrl;
+    private Long fileSize;
+}
+

+ 24 - 0
tnc-sms/src/main/java/org/jeecg/modules/paybill/vo/PayBillItemVo.java

@@ -0,0 +1,24 @@
+package org.jeecg.modules.paybill.vo;
+
+import lombok.Data;
+
+import java.io.Serializable;
+import java.math.BigDecimal;
+
+@Data
+public class PayBillItemVo implements Serializable {
+    private static final long serialVersionUID = 1L;
+
+    private String id;
+    private String businessPhone;
+    private String contractNo;
+    private BigDecimal payAmount;
+    private String payPeriod;
+    private String payDesc;
+    private String paySceneFirst;
+    private String paySceneSecond;
+    private String complaintOrderNo;
+    private String oaBillNo;
+    private String remark;
+    private Integer sortOrder;
+}

+ 22 - 0
tnc-sms/src/main/java/org/jeecg/modules/paybill/vo/PayBillSaveVo.java

@@ -0,0 +1,22 @@
+package org.jeecg.modules.paybill.vo;
+
+import lombok.Data;
+
+import java.io.Serializable;
+import java.util.List;
+
+@Data
+public class PayBillSaveVo implements Serializable {
+    private static final long serialVersionUID = 1L;
+
+    private String id;
+    private String payDeptCode;
+    private String customerName;
+    private String billNo;
+
+    /** 明细行:业务号码、合同号、赔付金额、赔付账期、赔付描述、赔付场景、工单编号、OA审批单编号、备注 */
+    private List<PayBillItemVo> items;
+
+    private List<PayBillFileVo> files;
+}
+

+ 23 - 0
tnc-sms/src/main/java/org/jeecg/modules/paybill/vo/PayBillStatusCountVo.java

@@ -0,0 +1,23 @@
+package org.jeecg.modules.paybill.vo;
+
+import lombok.Data;
+
+import java.io.Serializable;
+
+@Data
+public class PayBillStatusCountVo implements Serializable {
+    private static final long serialVersionUID = 1L;
+
+    private long allCount;
+    /** 待审核:仅 status=0 */
+    private long pendingCount;
+    /** 处理中:status in (1,2) */
+    private long processingCount;
+    /** 已赔付:status=5 */
+    private long paidCount;
+    /** 稽核结果:status=6 */
+    private long auditResultCount;
+    private long rejectedCount;
+    private long cancelledCount;
+}
+

+ 15 - 0
tnc-sms/src/main/java/org/jeecg/modules/paybill/vo/PayBillWarningVo.java

@@ -0,0 +1,15 @@
+package org.jeecg.modules.paybill.vo;
+
+import lombok.Data;
+
+import java.io.Serializable;
+
+@Data
+public class PayBillWarningVo implements Serializable {
+    private static final long serialVersionUID = 1L;
+
+    private String phone;
+    private long passedCountIn365Days;
+    private boolean triggered;
+}
+

+ 14 - 0
tnc-sms/src/main/java/org/jeecg/modules/paybill/vo/UserDeptVo.java

@@ -0,0 +1,14 @@
+package org.jeecg.modules.paybill.vo;
+
+import lombok.Data;
+
+import java.io.Serializable;
+
+@Data
+public class UserDeptVo implements Serializable {
+    private static final long serialVersionUID = 1L;
+
+    private String orgCode;
+    private String departName;
+}
+

+ 32 - 0
tnc-sms/src/main/java/org/jeecg/modules/quartz/job/ArchiveSyncJob.java

@@ -0,0 +1,32 @@
+package org.jeecg.modules.quartz.job;
+
+import lombok.extern.slf4j.Slf4j;
+import org.jeecg.modules.archive.service.IArchiveAccountService;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.scheduling.annotation.Scheduled;
+import org.springframework.stereotype.Component;
+
+/**
+ * 档案账号同步定时任务:定期将主系统 sys_user 同步至档案账号表
+ */
+@Slf4j
+@Component
+public class ArchiveSyncJob {
+
+    @Autowired
+    private IArchiveAccountService archiveAccountService;
+
+    /**
+     * 每日凌晨 2 点执行
+     */
+    @Scheduled(cron = "0 0 2 * * ?")
+    public void execute() {
+        log.info("--- 档案账号同步定时任务开始执行 ---");
+        try {
+            archiveAccountService.syncNowFromMainSystem();
+            log.info("--- 档案账号同步定时任务执行完成 ---");
+        } catch (Exception e) {
+            log.error("档案账号同步失败", e);
+        }
+    }
+}

+ 143 - 0
tnc-sms/src/main/java/org/jeecg/modules/quartz/job/BillSendJob.java

@@ -0,0 +1,143 @@
+package org.jeecg.modules.quartz.job;
+
+import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
+import lombok.extern.slf4j.Slf4j;
+import org.apache.commons.lang.StringUtils;
+import org.jeecg.modules.bill.entity.BillBatch;
+import org.jeecg.modules.bill.entity.BillCustomer;
+import org.jeecg.modules.bill.mapper.BillCustomerMapper;
+import org.jeecg.modules.bill.service.IBillBatchService;
+import org.jeecg.modules.bill.service.IBillCustomerService;
+import org.jeecg.modules.bill.service.IBillEmailService;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.scheduling.annotation.Scheduled;
+import org.springframework.stereotype.Component;
+
+import java.util.Calendar;
+import java.util.Date;
+import java.util.List;
+
+/**
+ * @Description: 账单发送定时任务 (Spring Scheduled 注解版)
+ * @Author: jeecg-boot
+ * @Date: 2025-01-27
+ * @Version: V1.1
+ */
+@Slf4j
+@Component
+public class BillSendJob {
+
+    @Autowired
+    private IBillBatchService billBatchService;
+
+    @Autowired
+    private IBillCustomerService billCustomerService;
+
+    @Autowired
+    private IBillEmailService billEmailService;
+
+    @Autowired
+    private BillCustomerMapper billCustomerMapper;
+
+    /**
+     * 每5分钟执行一次
+     */
+    @Scheduled(cron = "0 */1 * * * ?")
+    public void execute() {
+        log.info("--- 账单发送定时任务(注解版)开始执行 ---");
+
+        try {
+            // 1. 查询所有待发送批次
+            QueryWrapper<BillBatch> queryWrapper = new QueryWrapper<>();
+            List<BillBatch> batches = billBatchService.list(queryWrapper);
+            log.info("扫描到总批次数: {}", batches.size());
+
+            Calendar cal = Calendar.getInstance();
+            int currentDay = cal.get(Calendar.DAY_OF_MONTH);
+
+            for (BillBatch batch : batches) {
+                try {
+                    boolean isToday = false;
+                    String sendDateCfg = batch.getSendDate();
+                    if (StringUtils.isBlank(sendDateCfg)) continue;
+
+                    // 校验是否到达发送日期
+                    if (batch.getSendRuleType() == 1) {
+                        // 固定年月日:判断配置日期是否是今天
+                        Date targetDate = billBatchService.calculateSendDate(1, sendDateCfg);
+                        if (targetDate != null && org.apache.commons.lang.time.DateUtils.isSameDay(targetDate, new Date())) {
+                            isToday = true;
+                        }
+                    } else if (batch.getSendRuleType() == 2) {
+                        // 每月几日
+                        int targetDay = Integer.parseInt(sendDateCfg);
+                        if (currentDay == targetDay) {
+                            isToday = true;
+                        }
+                    }
+
+                    if (!isToday) continue;
+
+                    log.info("批次 {} [{}] 命中今日发送规则,开始处理...", batch.getBatchNo(), batch.getId());
+
+                    // 2. 查询该批次下今日应发且未发送成功的客户
+                    QueryWrapper<BillCustomer> customerWrapper = new QueryWrapper<>();
+                    customerWrapper.eq("batch_id", batch.getId());
+                    customerWrapper.and(wrapper -> wrapper.isNull("send_status").or().ne("send_status", 1));
+                    List<BillCustomer> customers = billCustomerService.list(customerWrapper);
+                    
+                    if (customers.isEmpty()) {
+                        log.info("批次 {} 今日无待发送客户数据", batch.getBatchNo());
+                        continue;
+                    }
+
+                    log.info("批次 {} 待发送客户数: {}", batch.getBatchNo(), customers.size());
+
+                    int successCount = 0;
+                    int failCount = 0;
+
+                    for (BillCustomer customer : customers) {
+                        try {
+                            boolean success = billEmailService.sendBillEmail(customer);
+                            if (success) {
+                                customer.setSendStatus(1);
+                                customer.setSendTime(new Date());
+                                customer.setSendResult("定时发送成功");
+                                customer.setErrorMsg(null);
+                                successCount++;
+                            } else {
+                                customer.setSendStatus(2);
+                                customer.setSendTime(new Date());
+                                customer.setSendResult("定时发送失败");
+                                customer.setErrorMsg("邮件服务器返回失败");
+                                failCount++;
+                            }
+                            billCustomerService.updateById(customer);
+                        } catch (Exception e) {
+                            log.error("发送客户 {} 邮件异常: ", customer.getId(), e);
+                            customer.setSendStatus(2);
+                            customer.setSendTime(new Date());
+                            customer.setSendResult("定时发送异常");
+                            customer.setErrorMsg(e.getMessage());
+                            billCustomerService.updateById(customer);
+                            failCount++;
+                        }
+                    }
+
+                    // 3. 更新批次统计
+                    int sentCount = billCustomerMapper.countSentByBatchId(batch.getId());
+                    batch.setSentCount(sentCount);
+                    billBatchService.updateById(batch);
+
+                    log.info("批次 {} 发送统计: 成功 {}, 失败 {}", batch.getBatchNo(), successCount, failCount);
+                } catch (Exception e) {
+                    log.error("处理批次 {} 出错: ", batch.getId(), e);
+                }
+            }
+
+            log.info("--- 账单发送定时任务(注解版)执行完成 ---");
+        } catch (Exception e) {
+            log.error("账单发送定时任务全局异常: ", e);
+        }
+    }
+}