feat(attendee): Excel 批量导入 (后端 /importData + /importTemplate + 5邀请差集推送, 前端 dialog-upload 套用 dialog-import SKILL)

This commit is contained in:
郭庆泰
2026-08-22 16:35:50 +08:00
parent da7c19af6c
commit 0dafb47e7d
5 changed files with 352 additions and 1 deletions
@@ -1,19 +1,27 @@
package com.ruoyi.business.controller;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import com.ruoyi.common.annotation.Log;
import com.ruoyi.common.core.controller.BaseController;
import com.ruoyi.common.core.domain.AjaxResult;
import com.ruoyi.common.enums.BusinessType;
import com.ruoyi.common.utils.SecurityUtils;
import com.ruoyi.common.utils.poi.ExcelUtil;
import com.ruoyi.business.domain.BizMeeting;
import com.ruoyi.business.domain.BizMeetingAttendee;
import com.ruoyi.business.domain.dto.ImportResult;
import com.ruoyi.business.domain.vo.BizMeetingAttendeeImportVo;
import com.ruoyi.business.notify.BizNotifyService;
import com.ruoyi.business.service.IBizMeetingAttendeeService;
import com.ruoyi.business.service.IBizMeetingService;
import javax.servlet.http.HttpServletResponse;
/**
* 会议参会人 Controller (劳务协议 / 手写签名 + 管理端 CRUD)
*
@@ -119,6 +127,56 @@ public class BizMeetingAttendeeController extends BaseController {
return toAjax(attendeeService.updateHandsign(entity));
}
/**
* 批量导入参会人 — Excel 模板下载 (Manager/Admin 在 MeetingDetail "批量导入" 按钮触发).
*
* <p>复用 {@link BizMeetingAttendeeImportVo} 的 @Excel 注解自动生成表头.
*/
@GetMapping("/importTemplate")
public void importTemplate(HttpServletResponse response) {
ExcelUtil<BizMeetingAttendeeImportVo> util = new ExcelUtil<>(BizMeetingAttendeeImportVo.class);
util.importTemplateExcel(response, "参会人导入");
}
/**
* 批量导入参会人 — 上传 Excel + 解析入库 + #5 会议邀请差集推送.
*
* <p>三步:
* <ol>
* <li>查"导入前"该会议已存在的参会人 userIds (Set)</li>
* <li>调 {@link IBizMeetingAttendeeService#importFromExcel} 逐行处理, 失败的进 ngList</li>
* <li>查"导入后"该会议 userIds, 与"前"做差集 → 仅给"新加入"的 userId 推 #5 会议邀请</li>
* </ol>
*
* <p>返回 ImportResult { okNum, ngNum, ngList: [{rowNum, message}] }, 前端 ImportResultDialog 直接渲染.
*
* @param meetingId 会议 id (query 传, 决定导入到哪个会议)
*/
@Log(title = "参会人批量导入", businessType = BusinessType.IMPORT)
@PostMapping("/importData")
public AjaxResult importData(@RequestParam("file") MultipartFile file,
@RequestParam("meetingId") Long meetingId) throws Exception {
// 1. 导入前快照
Set<Long> preUserIds = new HashSet<>(attendeeService.selectUserIdsByMeetingId(meetingId));
// 2. 解析 + 入库
ImportResult result = attendeeService.importFromExcel(file, meetingId, SecurityUtils.getUsername());
// 3. 差集 → 仅对"新加入" userId 推 #5 邀请
Set<Long> postUserIds = new HashSet<>(attendeeService.selectUserIdsByMeetingId(meetingId));
postUserIds.removeAll(preUserIds);
if (!postUserIds.isEmpty()) {
BizMeeting m = bizMeetingService.getById(meetingId);
String meetingName = m != null ? m.getMeetingName() : null;
java.util.Date startTime = m != null ? m.getStartTime() : null;
for (Long uid : postUserIds) {
bizNotifyService.meetingInvitation(uid, meetingId, meetingName, startTime);
}
}
return success(result);
}
/**
* 更新劳务协议 URL (OSS 上传后调本接口).
*
@@ -0,0 +1,113 @@
package com.ruoyi.business.domain.vo;
import java.math.BigDecimal;
import com.ruoyi.common.annotation.Excel;
/**
* 会议参会人 Excel 导入 VO
*
* <p>仅用于 Excel 批量导入 + 模板下载. 字段顺序与 Excel 列一一对应, 调 sort 时同步改模板下载.
*
* <p>关键约束:
* <ul>
* <li>手机号 必填, 11 位 — 按手机号查/建 sys_user (roleType='doctor')</li>
* <li>其它档案字段均 可选, 不填则存 NULL/0</li>
* <li>meetingId 不在 Excel 里, 通过 controller 的 ?meetingId=xxx query 传</li>
* <li>不导入签字字段 (handsign / labor_protocol / signed_at / signed_ip) — 由医生端流程触发</li>
* </ul>
*
* @author guoju
*/
public class BizMeetingAttendeeImportVo {
/** 手机号 (必填, 按此查/建 sys_user) */
@Excel(name = "手机号", sort = 1)
private String phone;
/** 姓名 */
@Excel(name = "姓名", sort = 2)
private String name;
/** 工作单位 */
@Excel(name = "工作单位", sort = 3)
private String workUnit;
/** 科室 */
@Excel(name = "科室", sort = 4)
private String department;
/** 职称 */
@Excel(name = "职称", sort = 5)
private String title;
/** 身份证号 */
@Excel(name = "身份证号", sort = 6)
private String idCard;
/** 银行 */
@Excel(name = "银行", sort = 7)
private String bankName;
/** 银行卡号码 */
@Excel(name = "银行卡号码", sort = 8)
private String bankCard;
/** 开户行 */
@Excel(name = "开户行", sort = 9)
private String bankBranch;
/** 角色 (原"劳务形式": 授课/主持/评审...) */
@Excel(name = "角色", sort = 10)
private String laborForm;
/** 应发金额 (decimal, 元) */
@Excel(name = "应发金额", sort = 11)
private BigDecimal feePreTax;
/** 个税税金 (decimal, 元) */
@Excel(name = "个税税金", sort = 12)
private BigDecimal tax;
/** 增值税及附加成本 (decimal, 元) */
@Excel(name = "增值税及附加成本", sort = 13)
private BigDecimal vatAndSurcharge;
/** 实发金额 (decimal, 元) */
@Excel(name = "实发金额", sort = 14)
private BigDecimal fee;
/** 摘要 */
@Excel(name = "摘要", sort = 15)
private String summary;
public String getPhone() { return phone; }
public void setPhone(String phone) { this.phone = phone; }
public String getName() { return name; }
public void setName(String name) { this.name = name; }
public String getWorkUnit() { return workUnit; }
public void setWorkUnit(String workUnit) { this.workUnit = workUnit; }
public String getDepartment() { return department; }
public void setDepartment(String department) { this.department = department; }
public String getTitle() { return title; }
public void setTitle(String title) { this.title = title; }
public String getIdCard() { return idCard; }
public void setIdCard(String idCard) { this.idCard = idCard; }
public String getBankName() { return bankName; }
public void setBankName(String bankName) { this.bankName = bankName; }
public String getBankCard() { return bankCard; }
public void setBankCard(String bankCard) { this.bankCard = bankCard; }
public String getBankBranch() { return bankBranch; }
public void setBankBranch(String bankBranch) { this.bankBranch = bankBranch; }
public String getLaborForm() { return laborForm; }
public void setLaborForm(String laborForm) { this.laborForm = laborForm; }
public BigDecimal getFeePreTax() { return feePreTax; }
public void setFeePreTax(BigDecimal feePreTax) { this.feePreTax = feePreTax; }
public BigDecimal getTax() { return tax; }
public void setTax(BigDecimal tax) { this.tax = tax; }
public BigDecimal getVatAndSurcharge() { return vatAndSurcharge; }
public void setVatAndSurcharge(BigDecimal vatAndSurcharge) { this.vatAndSurcharge = vatAndSurcharge; }
public BigDecimal getFee() { return fee; }
public void setFee(BigDecimal fee) { this.fee = fee; }
public String getSummary() { return summary; }
public void setSummary(String summary) { this.summary = summary; }
}
@@ -1,7 +1,9 @@
package com.ruoyi.business.service;
import java.util.List;
import org.springframework.web.multipart.MultipartFile;
import com.ruoyi.business.domain.BizMeetingAttendee;
import com.ruoyi.business.domain.dto.ImportResult;
public interface IBizMeetingAttendeeService {
int insert(BizMeetingAttendee entity);
@@ -45,4 +47,17 @@ public interface IBizMeetingAttendeeService {
* 列表实现层直接返 mapper 结果; 业务方通常用 {@code new HashSet<>(service.selectUserIdsByMeetingId(mid))} 做 contains 判断.
*/
List<Long> selectUserIdsByMeetingId(Long meetingId);
/**
* Excel 批量导入参会人 (MeetingDetail "批量导入" 按钮).
*
* <p>每行处理: 解析 → 校验手机号 → 复用 {@link #insertByPhoneWithProfile} (查/建 sys_user + 写 attendee 行) →
* 单行失败不入 ngList, 其它行继续. 返回的 ImportResult 含 okNum/ngNum/ngList 给前端 ImportResultDialog.
*
* <p>注意: 本方法**只插数据**, 不触发 #5 会议邀请通知. 调用方 (controller) 拿到成功行后统一调
* {@code bizNotifyService.meetingInvitation} 推通知, 避免在 service 层耦合 notify 依赖.
*
* @return ImportResult { okNum, ngNum, ngList: [{rowNum, message}] }
*/
ImportResult importFromExcel(MultipartFile file, Long meetingId, String operName) throws Exception;
}
@@ -6,12 +6,16 @@ import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
import com.ruoyi.business.domain.BizMeetingAttendee;
import com.ruoyi.business.domain.dto.ImportResult;
import com.ruoyi.business.domain.vo.BizMeetingAttendeeImportVo;
import com.ruoyi.business.mapper.BizMeetingAttendeeMapper;
import com.ruoyi.business.service.IBizMeetingAttendeeService;
import com.ruoyi.common.core.domain.entity.SysUser;
import com.ruoyi.common.exception.ServiceException;
import com.ruoyi.common.utils.SecurityUtils;
import com.ruoyi.common.utils.poi.ExcelUtil;
import com.ruoyi.system.mapper.SysUserMapper;
import com.ruoyi.system.service.ISysUserService;
@@ -160,4 +164,69 @@ public class BizMeetingAttendeeServiceImpl implements IBizMeetingAttendeeService
public List<Long> selectUserIdsByMeetingId(Long meetingId) {
return mapper.selectUserIdsByMeetingId(meetingId);
}
}
/**
* 批量导入参会人 (Excel → biz_meeting_attendee).
*
* <p>复用 {@link #insertByPhoneWithProfile} 做单行处理, 让"按手机号查/建 sys_user + 写 attendee"
* 逻辑和单条新增一致. 单行失败只计入 ngList, 不中断其它行.
*
* <p>注意: 本方法只插数据, 不发 #5 通知. controller 在 import 前快照 userIds, import 后 diff,
* 只对"新加入"的 userId 调 notify, 避免给已存在的参会人重发邀请.
*/
@Override
public ImportResult importFromExcel(MultipartFile file, Long meetingId, String operName) throws Exception {
if (meetingId == null) throw new ServiceException("meetingId 不能为空");
ExcelUtil<BizMeetingAttendeeImportVo> util = new ExcelUtil<>(BizMeetingAttendeeImportVo.class);
List<BizMeetingAttendeeImportVo> rows = util.importExcel(file.getInputStream());
if (rows == null || rows.isEmpty()) {
throw new ServiceException("导入数据不能为空");
}
ImportResult result = new ImportResult();
for (int i = 0; i < rows.size(); i++) {
BizMeetingAttendeeImportVo vo = rows.get(i);
int rowNo = i + 2; // Excel 行号: 1=表头, 2=第一行数据
try {
// 行级校验: 手机号必填 + 格式
if (vo.getPhone() == null || vo.getPhone().trim().isEmpty()) {
result.fail(rowNo, "手机号不能为空");
continue;
}
String phone = vo.getPhone().trim();
if (!phone.matches("^1[3-9]\\d{9}$")) {
result.fail(rowNo, "手机号格式不正确: " + phone);
continue;
}
// 构造 attendee 实体, 复用 insertByPhoneWithProfile (内部查/建 sys_user + 写 attendee)
BizMeetingAttendee body = new BizMeetingAttendee();
body.setMeetingId(meetingId);
body.setPhone(phone);
body.setName(vo.getName());
body.setWorkUnit(vo.getWorkUnit());
body.setDepartment(vo.getDepartment());
body.setTitle(vo.getTitle());
body.setIdCard(vo.getIdCard());
body.setBankName(vo.getBankName());
body.setBankCard(vo.getBankCard());
body.setBankBranch(vo.getBankBranch());
body.setLaborForm(vo.getLaborForm());
body.setFeePreTax(vo.getFeePreTax());
body.setTax(vo.getTax());
body.setVatAndSurcharge(vo.getVatAndSurcharge());
body.setFee(vo.getFee());
body.setSummary(vo.getSummary());
insertByPhoneWithProfile(body); // 失败抛 ServiceException, 被 catch
result.ok();
} catch (Exception e) {
String msg = e.getMessage();
result.fail(rowNo, msg == null ? "导入失败" : msg);
}
}
log.info("[attendee] 批量导入完成 meetingId={} ok={} ng={}",
meetingId, result.getOkNum(), result.getNgNum());
return result;
}