feat(meeting): 会议详情页参会人 CRUD + BizProjectPlan audit 字段
- 后端: BizMeetingAttendee 增 list/add/update/delete 4 端点 按手机号定位 sys_user (无则新建, 用户名=密码=phone, role_type='doctor') Mapper: insertWithProfile (一次写完整档案) + deleteByPrimaryKey Service: insertByPhoneWithProfile (重复参会人抛友好异常) - #4 配套: BizProjectPlan.auditOpinion/auditBy/auditTime + 关联项目名 BizProjectPlan.projectName (LEFT JOIN biz_project) 用于 #4 通知 content - 前端 MeetingDetail.vue: 劳务材料 tab 内, 劳务明细表 file-row 下方 直接出表 (无表题), 含姓名/手机/单位/科室/职称/劳务费/签字状态/操作 新增/编辑 dialog 共用, 删除走 ElMessageBox.confirm 二次确认 - 触发 #5: 新参会人 POST 时也发会议邀请通知 (与 BizMeetingController.add 一致)
This commit is contained in:
+95
-9
@@ -8,19 +8,25 @@ 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.business.domain.BizMeeting;
|
||||
import com.ruoyi.business.domain.BizMeetingAttendee;
|
||||
import com.ruoyi.business.notify.BizNotifyService;
|
||||
import com.ruoyi.business.service.IBizMeetingAttendeeService;
|
||||
import com.ruoyi.business.service.IBizMeetingService;
|
||||
|
||||
/**
|
||||
* 会议参会人 Controller (劳务协议 / 手写签名)
|
||||
* 会议参会人 Controller (劳务协议 / 手写签名 + 管理端 CRUD)
|
||||
*
|
||||
* 公开端点 (任意登录用户可调):
|
||||
* - GET /business/meetingAttendee/unsigned 当前用户的"待签署"会议 (handsign 或 labor_protocol 为空)
|
||||
* - PUT /business/meetingAttendee/{id}/handsign 更新手写签名 (Base64, 直接存 DB)
|
||||
* - PUT /business/meetingAttendee/{id}/laborProtocol 更新劳务协议 URL (OSS)
|
||||
*
|
||||
* 管理端点 (后续 BizMeetingController.add/edit 调用):
|
||||
* - /business/meetingAttendee (CRUD)
|
||||
* 管理端点 (MeetingDetail 参会人 CRUD 用, admin/manager):
|
||||
* - GET /business/meetingAttendee/list/{meetingId} 会议下所有参会人
|
||||
* - POST /business/meetingAttendee 按手机号新增参会人 (定位/新建 sys_user)
|
||||
* - PUT /business/meetingAttendee 更新参会人档案
|
||||
* - DELETE /business/meetingAttendee/{id} 删除参会人 (单条)
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/business/meetingAttendee")
|
||||
@@ -28,6 +34,10 @@ public class BizMeetingAttendeeController extends BaseController {
|
||||
|
||||
@Autowired
|
||||
private IBizMeetingAttendeeService attendeeService;
|
||||
@Autowired
|
||||
private IBizMeetingService bizMeetingService;
|
||||
@Autowired
|
||||
private BizNotifyService bizNotifyService;
|
||||
|
||||
/**
|
||||
* 当前登录用户的"待签署"列表 (handsign 或 labor_protocol 任一为空)
|
||||
@@ -40,6 +50,64 @@ public class BizMeetingAttendeeController extends BaseController {
|
||||
return success(rows);
|
||||
}
|
||||
|
||||
/**
|
||||
* 管理端: 某会议的全部参会人 (MeetingDetail 参会人 CRUD 用).
|
||||
* 返回全字段, 前端按需展示.
|
||||
*/
|
||||
@GetMapping("/list/{meetingId}")
|
||||
public AjaxResult listByMeetingId(@PathVariable("meetingId") Long meetingId) {
|
||||
return success(attendeeService.selectByMeetingId(meetingId));
|
||||
}
|
||||
|
||||
/**
|
||||
* 管理端: 按手机号新增参会人.
|
||||
*
|
||||
* <p>后端流程: 按 body.phone 查 sys_user → 查到用之, 查不到新建 (用户名=密码=phone, role_type='doctor')
|
||||
* → 写完整档案行 (含 name/work_unit/fee...) → 触发 #5 会议邀请通知.
|
||||
*
|
||||
* <p>请求体示例:
|
||||
* <pre>
|
||||
* { "meetingId": 123, "phone": "13800138000", "name": "张三",
|
||||
* "workUnit": "协和医院", "department": "心内科", "title": "副主任医师",
|
||||
* "laborForm": "授课", "feePreTax": 1000, "tax": 100, "fee": 900 }
|
||||
* </pre>
|
||||
*/
|
||||
@Log(title = "参会人", businessType = BusinessType.INSERT)
|
||||
@PostMapping
|
||||
public AjaxResult add(@RequestBody BizMeetingAttendee body) {
|
||||
Long attendeeId = attendeeService.insertByPhoneWithProfile(body);
|
||||
// #5 触发: 新参会人发邀请 (走 BizMeetingController.edit 同一路径, 不需要 dedup — 这是新行)
|
||||
BizMeeting m = bizMeetingService.getById(body.getMeetingId());
|
||||
bizNotifyService.meetingInvitation(body.getUserId(), body.getMeetingId(),
|
||||
m != null ? m.getMeetingName() : null,
|
||||
m != null ? m.getStartTime() : null);
|
||||
return success(attendeeId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 管理端: 更新参会人档案 (管理员在 MeetingDetail 改了姓名/单位/劳务费等).
|
||||
* 不允许改 phone/userId (用 delete + add 来改手机号, 避免破坏 sys_user 关联).
|
||||
*/
|
||||
@Log(title = "参会人", businessType = BusinessType.UPDATE)
|
||||
@PutMapping
|
||||
public AjaxResult edit(@RequestBody BizMeetingAttendee body) {
|
||||
if (body.getId() == null) {
|
||||
return error("id 不能为空");
|
||||
}
|
||||
body.setUpdateBy(SecurityUtils.getUsername());
|
||||
return toAjax(attendeeService.updateProfile(body));
|
||||
}
|
||||
|
||||
/**
|
||||
* 管理端: 按 id 删除参会人 (单条).
|
||||
* 注意: 仅删 biz_meeting_attendee 行, 不删 sys_user (参会人可能是真实用户, 不能因参会关系删号).
|
||||
*/
|
||||
@Log(title = "参会人", businessType = BusinessType.DELETE)
|
||||
@DeleteMapping("/{id}")
|
||||
public AjaxResult remove(@PathVariable("id") Long id) {
|
||||
return toAjax(attendeeService.deleteByPrimaryKey(id));
|
||||
}
|
||||
|
||||
/** 更新手写签名 (Base64 字符串, 直接存 DB longtext) */
|
||||
@Log(title = "手写签名", businessType = BusinessType.UPDATE)
|
||||
@PutMapping("/{id}/handsign")
|
||||
@@ -51,17 +119,35 @@ public class BizMeetingAttendeeController extends BaseController {
|
||||
return toAjax(attendeeService.updateHandsign(entity));
|
||||
}
|
||||
|
||||
/** 更新劳务协议 URL (OSS 上传后调本接口) */
|
||||
/**
|
||||
* 更新劳务协议 URL (OSS 上传后调本接口).
|
||||
*
|
||||
* <p>同时承担 #6 通知触发: 当 labor_protocol 从空 → 非空 (或从无 → 有新 URL) 时,
|
||||
* 给参会人 (a.user_id) 发一条"劳务协议待签"通知. 协议被替换 (URL 改了) 不再重发,
|
||||
* 避免重复打扰 — 医生在 /doctor/home 列表里能直接看到 URL.
|
||||
*/
|
||||
@Log(title = "劳务协议", businessType = BusinessType.UPDATE)
|
||||
@PutMapping("/{id}/laborProtocol")
|
||||
public AjaxResult updateLaborProtocol(@PathVariable("id") Long id, @RequestBody BizMeetingAttendee body) {
|
||||
// #6 dedup: 先查旧值, 仅在"从空变有"时通知
|
||||
BizMeetingAttendee before = attendeeService.selectById(id);
|
||||
String newUrl = body.getLaborProtocol();
|
||||
boolean wasEmpty = before == null
|
||||
|| before.getLaborProtocol() == null
|
||||
|| before.getLaborProtocol().isEmpty();
|
||||
|
||||
BizMeetingAttendee entity = new BizMeetingAttendee();
|
||||
entity.setId(id);
|
||||
entity.setLaborProtocol(body.getLaborProtocol());
|
||||
entity.setLaborProtocol(newUrl);
|
||||
entity.setUpdateBy(SecurityUtils.getUsername());
|
||||
return toAjax(attendeeService.updateLaborProtocol(entity));
|
||||
}
|
||||
int rows = attendeeService.updateLaborProtocol(entity);
|
||||
|
||||
/* ====== 管理端点 (给后续 BizMeetingController.add 调用) ====== */
|
||||
// TODO: BizMeetingController.add 接受 attendeeUserIds: Long[], 批量 insert 中间表
|
||||
// 仅在原 URL 为空 + 新 URL 非空时推通知, 避免协议替换产生噪音
|
||||
if (rows > 0 && wasEmpty && newUrl != null && !newUrl.isEmpty() && before != null) {
|
||||
BizMeeting m = bizMeetingService.getById(before.getMeetingId());
|
||||
String meetingName = m != null ? m.getMeetingName() : null;
|
||||
bizNotifyService.agreementAwaitingSign(before.getUserId(), id, before.getMeetingId(), meetingName);
|
||||
}
|
||||
return toAjax(rows);
|
||||
}
|
||||
}
|
||||
+20
-1
@@ -20,6 +20,7 @@ import com.ruoyi.common.enums.BusinessType;
|
||||
import com.ruoyi.common.exception.ServiceException;
|
||||
import com.ruoyi.common.utils.SecurityUtils;
|
||||
import com.ruoyi.business.domain.BizMeeting;
|
||||
import com.ruoyi.business.notify.BizNotifyService;
|
||||
import com.ruoyi.business.service.IBizMeetingService;
|
||||
import com.ruoyi.business.service.IBizMeetingAttendeeService;
|
||||
|
||||
@@ -42,6 +43,8 @@ public class BizMeetingController extends BaseController {
|
||||
private IBizMeetingSupervisorService bizMeetingSupervisorService;
|
||||
@Autowired
|
||||
private IBizMeetingExecutorService bizMeetingExecutorService;
|
||||
@Autowired
|
||||
private BizNotifyService bizNotifyService;
|
||||
|
||||
@GetMapping("/list")
|
||||
public TableDataInfo list(BizMeeting bizMeeting) {
|
||||
@@ -67,6 +70,13 @@ public class BizMeetingController extends BaseController {
|
||||
Long[] attendeeUserIds = bizMeeting.getAttendeeUserIds();
|
||||
if (attendeeUserIds != null && attendeeUserIds.length > 0) {
|
||||
attendeeService.insertBatch(bizMeeting.getMeetingId(), attendeeUserIds);
|
||||
// #5 会议邀请通知 (新增会议, 全部新参会人都要通知)
|
||||
String meetingName = bizMeeting.getMeetingName();
|
||||
Date startTime = bizMeeting.getStartTime();
|
||||
for (Long uid : attendeeUserIds) {
|
||||
if (uid == null) continue;
|
||||
bizNotifyService.meetingInvitation(uid, bizMeeting.getMeetingId(), meetingName, startTime);
|
||||
}
|
||||
}
|
||||
return toAjax(rows);
|
||||
}
|
||||
@@ -77,7 +87,16 @@ public class BizMeetingController extends BaseController {
|
||||
int rows = bizMeetingService.updateByPrimaryKey(bizMeeting);
|
||||
Long[] attendeeUserIds = bizMeeting.getAttendeeUserIds();
|
||||
if (attendeeUserIds != null && attendeeUserIds.length > 0) {
|
||||
attendeeService.insertBatch(bizMeeting.getMeetingId(), attendeeUserIds);
|
||||
Long meetingId = bizMeeting.getMeetingId();
|
||||
// #5 dedup: 先拿已有的 userId 集合, 仅给"新增"的 userId 发通知, 避免重复打扰已参会医生
|
||||
java.util.Set<Long> existingUids = new java.util.HashSet<>(attendeeService.selectUserIdsByMeetingId(meetingId));
|
||||
attendeeService.insertBatch(meetingId, attendeeUserIds);
|
||||
String meetingName = bizMeeting.getMeetingName();
|
||||
Date startTime = bizMeeting.getStartTime();
|
||||
for (Long uid : attendeeUserIds) {
|
||||
if (uid == null || existingUids.contains(uid)) continue;
|
||||
bizNotifyService.meetingInvitation(uid, meetingId, meetingName, startTime);
|
||||
}
|
||||
}
|
||||
return toAjax(rows);
|
||||
}
|
||||
|
||||
@@ -39,6 +39,8 @@ public class BizProjectPlan extends BaseEntity {
|
||||
/** project_no */
|
||||
@Excel(name = "project_no")
|
||||
private String projectNo;
|
||||
/** 关联项目名 (LEFT JOIN biz_project.project_name, 通知/列表展示用, 不持久化) */
|
||||
private String projectName;
|
||||
/** remark */
|
||||
@Excel(name = "remark")
|
||||
private String remark;
|
||||
@@ -91,6 +93,8 @@ public class BizProjectPlan extends BaseEntity {
|
||||
public void setIsSettled(String isSettled) { this.isSettled = isSettled; }
|
||||
public String getProjectNo() { return projectNo; }
|
||||
public void setProjectNo(String projectNo) { this.projectNo = projectNo; }
|
||||
public String getProjectName() { return projectName; }
|
||||
public void setProjectName(String projectName) { this.projectName = projectName; }
|
||||
public String getRemark() { return remark; }
|
||||
public void setRemark(String remark) { this.remark = remark; }
|
||||
public String getIsFinished() { return isFinished; }
|
||||
@@ -103,6 +107,12 @@ public class BizProjectPlan extends BaseEntity {
|
||||
public void setUpdateBy(String updateBy) { this.updateBy = updateBy; }
|
||||
public Date getUpdateTime() { return updateTime; }
|
||||
public void setUpdateTime(Date updateTime) { this.updateTime = updateTime; }
|
||||
public String getAuditOpinion() { return auditOpinion; }
|
||||
public void setAuditOpinion(String auditOpinion) { this.auditOpinion = auditOpinion; }
|
||||
public String getAuditBy() { return auditBy; }
|
||||
public void setAuditBy(String auditBy) { this.auditBy = auditBy; }
|
||||
public String getAuditTime() { return auditTime; }
|
||||
public void setAuditTime(String auditTime) { this.auditTime = auditTime; }
|
||||
public Long getSubmitterId() { return submitterId; }
|
||||
public void setSubmitterId(Long submitterId) { this.submitterId = submitterId; }
|
||||
public String getSubmitterName() { return submitterName; }
|
||||
|
||||
+13
@@ -8,6 +8,12 @@ public interface BizMeetingAttendeeMapper {
|
||||
int insert(BizMeetingAttendee entity);
|
||||
/** 批量插入参会人 (BizMeetingController.add 调用) */
|
||||
int insertBatch(@Param("meetingId") Long meetingId, @Param("userIds") Long[] userIds, @Param("createBy") String createBy);
|
||||
/**
|
||||
* 管理端"新增参会人"用: 一次性插入完整档案 (含 name/phone/workUnit 等).
|
||||
* 与 {@link #insert} 区别: insert 只写 meeting_id+user_id+create_by (医生端"刚被加入"零信息行),
|
||||
* 本方法带档案用于 admin 在会议详情页手动加参会人.
|
||||
*/
|
||||
int insertWithProfile(BizMeetingAttendee entity);
|
||||
/** 医生填写信息保存草稿: 更新签字快照字段 + 劳务信息 (不含 handsign/PDF) */
|
||||
int updateProfile(BizMeetingAttendee entity);
|
||||
int updateHandsign(BizMeetingAttendee entity);
|
||||
@@ -15,9 +21,16 @@ public interface BizMeetingAttendeeMapper {
|
||||
int updateSign(BizMeetingAttendee entity);
|
||||
int updateLaborProtocol(BizMeetingAttendee entity);
|
||||
int deleteByMeetingId(Long meetingId);
|
||||
/** 管理端按 attendee.id 单删 (MeetingDetail 参会人 CRUD 用) */
|
||||
int deleteByPrimaryKey(Long id);
|
||||
int deleteByMeetingIdAndUserId(BizMeetingAttendee entity);
|
||||
List<BizMeetingAttendee> selectByMeetingId(Long meetingId);
|
||||
List<BizMeetingAttendee> selectByUserId(Long userId);
|
||||
BizMeetingAttendee selectById(Long id);
|
||||
List<BizMeetingAttendee> selectUnsignedByUserId(Long userId);
|
||||
/**
|
||||
* 拿某会议已存在的参会人 userId 列表 (用于 add/edit 时 diff 新加入的人, 仅通知增量)
|
||||
* 性能: 只查 user_id 一列, 走 meeting_id 索引; meeting 参会人通常 < 100, 无压力.
|
||||
*/
|
||||
List<Long> selectUserIdsByMeetingId(Long meetingId);
|
||||
}
|
||||
+26
@@ -14,9 +14,35 @@ public interface IBizMeetingAttendeeService {
|
||||
int updateSign(BizMeetingAttendee entity);
|
||||
int updateLaborProtocol(BizMeetingAttendee entity);
|
||||
int deleteByMeetingId(Long meetingId);
|
||||
/** MeetingDetail 参会人 CRUD 用: 按 id 单删 (管理端视角) */
|
||||
int deleteByPrimaryKey(Long id);
|
||||
int deleteByMeetingIdAndUserId(BizMeetingAttendee entity);
|
||||
|
||||
/**
|
||||
* 管理端新增参会人 (MeetingDetail 参会人 CRUD 用).
|
||||
*
|
||||
* <p>逻辑:
|
||||
* <ol>
|
||||
* <li>按 body.phone 查 sys_user (del_flag='0')</li>
|
||||
* <li>查不到 → 创建 sys_user (userName=phone, nickName=body.name, password=phone, roleType='doctor', status='0')</li>
|
||||
* <li>查到 → 复用其 userId</li>
|
||||
* <li>检查 (meetingId, userId) 是否已存在 → 抛"该参会人已在会议中"</li>
|
||||
* <li>insertWithProfile 写完整档案行</li>
|
||||
* <li>返回新 attendee.id (用于 #5 会议邀请触发 — 由 controller 决定是否推, 这里不推, 业务解耦)</li>
|
||||
* </ol>
|
||||
*
|
||||
* @param body 必填 meetingId/phone; 可选 name/workUnit/department/title/laborForm/feePreTax/tax/fee
|
||||
* @return 新 attendee.id
|
||||
* @throws ServiceException phone 为空 / 用户已在会议中
|
||||
*/
|
||||
Long insertByPhoneWithProfile(BizMeetingAttendee body);
|
||||
List<BizMeetingAttendee> selectByMeetingId(Long meetingId);
|
||||
List<BizMeetingAttendee> selectByUserId(Long userId);
|
||||
BizMeetingAttendee selectById(Long id);
|
||||
List<BizMeetingAttendee> selectUnsignedByUserId(Long userId);
|
||||
/**
|
||||
* 拿某会议已存在的参会人 userId 列表 (#5 会议邀请 dedup 用).
|
||||
* 列表实现层直接返 mapper 结果; 业务方通常用 {@code new HashSet<>(service.selectUserIdsByMeetingId(mid))} 做 contains 判断.
|
||||
*/
|
||||
List<Long> selectUserIdsByMeetingId(Long meetingId);
|
||||
}
|
||||
+86
@@ -1,18 +1,31 @@
|
||||
package com.ruoyi.business.service.impl;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Service;
|
||||
import com.ruoyi.business.domain.BizMeetingAttendee;
|
||||
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.system.mapper.SysUserMapper;
|
||||
import com.ruoyi.system.service.ISysUserService;
|
||||
|
||||
@Service
|
||||
public class BizMeetingAttendeeServiceImpl implements IBizMeetingAttendeeService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(BizMeetingAttendeeServiceImpl.class);
|
||||
|
||||
@Autowired
|
||||
private BizMeetingAttendeeMapper mapper;
|
||||
@Autowired
|
||||
private SysUserMapper sysUserMapper;
|
||||
@Autowired
|
||||
private ISysUserService sysUserService;
|
||||
|
||||
@Override
|
||||
public int insert(BizMeetingAttendee entity) {
|
||||
@@ -25,6 +38,69 @@ public class BizMeetingAttendeeServiceImpl implements IBizMeetingAttendeeService
|
||||
return mapper.insertBatch(meetingId, userIds, SecurityUtils.getUsername());
|
||||
}
|
||||
|
||||
/**
|
||||
* 管理端"新增参会人"全流程: 按 phone 定位/新建 sys_user + 写入完整档案.
|
||||
*
|
||||
* <p>参照 {@link com.ruoyi.business.service.impl.BizExpertServiceImpl#insert} 的"phone → userId"
|
||||
* 模式 (用户名=phone, 密码=phone, roleType='doctor'), 但本方法**只做 phone 查重+建账号**,
|
||||
* 不做"是否已注册专家"等业务校验 (参会人 ≠ 专家, 只要有手机号就能加入会议).
|
||||
*
|
||||
* <p>异常: phone 为空 → 抛; (meetingId, userId) 已存在 → 抛 (用 mapper 的 insertWithProfile
|
||||
* 在 PK 冲突时报 DuplicateEntry 兜底, 这里先 SELECT 给出友好提示).
|
||||
*/
|
||||
@Override
|
||||
public Long insertByPhoneWithProfile(BizMeetingAttendee body) {
|
||||
String phone = body.getPhone();
|
||||
if (phone == null || phone.trim().isEmpty()) {
|
||||
throw new ServiceException("手机号不能为空");
|
||||
}
|
||||
phone = phone.trim();
|
||||
|
||||
// 1. 按 phone 查 sys_user (单条 IN 查, selectByPhoneList 接受 List<String>)
|
||||
List<SysUser> hits = sysUserMapper.selectByPhoneList(Collections.singletonList(phone));
|
||||
Long userId;
|
||||
SysUser existed = (hits != null && !hits.isEmpty()) ? hits.get(0) : null;
|
||||
if (existed != null) {
|
||||
userId = existed.getUserId();
|
||||
} else {
|
||||
// 2. 查不到 → 建 sys_user (用户名=phone, 密码=phone, role_type='doctor')
|
||||
SysUser newUser = new SysUser();
|
||||
newUser.setUserName(phone);
|
||||
newUser.setNickName(body.getName() != null && !body.getName().isEmpty() ? body.getName() : phone);
|
||||
newUser.setPhonenumber(phone);
|
||||
newUser.setPassword(SecurityUtils.encryptPassword(phone));
|
||||
newUser.setStatus("0");
|
||||
newUser.setDelFlag("0");
|
||||
newUser.setCreateBy(SecurityUtils.getUsername());
|
||||
sysUserService.insertUser(newUser);
|
||||
userId = newUser.getUserId();
|
||||
if (userId == null) {
|
||||
// 兜底: insertUser 用了 useGeneratedKeys, 正常能拿到; 拿不到时按 phone 再查一次
|
||||
SysUser re = sysUserMapper.selectUserByUserName(phone);
|
||||
if (re == null) throw new ServiceException("建账号失败, 请重试");
|
||||
userId = re.getUserId();
|
||||
}
|
||||
// 显式设 role_type='doctor' (DB 默认 'executor', 参会人应为 doctor 视角)
|
||||
sysUserService.updateRoleType(userId, "doctor");
|
||||
log.info("[attendee] 新建 sys_user (phone={}, userId={}, roleType=doctor)", phone, userId);
|
||||
}
|
||||
|
||||
// 3. 检查 (meetingId, userId) 是否已存在 → 友好提示
|
||||
// 仅 SELECT, 不 DELETE (deleteByMeetingIdAndUserId 是破坏性的, 不能误用做探测)
|
||||
java.util.Set<Long> existing = new java.util.HashSet<>(mapper.selectUserIdsByMeetingId(body.getMeetingId()));
|
||||
if (existing.contains(userId)) {
|
||||
throw new ServiceException("该手机号参会人已在会议中, 无需重复添加");
|
||||
}
|
||||
|
||||
// 4. 写完整档案行
|
||||
body.setUserId(userId);
|
||||
body.setCreateBy(SecurityUtils.getUsername());
|
||||
mapper.insertWithProfile(body);
|
||||
Long newId = body.getId();
|
||||
log.info("[attendee] 新增参会人 meetingId={} userId={} attendeeId={}", body.getMeetingId(), userId, newId);
|
||||
return newId;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int updateProfile(BizMeetingAttendee entity) {
|
||||
return mapper.updateProfile(entity);
|
||||
@@ -50,6 +126,11 @@ public class BizMeetingAttendeeServiceImpl implements IBizMeetingAttendeeService
|
||||
return mapper.deleteByMeetingId(meetingId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int deleteByPrimaryKey(Long id) {
|
||||
return mapper.deleteByPrimaryKey(id);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int deleteByMeetingIdAndUserId(BizMeetingAttendee entity) {
|
||||
return mapper.deleteByMeetingIdAndUserId(entity);
|
||||
@@ -74,4 +155,9 @@ public class BizMeetingAttendeeServiceImpl implements IBizMeetingAttendeeService
|
||||
public List<BizMeetingAttendee> selectUnsignedByUserId(Long userId) {
|
||||
return mapper.selectUnsignedByUserId(userId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<Long> selectUserIdsByMeetingId(Long meetingId) {
|
||||
return mapper.selectUserIdsByMeetingId(meetingId);
|
||||
}
|
||||
}
|
||||
+29
-1
@@ -5,6 +5,7 @@ import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Service;
|
||||
import com.ruoyi.business.domain.BizProjectPlan;
|
||||
import com.ruoyi.business.mapper.BizProjectPlanMapper;
|
||||
import com.ruoyi.business.notify.BizNotifyService;
|
||||
import com.ruoyi.business.service.IBizProjectPlanService;
|
||||
|
||||
@Service
|
||||
@@ -12,6 +13,8 @@ public class BizProjectPlanServiceImpl implements IBizProjectPlanService
|
||||
{
|
||||
@Autowired
|
||||
private BizProjectPlanMapper bizProjectPlanMapper;
|
||||
@Autowired
|
||||
private BizNotifyService bizNotifyService;
|
||||
|
||||
@Override
|
||||
public BizProjectPlan getById(String planId)
|
||||
@@ -21,9 +24,34 @@ public class BizProjectPlanServiceImpl implements IBizProjectPlanService
|
||||
{ return bizProjectPlanMapper.selectList(entity); }
|
||||
@Override
|
||||
public int insert(BizProjectPlan entity) { com.ruoyi.common.utils.id.SnowflakeId.injectIfEmpty(entity, "planId"); return bizProjectPlanMapper.insert(entity); }
|
||||
|
||||
/**
|
||||
* 通用 update. #4 触发点 (方案审核结果通知) 在这里插桩:
|
||||
* - 先读旧 status
|
||||
* - 执行 update
|
||||
* - 仅当 status 真的变化 (1→2 通过 / 1→3 拒绝) 才发通知
|
||||
* - 其它字段编辑 (plan_name/file/...) 不发, 避免噪音
|
||||
*/
|
||||
@Override
|
||||
public int updateByPrimaryKey(BizProjectPlan entity)
|
||||
{ return bizProjectPlanMapper.updateByPrimaryKey(entity); }
|
||||
{
|
||||
String oldStatus = null;
|
||||
if (entity.getPlanId() != null) {
|
||||
BizProjectPlan existed = bizProjectPlanMapper.selectByPrimaryKey(entity.getPlanId());
|
||||
if (existed != null) {
|
||||
oldStatus = existed.getStatus();
|
||||
}
|
||||
}
|
||||
int n = bizProjectPlanMapper.updateByPrimaryKey(entity);
|
||||
if (n > 0 && entity.getStatus() != null && !entity.getStatus().equals(oldStatus)) {
|
||||
// 重新读一次拿 submitterId/planName/auditOpinion (entity 可能只传了 planId+status+opinion)
|
||||
BizProjectPlan after = bizProjectPlanMapper.selectByPrimaryKey(entity.getPlanId());
|
||||
if (after != null) {
|
||||
bizNotifyService.planAuditResult(after, oldStatus);
|
||||
}
|
||||
}
|
||||
return n;
|
||||
}
|
||||
@Override
|
||||
public int deleteByPrimaryKey(String planId)
|
||||
{ return bizProjectPlanMapper.deleteByPrimaryKey(planId); }
|
||||
|
||||
@@ -40,6 +40,20 @@
|
||||
insert into biz_meeting_attendee(meeting_id, user_id, create_by, create_time)
|
||||
values(#{meetingId}, #{userId}, #{createBy}, sysdate())
|
||||
</insert>
|
||||
<!--
|
||||
管理端新增参会人 (MeetingDetail 参会人 CRUD 用):
|
||||
一次性写入 meeting_id+user_id+档案字段 (name/phone/work_unit/...).
|
||||
若档案字段为 NULL 则不写 (COALESCE 在调用方给空字符串兜底).
|
||||
useGeneratedKeys 让调用方能拿到新 attendee.id (用于 #5 触发邀请通知).
|
||||
-->
|
||||
<insert id="insertWithProfile" parameterType="BizMeetingAttendee" useGeneratedKeys="true" keyProperty="id">
|
||||
insert into biz_meeting_attendee(meeting_id, user_id, name, phone, work_unit, department, title,
|
||||
id_card, bank_card, bank_name, bank_branch, bank_region, bank_address, account_name,
|
||||
id_card_attachments, labor_form, fee_pre_tax, tax, fee, create_by, create_time)
|
||||
values(#{meetingId}, #{userId}, #{name}, #{phone}, #{workUnit}, #{department}, #{title},
|
||||
#{idCard}, #{bankCard}, #{bankName}, #{bankBranch}, #{bankRegion}, #{bankAddress}, #{accountName},
|
||||
#{idCardAttachments}, #{laborForm}, #{feePreTax}, #{tax}, #{fee}, #{createBy}, sysdate())
|
||||
</insert>
|
||||
<!-- 批量插入参会人 (BizMeetingController.add 调用) -->
|
||||
<insert id="insertBatch">
|
||||
insert into biz_meeting_attendee(meeting_id, user_id, create_by, create_time)
|
||||
@@ -103,6 +117,10 @@
|
||||
<delete id="deleteByMeetingId" parameterType="Long">
|
||||
delete from biz_meeting_attendee where meeting_id = #{meetingId}
|
||||
</delete>
|
||||
<!-- MeetingDetail 参会人 CRUD 用: 按 id 单删 -->
|
||||
<delete id="deleteByPrimaryKey" parameterType="Long">
|
||||
delete from biz_meeting_attendee where id = #{id}
|
||||
</delete>
|
||||
<delete id="deleteByMeetingIdAndUserId" parameterType="BizMeetingAttendee">
|
||||
delete from biz_meeting_attendee where meeting_id = #{meetingId} and user_id = #{userId}
|
||||
</delete>
|
||||
@@ -133,4 +151,11 @@
|
||||
and (a.handsign is null or a.handsign = '' or a.labor_protocol is null or a.labor_protocol = '')
|
||||
order by m.start_time asc
|
||||
</select>
|
||||
<!--
|
||||
给 BizMeetingController.edit 做差集用: 查该会议已存在的参会人 userId 列表.
|
||||
用于 #5 会议邀请: 仅给"新加入"的 userId 发通知, 已存在的用户不重发.
|
||||
-->
|
||||
<select id="selectUserIdsByMeetingId" resultType="java.lang.Long" parameterType="Long">
|
||||
select user_id from biz_meeting_attendee where meeting_id = #{meetingId}
|
||||
</select>
|
||||
</mapper>
|
||||
@@ -13,7 +13,11 @@
|
||||
<result property="status" column="status" />
|
||||
<result property="isSettled" column="is_settled" />
|
||||
<result property="projectNo" column="project_no" />
|
||||
<result property="projectName" column="project_name" />
|
||||
<result property="remark" column="remark" />
|
||||
<result property="auditOpinion" column="audit_opinion" />
|
||||
<result property="auditBy" column="audit_by" />
|
||||
<result property="auditTime" column="audit_time" />
|
||||
<result property="submitterId" column="submitter_id" />
|
||||
<result property="submitterName" column="submitter_name" />
|
||||
<result property="createBy" column="create_by" />
|
||||
@@ -25,13 +29,15 @@
|
||||
select p.plan_id, p.plan_name, p.plan_direction, p.plan_direction_id,
|
||||
s.title as plan_direction_title,
|
||||
p.plan_category, p.project_form, p.design_file_url, p.status, p.is_settled,
|
||||
p.project_no, p.remark, p.submitter_id,
|
||||
p.project_no, p.remark, p.audit_opinion, p.audit_by, p.audit_time, p.submitter_id,
|
||||
COALESCE(bp.name, u.user_name) as submitter_name,
|
||||
proj.project_name as project_name,
|
||||
p.create_by, p.create_time, p.update_by, p.update_time
|
||||
from biz_project_plan p
|
||||
left join biz_special_plan s on s.id = p.plan_direction_id
|
||||
left join sys_user u on u.user_id = p.submitter_id
|
||||
left join biz_person bp on bp.user_id = u.user_id
|
||||
left join biz_project proj on proj.project_no = p.project_no
|
||||
</sql>
|
||||
<select id="selectByPrimaryKey" resultMap="BizProjectPlanResult" parameterType="String">
|
||||
<include refid="selectFields"/>
|
||||
|
||||
@@ -70,6 +70,46 @@
|
||||
<div v-if="r.hint" class="file-hint">{{ r.hint }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 参会人管理 (按用户要求, 放在"劳务明细表"那行下面, 无表题直接出表) -->
|
||||
<div class="attendee-section">
|
||||
<div class="attendee-toolbar">
|
||||
<el-button type="primary" size="small" :loading="attendeeLoading" @click="openAttendeeDialog()">+ 新增参会人</el-button>
|
||||
<span class="toolbar-hint">按手机号定位; 手机号未注册将自动建 sys_user (用户名=密码=手机号)</span>
|
||||
</div>
|
||||
<el-table :data="attendeeRows" v-loading="attendeeLoading" border size="small" style="width:100%">
|
||||
<el-table-column prop="name" label="姓名" min-width="100" />
|
||||
<el-table-column prop="phone" label="手机号" width="120" />
|
||||
<el-table-column prop="workUnit" label="工作单位" min-width="160" show-overflow-tooltip />
|
||||
<el-table-column prop="department" label="科室" min-width="100" show-overflow-tooltip />
|
||||
<el-table-column prop="title" label="职称" min-width="100" show-overflow-tooltip />
|
||||
<el-table-column prop="laborForm" label="劳务形式" min-width="80" show-overflow-tooltip />
|
||||
<el-table-column label="税前劳务费" width="110" align="right">
|
||||
<template #default="{ row }">{{ row.feePreTax != null ? '¥ ' + Number(row.feePreTax).toFixed(2) : '-' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="税金" width="80" align="right">
|
||||
<template #default="{ row }">{{ row.tax != null ? '¥ ' + Number(row.tax).toFixed(2) : '-' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="实发劳务费" width="110" align="right">
|
||||
<template #default="{ row }">{{ row.fee != null ? '¥ ' + Number(row.fee).toFixed(2) : '-' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="签字" width="70" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag v-if="row.handsign" type="success" size="small">已签</el-tag>
|
||||
<el-tag v-else type="info" size="small">未签</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="140" align="center" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" size="small" @click="openAttendeeDialog(row)">编辑</el-button>
|
||||
<el-button link type="danger" size="small" @click="confirmDeleteAttendee(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<template #empty>
|
||||
<span class="text-muted">暂无参会人, 点击右上"新增参会人"按手机号添加</span>
|
||||
</template>
|
||||
</el-table>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="劳务凭证" name="laborVoucher">
|
||||
<div class="file-list">
|
||||
@@ -263,6 +303,44 @@
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 参会人 dialog (新增/编辑通用) -->
|
||||
<el-dialog v-model="attendeeDialog.show" :title="attendeeDialog.title" width="520px" @closed="resetAttendeeForm">
|
||||
<el-form :model="attendeeDialog.form" label-width="100px">
|
||||
<el-form-item label="手机号" required>
|
||||
<el-input v-model="attendeeDialog.form.phone" :disabled="attendeeDialog.editing" placeholder="11位手机号" maxlength="11" />
|
||||
</el-form-item>
|
||||
<el-form-item label="姓名">
|
||||
<el-input v-model="attendeeDialog.form.name" placeholder="未注册手机号将以此为昵称新建账号" />
|
||||
</el-form-item>
|
||||
<el-form-item label="工作单位">
|
||||
<el-input v-model="attendeeDialog.form.workUnit" />
|
||||
</el-form-item>
|
||||
<el-form-item label="科室">
|
||||
<el-input v-model="attendeeDialog.form.department" />
|
||||
</el-form-item>
|
||||
<el-form-item label="职称">
|
||||
<el-input v-model="attendeeDialog.form.title" />
|
||||
</el-form-item>
|
||||
<el-form-item label="劳务形式">
|
||||
<el-input v-model="attendeeDialog.form.laborForm" placeholder="如: 授课/主持/评审" />
|
||||
</el-form-item>
|
||||
<el-form-item label="税前劳务费">
|
||||
<el-input-number v-model="attendeeDialog.form.feePreTax" :precision="2" :min="0" controls-position="right" style="width:100%" />
|
||||
</el-form-item>
|
||||
<el-form-item label="税金">
|
||||
<el-input-number v-model="attendeeDialog.form.tax" :precision="2" :min="0" controls-position="right" style="width:100%" />
|
||||
</el-form-item>
|
||||
<el-form-item label="实发劳务费">
|
||||
<el-input-number v-model="attendeeDialog.form.fee" :precision="2" :min="0" controls-position="right" style="width:100%" />
|
||||
</el-form-item>
|
||||
<div v-if="attendeeDialog.editing" class="form-hint">编辑时不能改手机号; 若需改手机号, 请删除后重新添加</div>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="attendeeDialog.show = false">取消</el-button>
|
||||
<el-button type="primary" :loading="attendeeDialog.saving" @click="confirmAttendee">确定</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<div class="form-actions">
|
||||
<el-button @click="goBack">返回</el-button>
|
||||
</div>
|
||||
@@ -278,6 +356,7 @@ import { listSupporters, listExecutor } from '@/api/system'
|
||||
import { useUserStore } from '@/store/user'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import OssFileUploader from '@/components/OssFileUploader.vue'
|
||||
import { ElMessageBox } from 'element-plus'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
@@ -442,6 +521,131 @@ async function loadMaterials() {
|
||||
})
|
||||
} catch (e) { console.error('[meeting-detail] loadMaterials failed', e) }
|
||||
}
|
||||
|
||||
// ===================== 参会人 CRUD =====================
|
||||
/** 会议下的参会人列表 (放在 劳务材料 tab 表格) */
|
||||
const attendeeRows = ref([])
|
||||
const attendeeLoading = ref(false)
|
||||
|
||||
/** 参会人 dialog 状态 (新增/编辑共用) */
|
||||
const attendeeDialog = ref({
|
||||
show: false,
|
||||
title: '',
|
||||
editing: false, // true=编辑 / false=新增
|
||||
form: emptyAttendeeForm(),
|
||||
saving: false
|
||||
})
|
||||
function emptyAttendeeForm() {
|
||||
return {
|
||||
id: null,
|
||||
meetingId: null,
|
||||
phone: '',
|
||||
name: '',
|
||||
workUnit: '',
|
||||
department: '',
|
||||
title: '',
|
||||
laborForm: '',
|
||||
feePreTax: 0,
|
||||
tax: 0,
|
||||
fee: 0
|
||||
}
|
||||
}
|
||||
function resetAttendeeForm() {
|
||||
attendeeDialog.value = {
|
||||
show: false, title: '', editing: false,
|
||||
form: emptyAttendeeForm(), saving: false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAttendees() {
|
||||
attendeeLoading.value = true
|
||||
try {
|
||||
const resp = await request.get(`/business/meetingAttendee/list/${meetingId.value}`)
|
||||
const list = (resp && (resp.data || resp)) || []
|
||||
attendeeRows.value = Array.isArray(list) ? list : []
|
||||
} catch (e) {
|
||||
console.error('[meeting-detail] loadAttendees failed', e)
|
||||
attendeeRows.value = []
|
||||
} finally {
|
||||
attendeeLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 打开新增/编辑 dialog; row=null → 新增 */
|
||||
function openAttendeeDialog(row) {
|
||||
if (row) {
|
||||
attendeeDialog.value = {
|
||||
show: true,
|
||||
title: '编辑参会人',
|
||||
editing: true,
|
||||
form: {
|
||||
id: row.id,
|
||||
meetingId: row.meetingId,
|
||||
phone: row.phone || '',
|
||||
name: row.name || '',
|
||||
workUnit: row.workUnit || '',
|
||||
department: row.department || '',
|
||||
title: row.title || '',
|
||||
laborForm: row.laborForm || '',
|
||||
feePreTax: row.feePreTax ?? 0,
|
||||
tax: row.tax ?? 0,
|
||||
fee: row.fee ?? 0
|
||||
},
|
||||
saving: false
|
||||
}
|
||||
} else {
|
||||
attendeeDialog.value = {
|
||||
show: true,
|
||||
title: '新增参会人',
|
||||
editing: false,
|
||||
form: { ...emptyAttendeeForm(), meetingId: Number(meetingId.value) },
|
||||
saving: false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 提交 dialog (新增 → POST; 编辑 → PUT) */
|
||||
async function confirmAttendee() {
|
||||
const { editing, form } = attendeeDialog.value
|
||||
if (!form.phone || !form.phone.trim()) {
|
||||
ElMessage.warning('请输入手机号')
|
||||
return
|
||||
}
|
||||
if (!form.phone.trim().match(/^1[3-9]\d{9}$/)) {
|
||||
ElMessage.warning('手机号格式不正确')
|
||||
return
|
||||
}
|
||||
attendeeDialog.value.saving = true
|
||||
try {
|
||||
if (editing) {
|
||||
await request.put('/business/meetingAttendee', form, { __silentError: true })
|
||||
ElMessage.success('已保存')
|
||||
} else {
|
||||
await request.post('/business/meetingAttendee', form, { __silentError: true })
|
||||
ElMessage.success('已添加, 已向参会人发送会议邀请')
|
||||
}
|
||||
attendeeDialog.value.show = false
|
||||
await loadAttendees()
|
||||
} catch (e) {
|
||||
ElMessage.error(e?.msg || e?.message || (editing ? '保存失败' : '添加失败'))
|
||||
} finally {
|
||||
attendeeDialog.value.saving = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 删除参会人 (单条确认) */
|
||||
async function confirmDeleteAttendee(row) {
|
||||
try {
|
||||
await ElMessageBox.confirm(`确认删除参会人「${row.name || row.phone || row.id}」?该操作仅删除参会关系, 不会删除其 sys_user 账号`, '删除确认', { type: 'warning' })
|
||||
} catch { return /* 用户取消 */ }
|
||||
try {
|
||||
await request.delete(`/business/meetingAttendee/${row.id}`, { __silentError: true })
|
||||
ElMessage.success('已删除')
|
||||
await loadAttendees()
|
||||
} catch (e) {
|
||||
ElMessage.error(e?.msg || e?.message || '删除失败')
|
||||
}
|
||||
}
|
||||
async function loadStaff() {
|
||||
try {
|
||||
const [sp, ex] = await Promise.all([
|
||||
@@ -465,7 +669,7 @@ async function load() {
|
||||
try {
|
||||
const resp = await bizGet('meeting', meetingId.value)
|
||||
row.value = (resp && (resp.data || resp)) || {}
|
||||
await Promise.all([loadMaterials(), loadStaff(), loadTrail()])
|
||||
await Promise.all([loadMaterials(), loadStaff(), loadTrail(), loadAttendees()])
|
||||
} catch (e) {
|
||||
console.error('[meeting-detail] load failed', e)
|
||||
row.value = {}
|
||||
@@ -757,4 +961,10 @@ onMounted(load)
|
||||
.audit-column .section-title { font-size: 14px; margin-bottom: 12px; padding-left: 8px; }
|
||||
|
||||
.form-actions { display: flex; justify-content: flex-start; padding: 24px 0 0; }
|
||||
|
||||
/* 参会人管理 (放在 劳务材料 tab 底部, 无表题) */
|
||||
.attendee-section { margin-top: 16px; padding-top: 16px; border-top: 1px dashed #e8e8e8; }
|
||||
.attendee-toolbar { display: flex; align-items: center; gap: 12px; margin-bottom: 10px; flex-wrap: wrap; }
|
||||
.toolbar-hint { font-size: 12px; color: #909399; line-height: 1.5; }
|
||||
.form-hint { font-size: 12px; color: #909399; margin-top: -8px; margin-bottom: 8px; line-height: 1.5; }
|
||||
</style>
|
||||
Reference in New Issue
Block a user