feat: 合并 admin/manager 重复页 + 公示意向 + 劳务签署

页合并 (admin/manager 共用, route.path 角色感知):
- expert: Experts.vue + ExpertNew.vue (list/new/edit/view 一套)
- sponsor-orgs: SponsorOrgs.vue
- sponsor-people: SponsorPeople.vue + SponsorPersonNew.vue + SponsorPersonDetail.vue
- executor-orgs: ExecutorOrgs.vue
- executor-people: ExecutorPeople.vue + ExecutorPersonNew.vue + ExecutorPersonDetail.vue
- 给 SponsorPeople / ExecutorPeople op 列补上 查看/编辑 按钮 (潜在 bug 修复)
- 详情弹窗统一用 el-descriptions 风格 (替代 admin 旧版 ElMessageBox.alert)

后端:
- BizSignController + BizSignServiceImpl + PdfService (劳务签署 PDF 流程)
- BizPublicityIntentController (公示意向: 支持/执行)
- BizPublicitySupportIntent / BizPublicityExecutionIntent ExportVo
- BizMeetingAttendee 字段合并 (bank_region 替代 bankProvince/bankCity)
- BizExpert 字段合并 (id_card_attachments CSV, bank_region)
- Excel 导入模板精简 (开户行 1 列)

前端:
- doctor/SignFill + SignContract + SignSuccess (劳务签署 3 页流程)
- ImportResultDialog 通用组件
- IdCardUploader 重构 (单 v-model:idCardAttachments, CSV 格式)
- AreaCascader 调整
- Login.vue + AdminLayout.vue + PortalShell.vue + Home.vue 适配

DB: 字段合并 (id_card_front/back → id_card_attachments, bank_province/city → bank_region)
This commit is contained in:
郭庆泰
2026-08-20 21:02:56 +08:00
parent 3198c408b7
commit 1e86865a40
68 changed files with 3034 additions and 2617 deletions
@@ -131,9 +131,14 @@ public class BizAuthController extends BaseController {
return error("验证码错误或已过期: " + e.getMessage());
}
// 3. 查用户 (mapper.checkPhoneUnique 返回 SysUser 或 null)
SysUser user = userMapper.checkPhoneUnique(phone);
if (user == null || user.getUserId() == null) {
// 3. 查用户 (checkPhoneUnique 只 select user_id+phonenumber, 缺 role_type/user_name/status,
// 需再按 userId 查完整 user, 否则 LoginUser 里 roleType=null 导致前端拿不到角色而跳回登录页)
SysUser probe = userMapper.checkPhoneUnique(phone);
if (probe == null || probe.getUserId() == null) {
return error("该手机号未注册");
}
SysUser user = userMapper.selectUserById(probe.getUserId());
if (user == null) {
return error("该手机号未注册");
}
@@ -3,7 +3,6 @@ package com.ruoyi.business.controller;
import java.util.List;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import com.ruoyi.common.annotation.Log;
@@ -15,6 +14,7 @@ import com.ruoyi.common.enums.BusinessType;
import com.ruoyi.common.utils.SecurityUtils;
import com.ruoyi.common.utils.poi.ExcelUtil;
import com.ruoyi.business.domain.BizExpert;
import com.ruoyi.business.domain.dto.ImportResult;
import com.ruoyi.business.domain.vo.BizExpertExportVo;
import com.ruoyi.business.domain.vo.BizExpertImportVo;
import com.ruoyi.business.service.IBizExpertService;
@@ -98,7 +98,7 @@ public class BizExpertController extends BaseController
* 导出专家列表 (中文列头)
*/
@Log(title = "专家", businessType = BusinessType.EXPORT)
@PreAuthorize("@ss.hasPermi('business:expert:export')")
// 注: 不加 @PreAuthorize, 与本 controller 其他 CRUD 端点一致 — manager 是业务角色, 不走 RuoYi RBAC 权限
@PostMapping("/export")
public void export(HttpServletResponse response, BizExpert bizExpert)
{
@@ -116,8 +116,7 @@ public class BizExpertController extends BaseController
v.setIdCard(e.getIdCard());
v.setBankCard(e.getBankCard());
v.setBankName(e.getBankName());
v.setBankProvince(e.getBankProvince());
v.setBankCity(e.getBankCity());
v.setBankRegion(e.getBankRegion());
v.setBankAddress(e.getBankAddress());
v.setStatus(e.getStatus());
v.setAuditStatus(e.getAuditStatus());
@@ -141,19 +140,22 @@ public class BizExpertController extends BaseController
}
/**
* 批量导入专家 (Excel), 参考 RuoYi 系统用户导入模式
* updateSupport=true: 同一手机号已存在 → 跳过 (按成功计)
* 返回 message 含成功/失败计数 + 失败明细
* 批量导入专家 (3 阶段批量):
* <ol>
* <li>Phase A: IN 查 sys_user (100/批), 记录已存在 phone</li>
* <li>Phase B: batch insert sys_user (新 phone) (100/批)</li>
* <li>Phase C: batch insert biz_expert (100/批)</li>
* </ol>
* updateSupport: 当前实现固定绑定已存在 phone → user_id (不再"跳过", 复用既有账号)
* 返回 {@link ImportResult} 含成功/失败计数 + 失败明细 (行号 + 原因)
*/
@Log(title = "专家", businessType = BusinessType.IMPORT)
@PreAuthorize("@ss.hasPermi('business:expert:import')")
// 注: 不加 @PreAuthorize, 与本 controller 其他 CRUD 端点一致 — manager 是业务角色, 不走 RuoYi RBAC 权限
@PostMapping("/importData")
public AjaxResult importData(MultipartFile file, boolean updateSupport) throws Exception
{
ExcelUtil<BizExpertImportVo> util = new ExcelUtil<BizExpertImportVo>(BizExpertImportVo.class);
List<BizExpertImportVo> importList = util.importExcel(file.getInputStream());
String operName = SecurityUtils.getUsername();
String message = bizExpertService.importExpert(importList, updateSupport, operName);
return success(message);
ImportResult result = bizExpertService.importExpert(file, updateSupport, operName);
return success(result);
}
}
@@ -1,6 +1,8 @@
package com.ruoyi.business.controller;
import java.util.List;
import com.ruoyi.business.service.IBizMeetingAttendeeService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
import com.ruoyi.common.annotation.Log;
@@ -38,17 +40,32 @@ public class BizMeetingController extends BaseController
{
return success(bizMeetingService.getById(meetingId));
}
@Autowired
private IBizMeetingAttendeeService attendeeService;
@Log(title = "会议", businessType = BusinessType.INSERT)
@PostMapping
public AjaxResult add(@RequestBody BizMeeting bizMeeting)
{
return toAjax(bizMeetingService.insert(bizMeeting));
int rows = bizMeetingService.insert(bizMeeting);
// 同步创建参会人中间表 (可选: 前端传 attendeeUserIds)
Long[] attendeeUserIds = bizMeeting.getAttendeeUserIds();
if (attendeeUserIds != null && attendeeUserIds.length > 0) {
attendeeService.insertBatch(bizMeeting.getMeetingId(), attendeeUserIds);
}
return toAjax(rows);
}
@Log(title = "会议", businessType = BusinessType.UPDATE)
@PutMapping
public AjaxResult edit(@RequestBody BizMeeting bizMeeting)
{
return toAjax(bizMeetingService.updateByPrimaryKey(bizMeeting));
int rows = bizMeetingService.updateByPrimaryKey(bizMeeting);
// 同步追加参会人 (不去重, 由前端控制)
Long[] attendeeUserIds = bizMeeting.getAttendeeUserIds();
if (attendeeUserIds != null && attendeeUserIds.length > 0) {
attendeeService.insertBatch(bizMeeting.getMeetingId(), attendeeUserIds);
}
return toAjax(rows);
}
@Log(title = "会议", businessType = BusinessType.DELETE)
@DeleteMapping("/{ids}")
@@ -1,6 +1,8 @@
package com.ruoyi.business.controller;
import java.util.ArrayList;
import java.util.List;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
import com.ruoyi.common.annotation.Log;
@@ -9,9 +11,12 @@ import com.ruoyi.common.core.domain.AjaxResult;
import com.ruoyi.common.core.page.TableDataInfo;
import com.ruoyi.common.enums.BusinessType;
import com.ruoyi.common.utils.SecurityUtils;
import com.ruoyi.common.utils.poi.ExcelUtil;
import com.ruoyi.business.domain.BizProject;
import com.ruoyi.business.domain.BizPublicityExecutionIntent;
import com.ruoyi.business.domain.BizPublicitySupportIntent;
import com.ruoyi.business.domain.vo.BizPublicityExecutionIntentExportVo;
import com.ruoyi.business.domain.vo.BizPublicitySupportIntentExportVo;
import com.ruoyi.business.service.IBizProjectService;
import com.ruoyi.business.service.IBizPublicityExecutionIntentService;
import com.ruoyi.business.service.IBizPublicitySupportIntentService;
@@ -205,4 +210,60 @@ public class BizPublicityIntentController extends BaseController {
public AjaxResult removeExecution(@PathVariable Long[] ids) {
return toAjax(executionIntentService.deleteByPrimaryKeys(ids));
}
// ============== 导出 (后端 ExcelUtil 写 .xlsx) ==============
/**
* 导出执行意向 (按页面筛选条件透传, 无 startPage, 全量导出)
* POST /business/publicityExecutionIntent/export?projectNo=&name=&...
*/
@Log(title = "导出执行意向", businessType = BusinessType.EXPORT)
@PostMapping("/business/publicityExecutionIntent/export")
public void exportExecution(HttpServletResponse response, BizPublicityExecutionIntent intent) {
List<BizPublicityExecutionIntent> list = executionIntentService.selectList(intent);
List<BizPublicityExecutionIntentExportVo> exportList = new ArrayList<>(list.size());
for (BizPublicityExecutionIntent e : list) {
BizPublicityExecutionIntentExportVo v = new BizPublicityExecutionIntentExportVo();
v.setProjectNo(e.getProjectNo());
v.setProjectName(e.getProjectName());
v.setName(e.getName());
v.setWorkUnit(e.getWorkUnit());
v.setDepartment(e.getDepartment());
v.setPosition(e.getPosition());
v.setPhone(e.getPhone());
v.setUserStatus(e.getUserId() != null ? "存在" : "不存在");
v.setIntentStatus(e.getIntentStatus() == null || e.getIntentStatus().isEmpty() ? "待审核" : e.getIntentStatus());
v.setCreateTime(e.getCreateTime());
exportList.add(v);
}
ExcelUtil<BizPublicityExecutionIntentExportVo> util = new ExcelUtil<>(BizPublicityExecutionIntentExportVo.class);
util.exportExcel(response, exportList, "执行意向");
}
/**
* 导出支持意向
* POST /business/publicitySupportIntent/export
*/
@Log(title = "导出支持意向", businessType = BusinessType.EXPORT)
@PostMapping("/business/publicitySupportIntent/export")
public void exportSupport(HttpServletResponse response, BizPublicitySupportIntent intent) {
List<BizPublicitySupportIntent> list = supportIntentService.selectList(intent);
List<BizPublicitySupportIntentExportVo> exportList = new ArrayList<>(list.size());
for (BizPublicitySupportIntent e : list) {
BizPublicitySupportIntentExportVo v = new BizPublicitySupportIntentExportVo();
v.setProjectNo(e.getProjectNo());
v.setProjectName(e.getProjectName());
v.setName(e.getName());
v.setWorkUnit(e.getWorkUnit());
v.setDepartment(e.getDepartment());
v.setPosition(e.getPosition());
v.setPhone(e.getPhone());
v.setUserStatus(e.getUserId() != null ? "存在" : "不存在");
v.setIntentStatus(e.getIntentStatus() == null || e.getIntentStatus().isEmpty() ? "待审核" : e.getIntentStatus());
v.setCreateTime(e.getCreateTime());
exportList.add(v);
}
ExcelUtil<BizPublicitySupportIntentExportVo> util = new ExcelUtil<>(BizPublicitySupportIntentExportVo.class);
util.exportExcel(response, exportList, "支持意向");
}
}
@@ -0,0 +1,71 @@
package com.ruoyi.business.controller;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import com.ruoyi.common.core.controller.BaseController;
import com.ruoyi.common.core.domain.AjaxResult;
import com.ruoyi.business.domain.BizMeetingAttendee;
import com.ruoyi.business.service.BizSignService;
/**
* 医生劳务协议签署 Controller
* 简化版: 不用 token, 走 SecurityUtils.getUserId() 识别医生 (用户登录后访问)
*
* 接口:
* - GET /business/sign/info?attendeeId=X 拉填写页所有数据
* - POST /business/sign/saveProfile 保存医生填的字段
* - GET /business/sign/contract?attendeeId=X 拉完整协议 HTML
* - POST /business/sign/submit 提交签字
*/
@RestController
@RequestMapping("/business/sign")
public class BizSignController extends BaseController {
@Autowired
private BizSignService signService;
/** 医生填写页 GET /info?attendeeId=X */
@GetMapping("/info")
public AjaxResult getSignInfo(@RequestParam("attendeeId") Long attendeeId) {
return success(signService.getSignInfo(attendeeId));
}
/** 医生点 "保存" / "下一步" 后调 */
@PostMapping("/saveProfile")
public AjaxResult saveProfile(@RequestParam("attendeeId") Long attendeeId,
@RequestBody BizMeetingAttendee form) {
signService.saveProfile(attendeeId, form);
return success("已保存");
}
/** 拉完整协议 HTML (占位符已替换) */
@GetMapping("/contract")
public AjaxResult getContractHtml(@RequestParam("attendeeId") Long attendeeId) {
// 注意: 不能用 success(String) (会把 HTML 当 msg), 必须显式传 data
return AjaxResult.success("操作成功", signService.getContractHtml(attendeeId));
}
/** 医生点 "提交" 签字 */
@PostMapping("/submit")
public AjaxResult submitSign(@RequestParam("attendeeId") Long attendeeId,
@RequestBody java.util.Map<String, String> body,
@RequestHeader(value = "X-Real-IP", required = false) String realIp) {
String handsign = body.get("handsign");
String contentHtml = body.get("contentHtml");
String signedIp = (realIp != null && !realIp.isEmpty()) ? realIp : currentRequestIp();
java.util.Map<String, String> pdfUrls = signService.submitSign(attendeeId, handsign, contentHtml, signedIp);
return success(pdfUrls);
}
private String currentRequestIp() {
try {
ServletRequestAttributes attrs = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
if (attrs != null && attrs.getRequest() != null) {
return attrs.getRequest().getRemoteAddr();
}
} catch (Exception ignored) {}
return "unknown";
}
}
@@ -66,16 +66,12 @@ public class BizExpert extends BaseEntity {
private String bankCard;
/** 银行名称 */
private String bankName;
/** 开户行省 */
private String bankProvince;
/** 开户行城市 */
private String bankCity;
/** 开户行省/市 (1-3 段, / 分隔, 直辖市省=市) */
private String bankRegion;
/** 开户行地址 */
private String bankAddress;
/** 身份证正面URL */
private String idCardFrontUrl;
/** 身份证反面URL */
private String idCardBackUrl;
/** 身份证正面/反面 URL (CSV, 逗号分隔, 空段保留位) */
private String idCardAttachments;
/** 审核意见 */
private String auditOpinion;
/** 状态 0正常 1禁用 */
@@ -124,14 +120,10 @@ public class BizExpert extends BaseEntity {
public void setBankCard(String bankCard) { this.bankCard = bankCard; }
public String getBankName() { return bankName; }
public void setBankName(String bankName) { this.bankName = bankName; }
public String getBankProvince() { return bankProvince; }
public void setBankProvince(String bankProvince) { this.bankProvince = bankProvince; }
public String getBankCity() { return bankCity; }
public void setBankCity(String bankCity) { this.bankCity = bankCity; }
public String getBankRegion() { return bankRegion; }
public void setBankRegion(String bankRegion) { this.bankRegion = bankRegion; }
public String getBankAddress() { return bankAddress; }
public void setBankAddress(String bankAddress) { this.bankAddress = bankAddress; }
public String getIdCardFrontUrl() { return idCardFrontUrl; }
public void setIdCardFrontUrl(String idCardFrontUrl) { this.idCardFrontUrl = idCardFrontUrl; }
public String getIdCardBackUrl() { return idCardBackUrl; }
public void setIdCardBackUrl(String idCardBackUrl) { this.idCardBackUrl = idCardBackUrl; }
public String getIdCardAttachments() { return idCardAttachments; }
public void setIdCardAttachments(String idCardAttachments) { this.idCardAttachments = idCardAttachments; }
}
@@ -78,6 +78,8 @@ public class BizMeeting extends BaseEntity {
private String laborSigned;
/** 参会人 user_id (非持久化字段, 仅用于 mapper WHERE 过滤; controller 注入, 配合 biz_meeting_attendee 中间表) */
private transient Long userId;
/** 新增/编辑会议时传入, 自动批量写入 biz_meeting_attendee 中间表 (非持久化) */
private Long[] attendeeUserIds;
public Long getMeetingId() { return meetingId; }
public void setMeetingId(Long meetingId) { this.meetingId = meetingId; }
public String getProjectNo() { return projectNo; }
@@ -112,6 +114,9 @@ public class BizMeeting extends BaseEntity {
public void setProjectName(String projectName) { this.projectName = projectName; }
public String getOrgName() { return orgName; }
public void setOrgName(String orgName) { this.orgName = orgName; }
private String address;
public String getAddress() { return address; }
public void setAddress(String address) { this.address = address; }
public String getSupervisionOpinion() { return supervisionOpinion; }
public void setSupervisionOpinion(String supervisionOpinion) { this.supervisionOpinion = supervisionOpinion; }
public String getSupervisionBy() { return supervisionBy; }
@@ -126,4 +131,6 @@ public class BizMeeting extends BaseEntity {
public void setLaborSigned(String laborSigned) { this.laborSigned = laborSigned; }
public Long getUserId() { return userId; }
public void setUserId(Long userId) { this.userId = userId; }
public Long[] getAttendeeUserIds() { return attendeeUserIds; }
public void setAttendeeUserIds(Long[] attendeeUserIds) { this.attendeeUserIds = attendeeUserIds; }
}
@@ -1,5 +1,6 @@
package com.ruoyi.business.domain;
import java.math.BigDecimal;
import java.util.Date;
import com.fasterxml.jackson.annotation.JsonFormat;
import com.ruoyi.common.annotation.Excel;
@@ -7,7 +8,7 @@ import com.ruoyi.common.core.domain.BaseEntity;
/**
* 会议参会人 (biz_meeting_attendee 中间表)
* 用于按 user_id 过滤"我参加的会议", 替代在 biz_meeting 上加冗余字段
* 用于按 user_id 过滤"我参加的会议" + 医生签字劳务协议的快照数据
*/
public class BizMeetingAttendee extends BaseEntity {
private static final long serialVersionUID = 1L;
@@ -17,10 +18,36 @@ public class BizMeetingAttendee extends BaseEntity {
private Long meetingId;
/** user_id (FK sys_user.user_id) */
private Long userId;
/* ===== 签字快照字段 (从 biz_expert 预填,医生可改) ===== */
private String name;
private String phone;
private String workUnit;
private String department;
private String title;
private String idCard;
private String bankCard;
private String bankName;
private String bankBranch;
/** 开户行省/市 (1-3 段, / 分隔, 直辖市省=市) */
private String bankRegion;
private String bankAddress;
private String accountName;
private String idCardAttachments;
/* ===== 劳务信息 (admin 预填,前端只读) ===== */
private String laborForm;
private BigDecimal feePreTax;
private BigDecimal tax;
private BigDecimal fee;
/* ===== 审计 + 签字结果 ===== */
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date signedAt;
private String signedIp;
/** 手写签名 Base64 (longtext) — 由前端手写板生成 */
private String handsign;
/** 劳务协议 URL (OSS) */
private String laborProtocol;
/** 脱敏版协议 URL (身份证/银行卡用 * 替换) */
private String laborProtocolMasked;
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date createTime;
/* ====== 非持久化字段, 用于 selectUnsignedByUserId 联表查询 ====== */
@@ -29,16 +56,57 @@ public class BizMeetingAttendee extends BaseEntity {
private transient Date endTime;
private transient String projectName;
private transient String projectNo;
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public Long getMeetingId() { return meetingId; }
public void setMeetingId(Long meetingId) { this.meetingId = meetingId; }
public Long getUserId() { return userId; }
public void setUserId(Long userId) { this.userId = userId; }
public String getName() { return name; }
public void setName(String name) { this.name = name; }
public String getPhone() { return phone; }
public void setPhone(String phone) { this.phone = phone; }
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 getBankCard() { return bankCard; }
public void setBankCard(String bankCard) { this.bankCard = bankCard; }
public String getBankName() { return bankName; }
public void setBankName(String bankName) { this.bankName = bankName; }
public String getBankBranch() { return bankBranch; }
public void setBankBranch(String bankBranch) { this.bankBranch = bankBranch; }
public String getBankRegion() { return bankRegion; }
public void setBankRegion(String bankRegion) { this.bankRegion = bankRegion; }
public String getBankAddress() { return bankAddress; }
public void setBankAddress(String bankAddress) { this.bankAddress = bankAddress; }
public String getAccountName() { return accountName; }
public void setAccountName(String accountName) { this.accountName = accountName; }
public String getIdCardAttachments() { return idCardAttachments; }
public void setIdCardAttachments(String idCardAttachments) { this.idCardAttachments = idCardAttachments; }
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 getFee() { return fee; }
public void setFee(BigDecimal fee) { this.fee = fee; }
public Date getSignedAt() { return signedAt; }
public void setSignedAt(Date signedAt) { this.signedAt = signedAt; }
public String getSignedIp() { return signedIp; }
public void setSignedIp(String signedIp) { this.signedIp = signedIp; }
public String getHandsign() { return handsign; }
public void setHandsign(String handsign) { this.handsign = handsign; }
public String getLaborProtocol() { return laborProtocol; }
public void setLaborProtocol(String laborProtocol) { this.laborProtocol = laborProtocol; }
public String getLaborProtocolMasked() { return laborProtocolMasked; }
public void setLaborProtocolMasked(String laborProtocolMasked) { this.laborProtocolMasked = laborProtocolMasked; }
public Date getCreateTime() { return createTime; }
public void setCreateTime(Date createTime) { this.createTime = createTime; }
public String getMeetingName() { return meetingName; }
@@ -0,0 +1,60 @@
package com.ruoyi.business.domain.dto;
import java.util.ArrayList;
import java.util.List;
/**
* Excel 批量导入结果 (前端通用)
*
* <p>与前端 {@code <ImportResultDialog>} 直接对应:
* <ul>
* <li>{@code okNum} / {@code ngNum} — 顶部汇总</li>
* <li>{@code ngList} — 失败明细, 每条含 rowNum + message</li>
* </ul>
*
* <p>当前仅 biz_expert 批量导入使用; 后续如需复用, 移至 ruoyi-common。
*
* @author guoju
*/
public class ImportResult {
/** 成功条数 */
private int okNum;
/** 失败条数 */
private int ngNum;
/** 失败明细 (Excel 行号 + 失败原因) */
private List<NgRow> ngList = new ArrayList<>();
public void ok() { okNum++; }
public void fail(int rowNum, String message) {
ngNum++;
ngList.add(new NgRow(rowNum, message));
}
public boolean hasFailure() { return ngNum > 0; }
public int getOkNum() { return okNum; }
public void setOkNum(int okNum) { this.okNum = okNum; }
public int getNgNum() { return ngNum; }
public void setNgNum(int ngNum) { this.ngNum = ngNum; }
public List<NgRow> getNgList() { return ngList; }
public void setNgList(List<NgRow> ngList) { this.ngList = ngList; }
/** 失败行 (Excel 行号 + 原因) */
public static class NgRow {
private int rowNum;
private String message;
public NgRow() {}
public NgRow(int rowNum, String message) {
this.rowNum = rowNum;
this.message = message;
}
public int getRowNum() { return rowNum; }
public void setRowNum(int rowNum) { this.rowNum = rowNum; }
public String getMessage() { return message; }
public void setMessage(String message) { this.message = message; }
}
}
@@ -40,13 +40,10 @@ public class BizExpertExportVo {
@Excel(name = "银行名称", sort = 9)
private String bankName;
@Excel(name = "开户行省", sort = 10)
private String bankProvince;
@Excel(name = "开户行省/市", sort = 10)
private String bankRegion;
@Excel(name = "开户行城市", sort = 11)
private String bankCity;
@Excel(name = "开户行地址", sort = 12)
@Excel(name = "开户行地址", sort = 11)
private String bankAddress;
@Excel(name = "状态", sort = 13, readConverterExp = "Y=正常,N=禁用")
@@ -84,10 +81,8 @@ public class BizExpertExportVo {
public void setBankCard(String bankCard) { this.bankCard = bankCard; }
public String getBankName() { return bankName; }
public void setBankName(String bankName) { this.bankName = bankName; }
public String getBankProvince() { return bankProvince; }
public void setBankProvince(String bankProvince) { this.bankProvince = bankProvince; }
public String getBankCity() { return bankCity; }
public void setBankCity(String bankCity) { this.bankCity = bankCity; }
public String getBankRegion() { return bankRegion; }
public void setBankRegion(String bankRegion) { this.bankRegion = bankRegion; }
public String getBankAddress() { return bankAddress; }
public void setBankAddress(String bankAddress) { this.bankAddress = bankAddress; }
public String getStatus() { return status; }
@@ -8,6 +8,14 @@ import com.ruoyi.common.annotation.Excel;
* <p>仅用于 Excel 批量导入 / 模板下载 / 导出, 不参与业务逻辑.
* 字段顺序与 Excel 列一致, 调整时同步修改模板下载体验.
*
* <p>不包含的字段 (固定后端默认值或走其他导入通道):
* <ul>
* <li>状态 — 默认 'Y'(正常)</li>
* <li>审核状态 — 默认 '2'(通过)</li>
* <li>sys_user.status — 默认 '0'(正常)</li>
* <li>身份证正面/反面 URL, 执业证书 URL, 职称证明 URL — 走单独的证书导入</li>
* </ul>
*
* @author guoju
*/
public class BizExpertImportVo {
@@ -40,46 +48,22 @@ public class BizExpertImportVo {
@Excel(name = "身份证件号码", sort = 7)
private String idCard;
/** 身份证件照片 正面URL */
@Excel(name = "身份证正面URL", sort = 8)
private String idCardFrontUrl;
/** 身份证件照片 反面URL */
@Excel(name = "身份证反面URL", sort = 9)
private String idCardBackUrl;
/** 执业医师证书 URL */
@Excel(name = "执业医师证书URL", sort = 10)
private String practiceCertUrl;
/** 职称证明 URL */
@Excel(name = "职称证明URL", sort = 11)
private String titleCertUrl;
/** 银行卡号 */
@Excel(name = "银行卡号", sort = 12)
@Excel(name = "银行卡号", sort = 8)
private String bankCard;
/** 银行名称 */
@Excel(name = "银行名称", sort = 13)
@Excel(name = "银行名称", sort = 9)
private String bankName;
/** 开户行省 */
@Excel(name = "开户行省", sort = 14)
private String bankProvince;
/** 开户行城市 */
@Excel(name = "开户行城市", sort = 15)
private String bankCity;
/** 开户行省/市 (与 DB bank_region 一致, /分隔) */
@Excel(name = "开户行省/市", sort = 10)
private String bankRegion;
/** 开户行地址 */
@Excel(name = "开户行地址", sort = 16)
@Excel(name = "开户行地址", sort = 11)
private String bankAddress;
/** 状态 (Y=正常 N=禁用, 留空默认 Y) */
@Excel(name = "状态", sort = 17, readConverterExp = "Y=正常,N=禁用")
private String status;
public String getName() { return name; }
public void setName(String name) { this.name = name; }
public String getPhone() { return phone; }
@@ -94,24 +78,12 @@ public class BizExpertImportVo {
public void setRegion(String region) { this.region = region; }
public String getIdCard() { return idCard; }
public void setIdCard(String idCard) { this.idCard = idCard; }
public String getIdCardFrontUrl() { return idCardFrontUrl; }
public void setIdCardFrontUrl(String idCardFrontUrl) { this.idCardFrontUrl = idCardFrontUrl; }
public String getIdCardBackUrl() { return idCardBackUrl; }
public void setIdCardBackUrl(String idCardBackUrl) { this.idCardBackUrl = idCardBackUrl; }
public String getPracticeCertUrl() { return practiceCertUrl; }
public void setPracticeCertUrl(String practiceCertUrl) { this.practiceCertUrl = practiceCertUrl; }
public String getTitleCertUrl() { return titleCertUrl; }
public void setTitleCertUrl(String titleCertUrl) { this.titleCertUrl = titleCertUrl; }
public String getBankCard() { return bankCard; }
public void setBankCard(String bankCard) { this.bankCard = bankCard; }
public String getBankName() { return bankName; }
public void setBankName(String bankName) { this.bankName = bankName; }
public String getBankProvince() { return bankProvince; }
public void setBankProvince(String bankProvince) { this.bankProvince = bankProvince; }
public String getBankCity() { return bankCity; }
public void setBankCity(String bankCity) { this.bankCity = bankCity; }
public String getBankRegion() { return bankRegion; }
public void setBankRegion(String bankRegion) { this.bankRegion = bankRegion; }
public String getBankAddress() { return bankAddress; }
public void setBankAddress(String bankAddress) { this.bankAddress = bankAddress; }
public String getStatus() { return status; }
public void setStatus(String status) { this.status = status; }
}
@@ -0,0 +1,68 @@
package com.ruoyi.business.domain.vo;
import java.util.Date;
import com.fasterxml.jackson.annotation.JsonFormat;
import com.ruoyi.common.annotation.Excel;
/**
* 管理端 - 公示页执行意向导出 VO (中文列头)
*
* <p>数据源: biz_publicity_execution_intent (匿名+管理后台共用)
* 仅用于 Excel 导出, 不参与业务逻辑.
*
* @author guoju
*/
public class BizPublicityExecutionIntentExportVo {
@Excel(name = "项目编号", sort = 1)
private String projectNo;
@Excel(name = "项目名称", sort = 2)
private String projectName;
@Excel(name = "姓名", sort = 3)
private String name;
@Excel(name = "工作单位", sort = 4)
private String workUnit;
@Excel(name = "部门", sort = 5)
private String department;
@Excel(name = "职务", sort = 6)
private String position;
@Excel(name = "手机号", sort = 7)
private String phone;
@Excel(name = "账号状态", sort = 8)
private String userStatus;
@Excel(name = "审核状态", sort = 9)
private String intentStatus;
@Excel(name = "创建时间", sort = 10, dateFormat = "yyyy-MM-dd HH:mm:ss")
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date createTime;
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 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 getPosition() { return position; }
public void setPosition(String position) { this.position = position; }
public String getPhone() { return phone; }
public void setPhone(String phone) { this.phone = phone; }
public String getUserStatus() { return userStatus; }
public void setUserStatus(String userStatus) { this.userStatus = userStatus; }
public String getIntentStatus() { return intentStatus; }
public void setIntentStatus(String intentStatus) { this.intentStatus = intentStatus; }
public Date getCreateTime() { return createTime; }
public void setCreateTime(Date createTime) { this.createTime = createTime; }
}
@@ -0,0 +1,68 @@
package com.ruoyi.business.domain.vo;
import java.util.Date;
import com.fasterxml.jackson.annotation.JsonFormat;
import com.ruoyi.common.annotation.Excel;
/**
* 管理端 - 公示页支持意向导出 VO (中文列头)
*
* <p>数据源: biz_publicity_support_intent (匿名+管理后台共用)
* 仅用于 Excel 导出, 不参与业务逻辑.
*
* @author guoju
*/
public class BizPublicitySupportIntentExportVo {
@Excel(name = "项目编号", sort = 1)
private String projectNo;
@Excel(name = "项目名称", sort = 2)
private String projectName;
@Excel(name = "姓名", sort = 3)
private String name;
@Excel(name = "工作单位", sort = 4)
private String workUnit;
@Excel(name = "部门", sort = 5)
private String department;
@Excel(name = "职务", sort = 6)
private String position;
@Excel(name = "手机号", sort = 7)
private String phone;
@Excel(name = "账号状态", sort = 8)
private String userStatus;
@Excel(name = "审核状态", sort = 9)
private String intentStatus;
@Excel(name = "创建时间", sort = 10, dateFormat = "yyyy-MM-dd HH:mm:ss")
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date createTime;
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 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 getPosition() { return position; }
public void setPosition(String position) { this.position = position; }
public String getPhone() { return phone; }
public void setPhone(String phone) { this.phone = phone; }
public String getUserStatus() { return userStatus; }
public void setUserStatus(String userStatus) { this.userStatus = userStatus; }
public String getIntentStatus() { return intentStatus; }
public void setIntentStatus(String intentStatus) { this.intentStatus = intentStatus; }
public Date getCreateTime() { return createTime; }
public void setCreateTime(Date createTime) { this.createTime = createTime; }
}
@@ -11,6 +11,12 @@ public interface BizExpertMapper
BizExpert selectByUserId(Long userId);
List<BizExpert> selectList(BizExpert entity);
int insert(BizExpert entity);
/**
* 批量插入 biz_expert (foreach, 100/批由 service 层 chunk 控制)
* @param list BizExpert 列表, caller 须自己 chunk 到 100 以内
* @return 影响行数
*/
int batchInsert(List<BizExpert> list);
int insertWithUserId(BizExpert entity);
int updateByPrimaryKey(BizExpert entity);
int updateByUserId(BizExpert entity);
@@ -1,15 +1,23 @@
package com.ruoyi.business.mapper;
import java.util.List;
import org.apache.ibatis.annotations.Param;
import com.ruoyi.business.domain.BizMeetingAttendee;
public interface BizMeetingAttendeeMapper {
int insert(BizMeetingAttendee entity);
/** 批量插入参会人 (BizMeetingController.add 调用) */
int insertBatch(@Param("meetingId") Long meetingId, @Param("userIds") Long[] userIds, @Param("createBy") String createBy);
/** 医生填写信息保存草稿: 更新签字快照字段 + 劳务信息 (不含 handsign/PDF) */
int updateProfile(BizMeetingAttendee entity);
int updateHandsign(BizMeetingAttendee entity);
/** 提交签字: 一次性存 handsign + labor_protocol + signed_at + signed_ip */
int updateSign(BizMeetingAttendee entity);
int updateLaborProtocol(BizMeetingAttendee entity);
int deleteByMeetingId(Long meetingId);
int deleteByMeetingIdAndUserId(BizMeetingAttendee entity);
List<BizMeetingAttendee> selectByMeetingId(Long meetingId);
List<BizMeetingAttendee> selectByUserId(Long userId);
BizMeetingAttendee selectById(Long id);
List<BizMeetingAttendee> selectUnsignedByUserId(Long userId);
}
@@ -0,0 +1,20 @@
package com.ruoyi.business.service;
import com.ruoyi.business.domain.BizMeetingAttendee;
import java.util.Map;
/**
* 医生劳务协议签署 service
* 简化版: 不用 token, 走标准 Spring Security 的 SecurityUtils.getUserId() 识别医生
* doctor 登录后, 移动端 /doctor/sign-fill?attendeeId=X 打开, 后端从 SecurityUtils 取用户
*/
public interface BizSignService {
/** 医生填写页 GET /info?attendeeId=X: 返回默认值 (biz_expert 预填) + 已存 attendee 字段 + 选项 */
Map<String, Object> getSignInfo(Long attendeeId);
/** 医生填写页 POST /saveProfile: 批量 UPDATE attendee 字段 (不含签名) */
void saveProfile(Long attendeeId, BizMeetingAttendee form);
/** 签署页 GET /contract?attendeeId=X: 渲染完整 HTML (占位符替换 + 身份证附件 + 手写签名) */
String getContractHtml(Long attendeeId);
/** 签署页 POST /submit: 返回 {fullPdfUrl, maskedPdfUrl} */
Map<String, String> submitSign(Long attendeeId, String handsignBase64, String contentHtml, String signedIp);
}
@@ -2,8 +2,10 @@ package com.ruoyi.business.service;
import java.util.List;
import com.ruoyi.business.domain.BizExpert;
import com.ruoyi.business.domain.dto.ImportResult;
import com.ruoyi.business.domain.vo.BizExpertImportVo;
import com.ruoyi.common.core.domain.entity.SysUser;
import org.springframework.web.multipart.MultipartFile;
/**
* 专家Service接口
@@ -31,8 +33,19 @@ public interface IBizExpertService
int deleteByPrimaryKeys(Long[] expertId);
/**
* 批量导入专家: 每行调用 insert, updateSupport=true 时跳过已存在手机号(视为成功)
* 返回 String 含成功/失败计数 + 失败明细 (前端 toast 显示)
* 批量导入专家 (3 阶段批量版):
* <ul>
* <li>Phase A: IN 查 sys_user (100/批), 记录已存在 phone → userId</li>
* <li>Phase B: 新 phone batch insert sys_user (100/批), 插完再 IN 查回 userId</li>
* <li>Phase C: batch insert biz_expert (100/批), 已存在 phone 复用 Phase A userId</li>
* </ul>
* 单行校验失败不中断, 错误累计到 {@link ImportResult}。
* 默认审核通过 (audit_status='2'), 用户名=密码=手机号, role_type='doctor'。
*
* @param file Excel 文件 (multipart)
* @param updateSupport 已存在 phone 是否覆盖 update (当前实现: 始终绑定已有 user_id, 仅插 biz_expert)
* @param operName 操作人 (写入 create_by / audit_by)
* @return 导入结果
*/
String importExpert(List<BizExpertImportVo> importList, boolean updateSupport, String operName);
ImportResult importExpert(MultipartFile file, boolean updateSupport, String operName) throws Exception;
}
@@ -5,12 +5,18 @@ import com.ruoyi.business.domain.BizMeetingAttendee;
public interface IBizMeetingAttendeeService {
int insert(BizMeetingAttendee entity);
/** 批量插入参会人 (BizMeetingController.add 调用) */
int insertBatch(Long meetingId, Long[] userIds);
/** 医生填写信息保存草稿: 更新签字快照字段 + 劳务信息 (不含 handsign/PDF) */
int updateProfile(BizMeetingAttendee entity);
int updateHandsign(BizMeetingAttendee entity);
/** 提交签字: 一次性存 handsign + labor_protocol + signed_at + signed_ip */
int updateSign(BizMeetingAttendee entity);
int updateLaborProtocol(BizMeetingAttendee entity);
int deleteByMeetingId(Long meetingId);
int deleteByMeetingIdAndUserId(BizMeetingAttendee entity);
List<BizMeetingAttendee> selectByMeetingId(Long meetingId);
List<BizMeetingAttendee> selectByUserId(Long userId);
/** 当前用户的"待签署"会议列表 (handsign 或 labor_protocol 任一为空) */
BizMeetingAttendee selectById(Long id);
List<BizMeetingAttendee> selectUnsignedByUserId(Long userId);
}
@@ -0,0 +1,102 @@
package com.ruoyi.business.service;
import com.itextpdf.html2pdf.ConverterProperties;
import com.itextpdf.html2pdf.HtmlConverter;
import com.itextpdf.io.font.PdfEncodings;
import com.itextpdf.kernel.font.PdfFont;
import com.itextpdf.kernel.font.PdfFontFactory;
import com.itextpdf.kernel.geom.PageSize;
import com.itextpdf.kernel.pdf.PdfDocument;
import com.itextpdf.kernel.pdf.PdfWriter;
import com.itextpdf.layout.font.FontProvider;
import com.ruoyi.common.config.RuoYiConfig;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.FileOutputStream;
import java.text.SimpleDateFormat;
import java.util.Date;
/**
* HTML 转 PDF 服务
* 用 iText 7 html2pdf (HtmlConverter) + 内置宋体 (simsun.ttc) 渲染中文
* 输入: HTML 字符串 → 输出: PDF 文件 (存到 ruoyi.profile 目录, 返回 URL)
* 说明: 相比 Flying Saucer (xhtmlrenderer 严格 XML 解析), html2pdf 走 jsoup HTML 解析,
* 能容忍前端拼出的非 XHTML 内容 (如 <img> 未自闭合), 不会报 SAXParseException。
*/
@Service
public class PdfService {
@Autowired
private RuoYiConfig ruoyiConfig;
/**
* HTML 字符串 → PDF 字节流
*/
public byte[] htmlToPdf(String htmlContent) {
if (htmlContent == null || htmlContent.trim().isEmpty()) {
throw new IllegalArgumentException("HTML 内容为空");
}
String fullHtml = wrapHtml(htmlContent);
try (ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
PdfWriter writer = new PdfWriter(baos);
PdfDocument pdfDocument = new PdfDocument(writer);
pdfDocument.setDefaultPageSize(PageSize.A4);
ConverterProperties properties = new ConverterProperties();
FontProvider fontProvider = new FontProvider();
// 中文字体 (对齐 hwt-serve): 基础目录下 simsun.ttc, ",0" 取第 0 个 face
PdfFont cjkFont = PdfFontFactory.createFont("simsun.ttc,0", PdfEncodings.IDENTITY_H, false);
fontProvider.addFont(cjkFont.getFontProgram(), PdfEncodings.IDENTITY_H);
properties.setFontProvider(fontProvider);
HtmlConverter.convertToPdf(fullHtml, pdfDocument, properties);
pdfDocument.close();
return baos.toByteArray();
} catch (Exception e) {
throw new RuntimeException("HTML 转 PDF 失败: " + e.getMessage(), e);
}
}
/**
* HTML → PDF 文件 (存到本地)
* @return 完整 URL (前端可直接打开)
*/
public String htmlToPdfFile(String htmlContent, String bizPath) {
byte[] pdfBytes = htmlToPdf(htmlContent);
// 按 RuoYi 风格分目录: profile/labor/{date}/{filename}
SimpleDateFormat dateDir = new SimpleDateFormat("yyyy-MM-dd");
String today = dateDir.format(new Date());
String datePath = (bizPath == null || bizPath.isEmpty() ? "labor" : bizPath) + "/" + today;
String filename = System.currentTimeMillis() + "_" + (int)(Math.random() * 1000) + ".pdf";
String profilePath = ruoyiConfig.getProfile();
File dir = new File(profilePath + File.separator + datePath);
if (!dir.exists()) {
dir.mkdirs();
}
File pdfFile = new File(dir, filename);
try (FileOutputStream fos = new FileOutputStream(pdfFile)) {
fos.write(pdfBytes);
} catch (Exception e) {
throw new RuntimeException("保存 PDF 失败: " + e.getMessage(), e);
}
// 返回 URL 路径 (前端拼 origin)
String url = "/profile/" + datePath + "/" + filename;
return url;
}
/**
* 包裹完整 HTML 结构 (HTML5, 供 html2pdf 解析)
*/
private String wrapHtml(String content) {
String lower = content.trim().toLowerCase();
if (lower.startsWith("<html") || lower.startsWith("<!doctype")) {
return content;
}
return "<!DOCTYPE html><html><head><meta charset=\"UTF-8\" /></head><body>"
+ content + "</body></html>";
}
}
@@ -1,9 +1,19 @@
package com.ruoyi.business.service.impl;
import java.util.ArrayList;
import java.util.Date;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
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 com.ruoyi.business.domain.BizExpert;
import com.ruoyi.business.domain.dto.ImportResult;
import com.ruoyi.business.domain.vo.BizExpertImportVo;
import com.ruoyi.business.mapper.BizExpertMapper;
import com.ruoyi.business.service.IBizExpertService;
@@ -11,17 +21,27 @@ 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.id.IdGenerator;
import com.ruoyi.common.utils.poi.ExcelUtil;
import com.ruoyi.system.mapper.SysUserMapper;
import com.ruoyi.system.service.ISysUserService;
@Service
public class BizExpertServiceImpl implements IBizExpertService
{
private static final Logger log = LoggerFactory.getLogger(BizExpertServiceImpl.class);
/** 批量导入 chunk 大小, IN 查和 INSERT 都是这个粒度 */
private static final int BATCH_SIZE = 100;
@Autowired
private BizExpertMapper bizExpertMapper;
@Autowired
private ISysUserService sysUserService;
@Autowired
private SysUserMapper sysUserMapper;
@Override
public BizExpert getById(Long expertId)
{ return bizExpertMapper.selectByPrimaryKey(expertId); }
@@ -139,105 +159,228 @@ public class BizExpertServiceImpl implements IBizExpertService
{ return bizExpertMapper.deleteByPrimaryKeys(expertId); }
/**
* 批量导入专家:
* 1. 每行 必填校验 (姓名/手机号/工作单位/科室/职称, 手机号正则 1[0-9]{10})
* 2. status 留空 → Y (正常); 非法值 → 抛错
* 3. sys_user 同步创建 (与单条 insert 共用 sys_userService.insertUser)
* 4. updateSupport=true 时: 同一手机号已存在 → 跳过 (按成功计)
* 5. 单行失败不中断, 错误信息累计
* 批量导入专家 (3 阶段批量版):
* <ol>
* <li>Phase 0: ExcelUtil 反序列化 + 行级必填校验, 失败行累计到 result.ngList</li>
* <li>Phase A: 100/批 IN 查 sys_user, 记录已有 phone → userId 映射</li>
* <li>Phase B: 100/批 batch insert sys_user (新 phone), 插完再 IN 查回 userId; 同步 updateRoleType='doctor'</li>
* <li>Phase C: 100/批 batch insert biz_expert (所有有效行), 已存在 phone 复用 Phase A userId</li>
* </ol>
* 失败行为:
* <ul>
* <li>Phase B 整批失败 → 退化为单条逐行 insert, 失败的行记 ngList</li>
* <li>Phase C 整批失败 → 同上</li>
* <li>跨批失败不回滚已成功的批 (避免一条脏数据拖垮整批)</li>
* </ul>
*/
@Override
public String importExpert(List<BizExpertImportVo> importList, boolean updateSupport, String operName) {
@Transactional(rollbackFor = Exception.class)
public ImportResult importExpert(MultipartFile file, boolean updateSupport, String operName) throws Exception {
// ==================== Phase 0: 解析 + 行级校验 ====================
ExcelUtil<BizExpertImportVo> util = new ExcelUtil<>(BizExpertImportVo.class);
List<BizExpertImportVo> importList = util.importExcel(file.getInputStream());
if (importList == null || importList.isEmpty()) {
throw new ServiceException("导入数据不能为空");
}
int successNum = 0;
int failureNum = 0;
StringBuilder successMsg = new StringBuilder();
StringBuilder failureMsg = new StringBuilder();
ImportResult result = new ImportResult();
// validRows: 校验通过的 {vo, rowNo}; status 固定 'Y'(正常), auditStatus 固定 '2'(通过)
List<ValidatedRow> validRows = new ArrayList<>(importList.size());
for (int i = 0; i < importList.size(); i++) {
BizExpertImportVo vo = importList.get(i);
int rowNo = i + 2; // Excel 行号 (1=表头)
try {
// 必填校验
if (vo.getName() == null || vo.getName().trim().isEmpty()) {
throw new ServiceException("姓名不能为空");
}
if (vo.getPhone() == null || vo.getPhone().trim().isEmpty()) {
throw new ServiceException("手机号不能为空");
}
if (!vo.getPhone().matches("^1[0-9]\\d{9}$")) {
throw new ServiceException("手机号格式不正确");
}
if (vo.getWorkUnit() == null || vo.getWorkUnit().trim().isEmpty()) {
throw new ServiceException("工作单位不能为空");
}
if (vo.getDepartment() == null || vo.getDepartment().trim().isEmpty()) {
throw new ServiceException("科室不能为空");
}
if (vo.getTitle() == null || vo.getTitle().trim().isEmpty()) {
throw new ServiceException("职称不能为空");
}
// status 校验, 留空 → Y
String status = vo.getStatus();
if (status == null || status.trim().isEmpty()) {
status = "Y";
} else if (!"Y".equals(status) && !"N".equals(status)) {
throw new ServiceException("状态只能是 Y(正常) 或 N(禁用)");
}
// 同一手机号已存在 → updateSupport=true 跳过, 否则报错
if (sysUserService.isPhoneRegistered(vo.getPhone())) {
if (updateSupport) {
successNum++;
successMsg.append("<br/>" + successNum + "、手机号 " + vo.getPhone() + " 已存在, 已跳过");
continue;
}
throw new ServiceException("手机号 " + vo.getPhone() + " 已存在");
}
// 复用单条 insert 逻辑 (含 sys_user 创建)
BizExpert entity = new BizExpert();
entity.setName(vo.getName());
entity.setPhone(vo.getPhone());
entity.setWorkUnit(vo.getWorkUnit());
entity.setDepartment(vo.getDepartment());
entity.setTitle(vo.getTitle());
entity.setRegion(vo.getRegion());
entity.setIdCard(vo.getIdCard());
entity.setIdCardFrontUrl(vo.getIdCardFrontUrl());
entity.setIdCardBackUrl(vo.getIdCardBackUrl());
entity.setPracticeCertUrl(vo.getPracticeCertUrl());
entity.setTitleCertUrl(vo.getTitleCertUrl());
entity.setBankCard(vo.getBankCard());
entity.setBankName(vo.getBankName());
entity.setBankProvince(vo.getBankProvince());
entity.setBankCity(vo.getBankCity());
entity.setBankAddress(vo.getBankAddress());
entity.setStatus(status);
// admin 批量导入默认通过审核 (与单条 admin 创建一致)
entity.setAuditStatus("2");
entity.setAuditBy(operName);
entity.setAuditTime(new java.util.Date());
entity.setCreateBy(operName);
insert(entity);
successNum++;
successMsg.append("<br/>" + successNum + "、账号 " + vo.getPhone() + " 导入成功");
ValidatedRow vr = validateRow(vo, rowNo);
validRows.add(vr);
} catch (Exception e) {
failureNum++;
String msg = e.getMessage();
if (msg == null) msg = e.getClass().getSimpleName();
failureMsg.append("<br/>" + failureNum + "、第 " + rowNo + " 行: " + msg);
result.fail(rowNo, e.getMessage() == null ? "校验失败" : e.getMessage());
}
}
if (failureNum > 0) {
failureMsg.insert(0, "很抱歉, 部分导入失败, 共 " + failureNum + " 条:");
if (validRows.isEmpty()) {
return result;
}
if (successNum > 0) {
successMsg.insert(0, "恭喜您, 数据已全部导入成功!共 " + successNum + " 条, 数据如下:");
// ==================== Phase A: IN 查 sys_user (100/批) ====================
// phone -> userId, 含已存在 user (del_flag='0')
Map<String, Long> phoneToUserId = new HashMap<>();
List<String> allPhones = validRows.stream()
.map(vr -> vr.vo.getPhone())
.distinct()
.collect(Collectors.toList());
for (List<String> chunk : chunk(allPhones, BATCH_SIZE)) {
List<SysUser> existing = sysUserMapper.selectByPhoneList(chunk);
for (SysUser u : existing) {
phoneToUserId.put(u.getPhonenumber(), u.getUserId());
}
}
// ==================== Phase B: batch insert sys_user (新 phone) ====================
List<ValidatedRow> needNewUser = validRows.stream()
.filter(vr -> !phoneToUserId.containsKey(vr.vo.getPhone()))
.collect(Collectors.toList());
for (List<ValidatedRow> chunk : chunk(needNewUser, BATCH_SIZE)) {
List<SysUser> newUsers = new ArrayList<>(chunk.size());
List<String> chunkPhones = new ArrayList<>(chunk.size());
for (ValidatedRow vr : chunk) {
SysUser u = new SysUser();
u.setUserName(vr.vo.getPhone());
u.setNickName(vr.vo.getName());
u.setPhonenumber(vr.vo.getPhone());
u.setPassword(SecurityUtils.encryptPassword(vr.vo.getPhone()));
u.setStatus("0");
u.setDelFlag("0");
u.setRoleType("doctor");
u.setCreateBy(operName);
newUsers.add(u);
chunkPhones.add(vr.vo.getPhone());
}
try {
sysUserMapper.batchInsertUsers(newUsers);
// 整批 insert 后再 IN 查回 userId (foreach+useGeneratedKeys 不可靠)
List<SysUser> created = sysUserMapper.selectByPhoneList(chunkPhones);
for (SysUser u : created) {
phoneToUserId.put(u.getPhonenumber(), u.getUserId());
}
// 兜底: 若某条 phone 仍未查到 userId, 退化为单条逐行, 计入 ngList
for (ValidatedRow vr : chunk) {
if (!phoneToUserId.containsKey(vr.vo.getPhone())) {
try {
insert(toEntity(vr.vo, operName, null));
// 单条 insert 已经写了 sys_user, 再查一次
SysUser u2 = sysUserMapper.selectUserByUserName(vr.vo.getPhone());
if (u2 != null) phoneToUserId.put(vr.vo.getPhone(), u2.getUserId());
else throw new ServiceException("建账号失败, 请重试");
} catch (Exception ex) {
result.fail(vr.rowNo, "建账号失败: " + ex.getMessage());
}
}
}
} catch (Exception ex) {
// 整批失败 → 单条逐行退化
log.warn("batchInsertUsers 整批失败, 退化单条, 原因: {}", ex.getMessage());
for (ValidatedRow vr : chunk) {
try {
insert(toEntity(vr.vo, operName, null));
SysUser u2 = sysUserMapper.selectUserByUserName(vr.vo.getPhone());
if (u2 != null) phoneToUserId.put(vr.vo.getPhone(), u2.getUserId());
else throw new ServiceException("建账号失败, 请重试");
} catch (Exception ex2) {
result.fail(vr.rowNo, "建账号失败: " + ex2.getMessage());
}
}
}
}
// ==================== Phase C: batch insert biz_expert (100/批) ====================
// 过滤出已有 userId 的有效行
List<ValidatedRow> readyForExpert = new ArrayList<>(validRows.size());
for (ValidatedRow vr : validRows) {
if (phoneToUserId.containsKey(vr.vo.getPhone())) {
readyForExpert.add(vr);
}
// 否则该行已在 Phase B 计入 ngList, 不再尝试插 biz_expert
}
for (List<ValidatedRow> chunk : chunk(readyForExpert, BATCH_SIZE)) {
List<BizExpert> experts = new ArrayList<>(chunk.size());
for (ValidatedRow vr : chunk) {
experts.add(toEntity(vr.vo, operName, phoneToUserId.get(vr.vo.getPhone())));
}
try {
bizExpertMapper.batchInsert(experts);
// 整批成功 → 累加 okNum
for (int k = 0; k < chunk.size(); k++) {
result.ok();
}
} catch (Exception ex) {
// 整批失败 → 单条逐行退化, 失败行记 ngList
log.warn("batchInsert experts 整批失败, 退化单条, 原因: {}", ex.getMessage());
for (ValidatedRow vr : chunk) {
try {
bizExpertMapper.insert(toEntity(vr.vo, operName, phoneToUserId.get(vr.vo.getPhone())));
result.ok();
} catch (Exception ex2) {
result.fail(vr.rowNo, "插 biz_expert 失败: " + ex2.getMessage());
}
}
}
}
return result;
}
/**
* 行级校验, 失败抛 ServiceException
*/
private ValidatedRow validateRow(BizExpertImportVo vo, int rowNo) {
if (vo.getName() == null || vo.getName().trim().isEmpty()) {
throw new ServiceException("姓名不能为空");
}
if (vo.getPhone() == null || vo.getPhone().trim().isEmpty()) {
throw new ServiceException("手机号不能为空");
}
if (!vo.getPhone().matches("^1[0-9]\\d{9}$")) {
throw new ServiceException("手机号格式不正确");
}
if (vo.getWorkUnit() == null || vo.getWorkUnit().trim().isEmpty()) {
throw new ServiceException("工作单位不能为空");
}
if (vo.getDepartment() == null || vo.getDepartment().trim().isEmpty()) {
throw new ServiceException("科室不能为空");
}
if (vo.getTitle() == null || vo.getTitle().trim().isEmpty()) {
throw new ServiceException("职称不能为空");
}
return new ValidatedRow(vo, rowNo);
}
/**
* Vo → BizExpert (含 userId / 雪花 expertId / 默认 status='Y', auditStatus='2')
* 不设 URL 字段 (idCardFrontUrl / idCardBackUrl / practiceCertUrl / titleCertUrl),
* 由单独的证书导入通道写入.
*/
private BizExpert toEntity(BizExpertImportVo vo, String operName, Long userId) {
BizExpert entity = new BizExpert();
entity.setExpertId(IdGenerator.generateId());
if (userId != null) entity.setUserId(userId);
entity.setName(vo.getName());
entity.setPhone(vo.getPhone());
entity.setWorkUnit(vo.getWorkUnit());
entity.setDepartment(vo.getDepartment());
entity.setTitle(vo.getTitle());
entity.setRegion(vo.getRegion());
entity.setIdCard(vo.getIdCard());
entity.setBankCard(vo.getBankCard());
entity.setBankName(vo.getBankName());
entity.setBankRegion(vo.getBankRegion());
entity.setBankAddress(vo.getBankAddress());
entity.setStatus("Y");
entity.setAuditStatus("2");
entity.setAuditBy(operName);
entity.setAuditTime(new Date());
entity.setCreateBy(operName);
return entity;
}
/**
* 通用 chunk 工具: list → List<List<T>>, 单批 ≤size
*/
private static <T> List<List<T>> chunk(List<T> list, int size) {
List<List<T>> out = new ArrayList<>();
for (int i = 0; i < list.size(); i += size) {
out.add(list.subList(i, Math.min(i + size, list.size())));
}
return out;
}
/** 校验通过的中间结构 */
private static class ValidatedRow {
final BizExpertImportVo vo;
final int rowNo;
ValidatedRow(BizExpertImportVo vo, int rowNo) {
this.vo = vo;
this.rowNo = rowNo;
}
return successMsg.toString() + failureMsg.toString();
}
}
@@ -6,50 +6,72 @@ 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.utils.SecurityUtils;
@Service
public class BizMeetingAttendeeServiceImpl implements IBizMeetingAttendeeService {
@Autowired
private BizMeetingAttendeeMapper bizMeetingAttendeeMapper;
private BizMeetingAttendeeMapper mapper;
@Override
public int insert(BizMeetingAttendee entity) {
return bizMeetingAttendeeMapper.insert(entity);
return mapper.insert(entity);
}
@Override
public int insertBatch(Long meetingId, Long[] userIds) {
if (userIds == null || userIds.length == 0) return 0;
return mapper.insertBatch(meetingId, userIds, SecurityUtils.getUsername());
}
@Override
public int updateProfile(BizMeetingAttendee entity) {
return mapper.updateProfile(entity);
}
@Override
public int updateHandsign(BizMeetingAttendee entity) {
return bizMeetingAttendeeMapper.updateHandsign(entity);
return mapper.updateHandsign(entity);
}
@Override
public int updateSign(BizMeetingAttendee entity) {
return mapper.updateSign(entity);
}
@Override
public int updateLaborProtocol(BizMeetingAttendee entity) {
return bizMeetingAttendeeMapper.updateLaborProtocol(entity);
return mapper.updateLaborProtocol(entity);
}
@Override
public int deleteByMeetingId(Long meetingId) {
return bizMeetingAttendeeMapper.deleteByMeetingId(meetingId);
return mapper.deleteByMeetingId(meetingId);
}
@Override
public int deleteByMeetingIdAndUserId(BizMeetingAttendee entity) {
return bizMeetingAttendeeMapper.deleteByMeetingIdAndUserId(entity);
return mapper.deleteByMeetingIdAndUserId(entity);
}
@Override
public List<BizMeetingAttendee> selectByMeetingId(Long meetingId) {
return bizMeetingAttendeeMapper.selectByMeetingId(meetingId);
return mapper.selectByMeetingId(meetingId);
}
@Override
public List<BizMeetingAttendee> selectByUserId(Long userId) {
return bizMeetingAttendeeMapper.selectByUserId(userId);
return mapper.selectByUserId(userId);
}
@Override
public BizMeetingAttendee selectById(Long id) {
return mapper.selectById(id);
}
@Override
public List<BizMeetingAttendee> selectUnsignedByUserId(Long userId) {
return bizMeetingAttendeeMapper.selectUnsignedByUserId(userId);
return mapper.selectUnsignedByUserId(userId);
}
}
@@ -0,0 +1,293 @@
package com.ruoyi.business.service.impl;
import java.math.BigDecimal;
import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import com.ruoyi.business.domain.BizExpert;
import com.ruoyi.business.domain.BizLaborProtocolTemplate;
import com.ruoyi.business.domain.BizMeeting;
import com.ruoyi.business.domain.BizMeetingAttendee;
import com.ruoyi.business.mapper.BizExpertMapper;
import com.ruoyi.business.mapper.BizLaborProtocolTemplateMapper;
import com.ruoyi.business.mapper.BizMeetingMapper;
import com.ruoyi.business.service.BizSignService;
import com.ruoyi.business.service.IBizMeetingAttendeeService;
import com.ruoyi.business.service.PdfService;
import com.ruoyi.common.exception.ServiceException;
import com.ruoyi.common.utils.SecurityUtils;
@Service
public class BizSignServiceImpl implements BizSignService {
@Autowired
private IBizMeetingAttendeeService attendeeService;
@Autowired
private BizExpertMapper expertMapper;
@Autowired
private BizMeetingMapper meetingMapper;
@Autowired
private BizLaborProtocolTemplateMapper templateMapper;
@Autowired
private PdfService pdfService;
@Override
public Map<String, Object> getSignInfo(Long attendeeId) {
BizMeetingAttendee attendee = attendeeService.selectById(attendeeId);
if (attendee == null) {
throw new ServiceException("参会人记录不存在");
}
// 安全: 当前登录用户必须是该 attendee 的 user_id
Long currentUserId = SecurityUtils.getUserId();
if (currentUserId == null || !currentUserId.equals(attendee.getUserId())) {
throw new ServiceException("无权访问该协议");
}
// 从 biz_expert 取默认值 (按 user_id 查)
BizExpert expert = expertMapper.selectByUserId(attendee.getUserId());
Map<String, Object> defaults = new HashMap<>();
if (expert != null) {
defaults.put("name", expert.getName() != null ? expert.getName() : "");
defaults.put("phone", expert.getPhone() != null ? expert.getPhone() : "");
defaults.put("workUnit", expert.getWorkUnit() != null ? expert.getWorkUnit() : "");
defaults.put("department", expert.getDepartment() != null ? expert.getDepartment() : "");
defaults.put("title", expert.getTitle() != null ? expert.getTitle() : "");
defaults.put("idCard", expert.getIdCard() != null ? expert.getIdCard() : "");
defaults.put("bankCard", expert.getBankCard() != null ? expert.getBankCard() : "");
defaults.put("bankName", expert.getBankName() != null ? expert.getBankName() : "");
defaults.put("bankRegion", expert.getBankRegion() != null ? expert.getBankRegion() : "");
defaults.put("bankAddress", expert.getBankAddress() != null ? expert.getBankAddress() : "");
}
Map<String, Object> current = new HashMap<>();
current.put("name", attendee.getName() != null ? attendee.getName() : (String) defaults.getOrDefault("name", ""));
current.put("phone", attendee.getPhone() != null ? attendee.getPhone() : (String) defaults.getOrDefault("phone", ""));
current.put("workUnit", attendee.getWorkUnit() != null ? attendee.getWorkUnit() : (String) defaults.getOrDefault("workUnit", ""));
current.put("department", attendee.getDepartment() != null ? attendee.getDepartment() : (String) defaults.getOrDefault("department", ""));
current.put("title", attendee.getTitle() != null ? attendee.getTitle() : (String) defaults.getOrDefault("title", ""));
current.put("idCard", attendee.getIdCard() != null ? attendee.getIdCard() : (String) defaults.getOrDefault("idCard", ""));
current.put("bankCard", attendee.getBankCard() != null ? attendee.getBankCard() : (String) defaults.getOrDefault("bankCard", ""));
current.put("bankName", attendee.getBankName() != null ? attendee.getBankName() : (String) defaults.getOrDefault("bankName", ""));
current.put("bankBranch", attendee.getBankBranch() != null ? attendee.getBankBranch() : "");
current.put("bankRegion", attendee.getBankRegion() != null ? attendee.getBankRegion() : (String) defaults.getOrDefault("bankRegion", ""));
current.put("bankAddress", attendee.getBankAddress() != null ? attendee.getBankAddress() : (String) defaults.getOrDefault("bankAddress", ""));
current.put("accountName", attendee.getAccountName() != null ? attendee.getAccountName() : "");
current.put("idCardAttachments", attendee.getIdCardAttachments() != null ? attendee.getIdCardAttachments() : "");
current.put("laborForm", attendee.getLaborForm() != null ? attendee.getLaborForm() : "");
current.put("feePreTax", attendee.getFeePreTax());
current.put("tax", attendee.getTax());
current.put("fee", attendee.getFee());
List<Map<String, String>> laborFormOptions = Arrays.asList(
map("讲课", "讲课"), map("讨论", "讨论"),
map("主持", "主持"), map("主席", "主席"),
map("__other__", "其他"));
List<String> titleOptions = Arrays.asList(
"主任医师", "副主任医师", "主治(主管)医师", "医士",
"主任药师", "药师", "药士",
"主任护师", "副主任护师", "主管护师", "护师", "护士",
"主任技师", "副主任技师", "主管技师", "技士",
"研究员", "副研究员", "助理研究员", "研究实习员");
Map<String, Object> result = new HashMap<>();
result.put("defaults", defaults);
result.put("current", current);
result.put("laborFormOptions", laborFormOptions);
result.put("titleOptions", titleOptions);
result.put("attendeeId", attendeeId);
return result;
}
private Map<String, String> map(String value, String label) {
Map<String, String> m = new HashMap<>();
m.put("value", value);
m.put("label", label);
return m;
}
@Override
public void saveProfile(Long attendeeId, BizMeetingAttendee form) {
BizMeetingAttendee attendee = attendeeService.selectById(attendeeId);
if (attendee == null) {
throw new ServiceException("参会人记录不存在");
}
Long currentUserId = SecurityUtils.getUserId();
if (currentUserId == null || !currentUserId.equals(attendee.getUserId())) {
throw new ServiceException("无权访问该协议");
}
BizMeetingAttendee entity = new BizMeetingAttendee();
entity.setId(attendeeId);
entity.setName(form.getName());
entity.setPhone(form.getPhone());
entity.setWorkUnit(form.getWorkUnit());
entity.setDepartment(form.getDepartment());
entity.setTitle(form.getTitle());
entity.setIdCard(form.getIdCard());
entity.setBankCard(form.getBankCard());
entity.setBankName(form.getBankName());
entity.setBankBranch(form.getBankBranch());
entity.setBankRegion(form.getBankRegion());
entity.setBankAddress(form.getBankAddress());
entity.setAccountName(form.getAccountName());
entity.setIdCardAttachments(form.getIdCardAttachments());
entity.setLaborForm(form.getLaborForm());
entity.setUpdateBy(SecurityUtils.getUsername());
attendeeService.updateProfile(entity);
}
@Override
public String getContractHtml(Long attendeeId) {
BizMeetingAttendee attendee = attendeeService.selectById(attendeeId);
if (attendee == null) {
throw new ServiceException("参会人记录不存在");
}
Long currentUserId = SecurityUtils.getUserId();
if (currentUserId == null || !currentUserId.equals(attendee.getUserId())) {
throw new ServiceException("无权访问该协议");
}
BizLaborProtocolTemplate template = templateMapper.selectDefault();
if (template == null) {
throw new ServiceException("系统未配置默认劳务协议模板");
}
String html = template.getTemplateContent();
html = replacePlaceholders(html, attendee);
BizMeeting meeting = meetingMapper.selectByPrimaryKey(attendee.getMeetingId());
if (meeting != null) {
html = html.replaceAll("\\{会议名称\\}", nullToEmpty(meeting.getMeetingName()));
html = html.replaceAll("\\{会议地址\\}", nullToEmpty(meeting.getAddress()));
html = html.replaceAll("\\{会议时间\\}", formatDateTime(meeting.getStartTime()));
html = html.replaceAll("\\{日期\\}", formatDate(meeting.getStartTime()));
}
if (attendee.getIdCardAttachments() != null && !attendee.getIdCardAttachments().isEmpty()) {
String[] urls = attendee.getIdCardAttachments().split(",");
StringBuilder imgs = new StringBuilder();
for (String u : urls) {
if (u.trim().isEmpty()) continue;
imgs.append("<div style=\"margin:8px auto;text-align:center;\"><img src='").append(u.trim())
.append("' style=\"max-width:240px;\"/></div>");
}
html = html.replaceAll("\\{身份证附件\\}", imgs.toString());
}
html = html.replaceAll("\\{费用总额\\}", attendee.getFee() != null ? attendee.getFee().toPlainString() : "");
if (attendee.getHandsign() != null && !attendee.getHandsign().isEmpty()) {
html = html.replaceAll("\\{手写签名\\}",
"<img style='width: 60px; height: 30px' src='" + attendee.getHandsign() + "' />");
}
return html;
}
private String replacePlaceholders(String html, BizMeetingAttendee a) {
html = html.replaceAll("\\{name\\}", nullToEmpty(a.getName()));
html = html.replaceAll("\\{phone\\}", nullToEmpty(a.getPhone()));
html = html.replaceAll("\\{dept\\}", nullToEmpty(a.getWorkUnit()));
html = html.replaceAll("\\{工作单位\\}", nullToEmpty(a.getWorkUnit()));
html = html.replaceAll("\\{科室\\}", nullToEmpty(a.getDepartment()));
html = html.replaceAll("\\{劳务形式\\}", nullToEmpty(a.getLaborForm()));
html = html.replaceAll("\\{账户名称(持卡人姓名)\\}", nullToEmpty(a.getAccountName()));
html = html.replaceAll("\\{身份证号(外宾填写护照号)\\}", nullToEmpty(a.getIdCard()));
html = html.replaceAll("\\{银行名称\\}", nullToEmpty(a.getBankName()));
html = html.replaceAll("\\{开户银行名称(具体到支行)\\}", nullToEmpty(a.getBankBranch()));
// 新版占位符 (合并字段): {开户行省/市} → bankRegion, {开户行地址} → bankAddress
html = html.replaceAll("\\{开户行省/市\\}", nullToEmpty(a.getBankRegion()));
html = html.replaceAll("\\{开户行地址\\}", nullToEmpty(a.getBankAddress()));
// 旧版占位符 {开户银行地址} 保留向下兼容: 合并 bankRegion + bankAddress
html = html.replaceAll("\\{开户银行地址\\}",
nullToEmpty(a.getBankRegion()) + " " + nullToEmpty(a.getBankAddress()));
html = html.replaceAll("\\{银行卡号\\}", nullToEmpty(a.getBankCard()));
html = html.replaceAll("\\{医务职称\\}", nullToEmpty(a.getTitle()));
return html;
}
private String nullToEmpty(String s) { return s == null ? "" : s; }
/** 日期格式: yyyy-MM-dd (例如 2026-01-10) */
private String formatDate(java.util.Date d) {
if (d == null) return "";
java.util.Calendar c = java.util.Calendar.getInstance();
c.setTime(d);
return String.format("%04d-%02d-%02d",
c.get(java.util.Calendar.YEAR),
c.get(java.util.Calendar.MONTH) + 1,
c.get(java.util.Calendar.DAY_OF_MONTH));
}
/** 日期时间格式: yyyy-MM-dd HH:mm (例如 2026-01-10 10:00) */
private String formatDateTime(java.util.Date d) {
if (d == null) return "";
java.util.Calendar c = java.util.Calendar.getInstance();
c.setTime(d);
return String.format("%04d-%02d-%02d %02d:%02d",
c.get(java.util.Calendar.YEAR),
c.get(java.util.Calendar.MONTH) + 1,
c.get(java.util.Calendar.DAY_OF_MONTH),
c.get(java.util.Calendar.HOUR_OF_DAY),
c.get(java.util.Calendar.MINUTE));
}
@Override
public Map<String, String> submitSign(Long attendeeId, String handsignBase64, String contentHtml, String signedIp) {
BizMeetingAttendee attendee = attendeeService.selectById(attendeeId);
if (attendee == null) {
throw new ServiceException("参会人记录不存在");
}
Long currentUserId = SecurityUtils.getUserId();
if (currentUserId == null || !currentUserId.equals(attendee.getUserId())) {
throw new ServiceException("无权访问该协议");
}
if (handsignBase64 == null || handsignBase64.isEmpty()) {
throw new ServiceException("手写签名不能为空");
}
String fullPdfUrl = pdfService.htmlToPdfFile(contentHtml, "labor/" + attendee.getMeetingId());
String maskedHtml = desensitizeHtml(contentHtml);
String maskedPdfUrl = pdfService.htmlToPdfFile(maskedHtml, "labor/" + attendee.getMeetingId() + "/masked");
BizMeetingAttendee update = new BizMeetingAttendee();
update.setId(attendeeId);
update.setHandsign(handsignBase64);
update.setLaborProtocol(fullPdfUrl);
update.setLaborProtocolMasked(maskedPdfUrl);
update.setSignedAt(new java.util.Date());
update.setSignedIp(signedIp);
update.setUpdateBy(SecurityUtils.getUsername());
attendeeService.updateSign(update);
Map<String, String> result = new HashMap<>();
result.put("fullPdfUrl", fullPdfUrl);
result.put("maskedPdfUrl", maskedPdfUrl);
return result;
}
private String desensitizeHtml(String html) {
if (html == null) return null;
java.util.regex.Pattern idCardP = java.util.regex.Pattern.compile("(?<!\\d)(\\d{17}[\\dXx])(?!\\d)");
java.util.regex.Matcher m = idCardP.matcher(html);
StringBuffer sb = new StringBuffer();
while (m.find()) {
String v = m.group(1);
String masked = v.substring(0, 6) + "********" + v.substring(14);
m.appendReplacement(sb, java.util.regex.Matcher.quoteReplacement(masked));
}
m.appendTail(sb);
html = sb.toString();
java.util.regex.Pattern bankP = java.util.regex.Pattern.compile("(?<!\\d)(\\d{16,19})(?!\\d)");
m = bankP.matcher(html);
sb = new StringBuffer();
while (m.find()) {
String v = m.group(1);
String masked = v.substring(0, 4) + "********" + v.substring(v.length() - 4);
m.appendReplacement(sb, java.util.regex.Matcher.quoteReplacement(masked));
}
m.appendTail(sb);
html = sb.toString();
java.util.regex.Pattern phoneP = java.util.regex.Pattern.compile("(?<!\\d)(1[3-9]\\d)(\\d{4})(\\d{4})(?!\\d)");
m = phoneP.matcher(html);
sb = new StringBuffer();
while (m.find()) {
String masked = m.group(1) + "****" + m.group(3);
m.appendReplacement(sb, java.util.regex.Matcher.quoteReplacement(masked));
}
m.appendTail(sb);
return sb.toString();
}
}
@@ -15,11 +15,9 @@
<result property="titleCertUrl" column="title_cert_url" />
<result property="bankCard" column="bank_card" />
<result property="bankName" column="bank_name" />
<result property="bankProvince" column="bank_province" />
<result property="bankCity" column="bank_city" />
<result property="bankRegion" column="bank_region" />
<result property="bankAddress" column="bank_address" />
<result property="idCardFrontUrl" column="id_card_front_url" />
<result property="idCardBackUrl" column="id_card_back_url" />
<result property="idCardAttachments" column="id_card_attachments" />
<result property="auditStatus" column="audit_status" />
<result property="auditBy" column="audit_by" />
<result property="auditTime" column="audit_time" />
@@ -29,7 +27,7 @@
<result property="updateTime" column="update_time" />
</resultMap>
<sql id="selectFields">
select expert_id, user_id, name, phone, region, id_card, work_unit, department, title, practice_cert_url, title_cert_url, bank_card, bank_name, bank_province, bank_city, bank_address, id_card_front_url, id_card_back_url, audit_status, audit_by, audit_time, status, create_by, create_time, update_by, update_time
select expert_id, user_id, name, phone, region, id_card, work_unit, department, title, practice_cert_url, title_cert_url, bank_card, bank_name, bank_region, bank_address, id_card_attachments, audit_status, audit_by, audit_time, status, create_by, create_time, update_by, update_time
from biz_expert
</sql>
<select id="selectByUserId" resultMap="BizExpertResult" parameterType="Long">
@@ -62,14 +60,12 @@
<if test="workUnit != null">work_unit,</if>
<if test="department != null">department,</if>
<if test="title != null">title,</if>
<if test="idCardFrontUrl != null">id_card_front_url,</if>
<if test="idCardBackUrl != null">id_card_back_url,</if>
<if test="idCardAttachments != null">id_card_attachments,</if>
<if test="practiceCertUrl != null">practice_cert_url,</if>
<if test="titleCertUrl != null">title_cert_url,</if>
<if test="bankCard != null">bank_card,</if>
<if test="bankName != null">bank_name,</if>
<if test="bankProvince != null">bank_province,</if>
<if test="bankCity != null">bank_city,</if>
<if test="bankRegion != null">bank_region,</if>
<if test="bankAddress != null">bank_address,</if>
<if test="auditStatus != null">audit_status,</if>
<if test="auditBy != null">audit_by,</if>
@@ -90,14 +86,12 @@
<if test="workUnit != null">#{workUnit},</if>
<if test="department != null">#{department},</if>
<if test="title != null">#{title},</if>
<if test="idCardFrontUrl != null">#{idCardFrontUrl},</if>
<if test="idCardBackUrl != null">#{idCardBackUrl},</if>
<if test="idCardAttachments != null">#{idCardAttachments},</if>
<if test="practiceCertUrl != null">#{practiceCertUrl},</if>
<if test="titleCertUrl != null">#{titleCertUrl},</if>
<if test="bankCard != null">#{bankCard},</if>
<if test="bankName != null">#{bankName},</if>
<if test="bankProvince != null">#{bankProvince},</if>
<if test="bankCity != null">#{bankCity},</if>
<if test="bankRegion != null">#{bankRegion},</if>
<if test="bankAddress != null">#{bankAddress},</if>
<if test="auditStatus != null">#{auditStatus},</if>
<if test="auditBy != null">#{auditBy},</if>
@@ -121,11 +115,9 @@
<if test="idCard != null">id_card = #{idCard},</if>
<if test="bankCard != null">bank_card = #{bankCard},</if>
<if test="bankName != null">bank_name = #{bankName},</if>
<if test="bankProvince != null">bank_province = #{bankProvince},</if>
<if test="bankCity != null">bank_city = #{bankCity},</if>
<if test="bankRegion != null">bank_region = #{bankRegion},</if>
<if test="bankAddress != null">bank_address = #{bankAddress},</if>
<if test="idCardFrontUrl != null">id_card_front_url = #{idCardFrontUrl},</if>
<if test="idCardBackUrl != null">id_card_back_url = #{idCardBackUrl},</if>
<if test="idCardAttachments != null">id_card_attachments = #{idCardAttachments},</if>
<if test="practiceCertUrl != null">practice_cert_url = #{practiceCertUrl},</if>
<if test="titleCertUrl != null">title_cert_url = #{titleCertUrl},</if>
<if test="auditOpinion != null">audit_opinion = #{auditOpinion},</if>
@@ -149,11 +141,9 @@
<if test="idCard != null">id_card,</if>
<if test="bankCard != null">bank_card,</if>
<if test="bankName != null">bank_name,</if>
<if test="bankProvince != null">bank_province,</if>
<if test="bankCity != null">bank_city,</if>
<if test="bankRegion != null">bank_region,</if>
<if test="bankAddress != null">bank_address,</if>
<if test="idCardFrontUrl != null">id_card_front_url,</if>
<if test="idCardBackUrl != null">id_card_back_url,</if>
<if test="idCardAttachments != null">id_card_attachments,</if>
<if test="practiceCertUrl != null">practice_cert_url,</if>
<if test="titleCertUrl != null">title_cert_url,</if>
</trim>
@@ -169,11 +159,9 @@
<if test="idCard != null">#{idCard},</if>
<if test="bankCard != null">#{bankCard},</if>
<if test="bankName != null">#{bankName},</if>
<if test="bankProvince != null">#{bankProvince},</if>
<if test="bankCity != null">#{bankCity},</if>
<if test="bankRegion != null">#{bankRegion},</if>
<if test="bankAddress != null">#{bankAddress},</if>
<if test="idCardFrontUrl != null">#{idCardFrontUrl},</if>
<if test="idCardBackUrl != null">#{idCardBackUrl},</if>
<if test="idCardAttachments != null">#{idCardAttachments},</if>
<if test="practiceCertUrl != null">#{practiceCertUrl},</if>
<if test="titleCertUrl != null">#{titleCertUrl},</if>
</trim>
@@ -187,11 +175,9 @@
<if test="idCard != null and idCard != ''">id_card = #{idCard},</if>
<if test="bankCard != null and bankCard != ''">bank_card = #{bankCard},</if>
<if test="bankName != null and bankName != ''">bank_name = #{bankName},</if>
<if test="bankProvince != null and bankProvince != ''">bank_province = #{bankProvince},</if>
<if test="bankCity != null and bankCity != ''">bank_city = #{bankCity},</if>
<if test="bankRegion != null and bankRegion != ''">bank_region = #{bankRegion},</if>
<if test="bankAddress != null and bankAddress != ''">bank_address = #{bankAddress},</if>
<if test="idCardFrontUrl != null and idCardFrontUrl != ''">id_card_front_url = #{idCardFrontUrl},</if>
<if test="idCardBackUrl != null and idCardBackUrl != ''">id_card_back_url = #{idCardBackUrl},</if>
<if test="idCardAttachments != null and idCardAttachments != ''">id_card_attachments = #{idCardAttachments},</if>
<if test="practiceCertUrl != null and practiceCertUrl != ''">practice_cert_url = #{practiceCertUrl},</if>
<if test="titleCertUrl != null and titleCertUrl != ''">title_cert_url = #{titleCertUrl},</if>
<if test="auditStatus != null and auditStatus != ''">audit_status = #{auditStatus},</if>
@@ -206,6 +192,28 @@
</trim>
where expert_id = #{expertId}
</update>
<!-- 批量插入 biz_expert (单批 caller 控制 ≤100; sysdate() 由 DB 生成) -->
<insert id="batchInsert" parameterType="list">
insert into biz_expert
(expert_id, user_id, name, phone, region, id_card,
work_unit, department, title,
practice_cert_url, title_cert_url,
bank_card, bank_name, bank_region, bank_address,
id_card_attachments,
audit_status, audit_by, audit_time, status,
create_by, create_time)
values
<foreach collection="list" item="e" separator=",">
(#{e.expertId}, #{e.userId}, #{e.name}, #{e.phone}, #{e.region}, #{e.idCard},
#{e.workUnit}, #{e.department}, #{e.title},
#{e.practiceCertUrl}, #{e.titleCertUrl},
#{e.bankCard}, #{e.bankName}, #{e.bankRegion}, #{e.bankAddress},
#{e.idCardAttachments},
#{e.auditStatus}, #{e.auditBy}, #{e.auditTime}, #{e.status},
#{e.createBy}, sysdate())
</foreach>
</insert>
<delete id="deleteByPrimaryKey" parameterType="Long">
delete from biz_expert where expert_id = #{expertId}
</delete>
@@ -5,11 +5,31 @@
<id property="id" column="id" />
<result property="meetingId" column="meeting_id" />
<result property="userId" column="user_id" />
<result property="name" column="name" />
<result property="phone" column="phone" />
<result property="workUnit" column="work_unit" />
<result property="department" column="department" />
<result property="title" column="title" />
<result property="idCard" column="id_card" />
<result property="bankCard" column="bank_card" />
<result property="bankName" column="bank_name" />
<result property="bankBranch" column="bank_branch" />
<result property="bankRegion" column="bank_region" />
<result property="bankAddress" column="bank_address" />
<result property="accountName" column="account_name" />
<result property="idCardAttachments" column="id_card_attachments" />
<result property="laborForm" column="labor_form" />
<result property="feePreTax" column="fee_pre_tax" />
<result property="tax" column="tax" />
<result property="fee" column="fee" />
<result property="signedAt" column="signed_at" />
<result property="signedIp" column="signed_ip" />
<result property="handsign" column="handsign" />
<result property="laborProtocol" column="labor_protocol" />
<result property="laborProtocolMasked" column="labor_protocol_masked" />
<result property="createBy" column="create_by" />
<result property="createTime" column="create_time" />
<!-- 联表字段 (非持久化, entity transient 字段接收) -->
<!-- 联表字段 (非持久化, entity transient property 接收) -->
<result property="meetingName" column="meeting_name" />
<result property="startTime" column="start_time" />
<result property="endTime" column="end_time" />
@@ -20,6 +40,40 @@
insert into biz_meeting_attendee(meeting_id, user_id, create_by, create_time)
values(#{meetingId}, #{userId}, #{createBy}, sysdate())
</insert>
<!-- 批量插入参会人 (BizMeetingController.add 调用) -->
<insert id="insertBatch">
insert into biz_meeting_attendee(meeting_id, user_id, create_by, create_time)
values
<foreach collection="userIds" item="userId" separator=",">
(#{meetingId}, #{userId}, #{createBy}, sysdate())
</foreach>
</insert>
<!-- 医生填写信息保存草稿: 更新所有签字字段 + 劳务信息 (不含签名/PDF) -->
<update id="updateProfile" parameterType="BizMeetingAttendee">
update biz_meeting_attendee
<set>
<if test="name != null">name = #{name},</if>
<if test="phone != null">phone = #{phone},</if>
<if test="workUnit != null">work_unit = #{workUnit},</if>
<if test="department != null">department = #{department},</if>
<if test="title != null">title = #{title},</if>
<if test="idCard != null">id_card = #{idCard},</if>
<if test="bankCard != null">bank_card = #{bankCard},</if>
<if test="bankName != null">bank_name = #{bankName},</if>
<if test="bankBranch != null">bank_branch = #{bankBranch},</if>
<if test="bankRegion != null">bank_region = #{bankRegion},</if>
<if test="bankAddress != null">bank_address = #{bankAddress},</if>
<if test="accountName != null">account_name = #{accountName},</if>
<if test="idCardAttachments != null">id_card_attachments = #{idCardAttachments},</if>
<if test="laborForm != null">labor_form = #{laborForm},</if>
<if test="feePreTax != null">fee_pre_tax = #{feePreTax},</if>
<if test="tax != null">tax = #{tax},</if>
<if test="fee != null">fee = #{fee},</if>
update_by = #{updateBy},
update_time = sysdate()
</set>
where id = #{id}
</update>
<update id="updateHandsign" parameterType="BizMeetingAttendee">
update biz_meeting_attendee
set handsign = #{handsign},
@@ -27,6 +81,18 @@
update_time = sysdate()
where id = #{id}
</update>
<!-- 提交签字: 存 handsign + labor_protocol + signed_at + signed_ip -->
<update id="updateSign" parameterType="BizMeetingAttendee">
update biz_meeting_attendee
set handsign = #{handsign},
labor_protocol = #{laborProtocol},
labor_protocol_masked = #{laborProtocolMasked},
signed_at = #{signedAt},
signed_ip = #{signedIp},
update_by = #{updateBy},
update_time = sysdate()
where id = #{id}
</update>
<update id="updateLaborProtocol" parameterType="BizMeetingAttendee">
update biz_meeting_attendee
set labor_protocol = #{laborProtocol},
@@ -41,13 +107,17 @@
delete from biz_meeting_attendee where meeting_id = #{meetingId} and user_id = #{userId}
</delete>
<select id="selectByMeetingId" resultMap="BizMeetingAttendeeResult" parameterType="Long">
select id, meeting_id, user_id, handsign, labor_protocol, create_by, create_time
select id, 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, signed_at, signed_ip, handsign, labor_protocol, create_by, create_time
from biz_meeting_attendee where meeting_id = #{meetingId}
</select>
<select id="selectByUserId" resultMap="BizMeetingAttendeeResult" parameterType="Long">
select id, meeting_id, user_id, handsign, labor_protocol, create_by, create_time
select id, 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, signed_at, signed_ip, handsign, labor_protocol, create_by, create_time
from biz_meeting_attendee where user_id = #{userId}
</select>
<select id="selectById" resultMap="BizMeetingAttendeeResult" parameterType="Long">
select id, 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, signed_at, signed_ip, handsign, labor_protocol, create_by, create_time
from biz_meeting_attendee where id = #{id}
</select>
<!--
当前用户的"待签署"会议列表 (任一未签: handsign 或 labor_protocol 为 NULL)
INNER JOIN biz_meeting 取会议名/时间/项目名, 用于 /doctor/home 工作台
@@ -32,10 +32,10 @@
<include refid="selectFields"/>
<where>
<if test="projectId != null"> and project_id = #{projectId}</if>
<if test="projectNo != null and projectNo != ''"> and project_no = #{projectNo}</if>
<if test="projectNo != null and projectNo != ''"> and project_no like concat('%', #{projectNo}, '%')</if>
<if test="projectName != null and projectName != ''"> and project_name like concat('%', #{projectName}, '%')</if>
<if test="name != null and name != ''"> and name like concat('%', #{name}, '%')</if>
<if test="phone != null and phone != ''"> and phone = #{phone}</if>
<if test="phone != null and phone != ''"> and phone like concat('%', #{phone}, '%')</if>
<if test="workUnit != null and workUnit != ''"> and work_unit like concat('%', #{workUnit}, '%')</if>
<if test="intentStatus != null and intentStatus != ''"> and intent_status = #{intentStatus}</if>
<if test="source != null and source != ''"> and source = #{source}</if>
@@ -32,10 +32,10 @@
<include refid="selectFields"/>
<where>
<if test="projectId != null"> and project_id = #{projectId}</if>
<if test="projectNo != null and projectNo != ''"> and project_no = #{projectNo}</if>
<if test="projectNo != null and projectNo != ''"> and project_no like concat('%', #{projectNo}, '%')</if>
<if test="projectName != null and projectName != ''"> and project_name like concat('%', #{projectName}, '%')</if>
<if test="name != null and name != ''"> and name like concat('%', #{name}, '%')</if>
<if test="phone != null and phone != ''"> and phone = #{phone}</if>
<if test="phone != null and phone != ''"> and phone like concat('%', #{phone}, '%')</if>
<if test="workUnit != null and workUnit != ''"> and work_unit like concat('%', #{workUnit}, '%')</if>
<if test="intentStatus != null and intentStatus != ''"> and intent_status = #{intentStatus}</if>
<if test="source != null and source != ''"> and source = #{source}</if>