feat: OA(ecology)项目拉取 + 会议邀请函PDF/微信分享 + 参会人/执行意向增强
OA ecology: - BizEcologyController + EcologyProjectService: 项目编号搜索/详情回填, 负责人静默建档 - ComplianceLoginService: 合规/负责人登录校验 + 视图 role 匹配 - application-prod/test.yml: ecology 只读库数据源 会议邀请函/分享: - InvitationPdfService + WxShareService: 邀请函 PDF 生成 + 微信分享 - PdfViewer.vue (pdfjs-dist) + MeetingInvitation.vue: PDF 预览 + 邀请函页 参会人/执行意向: - BizMeetingAttendee*: 参会人模块增强 - BizExecutionIntent*: 执行意向增强 签到/劳务/导入: - BizSignServiceImpl + SignFill.vue: 签到劳务 - FieldMismatch/ImportMismatch: 导入校验 VO 公示/会议详情: - PublicityDetail.vue / MeetingDetail.vue 等前端增强 配置: - test 短信 mock + 供应商/招标接口切生产域名
This commit is contained in:
@@ -20,6 +20,8 @@ spring:
|
||||
# 生产环境外部接口地址
|
||||
ruoyi:
|
||||
sms:
|
||||
# 生产环境: aliyun 真发短信
|
||||
mock-enabled: false
|
||||
esignBaseUrl: https://hegui.bahim.org.cn
|
||||
# 扫码拍照 (ry-h5 相机网页地址, 前端二维码目标 URL)
|
||||
camera:
|
||||
|
||||
@@ -16,18 +16,20 @@ spring:
|
||||
# 测试环境外部接口地址
|
||||
ruoyi:
|
||||
sms:
|
||||
# 测试环境: 验证码固定 1234 且不真发短信
|
||||
mock-enabled: true
|
||||
esignBaseUrl: https://risingdoctor.com/hg
|
||||
# 扫码拍照 (ry-h5 相机网页地址, 前端二维码目标 URL)
|
||||
camera:
|
||||
base-url: https://risingdoctor.com/camera/
|
||||
|
||||
supplier-account-api:
|
||||
base-url: https://zbsuppliertest.guojustar.com/supplier-api/bidding/supplier/openapi/accounts
|
||||
base-url: https://zbsupplier.guojustar.com/supplier-api/bidding/supplier/openapi/accounts
|
||||
|
||||
supplier-account-api-aes:
|
||||
key-id: supplier-api-key
|
||||
key: 'kY0+oIO/laeaaaYDd+9TCYbCQR0b/vqMeJ9sUnSVd9U='
|
||||
|
||||
bidding-api:
|
||||
notice-list-url: https://zbpublictest.guojustar.com/portal-api/bidding/public/notice/list
|
||||
detail-url: https://zbpublictest.guojustar.com/#/detail/
|
||||
notice-list-url: https://zbpublic.guojustar.com/portal-api/bidding/public/notice/list
|
||||
detail-url: https://zbpublic.guojustar.com/#/detail/
|
||||
|
||||
@@ -1,93 +1,3 @@
|
||||
# 项目相关配置
|
||||
ruoyi:
|
||||
# 名称
|
||||
name: RuoYi
|
||||
# 版本
|
||||
version: 3.9.2
|
||||
# 版权年份
|
||||
copyrightYear: 2026
|
||||
# 文件路径 示例( Windows配置D:/ruoyi/uploadPath,Linux配置 /home/ruoyi/uploadPath)
|
||||
profile: D:/ruoyi/uploadPath
|
||||
# 医生劳务协议签署 — token 签名密钥 (HMAC-SHA1)
|
||||
sign-token-secret: hwt-sign-secret-2026
|
||||
# 获取ip地址开关
|
||||
addressEnabled: false
|
||||
# 验证码类型 math 数字计算 char 字符验证
|
||||
captchaType: math
|
||||
# OSS 配置 (前端直传签名, secretKey 仅后端使用)
|
||||
oss:
|
||||
enabled: true
|
||||
endpoint: https://hwtossbamlorgcn.oss-cn-beijing.aliyuncs.com
|
||||
bucket: hwtossbamlorgcn
|
||||
accessKeyId: LTAI5tQKWWu96ihmg2VChQsY
|
||||
accessKeySecret: gA0fTFa8arUYaYA5Us2ClPKVSM1Ypv
|
||||
dirPrefix: ry8080/
|
||||
expireSeconds: 3600
|
||||
# 阿里云函数计算 (FC) 打 zip 端点 (与 hwt-serve 共用同一 bucket, 参考 OssApi.ossDownload)
|
||||
zip-func-url: https://zip-oss-func-zip-oss-swqpembfsl.cn-beijing.fcapp.run
|
||||
# 阿里云短信配置 (hwt-code ali.sms 模式)
|
||||
# dev/prod 区分走代码: phone 以 "10" 开头视为 dev 测试 (固定码 1234), 其它走 aliyun 真发
|
||||
sms:
|
||||
accessKeyId: LTAI5tAgeAUviVdPSsxYCkPY
|
||||
accessKeySecret: 556LX6mXnJIPZgf0vFXcl4mKCzzgKq
|
||||
signName: 北京整合医学学会
|
||||
template: SMS_291440833
|
||||
esignTemplate: SMS_512040098
|
||||
endpoint: dysmsapi.aliyuncs.com
|
||||
regionId: cn-hangzhou
|
||||
# 发票 OCR (本地 Java 识别, PaddleOCR ONNX Runtime, 替代原 ry-ocr Python 微服务)
|
||||
ocr:
|
||||
version: 0.1.0
|
||||
# 模型目录: 绝对路径 → 直读; 相对路径 → 相对进程工作目录 (java -jar 启动目录 = jar 同目录, 部署时 models/ 与 jar 同级)
|
||||
models-dir: models
|
||||
# 模型版本: v5_mobile (H=48, 20MB, 推荐) / v5_server (90MB+) / v4_mobile (15MB, 最快)
|
||||
model-version: v5_mobile
|
||||
# 单页 OCR 超时 / 整流程超时 (秒)
|
||||
page-timeout-s: 15
|
||||
total-timeout-s: 60
|
||||
# QR 命中后是否继续跑全量 OCR (false = 快路径只返回 QR 3 字段)
|
||||
qr-full-ocr: false
|
||||
lang: ch
|
||||
# PDF 优先抽内嵌文本层 (电子发票秒出, 扫描件自动回退 ONNX)
|
||||
use-pdf-text-first: true
|
||||
pdf-text-min-chars: 30
|
||||
upload:
|
||||
max-mb: 20
|
||||
pdf-dpi: 150
|
||||
# 供应商账号接口 (拉取/解密) 配置 (base-url 按环境拆分到 application-test/prod.yml)
|
||||
supplier-account-api:
|
||||
# 每页条数
|
||||
page-size: 20
|
||||
# 每次拉取最近 N 分钟更新的数据
|
||||
pull-minutes: 5
|
||||
# 供应商账号接口 AES 密钥 (supplier-account-api-aes.key 按环境拆分到 application-test/prod.yml)
|
||||
|
||||
# 招标公告查询接口 (notice-list-url / detail-url 按环境拆分到 application-test/prod.yml)
|
||||
|
||||
# 开发环境配置
|
||||
server:
|
||||
# 服务器的HTTP端口,默认为8080
|
||||
port: 8080
|
||||
servlet:
|
||||
# 应用的访问路径
|
||||
context-path: /
|
||||
tomcat:
|
||||
# tomcat的URI编码
|
||||
uri-encoding: UTF-8
|
||||
# 连接数满后的排队数,默认为100
|
||||
accept-count: 1000
|
||||
threads:
|
||||
# tomcat最大线程数,默认为200
|
||||
max: 800
|
||||
# Tomcat启动初始化的线程数,默认值10
|
||||
min-spare: 100
|
||||
|
||||
# 日志配置
|
||||
logging:
|
||||
level:
|
||||
com.ruoyi: debug
|
||||
org.springframework: debug
|
||||
com.ruoyi.business: debug
|
||||
|
||||
# Spring配置
|
||||
spring:
|
||||
@@ -192,4 +102,99 @@ xss:
|
||||
# 排除链接(多个用逗号分隔)
|
||||
excludes: /system/notice
|
||||
# 匹配链接
|
||||
urlPatterns: /system/*,/monitor/*,/tool/*
|
||||
urlPatterns: /system/*,/monitor/*,/tool/*
|
||||
# 项目相关配置
|
||||
ruoyi:
|
||||
# 名称
|
||||
name: RuoYi
|
||||
# 版本
|
||||
version: 3.9.2
|
||||
# 版权年份
|
||||
copyrightYear: 2026
|
||||
# 文件路径 示例( Windows配置D:/ruoyi/uploadPath,Linux配置 /home/ruoyi/uploadPath)
|
||||
profile: D:/ruoyi/uploadPath
|
||||
# 医生劳务协议签署 — token 签名密钥 (HMAC-SHA1)
|
||||
sign-token-secret: hwt-sign-secret-2026
|
||||
# 获取ip地址开关
|
||||
addressEnabled: false
|
||||
# 验证码类型 math 数字计算 char 字符验证
|
||||
captchaType: math
|
||||
# OSS 配置 (前端直传签名, secretKey 仅后端使用)
|
||||
oss:
|
||||
enabled: true
|
||||
endpoint: https://hwtossbamlorgcn.oss-cn-beijing.aliyuncs.com
|
||||
bucket: hwtossbamlorgcn
|
||||
accessKeyId: LTAI5tQKWWu96ihmg2VChQsY
|
||||
accessKeySecret: gA0fTFa8arUYaYA5Us2ClPKVSM1Ypv
|
||||
dirPrefix: ry8080/
|
||||
expireSeconds: 3600
|
||||
# 阿里云函数计算 (FC) 打 zip 端点 (与 hwt-serve 共用同一 bucket, 参考 OssApi.ossDownload)
|
||||
zip-func-url: https://zip-oss-func-zip-oss-swqpembfsl.cn-beijing.fcapp.run
|
||||
# 阿里云短信配置 (hwt-code ali.sms 模式)
|
||||
# mock-enabled 按环境拆分到 application-test/prod.yml: test=true 固定码 1234 不真发, prod=false aliyun 真发
|
||||
sms:
|
||||
accessKeyId: LTAI5tAgeAUviVdPSsxYCkPY
|
||||
accessKeySecret: 556LX6mXnJIPZgf0vFXcl4mKCzzgKq
|
||||
signName: 北京整合医学学会
|
||||
template: SMS_291440833
|
||||
esignTemplate: SMS_512040098
|
||||
endpoint: dysmsapi.aliyuncs.com
|
||||
regionId: cn-hangzhou
|
||||
# 发票 OCR (本地 Java 识别, PaddleOCR ONNX Runtime, 替代原 ry-ocr Python 微服务)
|
||||
ocr:
|
||||
version: 0.1.0
|
||||
# 模型目录: 绝对路径 → 直读; 相对路径 → 相对进程工作目录 (java -jar 启动目录 = jar 同目录, 部署时 models/ 与 jar 同级)
|
||||
models-dir: models
|
||||
# 模型版本: v5_mobile (H=48, 20MB, 推荐) / v5_server (90MB+) / v4_mobile (15MB, 最快)
|
||||
model-version: v5_mobile
|
||||
# 单页 OCR 超时 / 整流程超时 (秒)
|
||||
page-timeout-s: 15
|
||||
total-timeout-s: 60
|
||||
# QR 命中后是否继续跑全量 OCR (false = 快路径只返回 QR 3 字段)
|
||||
qr-full-ocr: false
|
||||
lang: ch
|
||||
# PDF 优先抽内嵌文本层 (电子发票秒出, 扫描件自动回退 ONNX)
|
||||
use-pdf-text-first: true
|
||||
pdf-text-min-chars: 30
|
||||
upload:
|
||||
max-mb: 20
|
||||
pdf-dpi: 150
|
||||
# 供应商账号接口 (拉取/解密) 配置 (base-url 按环境拆分到 application-test/prod.yml)
|
||||
supplier-account-api:
|
||||
# 每页条数
|
||||
page-size: 20
|
||||
# 每次拉取最近 N 分钟更新的数据
|
||||
pull-minutes: 5
|
||||
# 供应商账号接口 AES 密钥 (supplier-account-api-aes.key 按环境拆分到 application-test/prod.yml)
|
||||
|
||||
# 招标公告查询接口 (notice-list-url / detail-url 按环境拆分到 application-test/prod.yml)
|
||||
|
||||
# 公众号 JS-SDK 分享签名 (appId/appSecret 与 hwt-serve biz_conf 表一致)
|
||||
wx:
|
||||
app-id: wx2414cddf4bd56092
|
||||
app-secret: 792300428a4998da9294e263ec55e71a
|
||||
|
||||
# 开发环境配置
|
||||
server:
|
||||
# 服务器的HTTP端口,默认为8080
|
||||
port: 8080
|
||||
servlet:
|
||||
# 应用的访问路径
|
||||
context-path: /
|
||||
tomcat:
|
||||
# tomcat的URI编码
|
||||
uri-encoding: UTF-8
|
||||
# 连接数满后的排队数,默认为100
|
||||
accept-count: 1000
|
||||
threads:
|
||||
# tomcat最大线程数,默认为200
|
||||
max: 800
|
||||
# Tomcat启动初始化的线程数,默认值10
|
||||
min-spare: 100
|
||||
|
||||
# 日志配置
|
||||
logging:
|
||||
level:
|
||||
com.ruoyi: debug
|
||||
org.springframework: debug
|
||||
com.ruoyi.business: debug
|
||||
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
package com.ruoyi.business.controller;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import com.ruoyi.business.service.EcologyProjectService;
|
||||
import com.ruoyi.common.core.controller.BaseController;
|
||||
import com.ruoyi.common.core.domain.AjaxResult;
|
||||
import com.ruoyi.common.exception.ServiceException;
|
||||
|
||||
/**
|
||||
* ecology 项目信息拉取 (admin/projects/new 按项目编号自动回填).
|
||||
* <p>
|
||||
* 读接口开放给已登录用户 (admin/manager 新建项目用), 与 /business/org/*Options 下拉一致, 不加 @PreAuthorize.
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/business/ecology")
|
||||
public class BizEcologyController extends BaseController
|
||||
{
|
||||
@Autowired
|
||||
private EcologyProjectService ecologyProjectService;
|
||||
|
||||
/**
|
||||
* 项目编号模糊搜索 (下拉选项): GET /business/ecology/project/search?xmbh=xxx
|
||||
* 返回 [{projectNo, projectName}].
|
||||
*/
|
||||
@GetMapping("/project/search")
|
||||
public AjaxResult searchProjects(@RequestParam(value = "xmbh", required = false) String xmbh)
|
||||
{
|
||||
List<Map<String, Object>> list = ecologyProjectService.searchProjectNos(xmbh);
|
||||
return success(list);
|
||||
}
|
||||
|
||||
/**
|
||||
* 按项目编号拉取完整信息 + 负责人静默建档: GET /business/ecology/project/byNo?projectNo=xxx
|
||||
* 返回 {projectNo, projectName, projectForm, startTime, endTime, totalAmount, manageFee,
|
||||
* leaderResolved, leaderName, leaderPhone, leaderUserId}.
|
||||
*/
|
||||
@GetMapping("/project/byNo")
|
||||
public AjaxResult fetchProject(@RequestParam(value = "projectNo", required = false) String projectNo)
|
||||
{
|
||||
try
|
||||
{
|
||||
return success(ecologyProjectService.fetchProjectByNo(projectNo));
|
||||
}
|
||||
catch (ServiceException e)
|
||||
{
|
||||
return error(e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
+3
-8
@@ -64,6 +64,8 @@ public class BizExecutionIntentController extends BaseController
|
||||
q.setProjectNo(proj.getProjectNo());
|
||||
List<BizExecutionIntent> exists = bizExecutionIntentService.selectList(q);
|
||||
if (exists != null && !exists.isEmpty()) return error("您已报名过该项目,无需重复报名");
|
||||
// 真报名: 先物理删掉幽灵占位记录 (del_flag='1')
|
||||
bizExecutionIntentService.hardDeletePlaceholder(SecurityUtils.getUserId(), proj.getProjectNo());
|
||||
bizExecutionIntent.setProjectNo(proj.getProjectNo());
|
||||
bizExecutionIntent.setProjectName(proj.getProjectName());
|
||||
Long uid = SecurityUtils.getUserId();
|
||||
@@ -140,7 +142,7 @@ public class BizExecutionIntentController extends BaseController
|
||||
if (bizExecutionIntent.getProjectNo() == null || bizExecutionIntent.getProjectNo().isEmpty()) {
|
||||
throw new IllegalArgumentException("projectNo 不能为空");
|
||||
}
|
||||
List<BizExecutionIntent> list = bizExecutionIntentService.selectList(bizExecutionIntent);
|
||||
List<BizExecutionIntent> list = bizExecutionIntentService.selectListIncludingDeleted(bizExecutionIntent);
|
||||
List<BizSignupExpertExportVo> exportList = new ArrayList<>(list.size());
|
||||
for (BizExecutionIntent e : list) {
|
||||
BizSignupExpertExportVo v = new BizSignupExpertExportVo();
|
||||
@@ -158,13 +160,6 @@ public class BizExecutionIntentController extends BaseController
|
||||
if (department == null || department.isEmpty()) department = expert.getDepartment();
|
||||
if (workUnit == null || workUnit.isEmpty()) workUnit = expert.getWorkUnit();
|
||||
if (position == null || position.isEmpty()) position = expert.getTitle();
|
||||
v.setRegion(expert.getRegion());
|
||||
v.setIdCard(expert.getIdCard());
|
||||
v.setBankCard(expert.getBankCard());
|
||||
v.setBankName(expert.getBankName());
|
||||
v.setBankBranch(expert.getBankBranch());
|
||||
v.setBankRegion(expert.getBankRegion());
|
||||
v.setBankAddress(expert.getBankAddress());
|
||||
}
|
||||
v.setDepartment(department);
|
||||
v.setWorkUnit(workUnit);
|
||||
|
||||
+64
-9
@@ -1,7 +1,9 @@
|
||||
package com.ruoyi.business.controller;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
@@ -17,6 +19,7 @@ import com.ruoyi.business.domain.BizMeetingAttendee;
|
||||
import com.ruoyi.business.domain.dto.ImportResult;
|
||||
import com.ruoyi.business.domain.vo.BizMeetingAttendeeImportVo;
|
||||
import com.ruoyi.business.notify.BizNotifyService;
|
||||
import com.ruoyi.business.service.IBizExecutionIntentService;
|
||||
import com.ruoyi.business.service.IBizMeetingAttendeeService;
|
||||
import com.ruoyi.business.service.IBizMeetingService;
|
||||
|
||||
@@ -45,6 +48,8 @@ public class BizMeetingAttendeeController extends BaseController {
|
||||
@Autowired
|
||||
private IBizMeetingService bizMeetingService;
|
||||
@Autowired
|
||||
private IBizExecutionIntentService bizExecutionIntentService;
|
||||
@Autowired
|
||||
private BizNotifyService bizNotifyService;
|
||||
|
||||
/**
|
||||
@@ -69,6 +74,40 @@ public class BizMeetingAttendeeController extends BaseController {
|
||||
return success(rows);
|
||||
}
|
||||
|
||||
/**
|
||||
* 会议邀请函详情 (参会人/医生点开站内信里的邀请链接后查看).
|
||||
* <p>安全: 仅参会人本人可见 (attendee.userId == 当前登录用户), 防止横向越权.
|
||||
* <p>返回 {attendeeName, title, laborForm, projectName, meetingName, meetingTime, scheduleUrl, invitationUrl}.
|
||||
*/
|
||||
@GetMapping("/invitation/{attendeeId}")
|
||||
public AjaxResult invitationDetail(@PathVariable("attendeeId") Long attendeeId) {
|
||||
if (attendeeId == null) return error("缺少参会记录");
|
||||
BizMeetingAttendee a = attendeeService.selectById(attendeeId);
|
||||
if (a == null) return error("参会记录不存在");
|
||||
Long uid = SecurityUtils.getUserId();
|
||||
if (a.getUserId() == null || !a.getUserId().equals(uid)) {
|
||||
return error("无权限查看该邀请函");
|
||||
}
|
||||
BizMeeting m = a.getMeetingId() == null ? null : bizMeetingService.getById(a.getMeetingId());
|
||||
// 点开邀请函 = 立即报名: 确保该专家已"报名"该项目 (幂等, 已报名则跳过)
|
||||
if (m != null && m.getProjectNo() != null && !m.getProjectNo().trim().isEmpty()) {
|
||||
bizExecutionIntentService.signupByProject(m.getProjectNo(), m.getProjectName());
|
||||
}
|
||||
Map<String, Object> data = new HashMap<>();
|
||||
data.put("attendeeName", a.getName());
|
||||
data.put("title", a.getTitle());
|
||||
data.put("laborForm", a.getLaborForm());
|
||||
data.put("projectName", m != null ? m.getProjectName() : null);
|
||||
data.put("meetingName", m != null ? m.getMeetingName() : null);
|
||||
data.put("meetingTime", m != null ? m.getStartTime() : null);
|
||||
data.put("scheduleUrl", m != null ? m.getScheduleUrl() : null);
|
||||
// 优先参会人个性化邀请函 (盖章+姓名); 未生成回退会议级模板
|
||||
data.put("invitationUrl", a.getInvitationUrl() != null && !a.getInvitationUrl().trim().isEmpty()
|
||||
? a.getInvitationUrl()
|
||||
: (m != null ? m.getInvitationUrl() : null));
|
||||
return success(data);
|
||||
}
|
||||
|
||||
/**
|
||||
* 管理端: 某会议的全部参会人 (MeetingDetail 参会人 CRUD 用).
|
||||
* 返回全字段, 前端按需展示.
|
||||
@@ -133,6 +172,15 @@ public class BizMeetingAttendeeController extends BaseController {
|
||||
return toAjax(rows);
|
||||
}
|
||||
|
||||
/**
|
||||
* 新增/编辑参会人保存前的预检: 比对表单与专家表 (biz_expert) 档案, 返回不一致字段列表.
|
||||
* <p>前端据此弹窗 + 标红 + 二次确认, 确认后才真正 POST/PUT. 无差异返回空数组.
|
||||
*/
|
||||
@PostMapping("/diff")
|
||||
public AjaxResult diff(@RequestBody BizMeetingAttendee body) {
|
||||
return success(attendeeService.diffAgainstExpert(body));
|
||||
}
|
||||
|
||||
/**
|
||||
* 管理端: 按 id 删除参会人 (单条).
|
||||
* 注意: 仅删 biz_meeting_attendee 行, 不删 sys_user (参会人可能是真实用户, 不能因参会关系删号).
|
||||
@@ -194,21 +242,28 @@ public class BizMeetingAttendeeController extends BaseController {
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量导入参会人 — 上传 Excel + 解析入库.
|
||||
* 批量导入参会人 — 两段式: 默认 dry-run (force 缺省=false) 只解析 + 比对专家表, 不落库;
|
||||
* force=true 才真正入库.
|
||||
*
|
||||
* <p>调 {@link IBizMeetingAttendeeService#importFromExcel} 逐行处理, 失败的进 ngList.
|
||||
* 邀请参会已改为手动, 此处不再自动推"会议邀请".
|
||||
* <p>dry-run 返回 { total, mismatches: [{rowNo, phone, name, fields:[{field,label,expertValue,inputValue}]}] },
|
||||
* 前端据此弹「二次确认」; 确认后带 force=true 重发同一文件.
|
||||
*
|
||||
* <p>返回 ImportResult { okNum, ngNum, ngList: [{rowNum, message}] }, 前端 ImportResultDialog 直接渲染.
|
||||
* <p>force=true 调 {@link IBizMeetingAttendeeService#importFromExcel} 逐行入库, 失败进 ngList,
|
||||
* 返回 ImportResult { okNum, ngNum, ngList }.
|
||||
*
|
||||
* @param meetingId 会议 id (query 传, 决定导入到哪个会议)
|
||||
*/
|
||||
@Log(title = "参会人批量导入", businessType = BusinessType.IMPORT)
|
||||
@PostMapping("/importData")
|
||||
public AjaxResult importData(@RequestParam("file") MultipartFile file,
|
||||
@RequestParam("meetingId") Long meetingId) throws Exception {
|
||||
@RequestParam("meetingId") Long meetingId,
|
||||
@RequestParam(value = "force", required = false, defaultValue = "false") boolean force) throws Exception {
|
||||
// 参会人名单写操作拦截: executor 劳务提交后禁止 (导入)
|
||||
checkAttendeeEditable(meetingId);
|
||||
if (!force) {
|
||||
// dry-run: 解析 + 比对专家表, 不落库
|
||||
return success(attendeeService.previewImport(file, meetingId));
|
||||
}
|
||||
// 解析 + 入库 (邀请参会已改为手动, 不再自动推"会议邀请", 由前端"邀请参会"按钮触发)
|
||||
ImportResult result = attendeeService.importFromExcel(file, meetingId, SecurityUtils.getUsername());
|
||||
// 人员变化 → 立即重算劳务费 (有成功导入才需重算)
|
||||
@@ -287,8 +342,8 @@ public class BizMeetingAttendeeController extends BaseController {
|
||||
@PostMapping("/uploadAgreements")
|
||||
public AjaxResult uploadAgreements(@RequestParam("file") MultipartFile file,
|
||||
@RequestParam("meetingId") Long meetingId) throws Exception {
|
||||
int updated = attendeeService.uploadAgreements(file, meetingId);
|
||||
return success(updated);
|
||||
Map<String, Object> result = attendeeService.uploadAgreements(file, meetingId);
|
||||
return success(result);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -312,8 +367,8 @@ public class BizMeetingAttendeeController extends BaseController {
|
||||
@PostMapping("/uploadExpertPhotos")
|
||||
public AjaxResult uploadExpertPhotos(@RequestParam("file") MultipartFile file,
|
||||
@RequestParam("meetingId") Long meetingId) throws Exception {
|
||||
int updated = attendeeService.uploadExpertPhotos(file, meetingId);
|
||||
return success(updated);
|
||||
Map<String, Object> result = attendeeService.uploadExpertPhotos(file, meetingId);
|
||||
return success(result);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+5
-11
@@ -713,7 +713,7 @@ public class BizProjectController extends BaseController
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出已报名专家 (跟随当前筛选的项目范围, 仅 manager)
|
||||
* 导出已报名专家 (跟随当前筛选的项目范围, manager + admin)
|
||||
* POST /business/project/signupExpertExport
|
||||
* 先按筛选取项目 → 收集 projectNo → 查 biz_execution_intent 报名专家
|
||||
*/
|
||||
@@ -722,8 +722,8 @@ public class BizProjectController extends BaseController
|
||||
public void exportSignupExpert(HttpServletResponse response, BizProject bizProject)
|
||||
{
|
||||
String roleType = SecurityUtils.getLoginUser().getUser().getRoleType();
|
||||
if (!"manager".equals(roleType)) {
|
||||
throw new ServiceException("只有合规管理员可导出报名专家");
|
||||
if (!"manager".equals(roleType) && !"admin".equals(roleType)) {
|
||||
throw new ServiceException("只有合规管理员或后台管理员可导出报名专家");
|
||||
}
|
||||
applyManagerScope(bizProject);
|
||||
List<BizProject> projects = bizProjectService.selectList(bizProject);
|
||||
@@ -735,7 +735,8 @@ public class BizProjectController extends BaseController
|
||||
if (!projectNos.isEmpty()) {
|
||||
BizExecutionIntent q = new BizExecutionIntent();
|
||||
q.getParams().put("projectNos", projectNos);
|
||||
intents = bizExecutionIntentService.selectList(q);
|
||||
// 已报名专家导出: 唯一一处导出"含幽灵占位(del_flag='1')"的报名记录, 不标记哪些是幽灵
|
||||
intents = bizExecutionIntentService.selectListIncludingDeleted(q);
|
||||
}
|
||||
List<BizSignupExpertExportVo> exportList = new ArrayList<>(intents.size());
|
||||
for (BizExecutionIntent e : intents) {
|
||||
@@ -754,13 +755,6 @@ public class BizProjectController extends BaseController
|
||||
if (department == null || department.isEmpty()) department = expert.getDepartment();
|
||||
if (workUnit == null || workUnit.isEmpty()) workUnit = expert.getWorkUnit();
|
||||
if (position == null || position.isEmpty()) position = expert.getTitle();
|
||||
v.setRegion(expert.getRegion());
|
||||
v.setIdCard(expert.getIdCard());
|
||||
v.setBankCard(expert.getBankCard());
|
||||
v.setBankName(expert.getBankName());
|
||||
v.setBankBranch(expert.getBankBranch());
|
||||
v.setBankRegion(expert.getBankRegion());
|
||||
v.setBankAddress(expert.getBankAddress());
|
||||
}
|
||||
v.setDepartment(department);
|
||||
v.setWorkUnit(workUnit);
|
||||
|
||||
+16
@@ -29,6 +29,7 @@ import com.ruoyi.business.service.IBizSupportLetterService;
|
||||
import com.ruoyi.business.service.IBizInvitationService;
|
||||
import com.ruoyi.business.service.IBizProjectPlanService;
|
||||
import com.ruoyi.business.service.IBizSpecialPlanService;
|
||||
import com.ruoyi.business.service.WxShareService;
|
||||
import com.ruoyi.business.domain.BizSpecialPlan;
|
||||
|
||||
/**
|
||||
@@ -44,6 +45,7 @@ public class BizPublicController extends BaseController {
|
||||
@Autowired private IBizProjectPlanService projectPlanService;
|
||||
@Autowired private IBizArticleService articleService;
|
||||
@Autowired private IBizSpecialPlanService specialPlanService;
|
||||
@Autowired private WxShareService wxShareService;
|
||||
|
||||
@Autowired private Environment environment;
|
||||
private static final Logger log = LoggerFactory.getLogger(BizPublicController.class);
|
||||
@@ -153,6 +155,20 @@ public class BizPublicController extends BaseController {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 微信 JS-SDK 分享签名配置 (公开, 供公示详情页微信内分享)
|
||||
* url 必须是当前页面地址去掉 # 及之后 fragment 的部分
|
||||
*/
|
||||
@GetMapping("/wx/jssdk")
|
||||
public AjaxResult wxJssdk(@RequestParam String url)
|
||||
{
|
||||
if (url == null || url.trim().isEmpty())
|
||||
{
|
||||
return error("url不能为空");
|
||||
}
|
||||
return success(wxShareService.buildShareConfig(url));
|
||||
}
|
||||
|
||||
@GetMapping("/supportLetter/{annId}")
|
||||
public AjaxResult supportLetterDetail(@PathVariable String annId) {
|
||||
return success(supportLetterService.getById(annId));
|
||||
|
||||
@@ -51,6 +51,8 @@ public class BizExecutionIntent extends BaseEntity {
|
||||
@Excel(name = "update_time")
|
||||
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
|
||||
private Date updateTime;
|
||||
/** 删除标记 (0正常 1已删除, 幽灵占位记录用) */
|
||||
private String delFlag;
|
||||
public String getIntentId() { return intentId; }
|
||||
public void setIntentId(String intentId) { this.intentId = intentId; }
|
||||
public Long getUserId() { return userId; }
|
||||
@@ -79,4 +81,6 @@ public class BizExecutionIntent 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 getDelFlag() { return delFlag; }
|
||||
public void setDelFlag(String delFlag) { this.delFlag = delFlag; }
|
||||
}
|
||||
@@ -54,6 +54,8 @@ public class BizMeetingAttendee extends BaseEntity {
|
||||
private String laborProtocol;
|
||||
/** 脱敏版协议 URL (身份证/银行卡用 * 替换) */
|
||||
private String laborProtocolMasked;
|
||||
/** 盖章后个性化邀请函 URL (OSS, 发送邀请函时生成; null=未生成, 回退会议级模板) */
|
||||
private String invitationUrl;
|
||||
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
|
||||
private Date createTime;
|
||||
/* ====== 非持久化字段, 用于 selectUnsignedByUserId 联表查询 ====== */
|
||||
@@ -121,6 +123,8 @@ public class BizMeetingAttendee extends BaseEntity {
|
||||
public void setLaborProtocol(String laborProtocol) { this.laborProtocol = laborProtocol; }
|
||||
public String getLaborProtocolMasked() { return laborProtocolMasked; }
|
||||
public void setLaborProtocolMasked(String laborProtocolMasked) { this.laborProtocolMasked = laborProtocolMasked; }
|
||||
public String getInvitationUrl() { return invitationUrl; }
|
||||
public void setInvitationUrl(String invitationUrl) { this.invitationUrl = invitationUrl; }
|
||||
public Date getCreateTime() { return createTime; }
|
||||
public void setCreateTime(Date createTime) { this.createTime = createTime; }
|
||||
public String getMeetingName() { return meetingName; }
|
||||
|
||||
+9
-42
@@ -8,9 +8,11 @@ import com.ruoyi.common.annotation.Excel;
|
||||
* 报名专家导出 VO (中文列头)
|
||||
*
|
||||
* <p>数据源: biz_execution_intent (公开门户"立即报名"写入的执行意向表, 即已报名专家)
|
||||
* + biz_expert (医生档案, 报名专家 userId → biz_expert.userId 反查补全银行/证件/地区/科室/医院/职称).
|
||||
* + biz_expert (医生档案, 报名专家 userId → biz_expert.userId 反查补全科室/医院/职称).
|
||||
* 仅用于 Excel 导出, 不参与业务逻辑.
|
||||
*
|
||||
* <p>字段: 项目编号 项目名称 专家姓名 科室 医院 职称 报名时间 手机号
|
||||
*
|
||||
* @author guoju
|
||||
*/
|
||||
public class BizSignupExpertExportVo {
|
||||
@@ -33,34 +35,13 @@ public class BizSignupExpertExportVo {
|
||||
@Excel(name = "职称", sort = 6)
|
||||
private String position;
|
||||
|
||||
@Excel(name = "手机号", sort = 7)
|
||||
private String phone;
|
||||
|
||||
@Excel(name = "地区", sort = 8)
|
||||
private String region;
|
||||
|
||||
@Excel(name = "身份证件号码", sort = 9)
|
||||
private String idCard;
|
||||
|
||||
@Excel(name = "银行卡号", sort = 10)
|
||||
private String bankCard;
|
||||
|
||||
@Excel(name = "银行名称", sort = 11)
|
||||
private String bankName;
|
||||
|
||||
@Excel(name = "开户行支行", sort = 12)
|
||||
private String bankBranch;
|
||||
|
||||
@Excel(name = "开户行省/市", sort = 13)
|
||||
private String bankRegion;
|
||||
|
||||
@Excel(name = "开户行地址", sort = 14)
|
||||
private String bankAddress;
|
||||
|
||||
@Excel(name = "报名时间", sort = 15, dateFormat = "yyyy-MM-dd HH:mm:ss")
|
||||
@Excel(name = "报名时间", sort = 7, dateFormat = "yyyy-MM-dd HH:mm:ss")
|
||||
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
|
||||
private Date createTime;
|
||||
|
||||
@Excel(name = "手机号", sort = 8)
|
||||
private String phone;
|
||||
|
||||
public String getProjectNo() { return projectNo; }
|
||||
public void setProjectNo(String projectNo) { this.projectNo = projectNo; }
|
||||
public String getProjectName() { return projectName; }
|
||||
@@ -73,22 +54,8 @@ public class BizSignupExpertExportVo {
|
||||
public void setWorkUnit(String workUnit) { this.workUnit = workUnit; }
|
||||
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 getRegion() { return region; }
|
||||
public void setRegion(String region) { this.region = region; }
|
||||
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 Date getCreateTime() { return createTime; }
|
||||
public void setCreateTime(Date createTime) { this.createTime = createTime; }
|
||||
public String getPhone() { return phone; }
|
||||
public void setPhone(String phone) { this.phone = phone; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
package com.ruoyi.business.domain.vo;
|
||||
|
||||
/**
|
||||
* 参会人表单字段与专家表 (biz_expert) 档案的不一致项.
|
||||
*
|
||||
* <p>用于「新增/编辑参会人」和「批量导入」保存前的二次确认:
|
||||
* 后端按 手机号→sys_user→biz_expert 解析出专家档案, 与用户输入比对,
|
||||
* 专家表已有值且与输入不一致时生成一条 mismatch.
|
||||
*/
|
||||
public class FieldMismatch {
|
||||
|
||||
/** 字段 key (name/workUnit/...) */
|
||||
private String field;
|
||||
|
||||
/** 字段中文名 (姓名/医院/...) */
|
||||
private String label;
|
||||
|
||||
/** 专家表当前值 */
|
||||
private String expertValue;
|
||||
|
||||
/** 用户输入值 */
|
||||
private String inputValue;
|
||||
|
||||
public String getField() { return field; }
|
||||
public void setField(String field) { this.field = field; }
|
||||
public String getLabel() { return label; }
|
||||
public void setLabel(String label) { this.label = label; }
|
||||
public String getExpertValue() { return expertValue; }
|
||||
public void setExpertValue(String expertValue) { this.expertValue = expertValue; }
|
||||
public String getInputValue() { return inputValue; }
|
||||
public void setInputValue(String inputValue) { this.inputValue = inputValue; }
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package com.ruoyi.business.domain.vo;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 批量导入 dry-run 中某一行与专家表 (biz_expert) 档案的不一致 (导入二次确认用).
|
||||
*/
|
||||
public class ImportMismatch {
|
||||
|
||||
/** Excel 行号 (1=表头) */
|
||||
private int rowNo;
|
||||
|
||||
private String phone;
|
||||
|
||||
private String name;
|
||||
|
||||
/** 该行不一致的字段列表 */
|
||||
private List<FieldMismatch> fields;
|
||||
|
||||
public int getRowNo() { return rowNo; }
|
||||
public void setRowNo(int rowNo) { this.rowNo = rowNo; }
|
||||
public String getPhone() { return phone; }
|
||||
public void setPhone(String phone) { this.phone = phone; }
|
||||
public String getName() { return name; }
|
||||
public void setName(String name) { this.name = name; }
|
||||
public List<FieldMismatch> getFields() { return fields; }
|
||||
public void setFields(List<FieldMismatch> fields) { this.fields = fields; }
|
||||
}
|
||||
+7
@@ -1,5 +1,6 @@
|
||||
package com.ruoyi.business.mapper;
|
||||
import java.util.List;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import com.ruoyi.business.domain.BizExecutionIntent;
|
||||
|
||||
/**
|
||||
@@ -9,8 +10,14 @@ public interface BizExecutionIntentMapper
|
||||
{
|
||||
BizExecutionIntent selectByPrimaryKey(String intentId);
|
||||
List<BizExecutionIntent> selectList(BizExecutionIntent entity);
|
||||
/** 查询全部记录 (含 del_flag='1' 幽灵占位), 仅"已报名专家导出"用 */
|
||||
List<BizExecutionIntent> selectListIncludingDeleted(BizExecutionIntent entity);
|
||||
int insert(BizExecutionIntent entity);
|
||||
int updateByPrimaryKey(BizExecutionIntent entity);
|
||||
int deleteByPrimaryKey(String intentId);
|
||||
int deleteByPrimaryKeys(String[] intentIds);
|
||||
/** 物理删除幽灵占位记录 (del_flag='1'), 真报名时调用 */
|
||||
int hardDeletePlaceholder(@Param("userId") Long userId, @Param("projectNo") String projectNo);
|
||||
/** 该用户+项目是否已存在意向记录 (真/假都算), 建占位前去重 */
|
||||
int countByUserAndProject(@Param("userId") Long userId, @Param("projectNo") String projectNo);
|
||||
}
|
||||
+2
@@ -20,6 +20,8 @@ public interface BizMeetingAttendeeMapper {
|
||||
/** 提交签字: 一次性存 handsign + labor_protocol + signed_at + signed_ip */
|
||||
int updateSign(BizMeetingAttendee entity);
|
||||
int updateLaborProtocol(BizMeetingAttendee entity);
|
||||
/** 发送邀请函后回写盖章个性化邀请函 URL (单条) */
|
||||
int updateInvitationUrl(@Param("id") Long id, @Param("invitationUrl") String invitationUrl);
|
||||
/** 推送电子签后置 is_esigned=1 (单条) */
|
||||
int markEsignedById(@Param("id") Long id);
|
||||
/** 邀请参会后置 is_invited=1 (单条) */
|
||||
|
||||
@@ -59,6 +59,8 @@ public class BizNotifyService
|
||||
public static final String BIZ_PROJECT = "project";
|
||||
public static final String BIZ_PROJECT_PLAN = "projectPlan";
|
||||
public static final String BIZ_MEETING = "meeting";
|
||||
/** 会议邀请函 (参会人点击通知直接跳转邀请函详情页, bizId=attendeeId) */
|
||||
public static final String BIZ_MEETING_INVITATION = "meetingInvitation";
|
||||
public static final String BIZ_AGREEMENT = "agreement";
|
||||
public static final String BIZ_PUBLICITY = "publicity";
|
||||
public static final String BIZ_ORG = "org";
|
||||
@@ -166,11 +168,12 @@ public class BizNotifyService
|
||||
* 业务去重由调用方保证 (差集), 本方法不再判断.
|
||||
*
|
||||
* @param userId 被邀请人 sys_user.user_id
|
||||
* @param attendeeId biz_meeting_attendee.id (存 bizId, 前端点击通知跳转到邀请函页)
|
||||
* @param meetingId biz_meeting.meeting_id
|
||||
* @param meetingName 会议名 (可空, 兜底 "会议 #ID")
|
||||
* @param startTime 会议开始时间 (可空, 不展示具体时间)
|
||||
*/
|
||||
public void meetingInvitation(Long userId, Long meetingId, String meetingName, Date startTime)
|
||||
public void meetingInvitation(Long userId, Long attendeeId, Long meetingId, String meetingName, Date startTime)
|
||||
{
|
||||
if (userId == null) {
|
||||
log.warn("[notify] meetingInvitation: userId 为空, 跳过 (meetingId={})", meetingId);
|
||||
@@ -181,18 +184,18 @@ public class BizNotifyService
|
||||
if (startTime != null) {
|
||||
content.append(", 时间: ").append(new java.text.SimpleDateFormat("yyyy-MM-dd HH:mm").format(startTime));
|
||||
}
|
||||
content.append("。请登录系统查看详情。");
|
||||
content.append("。请点击查看邀请函。");
|
||||
|
||||
BizMessage msg = new BizMessage();
|
||||
msg.setReceiverUserId(userId);
|
||||
msg.setMsgType(TYPE_NOTIFY); // 会议邀请 = 普通通知 (医生只需知道被邀请, 不是必须立即操作)
|
||||
msg.setTitle("会议邀请: " + name);
|
||||
msg.setContent(content.toString());
|
||||
msg.setBizType(BIZ_MEETING);
|
||||
msg.setBizId(meetingId);
|
||||
msg.setBizType(BIZ_MEETING_INVITATION); // 独立类型: 前端点击直接跳邀请函页, 不弹 dialog
|
||||
msg.setBizId(attendeeId);
|
||||
msg.setCreateBy("system");
|
||||
bizMessageService.insert(msg);
|
||||
log.info("[notify] meetingInvitation 已发 uid={} meetingId={}", userId, meetingId);
|
||||
log.info("[notify] meetingInvitation 已发 uid={} meetingId={} attendeeId={}", userId, meetingId, attendeeId);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+41
@@ -2,6 +2,7 @@ package com.ruoyi.business.service;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
@@ -39,6 +40,9 @@ public class ComplianceLoginService
|
||||
private static final String ECOLOGY_LOOKUP_SQL =
|
||||
"SELECT lastname, type FROM v_compliance_project_manager WHERE mobile = ? LIMIT 1";
|
||||
|
||||
/** 登录白名单: 这些账号绕过 ecology 视图校验直接放行 (测试账号 manager01 / leader01) */
|
||||
private static final Set<String> LOGIN_WHITELIST = Set.of("manager01", "leader01");
|
||||
|
||||
@Autowired
|
||||
@Qualifier("ecologyJdbcTemplate")
|
||||
private JdbcTemplate ecologyJdbcTemplate;
|
||||
@@ -60,6 +64,10 @@ public class ComplianceLoginService
|
||||
public SysUser reconcileForLogin(String phone)
|
||||
{
|
||||
SysUser user = findUserByPhone(phone);
|
||||
if (isWhitelisted(user))
|
||||
{
|
||||
return user;
|
||||
}
|
||||
String role = user == null ? null : user.getRoleType();
|
||||
boolean needEcology = user == null || "manager".equals(role) || "leader".equals(role);
|
||||
if (!needEcology)
|
||||
@@ -96,6 +104,10 @@ public class ComplianceLoginService
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public String gateForPasswordLogin(SysUser user)
|
||||
{
|
||||
if (isWhitelisted(user))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
String phone = user.getPhonenumber();
|
||||
EcologyPerson p = queryEcology(phone == null ? "" : phone);
|
||||
if (p == null)
|
||||
@@ -121,6 +133,35 @@ public class ComplianceLoginService
|
||||
return queryEcology(phone) != null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 项目负责人(leader)静默查号/建号: 按手机号查 sys_user, 没有则按 name/phone 建 leader 账号.
|
||||
* 用于 admin/projects/new 从 ecology 拉取项目负责人时自动建档 (用户要求静默, 无则新建).
|
||||
* 返回 sys_user.userId (已存在或新建后的); 手机号缺失/异常返回 null.
|
||||
*/
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public Long ensureLeaderAccount(String name, String phone)
|
||||
{
|
||||
if (phone == null || phone.trim().isEmpty())
|
||||
{
|
||||
return null;
|
||||
}
|
||||
phone = phone.trim();
|
||||
SysUser existing = findUserByPhone(phone);
|
||||
if (existing != null)
|
||||
{
|
||||
return existing.getUserId();
|
||||
}
|
||||
EcologyPerson p = new EcologyPerson();
|
||||
p.type = 2;
|
||||
p.name = name;
|
||||
return provision(p, phone, "leader").getUserId();
|
||||
}
|
||||
|
||||
private boolean isWhitelisted(SysUser user)
|
||||
{
|
||||
return user != null && user.getUserName() != null && LOGIN_WHITELIST.contains(user.getUserName());
|
||||
}
|
||||
|
||||
private SysUser findUserByPhone(String phone)
|
||||
{
|
||||
SysUser probe = userMapper.checkPhoneUnique(phone);
|
||||
|
||||
+230
@@ -0,0 +1,230 @@
|
||||
package com.ruoyi.business.service;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.math.RoundingMode;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.beans.factory.annotation.Qualifier;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
import com.ruoyi.common.exception.ServiceException;
|
||||
|
||||
/**
|
||||
* 从泛微 ecology 只读视图拉取项目信息 (admin/projects/new 按项目编号自动回填).
|
||||
* <p>
|
||||
* 数据源: {@code v_compliance_project_list} (项目列表) + {@code v_compliance_project_manager} (负责人姓名/手机号).
|
||||
* <p>
|
||||
* 字段映射:
|
||||
* xmbh→项目编号, xmmc→项目名称, xmqssj/xmjssj→开始/结束时间(补全 00:00:00 / 23:59:59),
|
||||
* xmlx_name→项目形式(线上加线下→线上+线下), yqsry→总金额, glfsfbl→管理费=总金额×比例%,
|
||||
* xmfzr→项目负责人(通过 v_compliance_project_manager 解析姓名+手机号, 再查/建 sys_user 静默建档).
|
||||
*/
|
||||
@Service
|
||||
public class EcologyProjectService
|
||||
{
|
||||
private static final Logger log = LoggerFactory.getLogger(EcologyProjectService.class);
|
||||
|
||||
private static final String PROJECT_BY_NO_SQL =
|
||||
"SELECT id, xmbh, xmmc, xmfzr, yqsry, xmqssj, xmjssj, glfsfbl, xmlx_name " +
|
||||
"FROM v_compliance_project_list WHERE xmbh = ? ORDER BY id LIMIT 1";
|
||||
|
||||
private static final String PROJECT_SEARCH_SQL =
|
||||
"SELECT xmbh, xmmc FROM v_compliance_project_list WHERE xmbh LIKE ? " +
|
||||
"GROUP BY xmbh, xmmc ORDER BY xmbh LIMIT 50";
|
||||
|
||||
private static final String LEADER_BY_ID_SQL =
|
||||
"SELECT lastname, mobile FROM v_compliance_project_manager WHERE id = ? LIMIT 1";
|
||||
|
||||
@Autowired
|
||||
@Qualifier("ecologyJdbcTemplate")
|
||||
private JdbcTemplate ecologyJdbcTemplate;
|
||||
|
||||
@Autowired
|
||||
private ComplianceLoginService complianceLoginService;
|
||||
|
||||
/**
|
||||
* 项目编号模糊搜索 (下拉选项用): 返回 [{projectNo, projectName}], 空关键词返回空列表.
|
||||
*/
|
||||
public List<Map<String, Object>> searchProjectNos(String kw)
|
||||
{
|
||||
if (kw == null || kw.trim().isEmpty())
|
||||
{
|
||||
return new ArrayList<>();
|
||||
}
|
||||
String like = "%" + escapeLike(kw.trim()) + "%";
|
||||
try
|
||||
{
|
||||
List<Map<String, Object>> rows = ecologyJdbcTemplate.queryForList(PROJECT_SEARCH_SQL, like);
|
||||
List<Map<String, Object>> result = new ArrayList<>();
|
||||
for (Map<String, Object> row : rows)
|
||||
{
|
||||
Map<String, Object> m = new LinkedHashMap<>();
|
||||
m.put("projectNo", str(row, "xmbh"));
|
||||
m.put("projectName", str(row, "xmmc"));
|
||||
result.add(m);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
log.warn("查询 ecology 项目编号失败: {}", e.getMessage());
|
||||
return new ArrayList<>();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 按项目编号拉取完整项目信息 + 负责人静默查号/建号.
|
||||
* 返回字段: projectNo/projectName/projectForm/startTime/endTime/totalAmount/manageFee/
|
||||
* leaderResolved/leaderName/leaderPhone/leaderUserId.
|
||||
* 项目不存在或 ecology 不可达抛 {@link ServiceException}.
|
||||
*/
|
||||
public Map<String, Object> fetchProjectByNo(String projectNo)
|
||||
{
|
||||
if (projectNo == null || projectNo.trim().isEmpty())
|
||||
{
|
||||
throw new ServiceException("项目编号不能为空");
|
||||
}
|
||||
Map<String, Object> row;
|
||||
try
|
||||
{
|
||||
List<Map<String, Object>> rows = ecologyJdbcTemplate.queryForList(PROJECT_BY_NO_SQL, projectNo.trim());
|
||||
if (rows == null || rows.isEmpty())
|
||||
{
|
||||
throw new ServiceException("ecology 中未找到该项目编号");
|
||||
}
|
||||
row = rows.get(0);
|
||||
}
|
||||
catch (ServiceException e)
|
||||
{
|
||||
throw e;
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
log.warn("查询 ecology 项目信息失败: {}", e.getMessage());
|
||||
throw new ServiceException("ecology 数据源不可用");
|
||||
}
|
||||
|
||||
BigDecimal yqsry = decimal(row, "yqsry");
|
||||
BigDecimal ratio = decimal(row, "glfsfbl");
|
||||
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
result.put("projectNo", str(row, "xmbh"));
|
||||
result.put("projectName", str(row, "xmmc"));
|
||||
result.put("projectForm", mapProjectForm(str(row, "xmlx_name")));
|
||||
result.put("startTime", toStartTime(str(row, "xmqssj")));
|
||||
result.put("endTime", toEndTime(str(row, "xmjssj")));
|
||||
result.put("totalAmount", yqsry);
|
||||
result.put("manageFee", calcManageFee(yqsry, ratio));
|
||||
resolveLeader(row, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 负责人解析: xmfzr(int, hrmresource.id) → v_compliance_project_manager 查姓名/手机号 →
|
||||
* 静默查/建 sys_user(role_type=leader). 解析不到(负责人不在视图/无手机号)则 leaderResolved=false.
|
||||
*/
|
||||
private void resolveLeader(Map<String, Object> row, Map<String, Object> result)
|
||||
{
|
||||
Object xmfzr = row.get("xmfzr");
|
||||
result.put("leaderResolved", false);
|
||||
result.put("leaderName", null);
|
||||
result.put("leaderPhone", null);
|
||||
result.put("leaderUserId", null);
|
||||
if (xmfzr == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
try
|
||||
{
|
||||
List<Map<String, Object>> lr = ecologyJdbcTemplate.queryForList(
|
||||
LEADER_BY_ID_SQL, ((Number) xmfzr).longValue());
|
||||
if (lr == null || lr.isEmpty())
|
||||
{
|
||||
return;
|
||||
}
|
||||
String name = str(lr.get(0), "lastname");
|
||||
String phone = str(lr.get(0), "mobile");
|
||||
if (phone == null || phone.isEmpty())
|
||||
{
|
||||
return;
|
||||
}
|
||||
Long userId = complianceLoginService.ensureLeaderAccount(name, phone);
|
||||
result.put("leaderResolved", true);
|
||||
result.put("leaderName", name);
|
||||
result.put("leaderPhone", phone);
|
||||
result.put("leaderUserId", userId);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
log.warn("解析 ecology 项目负责人失败 (xmfzr={}): {}", xmfzr, e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private String str(Map<String, Object> row, String key)
|
||||
{
|
||||
Object v = row.get(key);
|
||||
if (v == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
String s = v.toString().trim();
|
||||
return s.isEmpty() ? null : s;
|
||||
}
|
||||
|
||||
private BigDecimal decimal(Map<String, Object> row, String key)
|
||||
{
|
||||
Object v = row.get(key);
|
||||
if (v == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
try
|
||||
{
|
||||
return new BigDecimal(v.toString());
|
||||
}
|
||||
catch (NumberFormatException e)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** 项目形式: 线上加线下 → 线上+线下 (表单口径), 其余原样返回. */
|
||||
private String mapProjectForm(String xmlxName)
|
||||
{
|
||||
if (xmlxName == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
return "线上加线下".equals(xmlxName) ? "线上+线下" : xmlxName;
|
||||
}
|
||||
|
||||
private String toStartTime(String day)
|
||||
{
|
||||
return day == null ? null : day + " 00:00:00";
|
||||
}
|
||||
|
||||
private String toEndTime(String day)
|
||||
{
|
||||
return day == null ? null : day + " 23:59:59";
|
||||
}
|
||||
|
||||
/** 管理费 = 总金额 × 比例 / 100, 保留 2 位小数. */
|
||||
private BigDecimal calcManageFee(BigDecimal yqsry, BigDecimal ratio)
|
||||
{
|
||||
if (yqsry == null || ratio == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
return yqsry.multiply(ratio).divide(BigDecimal.valueOf(100), 2, RoundingMode.HALF_UP);
|
||||
}
|
||||
|
||||
private String escapeLike(String s)
|
||||
{
|
||||
return s.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_");
|
||||
}
|
||||
}
|
||||
+14
@@ -10,8 +10,22 @@ public interface IBizExecutionIntentService
|
||||
{
|
||||
BizExecutionIntent getById(String intentId);
|
||||
List<BizExecutionIntent> selectList(BizExecutionIntent entity);
|
||||
/** 查询全部记录 (含 del_flag='1' 幽灵占位), 仅"已报名专家导出"用 */
|
||||
List<BizExecutionIntent> selectListIncludingDeleted(BizExecutionIntent entity);
|
||||
int insert(BizExecutionIntent entity);
|
||||
int updateByPrimaryKey(BizExecutionIntent entity);
|
||||
int deleteByPrimaryKey(String intentId);
|
||||
int deleteByPrimaryKeys(String[] intentId);
|
||||
|
||||
/**
|
||||
* 当前登录用户对某项目「报名」 (幂等): 按 userId + projectNo 查重, 已报名返回 null, 新报名返回新建记录.
|
||||
* name/phone 从 sys_user 兜底 (与"立即报名"口径一致), onboardStatus=未入库.
|
||||
*/
|
||||
BizExecutionIntent signupByProject(String projectNo, String projectName);
|
||||
|
||||
/** 物理删除幽灵占位记录 (del_flag='1'), 真报名时调用 */
|
||||
void hardDeletePlaceholder(Long userId, String projectNo);
|
||||
|
||||
/** 该用户+项目是否已存在意向记录 (真/假都算), 建占位前去重 */
|
||||
boolean existsAny(Long userId, String projectNo);
|
||||
}
|
||||
|
||||
+30
-6
@@ -1,9 +1,12 @@
|
||||
package com.ruoyi.business.service;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
import com.ruoyi.business.domain.BizMeetingAttendee;
|
||||
import com.ruoyi.business.domain.dto.ImportResult;
|
||||
import com.ruoyi.business.domain.vo.FieldMismatch;
|
||||
|
||||
public interface IBizMeetingAttendeeService {
|
||||
int insert(BizMeetingAttendee entity);
|
||||
@@ -63,6 +66,27 @@ public interface IBizMeetingAttendeeService {
|
||||
*/
|
||||
ImportResult importFromExcel(MultipartFile file, Long meetingId, String operName) throws Exception;
|
||||
|
||||
/**
|
||||
* 新增/编辑参会人前, 比对表单与专家表 (biz_expert) 档案, 返回不一致字段列表.
|
||||
*
|
||||
* <p>解析口径与 {@link #insertByPhoneWithProfile} 一致: 编辑按 body.id 反查 userId,
|
||||
* 新增按 body.phone 查 sys_user 得 userId (不建号), 再 userId→biz_expert. 查不到专家返回空列表.
|
||||
*
|
||||
* @param body 参会人表单 (含 id 或 phone 定位)
|
||||
* @return 不一致字段 (专家表已有值且与输入不同); 无差异/无专家 → 空列表
|
||||
*/
|
||||
List<FieldMismatch> diffAgainstExpert(BizMeetingAttendee body);
|
||||
|
||||
/**
|
||||
* 批量导入 dry-run: 解析 Excel + 逐行比对专家表, 不落库.
|
||||
*
|
||||
* @param file Excel 文件
|
||||
* @param meetingId 会议 id
|
||||
* @return { total: 数据行数, mismatches: [ImportMismatch] }
|
||||
* @throws Exception 文件为空 / 解析异常
|
||||
*/
|
||||
Map<String, Object> previewImport(MultipartFile file, Long meetingId) throws Exception;
|
||||
|
||||
/**
|
||||
* 推送电子签 (批量/单条通用): 对给定 attendee.id 列表逐个
|
||||
* 发短信 (电子签模板) + 推站内信 + 置 is_esigned=1.
|
||||
@@ -106,15 +130,15 @@ public interface IBizMeetingAttendeeService {
|
||||
byte[] buildAgreementTemplateZip(Long meetingId);
|
||||
|
||||
/**
|
||||
* 上传"劳务协议" zip, 解压后按 劳务协议/{序号}_{姓名}/ 目录匹配参会人,
|
||||
* 上传"劳务协议" zip, 解压后按 劳务协议/{序号}_{姓名}/ 目录匹配参会人 (姓名唯一命中优先, 序号兜底),
|
||||
* 取目录内第一个文件上传 OSS 并回填 labor_protocol (覆盖式).
|
||||
*
|
||||
* @param file 用户重新压缩的 zip
|
||||
* @param meetingId 会议 id (决定 OSS 子目录 + 参会人列表)
|
||||
* @return 成功回填的参会人数
|
||||
* @return { updated: 成功回填数, total: 参会人数, skipped: 跳过数, results: 每个目录的匹配明细 }
|
||||
* @throws Exception 文件为空 / 无参会人 / zip 解析异常
|
||||
*/
|
||||
int uploadAgreements(MultipartFile file, Long meetingId) throws Exception;
|
||||
Map<String, Object> uploadAgreements(MultipartFile file, Long meetingId) throws Exception;
|
||||
|
||||
/**
|
||||
* 生成"专家照片"空目录模板 zip (专家照片 "下载目录模板" 按钮).
|
||||
@@ -126,13 +150,13 @@ public interface IBizMeetingAttendeeService {
|
||||
byte[] buildExpertPhotoTemplateZip(Long meetingId);
|
||||
|
||||
/**
|
||||
* 上传"专家照片" zip, 解压后按 专家照片/{序号}_{姓名}/ 目录匹配参会人,
|
||||
* 上传"专家照片" zip, 解压后按 专家照片/{序号}_{姓名}/ 目录匹配参会人 (姓名唯一命中优先, 序号兜底),
|
||||
* 目录内所有文件上传 OSS 后逗号拼接, 覆盖式回填 on_site_photos.
|
||||
*
|
||||
* @param file 用户重新压缩的 zip
|
||||
* @param meetingId 会议 id
|
||||
* @return 成功回填的参会人数
|
||||
* @return { updated, total, skipped, results } (同 uploadAgreements)
|
||||
* @throws Exception 文件为空 / 无参会人 / zip 解析异常
|
||||
*/
|
||||
int uploadExpertPhotos(MultipartFile file, Long meetingId) throws Exception;
|
||||
Map<String, Object> uploadExpertPhotos(MultipartFile file, Long meetingId) throws Exception;
|
||||
}
|
||||
+292
@@ -0,0 +1,292 @@
|
||||
package com.ruoyi.business.service;
|
||||
|
||||
import cn.hutool.http.HttpUtil;
|
||||
import com.itextpdf.io.font.PdfEncodings;
|
||||
import com.itextpdf.io.image.ImageData;
|
||||
import com.itextpdf.io.image.ImageDataFactory;
|
||||
import com.itextpdf.kernel.colors.DeviceRgb;
|
||||
import com.itextpdf.kernel.font.PdfFont;
|
||||
import com.itextpdf.kernel.font.PdfFontFactory;
|
||||
import com.itextpdf.kernel.geom.LineSegment;
|
||||
import com.itextpdf.kernel.geom.Rectangle;
|
||||
import com.itextpdf.kernel.geom.Vector;
|
||||
import com.itextpdf.kernel.pdf.PdfDocument;
|
||||
import com.itextpdf.kernel.pdf.PdfPage;
|
||||
import com.itextpdf.kernel.pdf.PdfReader;
|
||||
import com.itextpdf.kernel.pdf.PdfWriter;
|
||||
import com.itextpdf.kernel.pdf.canvas.PdfCanvas;
|
||||
import com.itextpdf.kernel.pdf.canvas.parser.EventType;
|
||||
import com.itextpdf.kernel.pdf.canvas.parser.PdfTextExtractor;
|
||||
import com.itextpdf.kernel.pdf.canvas.parser.data.IEventData;
|
||||
import com.itextpdf.kernel.pdf.canvas.parser.data.TextRenderInfo;
|
||||
import com.itextpdf.kernel.pdf.canvas.parser.listener.ITextExtractionStrategy;
|
||||
import com.ruoyi.business.oss.OssUploader;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* 会议邀请函个性化 PDF 生成服务.
|
||||
*
|
||||
* <p>流程 (「发送邀请函」触发): 下载会议邀请函模板 PDF → 把模板里的 {@code xxx}/{@code XXX}
|
||||
* 替换为医生姓名 → 找到<strong>最后一个</strong>「北京整合医学学会」在其位置盖公章 → 上传 OSS.
|
||||
*
|
||||
* <p>文本定位用 iText7 的 {@link PdfTextExtractor} + 自定义 {@link ITextExtractionStrategy},
|
||||
* 逐字形拿到 baseline / ascent / descent / 字号, 白底盖住占位符后在原 baseline 处写姓名.
|
||||
* 模板若为扫描图片 PDF (无文本层), 抽取不到目标文本 → 仅记 warn、原样返回 (不盖名章).
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
public class InvitationPdfService {
|
||||
|
||||
@Autowired
|
||||
private OssUploader ossUploader;
|
||||
|
||||
/** 公章图片相对路径 (运行目录, 与 simsun.ttc / 劳务协议盖章同图) */
|
||||
private static final String SEAL_IMAGE_PATH = "zhenghe.png";
|
||||
/** 公章尺寸 (宽高, px), 复用 PdfService 劳务协议章口径 */
|
||||
private static final float SEAL_WIDTH = 120f;
|
||||
private static final float SEAL_HEIGHT = 120f;
|
||||
/** 需盖章的落款机构名 (取全文最后一个) */
|
||||
private static final String ORG_TEXT = "北京整合医学学会";
|
||||
/** 姓名占位符 (忽略大小写, 兼容 xxx / XXX) */
|
||||
private static final String PLACEHOLDER = "xxx";
|
||||
|
||||
/** 按 URL 下载邀请函模板 PDF 字节 (批量发送时对同一会议只调用一次) */
|
||||
public byte[] downloadTemplate(String url) {
|
||||
log.info("[invitation] 下载邀请函模板: {}", url);
|
||||
try {
|
||||
return HttpUtil.downloadBytes(url);
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException("下载邀请函模板失败: " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
/** 生成个性化 PDF 并上传 OSS, 返回完整 URL */
|
||||
public String generateAndUpload(byte[] template, String name) {
|
||||
if (template == null || template.length == 0) {
|
||||
throw new IllegalArgumentException("邀请函模板为空");
|
||||
}
|
||||
if (name == null || name.trim().isEmpty()) {
|
||||
name = "专家";
|
||||
}
|
||||
byte[] out = generate(template, name);
|
||||
String filename = System.currentTimeMillis() + "_" + (int) (Math.random() * 1000) + ".pdf";
|
||||
return ossUploader.upload(out, filename, "meeting/invitation");
|
||||
}
|
||||
|
||||
/** 模板 PDF → 个性化 PDF (替换姓名 + 盖章) */
|
||||
public byte[] generate(byte[] template, String name) {
|
||||
try {
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
PdfDocument doc = new PdfDocument(
|
||||
new PdfReader(new ByteArrayInputStream(template)),
|
||||
new PdfWriter(baos));
|
||||
try {
|
||||
render(doc, name);
|
||||
} finally {
|
||||
doc.close();
|
||||
}
|
||||
return baos.toByteArray();
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException("生成邀请函 PDF 失败: " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
/** 第一遍抽取文本定位 → 第二遍叠加姓名 + 公章 */
|
||||
private void render(PdfDocument doc, String name) throws IOException {
|
||||
List<NameTarget> nameTargets = new ArrayList<>();
|
||||
SealTarget seal = null;
|
||||
int pageCount = doc.getNumberOfPages();
|
||||
|
||||
// 第一遍: 逐页抽取文本, 记录 xxx 位置 + 最后一个「北京整合医学学会」位置
|
||||
for (int p = 1; p <= pageCount; p++) {
|
||||
PdfPage page = doc.getPage(p);
|
||||
TextPositionStrategy strategy = new TextPositionStrategy();
|
||||
PdfTextExtractor.getTextFromPage(page, strategy);
|
||||
|
||||
// 展平为「字符 → 字形」列表, 供子串定位 (要求 1 字符 = 1 字形, CJK/拉丁模板恒成立)
|
||||
List<TextRenderInfo> charInfos = new ArrayList<>();
|
||||
StringBuilder sb = new StringBuilder();
|
||||
for (TextRenderInfo info : strategy.infos()) {
|
||||
List<TextRenderInfo> chars = info.getCharacterRenderInfos();
|
||||
if (chars != null && !chars.isEmpty()) {
|
||||
for (TextRenderInfo c : chars) {
|
||||
String ch = c.getText();
|
||||
if (ch != null && !ch.isEmpty()) {
|
||||
charInfos.add(c);
|
||||
sb.append(ch);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
String t = info.getText();
|
||||
if (t != null && !t.isEmpty()) {
|
||||
charInfos.add(info);
|
||||
sb.append(t);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
String full = sb.toString();
|
||||
if (full.isEmpty()) continue;
|
||||
if (charInfos.size() != full.length()) {
|
||||
log.warn("[invitation] 第{}页字形/字符数不一致 ({}!={}), 跳过定位", p, charInfos.size(), full.length());
|
||||
continue;
|
||||
}
|
||||
|
||||
String lower = full.toLowerCase(Locale.ROOT);
|
||||
int i = 0;
|
||||
while ((i = lower.indexOf(PLACEHOLDER, i)) >= 0) {
|
||||
nameTargets.add(buildNameTarget(charInfos, i, PLACEHOLDER.length(), p));
|
||||
i += PLACEHOLDER.length();
|
||||
}
|
||||
int j = 0;
|
||||
while ((j = full.indexOf(ORG_TEXT, j)) >= 0) {
|
||||
seal = buildSealTarget(charInfos, j, ORG_TEXT.length(), p);
|
||||
j += ORG_TEXT.length();
|
||||
}
|
||||
}
|
||||
|
||||
// 第二遍: 叠加
|
||||
if (nameTargets.isEmpty()) {
|
||||
log.warn("[invitation] 未找到占位符 xxx, 跳过姓名替换");
|
||||
} else {
|
||||
PdfFont cjkFont = PdfFontFactory.createFont("simsun.ttc,0", PdfEncodings.IDENTITY_H, false);
|
||||
for (NameTarget t : nameTargets) {
|
||||
drawName(doc, cjkFont, t, name);
|
||||
}
|
||||
}
|
||||
if (seal == null) {
|
||||
log.warn("[invitation] 未找到 '{}', 跳过盖章", ORG_TEXT);
|
||||
} else {
|
||||
drawSeal(doc, seal);
|
||||
}
|
||||
}
|
||||
|
||||
private NameTarget buildNameTarget(List<TextRenderInfo> charInfos, int start, int len, int page) {
|
||||
TextRenderInfo first = charInfos.get(start);
|
||||
LineSegment firstBase = first.getBaseline();
|
||||
float x = firstBase.getStartPoint().get(Vector.I1);
|
||||
float baselineY = firstBase.getStartPoint().get(Vector.I2);
|
||||
float fontSize = first.getFontSize();
|
||||
float width = 0f;
|
||||
for (int k = start; k < start + len && k < charInfos.size(); k++) {
|
||||
width += charInfos.get(k).getBaseline().getLength();
|
||||
}
|
||||
return new NameTarget(page, x, baselineY, fontSize, width);
|
||||
}
|
||||
|
||||
private SealTarget buildSealTarget(List<TextRenderInfo> charInfos, int start, int len, int page) {
|
||||
TextRenderInfo first = charInfos.get(start);
|
||||
TextRenderInfo last = charInfos.get(start + len - 1);
|
||||
float x = first.getBaseline().getStartPoint().get(Vector.I1);
|
||||
float endX = last.getBaseline().getEndPoint().get(Vector.I1);
|
||||
float ascent = first.getAscentLine().getStartPoint().get(Vector.I2);
|
||||
float descent = first.getDescentLine().getStartPoint().get(Vector.I2);
|
||||
float cx = (x + endX) / 2f;
|
||||
float cy = (ascent + descent) / 2f;
|
||||
return new SealTarget(page, cx, cy);
|
||||
}
|
||||
|
||||
/** 白底盖住 xxx, 在原 baseline 处写医生姓名 (左对齐) */
|
||||
private void drawName(PdfDocument doc, PdfFont font, NameTarget t, String name) {
|
||||
PdfPage page = doc.getPage(t.page);
|
||||
PdfCanvas canvas = new PdfCanvas(page.newContentStreamAfter(), page.getResources(), doc);
|
||||
float pad = t.fontSize * 0.1f;
|
||||
float y = t.baselineY - t.fontSize * 0.28f;
|
||||
float h = t.fontSize * 1.2f;
|
||||
canvas.setFillColor(DeviceRgb.WHITE);
|
||||
canvas.rectangle(t.x - pad, y, t.width + 2 * pad, h);
|
||||
canvas.fill();
|
||||
|
||||
canvas.setFillColor(DeviceRgb.BLACK);
|
||||
canvas.beginText();
|
||||
canvas.setFontAndSize(font, t.fontSize);
|
||||
canvas.setTextMatrix(t.x, t.baselineY);
|
||||
canvas.showText(name);
|
||||
canvas.endText();
|
||||
}
|
||||
|
||||
/** 公章居中叠加到最后一个「北京整合医学学会」文本中心 */
|
||||
private void drawSeal(PdfDocument doc, SealTarget s) {
|
||||
PdfPage page = doc.getPage(s.page);
|
||||
PdfCanvas canvas = new PdfCanvas(page.newContentStreamAfter(), page.getResources(), doc);
|
||||
try {
|
||||
ImageData imageData = ImageDataFactory.create(SEAL_IMAGE_PATH);
|
||||
float x = s.cx - SEAL_WIDTH / 2f;
|
||||
// 公章中心相对落款文本中心再往下偏 1/2 个公章半径 (半径 = SEAL_HEIGHT/2, 即下移 SEAL_HEIGHT/4)
|
||||
// PDF 坐标系 Y 轴向上为正, "往下" 即减小 Y
|
||||
float y = s.cy - SEAL_HEIGHT / 2f - SEAL_HEIGHT / 4f;
|
||||
canvas.addImage(imageData, new Rectangle(x, y, SEAL_WIDTH, SEAL_HEIGHT), false);
|
||||
} catch (Exception e) {
|
||||
log.warn("[invitation] 公章图片加载失败, 跳过盖章: {}", SEAL_IMAGE_PATH, e);
|
||||
}
|
||||
}
|
||||
|
||||
/** 收集所有 RENDER_TEXT 事件的 TextRenderInfo (字形级定位) */
|
||||
private static class TextPositionStrategy implements ITextExtractionStrategy {
|
||||
private final List<TextRenderInfo> infos = new ArrayList<>();
|
||||
|
||||
@Override
|
||||
public void eventOccurred(IEventData data, EventType type) {
|
||||
if (type == EventType.RENDER_TEXT && data instanceof TextRenderInfo) {
|
||||
TextRenderInfo info = (TextRenderInfo) data;
|
||||
// 必须保留 graphics state, 否则抽取结束后 getBaseline()/getFontSize()/
|
||||
// getCharacterRenderInfos() 等会抛 IllegalStateException (iText7 默认丢弃)
|
||||
info.preserveGraphicsState();
|
||||
infos.add(info);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public Set<EventType> getSupportedEvents() {
|
||||
return Collections.singleton(EventType.RENDER_TEXT);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getResultantText() {
|
||||
return "";
|
||||
}
|
||||
|
||||
List<TextRenderInfo> infos() {
|
||||
return infos;
|
||||
}
|
||||
}
|
||||
|
||||
private static class NameTarget {
|
||||
final int page;
|
||||
final float x;
|
||||
final float baselineY;
|
||||
final float fontSize;
|
||||
final float width;
|
||||
|
||||
NameTarget(int page, float x, float baselineY, float fontSize, float width) {
|
||||
this.page = page;
|
||||
this.x = x;
|
||||
this.baselineY = baselineY;
|
||||
this.fontSize = fontSize;
|
||||
this.width = width;
|
||||
}
|
||||
}
|
||||
|
||||
private static class SealTarget {
|
||||
final int page;
|
||||
final float cx;
|
||||
final float cy;
|
||||
|
||||
SealTarget(int page, float cx, float cy) {
|
||||
this.page = page;
|
||||
this.cx = cx;
|
||||
this.cy = cy;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import com.ruoyi.common.core.redis.RedisCache;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.UUID;
|
||||
@@ -38,12 +39,13 @@ public class SysSmsService {
|
||||
@Autowired
|
||||
private AliyunSmsSender aliyunSmsSender;
|
||||
|
||||
/**
|
||||
* dev 模式判断: 手机号以 "10" 开头视为开发测试 (固定码 1234), 其它走 aliyun 真发
|
||||
* 该逻辑不依赖 application.yml 配置, 确保开发/生产两种手机号能并存
|
||||
*/
|
||||
private boolean isDev(String phone) {
|
||||
return phone.startsWith("10");
|
||||
/** 测试环境短信开关: true=固定码 1234 且不真发 (test), false=aliyun 真发 (prod) */
|
||||
@Value("${ruoyi.sms.mock-enabled:false}")
|
||||
private boolean mockEnabled;
|
||||
|
||||
/** 生产测试账号: 手机号以 "10" 开头视为测试号, 固定码 1234 且不真发 (生产环境测试用) */
|
||||
private boolean isTestAccount(String phone) {
|
||||
return phone != null && phone.startsWith("10");
|
||||
}
|
||||
|
||||
/** 发送短信验证码, 返回 uuid (前端提交验证时回传) */
|
||||
@@ -56,12 +58,12 @@ public class SysSmsService {
|
||||
}
|
||||
|
||||
String code;
|
||||
if (isDev(phone)) {
|
||||
// dev 模式 (10 开头): 固定码 1234 (供内部测试)
|
||||
if (mockEnabled || isTestAccount(phone)) {
|
||||
// 测试环境 / 生产测试账号(手机号 10 开头): 固定码 1234 (不真发)
|
||||
code = "1234";
|
||||
log.info("[SMS][DEV][phone-prefix=10] 向 {} 发送验证码: {}", phone, code);
|
||||
log.info("[SMS][MOCK] 向 {} 发送验证码(测试固定码): {}", phone, code);
|
||||
} else {
|
||||
// prod 模式: aliyun 真发
|
||||
// 生产环境: aliyun 真发
|
||||
code = generateCode(4);
|
||||
log.info("[SMS][PROD] 向 {} 发送验证码: {}", phone, code);
|
||||
boolean ok = aliyunSmsSender.sendCode(phone, code);
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
package com.ruoyi.business.service;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.core.env.Environment;
|
||||
import org.springframework.stereotype.Service;
|
||||
import com.alibaba.fastjson2.JSONObject;
|
||||
import com.ruoyi.common.core.redis.RedisCache;
|
||||
import com.ruoyi.common.utils.http.HttpUtils;
|
||||
|
||||
/**
|
||||
* 公众号 JS-SDK 分享签名服务
|
||||
* <p>链路: appId + appSecret → access_token → jsapi_ticket → signature(sha1)
|
||||
* <p>参考 hwt-serve WxService.getAccessToken/getTicket, 但签名改在后端完整计算,
|
||||
* 不把 jsapi_ticket 暴露给前端.
|
||||
*/
|
||||
@Service
|
||||
public class WxShareService
|
||||
{
|
||||
private static final Logger log = LoggerFactory.getLogger(WxShareService.class);
|
||||
|
||||
/** access_token / jsapi_ticket 缓存有效期 (官方 7200s, 提前 200s 刷新) */
|
||||
private static final int CACHE_TTL_SECONDS = 7000;
|
||||
|
||||
@Autowired
|
||||
private Environment environment;
|
||||
|
||||
@Autowired
|
||||
private RedisCache redisCache;
|
||||
|
||||
private String appId()
|
||||
{
|
||||
return environment.getProperty("wx.app-id", "wx2414cddf4bd56092");
|
||||
}
|
||||
|
||||
private String appSecret()
|
||||
{
|
||||
return environment.getProperty("wx.app-secret");
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取公众号 access_token (Redis 缓存, 避免频繁调用被限流 2000 次/日)
|
||||
*/
|
||||
private String getAccessToken()
|
||||
{
|
||||
String appId = appId();
|
||||
String key = "wx:access_token:" + appId;
|
||||
String token = redisCache.getCacheObject(key);
|
||||
if (token != null && !token.isEmpty())
|
||||
{
|
||||
return token;
|
||||
}
|
||||
String url = "https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid="
|
||||
+ appId + "&secret=" + appSecret();
|
||||
String resp = HttpUtils.sendGet(url);
|
||||
JSONObject obj = (resp == null || resp.isEmpty()) ? null : JSONObject.parseObject(resp);
|
||||
if (obj == null || obj.getString("access_token") == null)
|
||||
{
|
||||
log.error("获取微信 access_token 失败, resp={}", resp);
|
||||
throw new RuntimeException("获取微信 access_token 失败: "
|
||||
+ (obj == null ? resp : obj.getString("errmsg")));
|
||||
}
|
||||
token = obj.getString("access_token");
|
||||
redisCache.setCacheObject(key, token, CACHE_TTL_SECONDS, TimeUnit.SECONDS);
|
||||
return token;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 jsapi_ticket (Redis 缓存)
|
||||
*/
|
||||
private String getJsapiTicket(String accessToken)
|
||||
{
|
||||
String appId = appId();
|
||||
String key = "wx:jsapi_ticket:" + appId;
|
||||
String ticket = redisCache.getCacheObject(key);
|
||||
if (ticket != null && !ticket.isEmpty())
|
||||
{
|
||||
return ticket;
|
||||
}
|
||||
String url = "https://api.weixin.qq.com/cgi-bin/ticket/getticket?access_token="
|
||||
+ accessToken + "&type=jsapi";
|
||||
String resp = HttpUtils.sendGet(url);
|
||||
JSONObject obj = (resp == null || resp.isEmpty()) ? null : JSONObject.parseObject(resp);
|
||||
if (obj == null || !"0".equals(obj.getString("errcode")) || obj.getString("ticket") == null)
|
||||
{
|
||||
log.error("获取微信 jsapi_ticket 失败, resp={}", resp);
|
||||
throw new RuntimeException("获取微信 jsapi_ticket 失败: "
|
||||
+ (obj == null ? resp : obj.getString("errmsg")));
|
||||
}
|
||||
ticket = obj.getString("ticket");
|
||||
redisCache.setCacheObject(key, ticket, CACHE_TTL_SECONDS, TimeUnit.SECONDS);
|
||||
return ticket;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成前端 wx.config 所需参数
|
||||
*
|
||||
* @param url 当前页面地址 (必须不含 # 及之后 fragment, 微信签名校验会忽略 fragment)
|
||||
* @return {appId, timestamp, nonceStr, signature}
|
||||
*/
|
||||
public Map<String, Object> buildShareConfig(String url)
|
||||
{
|
||||
String ticket = getJsapiTicket(getAccessToken());
|
||||
long timestamp = System.currentTimeMillis() / 1000;
|
||||
String nonceStr = UUID.randomUUID().toString().replace("-", "").substring(0, 16);
|
||||
// 官方签名算法: key 按字典序 (jsapi_ticket < noncestr < timestamp < url) 拼接后 sha1
|
||||
String string1 = "jsapi_ticket=" + ticket + "&noncestr=" + nonceStr
|
||||
+ "×tamp=" + timestamp + "&url=" + url;
|
||||
String signature = sha1(string1);
|
||||
|
||||
Map<String, Object> ret = new HashMap<>();
|
||||
ret.put("appId", appId());
|
||||
ret.put("timestamp", timestamp);
|
||||
ret.put("nonceStr", nonceStr);
|
||||
ret.put("signature", signature);
|
||||
return ret;
|
||||
}
|
||||
|
||||
/**
|
||||
* SHA-1 十六进制小写摘要
|
||||
*/
|
||||
private static String sha1(String str)
|
||||
{
|
||||
try
|
||||
{
|
||||
MessageDigest digest = MessageDigest.getInstance("SHA-1");
|
||||
byte[] bytes = digest.digest(str.getBytes(StandardCharsets.UTF_8));
|
||||
StringBuilder sb = new StringBuilder();
|
||||
for (byte b : bytes)
|
||||
{
|
||||
String hex = Integer.toHexString(b & 0xFF);
|
||||
if (hex.length() < 2)
|
||||
{
|
||||
sb.append('0');
|
||||
}
|
||||
sb.append(hex);
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
catch (NoSuchAlgorithmException e)
|
||||
{
|
||||
throw new RuntimeException("SHA-1 算法不可用", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
+51
@@ -6,12 +6,17 @@ import org.springframework.stereotype.Service;
|
||||
import com.ruoyi.business.domain.BizExecutionIntent;
|
||||
import com.ruoyi.business.mapper.BizExecutionIntentMapper;
|
||||
import com.ruoyi.business.service.IBizExecutionIntentService;
|
||||
import com.ruoyi.common.core.domain.entity.SysUser;
|
||||
import com.ruoyi.common.utils.SecurityUtils;
|
||||
import com.ruoyi.system.mapper.SysUserMapper;
|
||||
|
||||
@Service
|
||||
public class BizExecutionIntentServiceImpl implements IBizExecutionIntentService
|
||||
{
|
||||
@Autowired
|
||||
private BizExecutionIntentMapper bizExecutionIntentMapper;
|
||||
@Autowired
|
||||
private SysUserMapper sysUserMapper;
|
||||
|
||||
@Override
|
||||
public BizExecutionIntent getById(String intentId)
|
||||
@@ -20,6 +25,9 @@ public class BizExecutionIntentServiceImpl implements IBizExecutionIntentService
|
||||
public List<BizExecutionIntent> selectList(BizExecutionIntent entity)
|
||||
{ return bizExecutionIntentMapper.selectList(entity); }
|
||||
@Override
|
||||
public List<BizExecutionIntent> selectListIncludingDeleted(BizExecutionIntent entity)
|
||||
{ return bizExecutionIntentMapper.selectListIncludingDeleted(entity); }
|
||||
@Override
|
||||
public int insert(BizExecutionIntent entity) { com.ruoyi.common.utils.id.SnowflakeId.injectIfEmpty(entity, "intentId"); return bizExecutionIntentMapper.insert(entity); }
|
||||
@Override
|
||||
public int updateByPrimaryKey(BizExecutionIntent entity)
|
||||
@@ -30,4 +38,47 @@ public class BizExecutionIntentServiceImpl implements IBizExecutionIntentService
|
||||
@Override
|
||||
public int deleteByPrimaryKeys(String[] intentId)
|
||||
{ return bizExecutionIntentMapper.deleteByPrimaryKeys(intentId); }
|
||||
|
||||
@Override
|
||||
public void hardDeletePlaceholder(Long userId, String projectNo)
|
||||
{
|
||||
if (userId == null || projectNo == null || projectNo.trim().isEmpty()) return;
|
||||
bizExecutionIntentMapper.hardDeletePlaceholder(userId, projectNo);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean existsAny(Long userId, String projectNo)
|
||||
{
|
||||
if (userId == null || projectNo == null || projectNo.trim().isEmpty()) return false;
|
||||
return bizExecutionIntentMapper.countByUserAndProject(userId, projectNo) > 0;
|
||||
}
|
||||
|
||||
@Override
|
||||
public BizExecutionIntent signupByProject(String projectNo, String projectName)
|
||||
{
|
||||
if (projectNo == null || projectNo.trim().isEmpty()) return null;
|
||||
Long uid = SecurityUtils.getUserId();
|
||||
BizExecutionIntent q = new BizExecutionIntent();
|
||||
q.setUserId(uid);
|
||||
q.setProjectNo(projectNo);
|
||||
List<BizExecutionIntent> exists = bizExecutionIntentMapper.selectList(q);
|
||||
if (exists != null && !exists.isEmpty()) return null;
|
||||
|
||||
// 真报名: 先物理删掉幽灵占位记录 (del_flag='1'), 避免残留
|
||||
hardDeletePlaceholder(uid, projectNo);
|
||||
|
||||
BizExecutionIntent e = new BizExecutionIntent();
|
||||
e.setUserId(uid);
|
||||
e.setProjectNo(projectNo);
|
||||
e.setProjectName(projectName);
|
||||
SysUser u = sysUserMapper.selectUserById(uid);
|
||||
if (u != null) {
|
||||
e.setName(u.getNickName());
|
||||
e.setPhone(u.getPhonenumber());
|
||||
}
|
||||
e.setOnboardStatus("未入库");
|
||||
e.setCreateBy(SecurityUtils.getUsername());
|
||||
insert(e);
|
||||
return e;
|
||||
}
|
||||
}
|
||||
|
||||
+315
-128
@@ -8,12 +8,18 @@ import java.math.BigDecimal;
|
||||
import java.math.RoundingMode;
|
||||
import java.nio.charset.Charset;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.ZoneId;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.Date;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ThreadLocalRandom;
|
||||
import java.util.zip.ZipEntry;
|
||||
import java.util.zip.ZipInputStream;
|
||||
import java.util.zip.ZipOutputStream;
|
||||
@@ -25,20 +31,25 @@ import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.ruoyi.business.domain.BizExecutionIntent;
|
||||
import com.ruoyi.business.domain.BizExpert;
|
||||
import com.ruoyi.business.domain.BizMeeting;
|
||||
import com.ruoyi.business.domain.BizMeetingAttendee;
|
||||
import com.ruoyi.business.domain.BizProject;
|
||||
import com.ruoyi.business.domain.dto.ImportResult;
|
||||
import com.ruoyi.business.domain.vo.BizMeetingAttendeeImportVo;
|
||||
import com.ruoyi.business.domain.vo.FieldMismatch;
|
||||
import com.ruoyi.business.domain.vo.ImportMismatch;
|
||||
import com.ruoyi.business.mapper.BizExpertMapper;
|
||||
import com.ruoyi.business.mapper.BizMeetingAttendeeMapper;
|
||||
import com.ruoyi.business.mapper.BizMeetingMapper;
|
||||
import com.ruoyi.business.mapper.BizProjectMapper;
|
||||
import com.ruoyi.business.notify.BizNotifyService;
|
||||
import com.ruoyi.business.oss.OssUploader;
|
||||
import com.ruoyi.business.service.IBizExecutionIntentService;
|
||||
import com.ruoyi.business.service.IBizMeetingAttendeeService;
|
||||
import com.ruoyi.business.service.IBizMeetingService;
|
||||
import com.ruoyi.business.service.InvitationPdfService;
|
||||
import com.ruoyi.business.sms.AliyunSmsSender;
|
||||
import com.ruoyi.common.core.domain.entity.SysUser;
|
||||
import com.ruoyi.common.exception.ServiceException;
|
||||
@@ -65,6 +76,8 @@ public class BizMeetingAttendeeServiceImpl implements IBizMeetingAttendeeService
|
||||
private ISysUserService sysUserService;
|
||||
@Autowired
|
||||
private BizExpertMapper bizExpertMapper;
|
||||
@Autowired
|
||||
private IBizExecutionIntentService bizExecutionIntentService;
|
||||
|
||||
/** Jackson (Spring Boot 自带), 解析 biz_project.role_labor JSON 数组 [{role, customName, amount}] */
|
||||
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||
@@ -77,6 +90,8 @@ public class BizMeetingAttendeeServiceImpl implements IBizMeetingAttendeeService
|
||||
private IBizMeetingService bizMeetingService;
|
||||
@Autowired
|
||||
private OssUploader ossUploader;
|
||||
@Autowired
|
||||
private InvitationPdfService invitationPdfService;
|
||||
|
||||
@Override
|
||||
public int insert(BizMeetingAttendee entity) {
|
||||
@@ -156,9 +171,6 @@ public class BizMeetingAttendeeServiceImpl implements IBizMeetingAttendeeService
|
||||
throw new ServiceException("该手机号参会人已在会议中, 无需重复添加");
|
||||
}
|
||||
|
||||
// 3.5 确保专家档案存在 (不存在则新建, 存在则补齐空字段), 让参会人在专家库可见
|
||||
ensureExpertProfile(body, userId);
|
||||
|
||||
// 4. 写完整档案行 (attendee.id 用雪花 ID, 不走 DB 自增)
|
||||
body.setUserId(userId);
|
||||
body.setCreateBy(SecurityUtils.getUsername());
|
||||
@@ -166,64 +178,60 @@ public class BizMeetingAttendeeServiceImpl implements IBizMeetingAttendeeService
|
||||
mapper.insertWithProfile(body);
|
||||
Long newId = body.getId();
|
||||
log.info("[attendee] 新增参会人 meetingId={} userId={} attendeeId={}", body.getMeetingId(), userId, newId);
|
||||
|
||||
// 5. 同步一条"报名项目"幽灵占位记录 (del_flag='1'): 新增/导入都走这里, 一处覆盖两入口
|
||||
createFakeSignup(body.getMeetingId(), userId, phone, body);
|
||||
return newId;
|
||||
}
|
||||
|
||||
/**
|
||||
* 确保参会人对应的 biz_expert 记录存在且字段尽量完整:
|
||||
* - 不存在 → 新建 (status='Y' 正常, auditStatus='2' 通过, 与 importExpert 约定一致)
|
||||
* - 存在但部分字段为空 → 只用参会人档案里的非空值补齐空字段, 不覆盖已有值
|
||||
* 新增/导入参会人时, 同步给该医生建一条"报名项目"幽灵占位记录 (del_flag='1'):
|
||||
* - 纯占位, 报名列表/导出/报名查重/参会人 has_intent 均按 del_flag='0' 过滤, 不会露出;
|
||||
* - 报名时间随机: 日期 ∈ [项目开始日, 今天], 时间 ∈ [07:00, 21:00);
|
||||
* - 真报名(报名/邀请函)时会先物理删除这条占位, 再写真实报名.
|
||||
* 失败只记日志, 不阻断参会人新增/导入.
|
||||
*/
|
||||
private void ensureExpertProfile(BizMeetingAttendee body, Long userId) {
|
||||
if (userId == null) return;
|
||||
BizExpert existed = bizExpertMapper.selectByUserId(userId);
|
||||
if (existed == null) {
|
||||
BizExpert e = new BizExpert();
|
||||
e.setExpertId(IdGenerator.generateId());
|
||||
private void createFakeSignup(Long meetingId, Long userId, String phone, BizMeetingAttendee body) {
|
||||
try {
|
||||
if (meetingId == null || userId == null) return;
|
||||
BizMeeting meeting = meetingMapper.selectByPrimaryKey(meetingId);
|
||||
if (meeting == null || meeting.getProjectId() == null) return;
|
||||
BizProject project = projectMapper.selectByPrimaryKey(meeting.getProjectId());
|
||||
if (project == null || project.getProjectNo() == null || project.getProjectNo().trim().isEmpty()) return;
|
||||
String projectNo = project.getProjectNo();
|
||||
// 去重: 该 user+project 已有意向(真/假都算)则不再建占位
|
||||
if (bizExecutionIntentService.existsAny(userId, projectNo)) return;
|
||||
|
||||
String name = (body.getName() != null && !body.getName().trim().isEmpty()) ? body.getName().trim() : phone;
|
||||
BizExecutionIntent e = new BizExecutionIntent();
|
||||
e.setUserId(userId);
|
||||
e.setName(isBlank(body.getName()) ? body.getPhone() : body.getName());
|
||||
e.setPhone(body.getPhone());
|
||||
e.setWorkUnit(body.getWorkUnit());
|
||||
e.setDepartment(body.getDepartment());
|
||||
e.setTitle(body.getTitle());
|
||||
e.setIdCard(body.getIdCard());
|
||||
e.setBankCard(body.getBankCard());
|
||||
e.setBankName(body.getBankName());
|
||||
e.setBankBranch(body.getBankBranch());
|
||||
e.setBankRegion(body.getBankRegion());
|
||||
e.setBankAddress(body.getBankAddress());
|
||||
e.setIdCardAttachments(body.getIdCardAttachments());
|
||||
e.setStatus("Y");
|
||||
e.setAuditStatus("2");
|
||||
e.setAuditBy(SecurityUtils.getUsername());
|
||||
e.setAuditTime(new Date());
|
||||
e.setProjectNo(projectNo);
|
||||
e.setProjectName(project.getProjectName());
|
||||
e.setName(name);
|
||||
e.setPhone(phone);
|
||||
e.setOnboardStatus("未入库");
|
||||
e.setDelFlag("1");
|
||||
e.setCreateTime(randomSignupTime(project.getStartTime()));
|
||||
e.setCreateBy(SecurityUtils.getUsername());
|
||||
e.setCreateTime(new Date());
|
||||
bizExpertMapper.insert(e);
|
||||
return;
|
||||
}
|
||||
// 补充: 只填 biz_expert 里为空的字段, 已有值不覆盖
|
||||
BizExpert u = new BizExpert();
|
||||
u.setUserId(userId);
|
||||
boolean changed = false;
|
||||
if (isBlank(existed.getName()) && !isBlank(body.getName())) { u.setName(body.getName()); changed = true; }
|
||||
if (isBlank(existed.getPhone()) && !isBlank(body.getPhone())) { u.setPhone(body.getPhone()); changed = true; }
|
||||
if (isBlank(existed.getWorkUnit()) && !isBlank(body.getWorkUnit())) { u.setWorkUnit(body.getWorkUnit()); changed = true; }
|
||||
if (isBlank(existed.getDepartment()) && !isBlank(body.getDepartment())) { u.setDepartment(body.getDepartment()); changed = true; }
|
||||
if (isBlank(existed.getTitle()) && !isBlank(body.getTitle())) { u.setTitle(body.getTitle()); changed = true; }
|
||||
if (isBlank(existed.getIdCard()) && !isBlank(body.getIdCard())) { u.setIdCard(body.getIdCard()); changed = true; }
|
||||
if (isBlank(existed.getBankCard()) && !isBlank(body.getBankCard())) { u.setBankCard(body.getBankCard()); changed = true; }
|
||||
if (isBlank(existed.getBankName()) && !isBlank(body.getBankName())) { u.setBankName(body.getBankName()); changed = true; }
|
||||
if (isBlank(existed.getBankBranch()) && !isBlank(body.getBankBranch())) { u.setBankBranch(body.getBankBranch()); changed = true; }
|
||||
if (isBlank(existed.getBankRegion()) && !isBlank(body.getBankRegion())) { u.setBankRegion(body.getBankRegion()); changed = true; }
|
||||
if (isBlank(existed.getBankAddress()) && !isBlank(body.getBankAddress())) { u.setBankAddress(body.getBankAddress()); changed = true; }
|
||||
if (isBlank(existed.getIdCardAttachments()) && !isBlank(body.getIdCardAttachments())) { u.setIdCardAttachments(body.getIdCardAttachments()); changed = true; }
|
||||
if (changed) {
|
||||
bizExpertMapper.updateByUserId(u);
|
||||
bizExecutionIntentService.insert(e);
|
||||
log.info("[attendee] 建报名占位记录 userId={} projectNo={}", userId, projectNo);
|
||||
} catch (Exception ex) {
|
||||
log.warn("[attendee] 建报名占位记录失败 meetingId={} userId={}: {}", meetingId, userId, ex.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean isBlank(String s) { return s == null || s.trim().isEmpty(); }
|
||||
/** 报名时间随机: 日期 ∈ [项目开始日, 今天], 时间 ∈ [07:00, 21:00) (分钟粒度) */
|
||||
private Date randomSignupTime(Date projectStart) {
|
||||
LocalDate today = LocalDate.now();
|
||||
LocalDate from = projectStart == null ? today
|
||||
: projectStart.toInstant().atZone(ZoneId.systemDefault()).toLocalDate();
|
||||
if (from.isAfter(today)) from = today;
|
||||
long days = ChronoUnit.DAYS.between(from, today) + 1;
|
||||
LocalDate day = from.plusDays(ThreadLocalRandom.current().nextLong(days));
|
||||
int minute = ThreadLocalRandom.current().nextInt(7 * 60, 21 * 60); // 420..1259
|
||||
LocalDateTime dt = day.atTime(minute / 60, minute % 60);
|
||||
return Date.from(dt.atZone(ZoneId.systemDefault()).toInstant());
|
||||
}
|
||||
|
||||
@Override
|
||||
public int updateProfile(BizMeetingAttendee entity) {
|
||||
@@ -458,6 +466,12 @@ public class BizMeetingAttendeeServiceImpl implements IBizMeetingAttendeeService
|
||||
result.fail(rowNo, "手机号格式不正确: " + phone);
|
||||
continue;
|
||||
}
|
||||
// 身份证号位数校验 (18位, 末位可为 X/x); 空值跳过 (导入不强制必填, 与前端 dialog 的必填规则分开)
|
||||
String idCard = vo.getIdCard() == null ? null : vo.getIdCard().trim();
|
||||
if (idCard != null && !idCard.isEmpty() && !idCard.matches("^\\d{17}[\\dXx]$")) {
|
||||
result.fail(rowNo, "身份证号应为18位(末位可为X): " + idCard);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 构造 attendee 实体, 复用 insertByPhoneWithProfile (内部查/建 sys_user + 写 attendee)
|
||||
BizMeetingAttendee body = new BizMeetingAttendee();
|
||||
@@ -467,7 +481,7 @@ public class BizMeetingAttendeeServiceImpl implements IBizMeetingAttendeeService
|
||||
body.setWorkUnit(vo.getWorkUnit());
|
||||
body.setDepartment(vo.getDepartment());
|
||||
body.setTitle(vo.getTitle());
|
||||
body.setIdCard(vo.getIdCard());
|
||||
body.setIdCard(idCard);
|
||||
body.setBankName(vo.getBankName());
|
||||
body.setBankCard(vo.getBankCard());
|
||||
body.setBankBranch(vo.getBankBranch());
|
||||
@@ -522,6 +536,111 @@ public class BizMeetingAttendeeServiceImpl implements IBizMeetingAttendeeService
|
||||
return result;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<FieldMismatch> diffAgainstExpert(BizMeetingAttendee body) {
|
||||
Long userId = null;
|
||||
if (body.getId() != null) {
|
||||
BizMeetingAttendee existing = mapper.selectById(body.getId());
|
||||
if (existing != null) userId = existing.getUserId();
|
||||
} else if (body.getPhone() != null && !body.getPhone().trim().isEmpty()) {
|
||||
String phone = body.getPhone().trim();
|
||||
List<SysUser> hits = sysUserMapper.selectByPhoneList(Collections.singletonList(phone));
|
||||
if (hits != null && !hits.isEmpty()) userId = hits.get(0).getUserId();
|
||||
}
|
||||
if (userId == null) return Collections.emptyList();
|
||||
BizExpert expert = bizExpertMapper.selectByUserId(userId);
|
||||
if (expert == null) return Collections.emptyList();
|
||||
return compareExpertProfile(expert, body);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Map<String, Object> previewImport(MultipartFile file, Long meetingId) throws Exception {
|
||||
if (meetingId == null) throw new ServiceException("meetingId 不能为空");
|
||||
|
||||
ExcelUtil<BizMeetingAttendeeImportVo> util = new ExcelUtil<>(BizMeetingAttendeeImportVo.class);
|
||||
List<BizMeetingAttendeeImportVo> rows = util.importExcel(file.getInputStream());
|
||||
if (rows == null || rows.isEmpty()) {
|
||||
throw new ServiceException("导入数据不能为空");
|
||||
}
|
||||
|
||||
List<ImportMismatch> mismatches = new ArrayList<>();
|
||||
for (int i = 0; i < rows.size(); i++) {
|
||||
BizMeetingAttendeeImportVo vo = rows.get(i);
|
||||
int rowNo = i + 2; // Excel 行号: 1=表头, 2=第一行数据
|
||||
String phone = vo.getPhone() == null ? null : vo.getPhone().trim();
|
||||
// 手机号非法/缺失的行走真导入时会被拦截, dry-run 不参与比对
|
||||
if (phone == null || phone.isEmpty() || !phone.matches("^1\\d{10}$")) continue;
|
||||
List<SysUser> hits = sysUserMapper.selectByPhoneList(Collections.singletonList(phone));
|
||||
Long userId = (hits != null && !hits.isEmpty()) ? hits.get(0).getUserId() : null;
|
||||
if (userId == null) continue;
|
||||
BizExpert expert = bizExpertMapper.selectByUserId(userId);
|
||||
if (expert == null) continue;
|
||||
|
||||
BizMeetingAttendee body = new BizMeetingAttendee();
|
||||
body.setPhone(phone);
|
||||
body.setName(vo.getName());
|
||||
body.setWorkUnit(vo.getWorkUnit());
|
||||
body.setDepartment(vo.getDepartment());
|
||||
body.setTitle(vo.getTitle());
|
||||
body.setIdCard(vo.getIdCard());
|
||||
body.setBankName(vo.getBankName());
|
||||
body.setBankCard(vo.getBankCard());
|
||||
body.setBankBranch(vo.getBankBranch());
|
||||
body.setBankRegion(vo.getBankRegion());
|
||||
body.setBankAddress(vo.getBankAddress());
|
||||
body.setIdCardAttachments(vo.getIdCardAttachments());
|
||||
|
||||
List<FieldMismatch> fields = compareExpertProfile(expert, body);
|
||||
if (!fields.isEmpty()) {
|
||||
ImportMismatch im = new ImportMismatch();
|
||||
im.setRowNo(rowNo);
|
||||
im.setPhone(phone);
|
||||
im.setName(vo.getName());
|
||||
im.setFields(fields);
|
||||
mismatches.add(im);
|
||||
}
|
||||
}
|
||||
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
result.put("total", rows.size());
|
||||
result.put("mismatches", mismatches);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 比对参会人表单与专家表档案的公共字段 (两表都有的文本档案字段, 手机号是定位键不比对,
|
||||
* 身份证附件是 OSS 文件 URL 不比对). 规则: 专家表已有值 (非空) 且与输入不一致 (含输入为空)
|
||||
* → 记一条 mismatch; 专家表为空不算冲突.
|
||||
*/
|
||||
private List<FieldMismatch> compareExpertProfile(BizExpert expert, BizMeetingAttendee form) {
|
||||
List<FieldMismatch> out = new ArrayList<>();
|
||||
addMismatch(out, "name", "姓名", expert.getName(), form.getName());
|
||||
addMismatch(out, "workUnit", "医院", expert.getWorkUnit(), form.getWorkUnit());
|
||||
addMismatch(out, "department", "科室", expert.getDepartment(), form.getDepartment());
|
||||
addMismatch(out, "title", "职称", expert.getTitle(), form.getTitle());
|
||||
addMismatch(out, "idCard", "身份证号", expert.getIdCard(), form.getIdCard());
|
||||
addMismatch(out, "bankCard", "银行卡号", expert.getBankCard(), form.getBankCard());
|
||||
addMismatch(out, "bankName", "银行名称", expert.getBankName(), form.getBankName());
|
||||
addMismatch(out, "bankBranch", "开户行支行", expert.getBankBranch(), form.getBankBranch());
|
||||
addMismatch(out, "bankRegion", "开户行地址", expert.getBankRegion(), form.getBankRegion());
|
||||
addMismatch(out, "bankAddress", "银行详细地址", expert.getBankAddress(), form.getBankAddress());
|
||||
// 身份证附件(idCardAttachments)是 OSS 文件 URL, 重新上传即变, 逐字符比对无意义 → 不参与比对
|
||||
return out;
|
||||
}
|
||||
|
||||
private void addMismatch(List<FieldMismatch> out, String field, String label, String expertValue, String inputValue) {
|
||||
String ev = expertValue == null ? "" : expertValue.trim();
|
||||
if (ev.isEmpty()) return; // 专家表为空 → 无"现有数据"可比, 不算冲突
|
||||
String iv = inputValue == null ? "" : inputValue.trim();
|
||||
if (ev.equals(iv)) return;
|
||||
FieldMismatch m = new FieldMismatch();
|
||||
m.setField(field);
|
||||
m.setLabel(label);
|
||||
m.setExpertValue(expertValue);
|
||||
m.setInputValue(inputValue == null ? "" : inputValue);
|
||||
out.add(m);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int pushEsign(List<Long> attendeeIds) {
|
||||
if (attendeeIds == null || attendeeIds.isEmpty()) return 0;
|
||||
@@ -568,6 +687,7 @@ public class BizMeetingAttendeeServiceImpl implements IBizMeetingAttendeeService
|
||||
if (attendeeIds == null || attendeeIds.isEmpty()) return 0;
|
||||
int sent = 0;
|
||||
Map<Long, BizMeeting> meetingCache = new HashMap<>();
|
||||
Map<Long, byte[]> templateCache = new HashMap<>();
|
||||
for (Long id : attendeeIds) {
|
||||
if (id == null) continue;
|
||||
try {
|
||||
@@ -585,8 +705,23 @@ public class BizMeetingAttendeeServiceImpl implements IBizMeetingAttendeeService
|
||||
String meetingName = m != null ? m.getMeetingName() : null;
|
||||
Date startTime = m != null ? m.getStartTime() : null;
|
||||
|
||||
// 生成个性化邀请函 (替换姓名 + 盖章 + 上传): 模板按会议只下载一次; 失败不阻断站内信
|
||||
try {
|
||||
if (m != null && m.getInvitationUrl() != null && !m.getInvitationUrl().trim().isEmpty()) {
|
||||
byte[] template = templateCache.get(mid);
|
||||
if (template == null) {
|
||||
template = invitationPdfService.downloadTemplate(m.getInvitationUrl().trim());
|
||||
templateCache.put(mid, template);
|
||||
}
|
||||
String url = invitationPdfService.generateAndUpload(template, a.getName());
|
||||
mapper.updateInvitationUrl(a.getId(), url);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("[attendee] 生成个性化邀请函失败 attendeeId={}: {}", id, e.getMessage());
|
||||
}
|
||||
|
||||
// 推"会议邀请"站内信 (不发短信) + 置 is_invited=1
|
||||
bizNotifyService.meetingInvitation(a.getUserId(), mid, meetingName, startTime);
|
||||
bizNotifyService.meetingInvitation(a.getUserId(), a.getId(), mid, meetingName, startTime);
|
||||
mapper.markInvitedById(id);
|
||||
sent++;
|
||||
} catch (Exception e) {
|
||||
@@ -665,107 +800,109 @@ public class BizMeetingAttendeeServiceImpl implements IBizMeetingAttendeeService
|
||||
}
|
||||
|
||||
@Override
|
||||
public int uploadAgreements(MultipartFile file, Long meetingId) throws Exception {
|
||||
public Map<String, Object> uploadAgreements(MultipartFile file, Long meetingId) throws Exception {
|
||||
if (meetingId == null) throw new ServiceException("meetingId 不能为空");
|
||||
if (file == null || file.isEmpty()) throw new ServiceException("请选择要上传的 zip 文件");
|
||||
|
||||
List<BizMeetingAttendee> list = selectByMeetingId(meetingId);
|
||||
if (list.isEmpty()) throw new ServiceException("该会议暂无参会人, 无法回填劳务协议");
|
||||
|
||||
byte[] zipBytes = file.getBytes();
|
||||
|
||||
// 目录序号 (下划线前数字, 0-based) → 该目录下文件字节 + 文件名
|
||||
Map<Integer, List<byte[]>> folderBytes = new HashMap<>();
|
||||
Map<Integer, List<String>> folderNames = new HashMap<>();
|
||||
try {
|
||||
readAttendeeZip(zipBytes, StandardCharsets.UTF_8, folderBytes, folderNames);
|
||||
} catch (IllegalArgumentException e) {
|
||||
// 中文 Windows 打包器 (WinRAR/资源管理器"发送到压缩文件夹") 用 GBK 编码条目名且不置 UTF-8 标志,
|
||||
// 强制 UTF-8 解码会抛 malformed input → 回退 GBK 重新解析
|
||||
log.warn("[attendee] 协议 zip UTF-8 解析失败, 回退 GBK: {}", e.getMessage());
|
||||
folderBytes.clear();
|
||||
folderNames.clear();
|
||||
readAttendeeZip(zipBytes, Charset.forName("GBK"), folderBytes, folderNames);
|
||||
}
|
||||
LinkedHashMap<String, ZipFolder> folders = parseAttendeeZip(file.getBytes());
|
||||
|
||||
int updated = 0;
|
||||
for (Map.Entry<Integer, List<byte[]>> e : folderBytes.entrySet()) {
|
||||
int idx = e.getKey();
|
||||
if (idx < 0 || idx >= list.size()) {
|
||||
log.warn("[attendee] 协议 zip 目录序号 {} 越界 (参会人共 {}), 跳过", idx + 1, list.size());
|
||||
List<Map<String, Object>> results = new ArrayList<>();
|
||||
for (ZipFolder f : folders.values()) {
|
||||
MatchResult m = matchAttendee(list, f);
|
||||
Map<String, Object> r = new HashMap<>();
|
||||
r.put("dir", f.dir);
|
||||
r.put("name", f.name);
|
||||
if (m.attendee == null) {
|
||||
r.put("matched", false);
|
||||
r.put("reason", m.reason);
|
||||
results.add(r);
|
||||
log.warn("[attendee] 协议目录 {} 无法定位参会人, 跳过", f.dir);
|
||||
continue;
|
||||
}
|
||||
BizMeetingAttendee a = list.get(idx);
|
||||
List<String> names = folderNames.get(idx);
|
||||
String fileName = (names != null && !names.isEmpty()) ? names.get(0) : "labor-protocol.pdf";
|
||||
// labor_protocol 是单 URL, 每目录只取第一个文件作为协议
|
||||
String url = ossUploader.upload(e.getValue().get(0), fileName, "ry8080/meeting/" + meetingId + "/labor-protocol");
|
||||
String fileName = (!f.fileNames.isEmpty()) ? f.fileNames.get(0) : "labor-protocol.pdf";
|
||||
String url = ossUploader.upload(f.files.get(0), fileName, "ry8080/meeting/" + meetingId + "/labor-protocol");
|
||||
|
||||
BizMeetingAttendee entity = new BizMeetingAttendee();
|
||||
entity.setId(a.getId());
|
||||
entity.setId(m.attendee.getId());
|
||||
entity.setLaborProtocol(url);
|
||||
entity.setUpdateBy(SecurityUtils.getUsername());
|
||||
updateLaborProtocol(entity);
|
||||
updated++;
|
||||
|
||||
r.put("matched", true);
|
||||
r.put("attendeeName", m.attendee.getName());
|
||||
results.add(r);
|
||||
}
|
||||
log.info("[attendee] 劳务协议回填完成 meetingId={} 共{}个", meetingId, updated);
|
||||
return updated;
|
||||
log.info("[attendee] 劳务协议回填完成 meetingId={} 共{}个 (跳过{}个)", meetingId, updated, folders.size() - updated);
|
||||
return buildZipResult(updated, list.size(), results);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int uploadExpertPhotos(MultipartFile file, Long meetingId) throws Exception {
|
||||
public Map<String, Object> uploadExpertPhotos(MultipartFile file, Long meetingId) throws Exception {
|
||||
if (meetingId == null) throw new ServiceException("meetingId 不能为空");
|
||||
if (file == null || file.isEmpty()) throw new ServiceException("请选择要上传的 zip 文件");
|
||||
|
||||
List<BizMeetingAttendee> list = selectByMeetingId(meetingId);
|
||||
if (list.isEmpty()) throw new ServiceException("该会议暂无参会人, 无法回填专家照片");
|
||||
|
||||
byte[] zipBytes = file.getBytes();
|
||||
Map<Integer, List<byte[]>> folderBytes = new HashMap<>();
|
||||
Map<Integer, List<String>> folderNames = new HashMap<>();
|
||||
try {
|
||||
readAttendeeZip(zipBytes, StandardCharsets.UTF_8, folderBytes, folderNames);
|
||||
} catch (IllegalArgumentException e) {
|
||||
log.warn("[attendee] 专家照片 zip UTF-8 解析失败, 回退 GBK: {}", e.getMessage());
|
||||
folderBytes.clear();
|
||||
folderNames.clear();
|
||||
readAttendeeZip(zipBytes, Charset.forName("GBK"), folderBytes, folderNames);
|
||||
}
|
||||
LinkedHashMap<String, ZipFolder> folders = parseAttendeeZip(file.getBytes());
|
||||
|
||||
int updated = 0;
|
||||
for (Map.Entry<Integer, List<byte[]>> e : folderBytes.entrySet()) {
|
||||
int idx = e.getKey();
|
||||
if (idx < 0 || idx >= list.size()) {
|
||||
log.warn("[attendee] 专家照片 zip 目录序号 {} 越界 (参会人共 {}), 跳过", idx + 1, list.size());
|
||||
List<Map<String, Object>> results = new ArrayList<>();
|
||||
for (ZipFolder f : folders.values()) {
|
||||
MatchResult m = matchAttendee(list, f);
|
||||
Map<String, Object> r = new HashMap<>();
|
||||
r.put("dir", f.dir);
|
||||
r.put("name", f.name);
|
||||
if (m.attendee == null) {
|
||||
r.put("matched", false);
|
||||
r.put("reason", m.reason);
|
||||
results.add(r);
|
||||
log.warn("[attendee] 专家照片目录 {} 无法定位参会人, 跳过", f.dir);
|
||||
continue;
|
||||
}
|
||||
BizMeetingAttendee a = list.get(idx);
|
||||
List<byte[]> files = e.getValue();
|
||||
List<String> names = folderNames.get(idx);
|
||||
// 专家照片可多张: 目录内所有文件都上传, 逗号拼接覆盖式回填 on_site_photos
|
||||
StringBuilder sb = new StringBuilder();
|
||||
for (int i = 0; i < files.size(); i++) {
|
||||
String fileName = (names != null && i < names.size()) ? names.get(i) : ("photo-" + (i + 1) + ".jpg");
|
||||
String url = ossUploader.upload(files.get(i), fileName, "ry8080/meeting/" + meetingId + "/expert-photo");
|
||||
for (int i = 0; i < f.files.size(); i++) {
|
||||
String fileName = (i < f.fileNames.size()) ? f.fileNames.get(i) : ("photo-" + (i + 1) + ".jpg");
|
||||
String url = ossUploader.upload(f.files.get(i), fileName, "ry8080/meeting/" + meetingId + "/expert-photo");
|
||||
if (sb.length() > 0) sb.append(",");
|
||||
sb.append(url);
|
||||
}
|
||||
|
||||
BizMeetingAttendee entity = new BizMeetingAttendee();
|
||||
entity.setId(a.getId());
|
||||
entity.setId(m.attendee.getId());
|
||||
entity.setOnSitePhotos(sb.toString());
|
||||
entity.setUpdateBy(SecurityUtils.getUsername());
|
||||
updateProfile(entity);
|
||||
updated++;
|
||||
|
||||
r.put("matched", true);
|
||||
r.put("attendeeName", m.attendee.getName());
|
||||
results.add(r);
|
||||
}
|
||||
log.info("[attendee] 专家照片回填完成 meetingId={} 共{}个", meetingId, updated);
|
||||
return updated;
|
||||
log.info("[attendee] 专家照片回填完成 meetingId={} 共{}个 (跳过{}个)", meetingId, updated, folders.size() - updated);
|
||||
return buildZipResult(updated, list.size(), results);
|
||||
}
|
||||
|
||||
/** 按指定字符集解压 zip, 把 {根目录}/{序号}_姓名/ 目录下的文件归集到对应参会人序号 */
|
||||
private void readAttendeeZip(byte[] zipBytes, Charset charset,
|
||||
Map<Integer, List<byte[]>> folderBytes,
|
||||
Map<Integer, List<String>> folderNames) throws IOException {
|
||||
/** 解压 zip, 归集每个 {序号}_姓名 目录下的文件; UTF-8 解析失败回退 GBK (中文 Windows 打包器). */
|
||||
private LinkedHashMap<String, ZipFolder> parseAttendeeZip(byte[] zipBytes) throws IOException {
|
||||
try {
|
||||
return readAttendeeZip(zipBytes, StandardCharsets.UTF_8);
|
||||
} catch (IllegalArgumentException e) {
|
||||
log.warn("[attendee] zip UTF-8 解析失败, 回退 GBK: {}", e.getMessage());
|
||||
return readAttendeeZip(zipBytes, Charset.forName("GBK"));
|
||||
}
|
||||
}
|
||||
|
||||
/** 按指定字符集解压 zip, 把 {根目录}/{序号}_姓名/ 目录下的文件归集到对应目录 */
|
||||
private LinkedHashMap<String, ZipFolder> readAttendeeZip(byte[] zipBytes, Charset charset) throws IOException {
|
||||
LinkedHashMap<String, ZipFolder> folders = new LinkedHashMap<>();
|
||||
try (ZipInputStream zis = new ZipInputStream(new ByteArrayInputStream(zipBytes), charset)) {
|
||||
ZipEntry entry;
|
||||
while ((entry = zis.getNextEntry()) != null) {
|
||||
@@ -773,31 +910,81 @@ public class BizMeetingAttendeeServiceImpl implements IBizMeetingAttendeeService
|
||||
String path = entry.getName();
|
||||
// 跳过 macOS 打包产生的元数据
|
||||
if (path.contains("__MACOSX") || path.endsWith(".DS_Store")) continue;
|
||||
Integer idx = parseAgreementIndex(path);
|
||||
if (idx == null) continue; // 不落在 {序号}_姓名 目录下, 忽略
|
||||
byte[] data = readAllBytes(zis);
|
||||
folderBytes.computeIfAbsent(idx, k -> new ArrayList<>()).add(data);
|
||||
folderNames.computeIfAbsent(idx, k -> new ArrayList<>()).add(lastSegment(path));
|
||||
String[] seg = parseAgreementDir(path);
|
||||
if (seg == null) continue; // 文件不在 {序号}_姓名 目录下, 忽略
|
||||
int seq;
|
||||
try {
|
||||
seq = Integer.parseInt(seg[0]);
|
||||
} catch (NumberFormatException ex) {
|
||||
continue; // 数字前缀溢出, 视为非法目录
|
||||
}
|
||||
String dir = seg[0] + "_" + seg[1];
|
||||
ZipFolder f = folders.get(dir);
|
||||
if (f == null) {
|
||||
f = new ZipFolder(dir, seq, seg[1]);
|
||||
folders.put(dir, f);
|
||||
}
|
||||
f.files.add(readAllBytes(zis));
|
||||
f.fileNames.add(lastSegment(path));
|
||||
}
|
||||
}
|
||||
return folders;
|
||||
}
|
||||
|
||||
/** 从 zip 条目路径里解析出 {序号}_姓名 目录的 0-based 序号; 不匹配返回 null */
|
||||
private static Integer parseAgreementIndex(String path) {
|
||||
/** 从 zip 条目路径里解析父目录段 {序号}_姓名 → [序号数字, 姓名段]; 文件不在该结构下返回 null.
|
||||
* 父目录 = 文件名前最后一段目录 (路径倒数第二段), 与模板生成的 {序号}_姓名/ 一致. */
|
||||
private static String[] parseAgreementDir(String path) {
|
||||
if (path == null || path.isEmpty()) return null;
|
||||
for (String seg : path.split("/")) {
|
||||
if (seg.isEmpty()) continue;
|
||||
int underscore = seg.indexOf('_');
|
||||
if (underscore <= 0) continue;
|
||||
String numPart = seg.substring(0, underscore);
|
||||
if (!numPart.matches("\\d+")) continue;
|
||||
try {
|
||||
return Integer.parseInt(numPart) - 1;
|
||||
} catch (NumberFormatException ex) {
|
||||
return null;
|
||||
}
|
||||
String[] segs = path.split("/");
|
||||
if (segs.length < 2) return null;
|
||||
String dir = segs[segs.length - 2];
|
||||
int underscore = dir.indexOf('_');
|
||||
if (underscore <= 0) return null;
|
||||
String numPart = dir.substring(0, underscore);
|
||||
if (!numPart.matches("\\d+")) return null;
|
||||
return new String[] { numPart, dir.substring(underscore + 1) };
|
||||
}
|
||||
|
||||
/** 严格匹配: 序号 (order by id 后的 1-based 下标) 必须落位, 且该位姓名与目录姓名一致才回填.
|
||||
* 两者任一不符 → 视为名单变动/重名错位, 跳过并给出原因, 避免把协议错挂到别人身上. */
|
||||
private MatchResult matchAttendee(List<BizMeetingAttendee> list, ZipFolder f) {
|
||||
int idx = f.seq - 1;
|
||||
if (idx < 0 || idx >= list.size()) {
|
||||
return new MatchResult(null, "序号" + f.seq + "越界(参会人共" + list.size() + "位)");
|
||||
}
|
||||
return null;
|
||||
BizMeetingAttendee a = list.get(idx);
|
||||
String want = safeAttendeeName(f.name);
|
||||
String actual = safeAttendeeName(a.getName());
|
||||
if (!want.equals(actual)) {
|
||||
return new MatchResult(null, "姓名不一致: 目录[" + f.name + "] ≠ 序号" + f.seq + "指向的[" + actual + "]");
|
||||
}
|
||||
return new MatchResult(a, null);
|
||||
}
|
||||
|
||||
private Map<String, Object> buildZipResult(int updated, int total, List<Map<String, Object>> results) {
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
result.put("updated", updated);
|
||||
result.put("total", total);
|
||||
result.put("skipped", results.size() - updated);
|
||||
result.put("results", results);
|
||||
return result;
|
||||
}
|
||||
|
||||
/** zip 里解析出的一个 {序号}_姓名 目录 */
|
||||
private static class ZipFolder {
|
||||
final String dir; // 目录段原文, 如 "1_张三"
|
||||
final int seq; // 1-based 序号
|
||||
final String name; // 姓名段 (下划线后)
|
||||
final List<byte[]> files = new ArrayList<>();
|
||||
final List<String> fileNames = new ArrayList<>();
|
||||
ZipFolder(String dir, int seq, String name) { this.dir = dir; this.seq = seq; this.name = name; }
|
||||
}
|
||||
|
||||
/** 目录 → 参会人匹配结果 */
|
||||
private static class MatchResult {
|
||||
final BizMeetingAttendee attendee; // null 表示未匹配
|
||||
final String reason; // null=匹配成功; 非空=失败原因
|
||||
MatchResult(BizMeetingAttendee attendee, String reason) { this.attendee = attendee; this.reason = reason; }
|
||||
}
|
||||
|
||||
private static String lastSegment(String path) {
|
||||
|
||||
+4
@@ -31,6 +31,10 @@ public class BizProjectPlanServiceImpl implements IBizProjectPlanService
|
||||
// 补 create_by / create_time (历史一直没写, 导致"提交时间"为空; 列表按 create_time 倒序)
|
||||
if (entity.getCreateBy() == null) entity.setCreateBy(SecurityUtils.getUsername());
|
||||
if (entity.getCreateTime() == null) entity.setCreateTime(new Date());
|
||||
// "保存并提交" 直接以 status='1' 入库: 与 update 提交口径一致, 写入提交时间 (0→1 不发通知)
|
||||
if ("1".equals(entity.getStatus()) && entity.getSubmitTime() == null) {
|
||||
entity.setSubmitTime(new Date());
|
||||
}
|
||||
return bizProjectPlanMapper.insert(entity);
|
||||
}
|
||||
|
||||
|
||||
+22
@@ -23,6 +23,7 @@ import com.ruoyi.business.mapper.BizLaborProtocolTemplateMapper;
|
||||
import com.ruoyi.business.mapper.BizMeetingMapper;
|
||||
import com.ruoyi.business.mapper.BizProjectMapper;
|
||||
import com.ruoyi.business.service.BizSignService;
|
||||
import com.ruoyi.business.service.IBizExpertService;
|
||||
import com.ruoyi.business.service.IBizMeetingAttendeeService;
|
||||
import com.ruoyi.business.service.IBizOrgService;
|
||||
import com.ruoyi.business.service.PdfService;
|
||||
@@ -37,6 +38,8 @@ public class BizSignServiceImpl implements BizSignService {
|
||||
@Autowired
|
||||
private BizExpertMapper expertMapper;
|
||||
@Autowired
|
||||
private IBizExpertService bizExpertService;
|
||||
@Autowired
|
||||
private BizMeetingMapper meetingMapper;
|
||||
@Autowired
|
||||
private BizLaborProtocolTemplateMapper templateMapper;
|
||||
@@ -224,6 +227,25 @@ public class BizSignServiceImpl implements BizSignService {
|
||||
entity.setLaborForm(form.getLaborForm());
|
||||
entity.setUpdateBy(SecurityUtils.getUsername());
|
||||
attendeeService.updateProfile(entity);
|
||||
|
||||
// 双写 biz_expert: 医生自己填写的档案覆盖专家表 (医生是可信源).
|
||||
// 只写两表共有的 11 个档案字段; 不动 status/auditStatus/practiceCertUrl/titleCertUrl/region.
|
||||
// accountName/laborForm 是参会表专属, 不进专家表.
|
||||
BizExpert expertUpsert = new BizExpert();
|
||||
expertUpsert.setUserId(attendee.getUserId());
|
||||
expertUpsert.setName(form.getName());
|
||||
expertUpsert.setPhone(form.getPhone());
|
||||
expertUpsert.setWorkUnit(form.getWorkUnit());
|
||||
expertUpsert.setDepartment(form.getDepartment());
|
||||
expertUpsert.setTitle(form.getTitle());
|
||||
expertUpsert.setIdCard(form.getIdCard());
|
||||
expertUpsert.setBankCard(form.getBankCard());
|
||||
expertUpsert.setBankName(form.getBankName());
|
||||
expertUpsert.setBankBranch(form.getBankBranch());
|
||||
expertUpsert.setBankRegion(form.getBankRegion());
|
||||
expertUpsert.setBankAddress(form.getBankAddress());
|
||||
expertUpsert.setIdCardAttachments(form.getIdCardAttachments());
|
||||
bizExpertService.updateProfileByUserId(expertUpsert);
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
+37
-2
@@ -13,9 +13,10 @@
|
||||
<result property="phone" column="phone" />
|
||||
<result property="onboardStatus" column="onboard_status" />
|
||||
<result property="createTime" column="create_time" />
|
||||
<result property="delFlag" column="del_flag" />
|
||||
</resultMap>
|
||||
<sql id="selectFields">
|
||||
select intent_id, user_id, project_no, project_name, name, work_unit, department, position, phone, onboard_status, create_by, create_time, update_by, update_time
|
||||
select intent_id, user_id, project_no, project_name, name, work_unit, department, position, phone, onboard_status, create_by, create_time, update_by, update_time, del_flag
|
||||
from biz_execution_intent
|
||||
</sql>
|
||||
<select id="selectByPrimaryKey" resultMap="BizExecutionIntentResult" parameterType="String">
|
||||
@@ -23,6 +24,27 @@
|
||||
where intent_id = #{intentId}
|
||||
</select>
|
||||
<select id="selectList" resultMap="BizExecutionIntentResult" parameterType="BizExecutionIntent">
|
||||
<include refid="selectFields"/>
|
||||
<where>
|
||||
del_flag = '0'
|
||||
<if test="userId != null"> and user_id = #{userId}</if>
|
||||
<if test="projectNo != null and projectNo != ''"> and project_no = #{projectNo}</if>
|
||||
<if test="params.projectNos != null and params.projectNos.size() > 0">
|
||||
and project_no in
|
||||
<foreach collection="params.projectNos" item="no" open="(" separator="," close=")">
|
||||
#{no}
|
||||
</foreach>
|
||||
</if>
|
||||
<if test="projectName != null and projectName != ''"> and project_name = #{projectName}</if>
|
||||
<if test="name != null and name != ''"> and name = #{name}</if>
|
||||
<if test="department != null and department != ''"> and department = #{department}</if>
|
||||
<if test="position != null and position != ''"> and position = #{position}</if>
|
||||
<if test="phone != null and phone != ''"> and phone = #{phone}</if>
|
||||
</where>
|
||||
order by intent_id desc
|
||||
</select>
|
||||
<!-- 查询全部 (含 del_flag='1' 幽灵占位), 仅"已报名专家导出"用: 不加 del_flag='0' 过滤 -->
|
||||
<select id="selectListIncludingDeleted" resultMap="BizExecutionIntentResult" parameterType="BizExecutionIntent">
|
||||
<include refid="selectFields"/>
|
||||
<where>
|
||||
<if test="userId != null"> and user_id = #{userId}</if>
|
||||
@@ -55,6 +77,7 @@
|
||||
<if test="position != null">position,</if>
|
||||
<if test="phone != null">phone,</if>
|
||||
<if test="createBy != null">create_by,</if>
|
||||
<if test="delFlag != null and delFlag != ''">del_flag,</if>
|
||||
create_time,
|
||||
</trim>
|
||||
<trim prefix="values (" suffix=")" suffixOverrides=",">
|
||||
@@ -69,7 +92,9 @@
|
||||
<if test="position != null">#{position},</if>
|
||||
<if test="phone != null">#{phone},</if>
|
||||
<if test="createBy != null">#{createBy},</if>
|
||||
sysdate(),
|
||||
<if test="delFlag != null and delFlag != ''">#{delFlag},</if>
|
||||
<if test="createTime != null">#{createTime},</if>
|
||||
<if test="createTime == null">sysdate(),</if>
|
||||
</trim>
|
||||
</insert>
|
||||
<update id="updateByPrimaryKey" parameterType="BizExecutionIntent">
|
||||
@@ -95,4 +120,14 @@
|
||||
#{intentId}
|
||||
</foreach>
|
||||
</delete>
|
||||
<!-- 物理删除幽灵占位记录: 真报名(报名/邀请函)时先删掉 del_flag='1' 的假记录 -->
|
||||
<delete id="hardDeletePlaceholder">
|
||||
delete from biz_execution_intent
|
||||
where user_id = #{userId} and project_no = #{projectNo} and del_flag = '1'
|
||||
</delete>
|
||||
<!-- 该 user+project 是否已有意向(真/假都算), 建占位记录前去重 -->
|
||||
<select id="countByUserAndProject" resultType="int">
|
||||
select count(1) from biz_execution_intent
|
||||
where user_id = #{userId} and project_no = #{projectNo}
|
||||
</select>
|
||||
</mapper>
|
||||
+12
-3
@@ -30,6 +30,7 @@
|
||||
<result property="handsign" column="handsign" />
|
||||
<result property="laborProtocol" column="labor_protocol" />
|
||||
<result property="laborProtocolMasked" column="labor_protocol_masked" />
|
||||
<result property="invitationUrl" column="invitation_url" />
|
||||
<result property="createBy" column="create_by" />
|
||||
<result property="createTime" column="create_time" />
|
||||
<!-- 联表字段 (非持久化, entity transient property 接收) -->
|
||||
@@ -126,6 +127,13 @@
|
||||
update_time = sysdate()
|
||||
where id = #{id}
|
||||
</update>
|
||||
<!-- 发送邀请函后回写盖章个性化邀请函 URL -->
|
||||
<update id="updateInvitationUrl">
|
||||
update biz_meeting_attendee
|
||||
set invitation_url = #{invitationUrl},
|
||||
update_time = sysdate()
|
||||
where id = #{id}
|
||||
</update>
|
||||
<!-- 推送电子签后置 is_esigned=1 -->
|
||||
<update id="markEsignedById" parameterType="Long">
|
||||
update biz_meeting_attendee
|
||||
@@ -153,21 +161,22 @@
|
||||
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, 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, vat_and_surcharge, summary, on_site_photos, signed_at, signed_ip, handsign, labor_protocol, labor_protocol_masked, create_by, create_time, is_deleted, is_esigned, is_invited,
|
||||
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, vat_and_surcharge, summary, on_site_photos, signed_at, signed_ip, handsign, labor_protocol, labor_protocol_masked, invitation_url, create_by, create_time, is_deleted, is_esigned, is_invited,
|
||||
(select if(exists(
|
||||
select 1 from biz_execution_intent e
|
||||
where e.user_id = biz_meeting_attendee.user_id
|
||||
and e.del_flag = '0'
|
||||
and e.project_no collate utf8mb4_unicode_ci = (select p.project_no collate utf8mb4_unicode_ci from biz_meeting m join biz_project p on m.project_id = p.project_id where m.meeting_id = #{meetingId})
|
||||
), 1, 0)) as has_intent
|
||||
from biz_meeting_attendee where meeting_id = #{meetingId} and is_deleted = 0
|
||||
order by id
|
||||
</select>
|
||||
<select id="selectByUserId" 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, vat_and_surcharge, summary, on_site_photos, signed_at, signed_ip, handsign, labor_protocol, create_by, create_time, is_deleted, is_esigned, is_invited
|
||||
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, vat_and_surcharge, summary, on_site_photos, signed_at, signed_ip, handsign, labor_protocol, invitation_url, create_by, create_time, is_deleted, is_esigned, is_invited
|
||||
from biz_meeting_attendee where user_id = #{userId} and is_deleted = 0
|
||||
</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, vat_and_surcharge, summary, on_site_photos, signed_at, signed_ip, handsign, labor_protocol, create_by, create_time, is_deleted, is_esigned, is_invited
|
||||
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, vat_and_surcharge, summary, on_site_photos, signed_at, signed_ip, handsign, labor_protocol, invitation_url, create_by, create_time, is_deleted, is_esigned, is_invited
|
||||
from biz_meeting_attendee where id = #{id} and is_deleted = 0
|
||||
</select>
|
||||
<!--
|
||||
|
||||
Reference in New Issue
Block a user