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;
}
@@ -56,6 +56,7 @@
<div class="attendee-section">
<div class="attendee-toolbar">
<el-button type="primary" size="small" :loading="attendeeLoading" @click="openAttendeeDialog()">+ 新增参会人</el-button>
<el-button size="small" @click="openAttendeeImport">批量导入</el-button>
<span class="toolbar-hint">按手机号定位; 手机号未注册将自动建 sys_user (用户名=密码=手机号)</span>
</div>
<!--
@@ -390,6 +391,35 @@
</template>
</el-dialog>
<!-- 参会人 Excel 批量导入 (沿用 dialog-import SKILL: v-if + on-error + 模板下载在框外 + ImportResultDialog) -->
<el-dialog v-model="importOpen" title="批量导入参会人" width="400px" append-to-body :close-on-click-modal="false">
<el-upload
v-if="importOpen"
ref="importUploadRef"
:limit="1"
:headers="importHeaders"
accept=".xlsx, .xls"
:action="importAction"
:on-progress="onImportProgress"
:on-success="onImportSuccess"
:on-error="onImportError"
:auto-upload="false"
drag
>
<el-icon class="el-icon--upload"><upload /></el-icon>
<div class="el-upload__text">将文件拖到此处<em>点击上传</em></div>
</el-upload>
<div style="margin-top:10px;font-size:13px;text-align:center">
<span style="color:#909399;margin-right:8px">仅允许导入 xlsxlsx 格式文件</span>
<el-link type="primary" :underline="false" @click="downloadAttendeeImportTpl">下载模板</el-link>
</div>
<template #footer>
<el-button @click="importOpen=false"> </el-button>
<el-button type="primary" :loading="importing" @click="submitImportFile"> </el-button>
</template>
</el-dialog>
<ImportResultDialog v-model="importResultOpen" :result="importResult" />
<div class="form-actions">
<el-button @click="goBack">返回</el-button>
</div>
@@ -405,7 +435,9 @@ import { listSupporters, listExecutor } from '@/api/system'
import { useUserStore } from '@/store/user'
import { ElMessage } from 'element-plus'
import OssFileUploader from '@/components/OssFileUploader.vue'
import ImportResultDialog from '@/components/ImportResultDialog.vue'
import { ElMessageBox } from 'element-plus'
import { Upload } from '@element-plus/icons-vue'
const route = useRoute()
const router = useRouter()
@@ -709,6 +741,70 @@ async function confirmDeleteAttendee(row) {
ElMessage.error(e?.msg || e?.message || '删除失败')
}
}
// ===================== 参会人 Excel 批量导入 (沿用 dialog-import SKILL) =====================
const importOpen = ref(false)
const importing = ref(false)
const importResultOpen = ref(false)
const importResult = ref(null)
const importUploadRef = ref(null)
// el-upload 用原生 XHR, 不走 axios interceptor, 必须显式带 Authorization
const importHeaders = computed(() => ({ Authorization: 'Bearer ' + (localStorage.getItem('ry_token') || '') }))
// 后端 baseURL 与 request.js 一致 (/dev-api), 拼绝对路径给 :action; meetingId 走 query 串
const importAction = computed(() => `/dev-api/business/meetingAttendee/importData?meetingId=${meetingId.value}`)
function openAttendeeImport() {
importResult.value = null
importOpen.value = true
}
function downloadAttendeeImportTpl() {
fetch('/dev-api/business/meetingAttendee/importTemplate', { headers: importHeaders.value })
.then(r => r.blob())
.then(blob => {
const a = document.createElement('a')
a.href = URL.createObjectURL(blob)
a.download = '参会人批量导入模板.xlsx'
a.click()
})
.catch(() => ElMessage.error('模板下载失败'))
}
function submitImportFile() {
importUploadRef.value?.submit()
}
function onImportProgress() { importing.value = true }
function onImportSuccess(res) {
importing.value = false
const result = (res && res.data) ? res.data : res
if (!result || typeof result.okNum !== 'number') {
ElMessage.error(res?.msg || '导入失败, 返回数据异常')
importResult.value = null
return
}
importResult.value = result
importOpen.value = false
importResultOpen.value = true
// 刷新列表 (新参会人会出现)
loadAttendees()
}
function onImportError(err) {
importing.value = false
let msg = '上传失败, 请重试'
if (err && err.response != null) {
try {
const body = typeof err.response === 'string' ? JSON.parse(err.response) : err.response
msg = body.msg || msg
} catch { /* JSON 解析失败用兜底 */ }
} else if (err && err.message) {
msg = err.message
}
ElMessage.error(msg)
importResult.value = null
}
async function loadStaff() {
try {
const [sp, ex] = await Promise.all([