From 56615f9806516cae33f2a8518a5445f9b56de86f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=83=AD=E5=BA=86=E6=B3=B0?= <12369755+htcloud1@user.noreply.gitee.com> Date: Thu, 3 Sep 2026 20:12:15 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20OA(ecology)=E9=A1=B9=E7=9B=AE=E6=8B=89?= =?UTF-8?q?=E5=8F=96=20+=20=E4=BC=9A=E8=AE=AE=E9=82=80=E8=AF=B7=E5=87=BDPD?= =?UTF-8?q?F/=E5=BE=AE=E4=BF=A1=E5=88=86=E4=BA=AB=20+=20=E5=8F=82=E4=BC=9A?= =?UTF-8?q?=E4=BA=BA/=E6=89=A7=E8=A1=8C=E6=84=8F=E5=90=91=E5=A2=9E?= =?UTF-8?q?=E5=BC=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 + 供应商/招标接口切生产域名 --- .../src/main/resources/application-prod.yml | 2 + .../src/main/resources/application-test.yml | 8 +- .../src/main/resources/application.yml | 187 +++--- .../controller/BizEcologyController.java | 55 ++ .../BizExecutionIntentController.java | 11 +- .../BizMeetingAttendeeController.java | 73 ++- .../controller/BizProjectController.java | 16 +- .../controller/BizPublicController.java | 16 + .../business/domain/BizExecutionIntent.java | 4 + .../business/domain/BizMeetingAttendee.java | 4 + .../domain/vo/BizSignupExpertExportVo.java | 51 +- .../business/domain/vo/FieldMismatch.java | 32 + .../business/domain/vo/ImportMismatch.java | 28 + .../mapper/BizExecutionIntentMapper.java | 7 + .../mapper/BizMeetingAttendeeMapper.java | 2 + .../business/notify/BizNotifyService.java | 13 +- .../service/ComplianceLoginService.java | 41 ++ .../service/EcologyProjectService.java | 230 +++++++ .../service/IBizExecutionIntentService.java | 14 + .../service/IBizMeetingAttendeeService.java | 36 +- .../service/InvitationPdfService.java | 292 +++++++++ .../ruoyi/business/service/SysSmsService.java | 22 +- .../business/service/WxShareService.java | 153 +++++ .../impl/BizExecutionIntentServiceImpl.java | 51 ++ .../impl/BizMeetingAttendeeServiceImpl.java | 443 +++++++++---- .../impl/BizProjectPlanServiceImpl.java | 4 + .../service/impl/BizSignServiceImpl.java | 22 + .../business/BizExecutionIntentMapper.xml | 39 +- .../business/BizMeetingAttendeeMapper.xml | 15 +- ry-vue3/package-lock.json | 588 +++++++++++++++++- ry-vue3/package.json | 1 + ry-vue3/src/api/business/meetingAttendee.js | 10 + ry-vue3/src/api/public.js | 10 + ry-vue3/src/components/NoticeList.vue | 36 +- ry-vue3/src/components/PdfViewer.vue | 130 ++++ ry-vue3/src/components/Preview.vue | 12 +- ry-vue3/src/router/index.js | 6 +- ry-vue3/src/utils/meetingStage.js | 7 +- ry-vue3/src/views/auth/Login.vue | 19 +- .../src/views/doctor/MeetingInvitation.vue | 267 ++++++++ ry-vue3/src/views/doctor/Meetings.vue | 7 +- ry-vue3/src/views/doctor/SignFill.vue | 27 +- ry-vue3/src/views/doctor/SubmissionNew.vue | 22 +- .../views/executor-people/ExecutorPeople.vue | 5 +- ry-vue3/src/views/executor/Meetings.vue | 18 +- .../views/manager/ManagerProjectDetail.vue | 3 +- .../views/manager/ManagerProjectsAssign.vue | 49 +- ry-vue3/src/views/manager/Projects.vue | 27 +- ry-vue3/src/views/manager/ProjectsNew.vue | 76 ++- ry-vue3/src/views/meetings/MeetingDetail.vue | 332 +++++++--- ry-vue3/src/views/meetings/MeetingNew.vue | 15 +- ry-vue3/src/views/meetings/Meetings.vue | 39 +- ry-vue3/src/views/portal/PublicityDetail.vue | 194 +++++- .../src/views/portal/SpecialPlanDetail.vue | 10 +- ry-vue3/src/views/sponsor/SponsorProjects.vue | 31 +- 55 files changed, 3296 insertions(+), 516 deletions(-) create mode 100644 ry-api/ruoyi-business/src/main/java/com/ruoyi/business/controller/BizEcologyController.java create mode 100644 ry-api/ruoyi-business/src/main/java/com/ruoyi/business/domain/vo/FieldMismatch.java create mode 100644 ry-api/ruoyi-business/src/main/java/com/ruoyi/business/domain/vo/ImportMismatch.java create mode 100644 ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/EcologyProjectService.java create mode 100644 ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/InvitationPdfService.java create mode 100644 ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/WxShareService.java create mode 100644 ry-vue3/src/components/PdfViewer.vue create mode 100644 ry-vue3/src/views/doctor/MeetingInvitation.vue diff --git a/ry-api/ruoyi-admin/src/main/resources/application-prod.yml b/ry-api/ruoyi-admin/src/main/resources/application-prod.yml index 6c3343a..9e32349 100644 --- a/ry-api/ruoyi-admin/src/main/resources/application-prod.yml +++ b/ry-api/ruoyi-admin/src/main/resources/application-prod.yml @@ -20,6 +20,8 @@ spring: # 生产环境外部接口地址 ruoyi: sms: + # 生产环境: aliyun 真发短信 + mock-enabled: false esignBaseUrl: https://hegui.bahim.org.cn # 扫码拍照 (ry-h5 相机网页地址, 前端二维码目标 URL) camera: diff --git a/ry-api/ruoyi-admin/src/main/resources/application-test.yml b/ry-api/ruoyi-admin/src/main/resources/application-test.yml index 9e936a2..37ff0f8 100644 --- a/ry-api/ruoyi-admin/src/main/resources/application-test.yml +++ b/ry-api/ruoyi-admin/src/main/resources/application-test.yml @@ -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/ diff --git a/ry-api/ruoyi-admin/src/main/resources/application.yml b/ry-api/ruoyi-admin/src/main/resources/application.yml index 257c176..66c520b 100644 --- a/ry-api/ruoyi-admin/src/main/resources/application.yml +++ b/ry-api/ruoyi-admin/src/main/resources/application.yml @@ -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/* \ No newline at end of file + 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 diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/controller/BizEcologyController.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/controller/BizEcologyController.java new file mode 100644 index 0000000..b06afda --- /dev/null +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/controller/BizEcologyController.java @@ -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 按项目编号自动回填). + *

+ * 读接口开放给已登录用户 (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> 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()); + } + } +} diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/controller/BizExecutionIntentController.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/controller/BizExecutionIntentController.java index 7f038ed..c01d17e 100644 --- a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/controller/BizExecutionIntentController.java +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/controller/BizExecutionIntentController.java @@ -64,6 +64,8 @@ public class BizExecutionIntentController extends BaseController q.setProjectNo(proj.getProjectNo()); List 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 list = bizExecutionIntentService.selectList(bizExecutionIntent); + List list = bizExecutionIntentService.selectListIncludingDeleted(bizExecutionIntent); List 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); diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/controller/BizMeetingAttendeeController.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/controller/BizMeetingAttendeeController.java index f5ba328..29320a9 100644 --- a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/controller/BizMeetingAttendeeController.java +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/controller/BizMeetingAttendeeController.java @@ -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); } + /** + * 会议邀请函详情 (参会人/医生点开站内信里的邀请链接后查看). + *

安全: 仅参会人本人可见 (attendee.userId == 当前登录用户), 防止横向越权. + *

返回 {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 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) 档案, 返回不一致字段列表. + *

前端据此弹窗 + 标红 + 二次确认, 确认后才真正 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 才真正入库. * - *

调 {@link IBizMeetingAttendeeService#importFromExcel} 逐行处理, 失败的进 ngList. - * 邀请参会已改为手动, 此处不再自动推"会议邀请". + *

dry-run 返回 { total, mismatches: [{rowNo, phone, name, fields:[{field,label,expertValue,inputValue}]}] }, + * 前端据此弹「二次确认」; 确认后带 force=true 重发同一文件. * - *

返回 ImportResult { okNum, ngNum, ngList: [{rowNum, message}] }, 前端 ImportResultDialog 直接渲染. + *

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 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 result = attendeeService.uploadExpertPhotos(file, meetingId); + return success(result); } /** diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/controller/BizProjectController.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/controller/BizProjectController.java index 7fe67fb..8ba151c 100644 --- a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/controller/BizProjectController.java +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/controller/BizProjectController.java @@ -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 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 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); diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/controller/BizPublicController.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/controller/BizPublicController.java index 471b997..d35250c 100644 --- a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/controller/BizPublicController.java +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/controller/BizPublicController.java @@ -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)); diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/domain/BizExecutionIntent.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/domain/BizExecutionIntent.java index 0316013..45139d6 100644 --- a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/domain/BizExecutionIntent.java +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/domain/BizExecutionIntent.java @@ -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; } } \ No newline at end of file diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/domain/BizMeetingAttendee.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/domain/BizMeetingAttendee.java index b426c0b..fb7e5c3 100644 --- a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/domain/BizMeetingAttendee.java +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/domain/BizMeetingAttendee.java @@ -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; } diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/domain/vo/BizSignupExpertExportVo.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/domain/vo/BizSignupExpertExportVo.java index 85f8a1e..d971695 100644 --- a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/domain/vo/BizSignupExpertExportVo.java +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/domain/vo/BizSignupExpertExportVo.java @@ -8,9 +8,11 @@ import com.ruoyi.common.annotation.Excel; * 报名专家导出 VO (中文列头) * *

数据源: biz_execution_intent (公开门户"立即报名"写入的执行意向表, 即已报名专家) - * + biz_expert (医生档案, 报名专家 userId → biz_expert.userId 反查补全银行/证件/地区/科室/医院/职称). + * + biz_expert (医生档案, 报名专家 userId → biz_expert.userId 反查补全科室/医院/职称). * 仅用于 Excel 导出, 不参与业务逻辑. * + *

字段: 项目编号 项目名称 专家姓名 科室 医院 职称 报名时间 手机号 + * * @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; } } diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/domain/vo/FieldMismatch.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/domain/vo/FieldMismatch.java new file mode 100644 index 0000000..0143d49 --- /dev/null +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/domain/vo/FieldMismatch.java @@ -0,0 +1,32 @@ +package com.ruoyi.business.domain.vo; + +/** + * 参会人表单字段与专家表 (biz_expert) 档案的不一致项. + * + *

用于「新增/编辑参会人」和「批量导入」保存前的二次确认: + * 后端按 手机号→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; } +} diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/domain/vo/ImportMismatch.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/domain/vo/ImportMismatch.java new file mode 100644 index 0000000..74b27bd --- /dev/null +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/domain/vo/ImportMismatch.java @@ -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 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 getFields() { return fields; } + public void setFields(List fields) { this.fields = fields; } +} diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/mapper/BizExecutionIntentMapper.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/mapper/BizExecutionIntentMapper.java index e285acc..a5c4a32 100644 --- a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/mapper/BizExecutionIntentMapper.java +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/mapper/BizExecutionIntentMapper.java @@ -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 selectList(BizExecutionIntent entity); + /** 查询全部记录 (含 del_flag='1' 幽灵占位), 仅"已报名专家导出"用 */ + List 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); } \ No newline at end of file diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/mapper/BizMeetingAttendeeMapper.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/mapper/BizMeetingAttendeeMapper.java index 63957fe..221b270 100644 --- a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/mapper/BizMeetingAttendeeMapper.java +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/mapper/BizMeetingAttendeeMapper.java @@ -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 (单条) */ diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/notify/BizNotifyService.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/notify/BizNotifyService.java index 529a376..b2824e5 100644 --- a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/notify/BizNotifyService.java +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/notify/BizNotifyService.java @@ -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); } /** diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/ComplianceLoginService.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/ComplianceLoginService.java index 6eba4e2..a302013 100644 --- a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/ComplianceLoginService.java +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/ComplianceLoginService.java @@ -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 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); diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/EcologyProjectService.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/EcologyProjectService.java new file mode 100644 index 0000000..bfc4132 --- /dev/null +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/EcologyProjectService.java @@ -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 按项目编号自动回填). + *

+ * 数据源: {@code v_compliance_project_list} (项目列表) + {@code v_compliance_project_manager} (负责人姓名/手机号). + *

+ * 字段映射: + * 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> searchProjectNos(String kw) + { + if (kw == null || kw.trim().isEmpty()) + { + return new ArrayList<>(); + } + String like = "%" + escapeLike(kw.trim()) + "%"; + try + { + List> rows = ecologyJdbcTemplate.queryForList(PROJECT_SEARCH_SQL, like); + List> result = new ArrayList<>(); + for (Map row : rows) + { + Map 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 fetchProjectByNo(String projectNo) + { + if (projectNo == null || projectNo.trim().isEmpty()) + { + throw new ServiceException("项目编号不能为空"); + } + Map row; + try + { + List> 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 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 row, Map 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> 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 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 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("_", "\\_"); + } +} diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/IBizExecutionIntentService.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/IBizExecutionIntentService.java index 32c80fd..3807724 100644 --- a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/IBizExecutionIntentService.java +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/IBizExecutionIntentService.java @@ -10,8 +10,22 @@ public interface IBizExecutionIntentService { BizExecutionIntent getById(String intentId); List selectList(BizExecutionIntent entity); + /** 查询全部记录 (含 del_flag='1' 幽灵占位), 仅"已报名专家导出"用 */ + List 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); } diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/IBizMeetingAttendeeService.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/IBizMeetingAttendeeService.java index f100b84..8c1c9de 100644 --- a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/IBizMeetingAttendeeService.java +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/IBizMeetingAttendeeService.java @@ -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) 档案, 返回不一致字段列表. + * + *

解析口径与 {@link #insertByPhoneWithProfile} 一致: 编辑按 body.id 反查 userId, + * 新增按 body.phone 查 sys_user 得 userId (不建号), 再 userId→biz_expert. 查不到专家返回空列表. + * + * @param body 参会人表单 (含 id 或 phone 定位) + * @return 不一致字段 (专家表已有值且与输入不同); 无差异/无专家 → 空列表 + */ + List diffAgainstExpert(BizMeetingAttendee body); + + /** + * 批量导入 dry-run: 解析 Excel + 逐行比对专家表, 不落库. + * + * @param file Excel 文件 + * @param meetingId 会议 id + * @return { total: 数据行数, mismatches: [ImportMismatch] } + * @throws Exception 文件为空 / 解析异常 + */ + Map 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 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 uploadExpertPhotos(MultipartFile file, Long meetingId) throws Exception; } \ No newline at end of file diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/InvitationPdfService.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/InvitationPdfService.java new file mode 100644 index 0000000..582defa --- /dev/null +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/InvitationPdfService.java @@ -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 生成服务. + * + *

流程 (「发送邀请函」触发): 下载会议邀请函模板 PDF → 把模板里的 {@code xxx}/{@code XXX} + * 替换为医生姓名 → 找到最后一个「北京整合医学学会」在其位置盖公章 → 上传 OSS. + * + *

文本定位用 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 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 charInfos = new ArrayList<>(); + StringBuilder sb = new StringBuilder(); + for (TextRenderInfo info : strategy.infos()) { + List 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 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 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 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 getSupportedEvents() { + return Collections.singleton(EventType.RENDER_TEXT); + } + + @Override + public String getResultantText() { + return ""; + } + + List 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; + } + } +} diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/SysSmsService.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/SysSmsService.java index d353c99..26654bf 100644 --- a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/SysSmsService.java +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/SysSmsService.java @@ -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); diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/WxShareService.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/WxShareService.java new file mode 100644 index 0000000..68751bd --- /dev/null +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/WxShareService.java @@ -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 分享签名服务 + *

链路: appId + appSecret → access_token → jsapi_ticket → signature(sha1) + *

参考 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 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 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); + } + } +} diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/impl/BizExecutionIntentServiceImpl.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/impl/BizExecutionIntentServiceImpl.java index 56ad398..91bfa2b 100644 --- a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/impl/BizExecutionIntentServiceImpl.java +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/impl/BizExecutionIntentServiceImpl.java @@ -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 selectList(BizExecutionIntent entity) { return bizExecutionIntentMapper.selectList(entity); } @Override + public List 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 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; + } } diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/impl/BizMeetingAttendeeServiceImpl.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/impl/BizMeetingAttendeeServiceImpl.java index 6b8f7c6..5b9b2a8 100644 --- a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/impl/BizMeetingAttendeeServiceImpl.java +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/impl/BizMeetingAttendeeServiceImpl.java @@ -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 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 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 previewImport(MultipartFile file, Long meetingId) throws Exception { + if (meetingId == null) throw new ServiceException("meetingId 不能为空"); + + ExcelUtil util = new ExcelUtil<>(BizMeetingAttendeeImportVo.class); + List rows = util.importExcel(file.getInputStream()); + if (rows == null || rows.isEmpty()) { + throw new ServiceException("导入数据不能为空"); + } + + List 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 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 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 result = new HashMap<>(); + result.put("total", rows.size()); + result.put("mismatches", mismatches); + return result; + } + + /** + * 比对参会人表单与专家表档案的公共字段 (两表都有的文本档案字段, 手机号是定位键不比对, + * 身份证附件是 OSS 文件 URL 不比对). 规则: 专家表已有值 (非空) 且与输入不一致 (含输入为空) + * → 记一条 mismatch; 专家表为空不算冲突. + */ + private List compareExpertProfile(BizExpert expert, BizMeetingAttendee form) { + List 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 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 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 meetingCache = new HashMap<>(); + Map 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 uploadAgreements(MultipartFile file, Long meetingId) throws Exception { if (meetingId == null) throw new ServiceException("meetingId 不能为空"); if (file == null || file.isEmpty()) throw new ServiceException("请选择要上传的 zip 文件"); List list = selectByMeetingId(meetingId); if (list.isEmpty()) throw new ServiceException("该会议暂无参会人, 无法回填劳务协议"); - byte[] zipBytes = file.getBytes(); - - // 目录序号 (下划线前数字, 0-based) → 该目录下文件字节 + 文件名 - Map> folderBytes = new HashMap<>(); - Map> 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 folders = parseAttendeeZip(file.getBytes()); int updated = 0; - for (Map.Entry> e : folderBytes.entrySet()) { - int idx = e.getKey(); - if (idx < 0 || idx >= list.size()) { - log.warn("[attendee] 协议 zip 目录序号 {} 越界 (参会人共 {}), 跳过", idx + 1, list.size()); + List> results = new ArrayList<>(); + for (ZipFolder f : folders.values()) { + MatchResult m = matchAttendee(list, f); + Map 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 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 uploadExpertPhotos(MultipartFile file, Long meetingId) throws Exception { if (meetingId == null) throw new ServiceException("meetingId 不能为空"); if (file == null || file.isEmpty()) throw new ServiceException("请选择要上传的 zip 文件"); List list = selectByMeetingId(meetingId); if (list.isEmpty()) throw new ServiceException("该会议暂无参会人, 无法回填专家照片"); - byte[] zipBytes = file.getBytes(); - Map> folderBytes = new HashMap<>(); - Map> 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 folders = parseAttendeeZip(file.getBytes()); int updated = 0; - for (Map.Entry> e : folderBytes.entrySet()) { - int idx = e.getKey(); - if (idx < 0 || idx >= list.size()) { - log.warn("[attendee] 专家照片 zip 目录序号 {} 越界 (参会人共 {}), 跳过", idx + 1, list.size()); + List> results = new ArrayList<>(); + for (ZipFolder f : folders.values()) { + MatchResult m = matchAttendee(list, f); + Map 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 files = e.getValue(); - List 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> folderBytes, - Map> folderNames) throws IOException { + /** 解压 zip, 归集每个 {序号}_姓名 目录下的文件; UTF-8 解析失败回退 GBK (中文 Windows 打包器). */ + private LinkedHashMap 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 readAttendeeZip(byte[] zipBytes, Charset charset) throws IOException { + LinkedHashMap 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 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 buildZipResult(int updated, int total, List> results) { + Map 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 files = new ArrayList<>(); + final List 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) { diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/impl/BizProjectPlanServiceImpl.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/impl/BizProjectPlanServiceImpl.java index 803ebe8..d4884ac 100644 --- a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/impl/BizProjectPlanServiceImpl.java +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/impl/BizProjectPlanServiceImpl.java @@ -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); } diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/impl/BizSignServiceImpl.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/impl/BizSignServiceImpl.java index fb3e959..6842659 100644 --- a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/impl/BizSignServiceImpl.java +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/impl/BizSignServiceImpl.java @@ -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 diff --git a/ry-api/ruoyi-business/src/main/resources/mapper/business/BizExecutionIntentMapper.xml b/ry-api/ruoyi-business/src/main/resources/mapper/business/BizExecutionIntentMapper.xml index 9f63216..571b7ce 100644 --- a/ry-api/ruoyi-business/src/main/resources/mapper/business/BizExecutionIntentMapper.xml +++ b/ry-api/ruoyi-business/src/main/resources/mapper/business/BizExecutionIntentMapper.xml @@ -13,9 +13,10 @@ + - 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 + + + select count(1) from biz_execution_intent + where user_id = #{userId} and project_no = #{projectNo} + \ No newline at end of file diff --git a/ry-api/ruoyi-business/src/main/resources/mapper/business/BizMeetingAttendeeMapper.xml b/ry-api/ruoyi-business/src/main/resources/mapper/business/BizMeetingAttendeeMapper.xml index dea115e..b8a78ae 100644 --- a/ry-api/ruoyi-business/src/main/resources/mapper/business/BizMeetingAttendeeMapper.xml +++ b/ry-api/ruoyi-business/src/main/resources/mapper/business/BizMeetingAttendeeMapper.xml @@ -30,6 +30,7 @@ + @@ -126,6 +127,13 @@ update_time = sysdate() where id = #{id} + + + update biz_meeting_attendee + set invitation_url = #{invitationUrl}, + update_time = sysdate() + where id = #{id} + update biz_meeting_attendee @@ -153,21 +161,22 @@ delete from biz_meeting_attendee where meeting_id = #{meetingId} and user_id = #{userId} - - +

该文件类型暂不支持页内预览, 请在新窗口打开查看。

@@ -94,7 +94,7 @@
- +
暂无签字 PDF