Compare commits

..
10 Commits
Author SHA1 Message Date
郭庆泰andClaude Code 0fb98311ce feat: 项目分配通知支持方主账号 + 结题前置检查未结算会议
#8 项目分配 → 支持方主账号待办通知
- BizProjectController.edit 检测 sponsor_org_id 变更 (null→org / A→B)
  调 BizNotifyService.projectAssignedToSupportOrg 发待办给支持方主账号
  (biz_org.user_id, MAIN sponsor). 去掉支持方 (置 null) 不发.
- BizNotifyService 新增 projectAssignedToSupportOrg, 与既有的
  projectAssignedToSponsor (通知 SUB 监察员) 配套, 共 2 条.

结题前置检查 (manager)
- BizProjectController 新增 GET /business/project/meetingSettlement
  入参 projectIds 逗号分隔, 查还有未结算会议 (is_settled<>1)
  的项目, 仅返回 unsettledCount>0 项.
- BizProjectMapper.checkMeetingSettled (LinkedHashMap, HAVING > 0).
- manager/Projects.vue openClose 先调该端点, 有未结算会议时弹警告
  ElMessageBox.confirm (允许强制结题). 后端失败兜底空数组不阻塞.

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-14 22:37:18 +08:00
郭庆泰andClaude Code 91bf0fa3b9 feat: 医院管理模块 (admin 端 CRUD + 导入导出)
- 后端: BizHospital controller/domain/vo/mapper/service + Mapper XML
- 前端: ry-vue3/src/views/admin/Hospital.vue + api/business/hospital.js
- 模块面向 admin 角色, 提供医院主数据维护

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-14 22:37:10 +08:00
郭庆泰 9ea4e18161 fix: 删除公告确认改用 ElMessageBox.confirm
- manager/Projects.vue: 删除空 body 的 el-dialog (header/footer 边框叠成两条线), 改用 ElMessageBox.confirm

- 合并 openDeleteAnnouncement/confirmDeleteAnn 为 deleteAnnouncement, 删无用 ref delAnnModalOpen/delAnnTargetRow
2026-09-13 21:03:10 +08:00
郭庆泰 9dc378974f chore: gitignore 临时测试产物 + Axure 设计源文件
- *.rar / proto/合规系统0904/ (Axure 设计源, 已展平到 proto/html)
- 11.pdf / 11_extracted.txt (发票 OCR 测试临时产物)
2026-09-10 20:52:12 +08:00
郭庆泰 5a0a892574 feat: 邀请函邮件发送 + 首次设密/密码为空 + 姓名单一可信源
邀请发送 (菜单暂隐藏, 待确认参数见 发送邀请-待确认参数.md):
- 后端: BizInvite / BizInviteRecipient + Controller/Service/Mapper/XML
- 邮件: InviteMailSender (spring-boot-starter-mail SMTP) + application*.yml 邮件配置
- 上传进度: UploadProgressRegistry + UploadProgressController
- 前端: InviteList / InviteNew / InviteDetail / InviteView + api/business/invite.js
- 原型: proto/html/components/invite-detail / new-invitation / send-invitation

登录/账号:
- 首次设密: /getInfo 返回 isPasswordEmpty, SysProfileController 密码为空时跳过旧密码校验, ForcePasswordDialog 强制弹窗
- 姓名单一可信源 resolveDisplayName: doctor→biz_expert.name, sponsor/executor→biz_person.name, 其余回退 nick_name
- OA compliance 门禁改为按手机号查 ecology 视图 (不再限定 manager/leader)

其它:
- OSS zip 在线查看 (列清单+取单文件, 公开只读) + SecurityConfig permitAll
- doctor 项目详情 ProjectDetail.vue
- 数据库/测试/设计文档 (md) 入库
2026-09-10 20:40:49 +08:00
郭庆泰 56615f9806 feat: OA(ecology)项目拉取 + 会议邀请函PDF/微信分享 + 参会人/执行意向增强
OA ecology:
- BizEcologyController + EcologyProjectService: 项目编号搜索/详情回填, 负责人静默建档
- ComplianceLoginService: 合规/负责人登录校验 + 视图 role 匹配
- application-prod/test.yml: ecology 只读库数据源

会议邀请函/分享:
- InvitationPdfService + WxShareService: 邀请函 PDF 生成 + 微信分享
- PdfViewer.vue (pdfjs-dist) + MeetingInvitation.vue: PDF 预览 + 邀请函页

参会人/执行意向:
- BizMeetingAttendee*: 参会人模块增强
- BizExecutionIntent*: 执行意向增强

签到/劳务/导入:
- BizSignServiceImpl + SignFill.vue: 签到劳务
- FieldMismatch/ImportMismatch: 导入校验 VO

公示/会议详情:
- PublicityDetail.vue / MeetingDetail.vue 等前端增强

配置:
- test 短信 mock + 供应商/招标接口切生产域名
2026-09-03 20:12:15 +08:00
郭庆泰 beb9799a06 feat: OA(泛微 ecology)登录身份校验 + 公示详情悬浮按钮/PDF缩放
OA 登录校验 (manager/leader 单一可信源 = ecology 合规视图 v_compliance_project_manager):
- ComplianceLoginService: 手机号登录查视图(type=1→manager/type=2→leader), 自动建号 sys_user+biz_person, 角色不匹配软删重建; 密码登录门禁 gateForPasswordLogin
- EcologyDataSourceConfig: 独立只读 JdbcTemplate(ecologyJdbcTemplate) 接泛微库, 不接入主库动态数据源
- SysLoginController: 密码登录前 complianceGate 校验 manager/leader 仍存于视图
- BizAuthController: smsLogin 改走 reconcileForLogin; 发码放行 ecology 视图内未注册手机号
- application*.yml: 新增 spring.datasource.ecology 只读连接(prod/test)
- Login.vue: 登录标题居中微调 + 供应商注册跳生产域名

公示详情 (publicity/:id):
- 右侧悬浮操作按钮从垂直居中改为主区右上(紧贴版心右缘), 中窄屏兜底贴右缘
- PDF 预览 #zoom 由 page-width 改为数值缩放(121), 放大正文占满宽度

文档:
- shouce/ 手册 md 入仓

注: oalogin/ (含 OA 管理员密码的临时笔记) 已加 .gitignore, 不入库
2026-09-01 20:45:15 +08:00
郭庆泰 a14d95288c 提交所有代码 2026-08-31 19:01:00 +08:00
郭庆泰andClaude f4d8bc1463 feat: 项目导出 + 会议跳转 + 机构软删除 + 供应商账号同步
- 项目/项目评价/报名专家 3 类导出, 勾选透传 projectIds
- 项目列表 总场次/已执行/未执行 三列点击跳对应会议列表 (executor 复用后端 role 隔离)
- 机构软删除 + 级联软删机构下账号
- 供应商开放接口账号同步

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-30 10:42:35 +08:00
郭庆泰andClaude 1876601ba7 feat: 会议阶段劳务/会务前缀投影 + 结算前未保存拦截
- 阶段投影: 待审核/审核通过/待整改/待结算 按劳务/会务前缀区分 (前后端镜像)
- 劳务材料提交门槛改为参会人员列表≥1人 (非上传文件)
- sponsor/manager 操作列「审核」「结算」提为直链
- 会议结算时间轴节点: 待结算态显示「补充凭证并结算」
- 结算前检测未保存改动, 弹「保存并结算」拦截

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-29 20:02:33 +08:00
243 changed files with 20396 additions and 1723 deletions
+9
View File
@@ -89,3 +89,12 @@ ry-vue2/ # 旧 vue 控制台
# ===== OCR 模型 (大二进制, 不进版本库, 部署时放 jar 同目录 models/) ===== # ===== OCR 模型 (大二进制, 不进版本库, 部署时放 jar 同目录 models/) =====
ry-api/models/ ry-api/models/
ry-ocr-java/src/main/resources/models/ ry-ocr-java/src/main/resources/models/
# ===== OA 单点登录临时笔记 (含 OA 管理员密码, 不入库) =====
oalogin/
# ===== 临时测试产物 / 设计源文件 (不入库) =====
*.rar
11.pdf
11_extracted.txt
proto/合规系统0904/
+1 -1
View File
@@ -212,7 +212,7 @@ COMMENT='ֵ֧λ';
| Key | 列 | 类型 | 用途覆盖 | | Key | 列 | 类型 | 用途覆盖 |
|---|---|---|---| |---|---|---|---|
| PRIMARY | `org_id` | UNIQUE BTREE | ✅ 主键查询 `WHERE org_id = ?` | | PRIMARY | `org_id` | UNIQUE BTREE | ✅ 主键查询 `WHERE org_id = ?` |
| `uk_org_user_type` | `(user_id, org_type)` | UNIQUE BTREE | ✅ "一用户一类型一公司" 强制; `selectMySponsorCompany` 用 ✓ | | `uk_org_user_type` | `(user_id, org_type)` | UNIQUE BTREE | ✅ "一用户一类型一公司" 强制; `selectMyCompany` 用 ✓ |
| `idx_org_type_status` | `(org_type, status)` | BTREE | ✅ 列表筛选 `WHERE org_type='sponsor' AND status=?` 命中 | | `idx_org_type_status` | `(org_type, status)` | BTREE | ✅ 列表筛选 `WHERE org_type='sponsor' AND status=?` 命中 |
| `idx_org_type_name` | `(org_type, org_name)` | BTREE | ✅ 列表筛选 `WHERE org_type='sponsor' AND org_name LIKE ?` 命中 | | `idx_org_type_name` | `(org_type, org_name)` | BTREE | ✅ 列表筛选 `WHERE org_type='sponsor' AND org_name LIKE ?` 命中 |
| `idx_org_user_type_name` | `(user_id, org_type, org_name)` | BTREE | ✅ `selectSponsorOrgOptions` / `selectExecutorOrgOptions` JOIN sys_user 命中 | | `idx_org_user_type_name` | `(user_id, org_type, org_name)` | BTREE | ✅ `selectSponsorOrgOptions` / `selectExecutorOrgOptions` JOIN sys_user 命中 |
+1 -1
View File
@@ -339,7 +339,7 @@ CREATE TABLE `biz_person` (
| uk_org_user_type | `user_id`,`org_type` (UNIQUE) | ✅ 一用户一类型一公司 | | uk_org_user_type | `user_id`,`org_type` (UNIQUE) | ✅ 一用户一类型一公司 |
| idx_org_type_status | `org_type`,`status` | ✅ | | idx_org_type_status | `org_type`,`status` | ✅ |
| idx_org_type_name | `org_type`,`org_name` | ✅ | | idx_org_type_name | `org_type`,`org_name` | ✅ |
| idx_org_user_type_name | `user_id`,`org_type`,`org_name` | ✅ selectMySponsorCompany | | idx_org_user_type_name | `user_id`,`org_type`,`org_name` | ✅ selectMyCompany |
> **结论**: 索引已覆盖 mapper 里所有查询路径, 无需新增. > **结论**: 索引已覆盖 mapper 里所有查询路径, 无需新增.
+339
View File
@@ -0,0 +1,339 @@
<!DOCTYPE html>
<!--
角色: 合规人员(manager)
父级: manager.html
进入: send-invitation.html 「查看」按钮
原型: 合规系统0904/专家列表.html (页面标题就是"邀请详情",面包屑 "发送邀请 > 邀请详情")
============================================================
对应原型元素:
面包屑(u6333) 发送邀请 > 邀请详情
h3 邀请详情(u6321)
顶部信息(会议名称u6315/项目编号u6317/期数u6319)
hint 提示(u6341)
h3 邀请专家列表(u6343)
table 列: 专家姓名(u6323)/手机号(u6339)/邮箱地址(u6329)
发送时间(u6347)/确认时间(u6359)
确认状态(u6345)/发送状态(u6392)/备注(u6394)
统计: 发送数量(u6388)/成功数量(u6477)/确认数量(u6481)
hint(u6414) 备注为空默认
按钮: 导出(u6385) / 返回(u6381)
hint(u6386) 导出文件名规则
============================================================
-->
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>邀请详情 - 项目管理系统</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, "PingFang SC", "Microsoft YaHei", sans-serif;
color: #333;
background: #fafafa;
min-height: 100vh;
}
.content { padding: 24px 32px; }
.breadcrumb {
font-size: 13px;
color: #8c8c8c;
margin-bottom: 12px;
}
.breadcrumb a { color: #1890ff; text-decoration: none; }
.breadcrumb a:hover { text-decoration: underline; }
.page-title {
font-size: 22px;
font-weight: 600;
color: #1a1a1a;
margin-bottom: 16px;
}
/* 顶部信息(对应原型 y=262/316/366, 左 3 行=会议信息 + 右 3 行=统计) */
.info-row {
display: grid;
grid-template-columns: 1fr 1fr;
column-gap: 32px;
row-gap: 6px;
background: #fff;
border: 1px solid #f0f0f0;
border-radius: 8px;
padding: 16px 24px;
margin-bottom: 12px;
}
.info-row .info-cell {
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
padding: 6px 0;
}
.info-row .info-cell .lbl {
color: #8c8c8c;
white-space: nowrap;
}
.info-row .info-cell .val {
color: #1a1a1a;
font-weight: 500;
}
.info-row .info-cell.stats .val {
color: #1890ff;
font-weight: 600;
}
/* hint(对应原型 u6341 短信邮箱提示) */
.hint-grey {
color: #8c8c8c;
font-size: 12px;
margin: 8px 0 16px;
}
.hint-red {
color: #ff4d4f;
font-size: 12px;
margin: 8px 0 16px;
}
/* 卡片 */
.card {
background: #fff;
border-radius: 8px;
border: 1px solid #f0f0f0;
margin-bottom: 16px;
overflow: hidden;
}
.card-title {
font-size: 15px;
font-weight: 600;
color: #1a1a1a;
padding: 16px 20px;
border-bottom: 1px solid #f0f0f0;
background: #fff;
}
/* 表格容器 */
.table-wrap { overflow-x: auto; }
/* 表格 */
.data-table {
width: 100%;
border-collapse: collapse;
font-size: 12px;
}
.data-table thead th {
background: #fafafa;
padding: 10px 12px;
text-align: left;
color: #1a1a1a;
font-weight: 600;
border-bottom: 1px solid #f0f0f0;
white-space: nowrap;
}
.data-table tbody td {
padding: 10px 12px;
border-bottom: 1px solid #f5f5f5;
color: #595959;
vertical-align: middle;
white-space: nowrap;
}
.data-table tbody tr:last-child td { border-bottom: none; }
.data-table tbody tr:hover { background: #fafcff; }
/* 状态标签 */
.status-tag {
display: inline-block;
padding: 2px 8px;
border-radius: 3px;
font-size: 12px;
line-height: 1.4;
}
.status-confirmed { background: #f6ffed; color: #389e0d; border: 1px solid #b7eb8f; }
.status-pending { background: #fff7e6; color: #d46b08; border: 1px solid #ffd591; }
.status-success { background: #f6ffed; color: #389e0d; border: 1px solid #b7eb8f; }
.status-failed { background: #fff1f0; color: #cf1322; border: 1px solid #ffa39e; }
/* 底部操作区 */
.form-actions {
display: flex;
gap: 12px;
margin-top: 16px;
}
.btn {
height: 32px;
padding: 0 20px;
border-radius: 4px;
font-size: 13px;
cursor: pointer;
min-width: 80px;
}
.btn-primary {
background: #1890ff;
color: #fff;
border: 1px solid #1890ff;
}
.btn-primary:hover { background: #096dd9; border-color: #096dd9; }
.btn-default {
background: #fff;
color: #333;
border: 1px solid #797979;
}
.btn-default:hover { border-color: #1890ff; color: #1890ff; }
@media (max-width: 900px) {
.info-row { grid-template-columns: 1fr; row-gap: 8px; }
}
</style>
</head>
<body>
<main class="content">
<!-- 面包屑(对应原型 u6333) -->
<div class="breadcrumb">
<a href="javascript:void(0)" onclick="goBack()">发送邀请</a> &gt;
<span>邀请详情</span>
</div>
<!-- 页面标题(对应原型 u6321) -->
<h1 class="page-title">邀请详情</h1>
<!-- 顶部信息(对应原型 y=262/316/366, 左 3 行=会议信息, 右 3 行=统计) -->
<div class="info-row">
<!-- 左列: 会议信息(对应 u6317/6315/6319) -->
<div class="info-cell">
<span class="lbl">项目编号:</span>
<span class="val" id="d_projectNo">ZH-2026-658</span>
</div>
<div class="info-cell">
<span class="lbl">会议名称:</span>
<span class="val" id="d_meetingName">小牛血清创新应用研讨会</span>
</div>
<div class="info-cell">
<span class="lbl">期数:</span>
<span class="val" id="d_period">第 5 期</span>
</div>
<!-- 右列: 统计(对应 u6388/6477/6481) -->
<div class="info-cell stats">
<span class="lbl">发送数量:</span>
<span class="val" id="s_total">6</span>
</div>
<div class="info-cell stats">
<span class="lbl">成功数量:</span>
<span class="val" id="s_success">5</span>
</div>
<div class="info-cell stats">
<span class="lbl">确认数量:</span>
<span class="val" id="s_confirmed">2</span>
</div>
</div>
<!-- hint(对应原型 u6341) -->
<p class="hint-grey">*短信和邮箱收到的是同一邀请链接,无论点击哪个进行确认,确认状态都是统一的</p>
<!-- 邀请专家列表(对应原型 u6343 标题) -->
<div class="card">
<div class="card-title">邀请专家列表</div>
<div class="table-wrap">
<table class="data-table">
<thead>
<tr>
<th>专家姓名</th>
<th>手机号</th>
<th>邮箱地址</th>
<th>发送时间</th>
<th>发送状态</th>
<th>确认状态</th>
<th>确认时间</th>
<th>备注</th>
</tr>
</thead>
<tbody>
<tr>
<td>张三</td>
<td>13548976548</td>
<td>zhengwg8976@126.com</td>
<td>2026.09.03</td>
<td>2026.09.05</td>
<td><span class="status-tag status-confirmed">已确认</span></td>
<td><span class="status-tag status-success">发送成功</span></td>
<td></td>
</tr>
<tr>
<td>李四</td>
<td>13548971234</td>
<td>13548976@qq.com</td>
<td>2026.09.03</td>
<td></td>
<td><span class="status-tag status-pending">未确认</span></td>
<td><span class="status-tag status-success">发送成功</span></td>
<td></td>
</tr>
<tr>
<td>张三</td>
<td>13548976548</td>
<td>zhengwg8976@126.com</td>
<td>2026.09.03</td>
<td>2026.09.05</td>
<td><span class="status-tag status-confirmed">已确认</span></td>
<td><span class="status-tag status-success">发送成功</span></td>
<td>1</td>
</tr>
<tr>
<td>张三</td>
<td>13548976548</td>
<td>zhengwg8976@126.com</td>
<td>2026.09.03</td>
<td></td>
<td><span class="status-tag status-pending">未确认</span></td>
<td><span class="status-tag status-success">发送成功</span></td>
<td></td>
</tr>
<tr>
<td>张三</td>
<td>13548976548</td>
<td>zhengwg8976@126.com</td>
<td>2026.09.03</td>
<td></td>
<td><span class="status-tag status-pending">未确认</span></td>
<td><span class="status-tag status-success">发送成功</span></td>
<td></td>
</tr>
<tr>
<td>张三</td>
<td></td>
<td></td>
<td>2026.09.03</td>
<td></td>
<td><span class="status-tag status-confirmed">已确认</span></td>
<td><span class="status-tag status-failed">发送失败</span></td>
<td>未查到联系方式</td>
</tr>
</tbody>
</table>
</div>
<!-- hint(对应原型 u6414 备注为空默认规则) -->
<p class="hint-grey" style="padding:12px 20px">*备注为空的,发送状态默认为发送成功,确认状态默认为已确认</p>
</div>
<!-- 操作(对应原型 u6385 导出 / u6381 返回) -->
<div class="form-actions">
<button type="button" class="btn btn-primary" onclick="alert('导出专家列表 Excel')">导出</button>
<button type="button" class="btn btn-default" onclick="goBack()">返回</button>
</div>
<p class="hint-grey">*导出表项与专家列表表项一致,导出文件名称为:项目编号+会议名称+期数</p>
</main>
<script>
function goBack() {
try {
if (window.parent && window.parent.document.getElementById('contentFrame')) {
window.parent.document.getElementById('contentFrame').src = 'send-invitation.html';
} else {
history.back();
}
} catch (e) { history.back(); }
}
</script>
</body>
</html>
+584
View File
@@ -0,0 +1,584 @@
<!DOCTYPE html>
<!--
角色: 合规人员(manager)
父级: manager.html
进入:
- send-invitation.html 的「+ 新建邀请」按钮 (无参数)
- send-invitation.html / invite-detail.html 的「再次发送」按钮 (resend=1)
原型: 合规系统0904/新建邀请.html
============================================================
对应原型元素:
面包屑(u6156) → 发送邀请 > 新建邀请
h1 标题(u6146) → 新建邀请
字段(u6137-6161,6166,6203,6208,6248,6250):
会议名称(*) / 项目编号(*) / 期数(*) / 会议时间(*) / 发送时间(*) / 专家(*)
+ 选择专家按钮(u6168)
+ 邀请内容(*)(u6169)
红头文件(u6184) + 选择文件(u6186)
邀请形式(u6193-6198): 短信 / 邮件 checkbox
提示文案(u6180,6182,6199,6201,6208,6248,6250)
按钮: 确定发送(u6170) / 返回(u6172) / 邀请预览(u6174)
弹窗:
- 选择专家(u6252)
- 邀请函预览(u6285)
- 发送成功(u6308)
============================================================
-->
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>新建邀请 - 项目管理系统</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, "PingFang SC", "Microsoft YaHei", sans-serif;
color: #333;
background: #fafafa;
min-height: 100vh;
}
.content { padding: 24px 32px; max-width: 1100px; }
.breadcrumb {
font-size: 13px;
color: #8c8c8c;
margin-bottom: 16px;
}
.breadcrumb a { color: #1890ff; text-decoration: none; }
.breadcrumb a:hover { text-decoration: underline; }
.page-title {
font-size: 22px;
font-weight: 600;
color: #1a1a1a;
margin-bottom: 20px;
}
.resend-hint { display: none; }
.card {
background: #fff;
border-radius: 8px;
padding: 24px 28px;
margin-bottom: 16px;
border: 1px solid #f0f0f0;
}
.card-title {
font-size: 15px;
font-weight: 600;
color: #1a1a1a;
margin-bottom: 18px;
padding-left: 10px;
border-left: 3px solid #1890ff;
line-height: 1;
}
.form-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 18px 32px;
}
.form-row {
display: flex;
align-items: center;
}
.form-row.full { grid-column: 1 / -1; }
.form-label {
flex: 0 0 110px;
font-size: 14px;
color: #595959;
text-align: right;
padding-right: 16px;
white-space: nowrap;
}
.form-label .req {
color: #ff4d4f;
margin-right: 4px;
font-weight: 700;
}
.form-input, .form-select, .form-textarea {
flex: 1;
min-width: 0;
padding: 6px 12px;
border: 1px solid #d9d9d9;
border-radius: 4px;
font-size: 13px;
color: #333;
background: #fff;
outline: none;
transition: all 0.2s;
}
.form-input { height: 32px; }
.form-textarea { min-height: 120px; resize: vertical; font-family: inherit; }
.form-input:focus, .form-select:focus, .form-textarea:focus {
border-color: #1890ff;
box-shadow: 0 0 0 3px rgba(24,144,255,0.1);
}
.inline-btn {
flex: 0 0 auto;
margin-left: 8px;
padding: 6px 16px;
background: #fff;
border: 1px solid #d9d9d9;
border-radius: 4px;
font-size: 13px;
color: #333;
cursor: pointer;
}
.inline-btn:hover { border-color: #1890ff; color: #1890ff; }
.checkbox-group {
display: flex;
gap: 24px;
flex: 1;
align-items: center;
}
.checkbox-group label {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 13px;
color: #333;
cursor: pointer;
}
/* 红字提示(对应原型 u6248/6250 等红字) */
.hint-red {
color: #ff4d4f;
font-size: 12px;
margin-top: 6px;
grid-column: 1 / -1;
}
.hint-grey {
color: #8c8c8c;
font-size: 12px;
margin-top: 6px;
grid-column: 1 / -1;
}
/* 操作区(对应原型 u6170/6172/6174) */
.form-actions {
display: flex;
gap: 12px;
margin-top: 8px;
}
.btn {
height: 40px;
padding: 0 32px;
border-radius: 5px;
font-size: 14px;
cursor: pointer;
min-width: 120px;
transition: all 0.2s;
}
.btn-primary {
background: #169bd5;
color: #fff;
border: 1px solid #169bd5;
}
.btn-primary:hover { background: #0f8db8; border-color: #0f8db8; }
.btn-default {
background: #fff;
color: #333;
border: 1px solid #797979;
}
.btn-default:hover { border-color: #1890ff; color: #1890ff; }
/* 短信/邮件内容预览(对应原型 u6180/6182/6187/6189/6191) */
.preview-box {
background: #fafafa;
border: 1px dashed #d9d9d9;
border-radius: 4px;
padding: 14px 18px;
font-size: 13px;
color: #595959;
line-height: 1.8;
flex: 1;
min-width: 0;
}
.preview-box .label { color: #8c8c8c; font-size: 12px; margin-bottom: 4px; }
.preview-box .link {
color: #1890ff;
word-break: break-all;
}
/* 弹窗(对应原型 u6252/6285/6308 动态面板) */
.modal-mask {
display: none;
position: fixed;
inset: 0;
background: rgba(0,0,0,0.45);
z-index: 1000;
align-items: center;
justify-content: center;
}
.modal-mask.show { display: flex; }
.modal {
background: #fff;
border-radius: 8px;
width: 560px;
max-width: calc(100vw - 32px);
max-height: calc(100vh - 64px);
display: flex;
flex-direction: column;
overflow: hidden;
}
.modal-header {
padding: 16px 24px;
border-bottom: 1px solid #f0f0f0;
display: flex;
align-items: center;
justify-content: space-between;
}
.modal-title {
font-size: 16px;
font-weight: 600;
color: #1a1a1a;
}
.modal-close {
cursor: pointer;
color: #8c8c8c;
font-size: 20px;
line-height: 1;
background: none;
border: none;
}
.modal-body {
padding: 20px 24px;
overflow-y: auto;
flex: 1;
}
.modal-footer {
padding: 12px 24px;
border-top: 1px solid #f0f0f0;
display: flex;
justify-content: flex-end;
gap: 8px;
}
.search-bar {
margin-bottom: 12px;
}
.search-bar input {
width: 100%;
height: 32px;
padding: 0 12px;
border: 1px solid #d9d9d9;
border-radius: 4px;
font-size: 13px;
outline: none;
}
.search-bar input:focus {
border-color: #1890ff;
}
/* 选择专家 弹窗列表 */
.expert-list {
border: 1px solid #f0f0f0;
border-radius: 4px;
max-height: 200px;
overflow-y: auto;
}
.expert-item {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 14px;
border-bottom: 1px solid #f5f5f5;
font-size: 13px;
}
.expert-item:last-child { border-bottom: none; }
.expert-item input { cursor: pointer; }
.import-row {
display: flex;
align-items: center;
gap: 12px;
margin: 12px 0;
padding: 10px 12px;
background: #fafafa;
border-radius: 4px;
}
.import-row .link {
color: #1890ff;
font-size: 13px;
cursor: pointer;
text-decoration: none;
}
/* 邀请函预览 */
.letter-preview {
background: #fff;
border: 1px solid #e8e8e8;
padding: 24px 28px;
font-size: 14px;
line-height: 2;
color: #1a1a1a;
border-radius: 4px;
}
.letter-preview .meta {
color: #8c8c8c;
font-size: 12px;
margin-bottom: 12px;
}
.letter-preview .confirm-btn {
display: inline-block;
margin-top: 12px;
padding: 8px 24px;
background: #169bd5;
color: #fff;
border-radius: 4px;
font-size: 13px;
}
.file-link {
color: #1890ff;
margin-right: 16px;
font-size: 13px;
cursor: pointer;
text-decoration: none;
}
@media (max-width: 768px) {
.content { padding: 16px; }
.form-grid { grid-template-columns: 1fr; }
.modal { width: 100%; }
}
</style>
</head>
<body>
<main class="content">
<!-- 面包屑(对应原型 u6156) -->
<div class="breadcrumb">
<a href="javascript:void(0)" onclick="goBack()">发送邀请</a> &gt;
<span>新建邀请</span>
</div>
<!-- h1 标题(对应原型 u6146),再次发送时也保持 "新建邀请" 不变 -->
<h1 class="page-title" id="pageTitle">新建邀请</h1>
<!-- 主表单(对应原型 u6137-6169,6203-6248) -->
<div class="card">
<div class="form-grid">
<!-- 会议名称(*) -->
<div class="form-row">
<span class="form-label"><span class="req">*</span>会议名称:</span>
<input type="text" class="form-input" id="f_meetingName" placeholder="请输入会议名称">
</div>
<!-- 项目编号(*) -->
<div class="form-row">
<span class="form-label"><span class="req">*</span>项目编号:</span>
<input type="text" class="form-input" id="f_projectNo" placeholder="请输入项目编号">
</div>
<!-- 期数(*) -->
<div class="form-row">
<span class="form-label"><span class="req">*</span>期数:</span>
<input type="text" class="form-input" id="f_period" placeholder="请输入期数">
</div>
<!-- 会议时间(*) -->
<div class="form-row">
<span class="form-label"><span class="req">*</span>会议时间:</span>
<input type="text" class="form-input" id="f_meetingTime" placeholder="选择会议时间(到年月日即可)">
</div>
<!-- 发送时间(*) -->
<div class="form-row">
<span class="form-label"><span class="req">*</span>发送时间:</span>
<input type="text" class="form-input" id="f_sendTime" placeholder="选择发送时间(默认当前时间)">
</div>
<!-- 专家(*) + 选择专家按钮(u6168) -->
<div class="form-row">
<span class="form-label"><span class="req">*</span>专家:</span>
<input type="text" class="form-input" id="f_expert" placeholder="选择或导入专家" readonly>
<button type="button" class="inline-btn" onclick="openExpertModal()">选择专家</button>
</div>
<!-- 邀请内容(*) -->
<div class="form-row full">
<span class="form-label"><span class="req">*</span>邀请内容:</span>
<textarea class="form-textarea" id="f_content" placeholder="请输入邀请函内容"></textarea>
</div>
<!-- 红字:发送时间规则(对应原型 u6248) -->
<div class="hint-red">*发送时间不能晚于会议时间,如果设置的发送时间晚于会议时间,则弹出报错提示</div>
<div class="hint-grey">*会议时间和发送时间到年月日即可</div>
<div class="hint-grey">*发送时间默认为当前时间,支持修改,该时间只显示在详情列表里,不做定时发送</div>
<!-- 红头文件(u6184) + 选择文件(u6186) -->
<div class="form-row full">
<span class="form-label">红头文件:</span>
<input type="text" class="form-input" placeholder="未选择任何文件" readonly>
<button type="button" class="inline-btn" onclick="alert('打开文件选择对话框')">选择文件</button>
</div>
<!-- 邀请形式(u6193/u6195/u6197) -->
<div class="form-row full">
<span class="form-label">邀请形式:</span>
<div class="checkbox-group">
<label><input type="checkbox" id="chkSms" checked> 短信</label>
<label><input type="checkbox" id="chkEmail" checked> 邮件</label>
</div>
</div>
<div class="hint-red">*提示:如邀请形式全部勾选,则两种方式均会发送,发送前请确认专家是否有该联系方式</div>
</div>
<!-- 按钮(对应原型 u6170/6172/6174) -->
<div class="form-actions" style="margin-top:24px">
<button type="button" class="btn btn-primary" onclick="send()">确定发送</button>
<button type="button" class="btn btn-default" onclick="openPreview()">邀请预览</button>
<button type="button" class="btn btn-default" onclick="goBack()">返回</button>
</div>
</div>
</main>
<!-- 弹窗1:选择专家(对应原型 u6252 动态面板) -->
<div class="modal-mask" id="modalExpert">
<div class="modal">
<div class="modal-header">
<span class="modal-title">选择专家</span>
<button class="modal-close" onclick="closeModal('modalExpert')">×</button>
</div>
<div class="modal-body">
<div class="search-bar">
<input type="text" placeholder="搜索专家姓名/手机号/邮箱">
</div>
<div class="expert-list">
<label class="expert-item"><input type="checkbox" value="张三"> 张三</label>
<label class="expert-item"><input type="checkbox" value="李四"> 李四</label>
<label class="expert-item"><input type="checkbox" value="王五"> 王五</label>
<label class="expert-item"><input type="checkbox" value="张三"> 张三</label>
</div>
<div class="import-row">
<button type="button" class="inline-btn" onclick="alert('打开导入对话框')">导入专家</button>
<a class="link" href="javascript:void(0)" onclick="alert('下载导入模板')">下载导入模板</a>
</div>
<div class="hint-grey">*专家可以从已有的专家库中进行选择,也支持批量导入</div>
<div class="hint-grey" style="margin-top:4px">*专家库中选择的专家为必发短信或邮件,批量导入的专家,备注写 1 的为发,备注为空的为不发</div>
<div class="hint-grey" style="margin-top:4px">*模板字段为:姓名,手机号,邮箱,备注</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-default" style="min-width:80px;height:32px;padding:0 16px" onclick="closeModal('modalExpert')">取消</button>
<button type="button" class="btn btn-primary" style="min-width:80px;height:32px;padding:0 16px" onclick="confirmExpert()">确定</button>
</div>
</div>
</div>
<!-- 弹窗2:邀请函预览(对应原型 u6285 动态面板) -->
<div class="modal-mask" id="modalPreview">
<div class="modal" style="width:680px">
<div class="modal-header">
<span class="modal-title">邀请函</span>
<button class="modal-close" onclick="closeModal('modalPreview')">×</button>
</div>
<div class="modal-body">
<div class="letter-preview">
<div class="meta">会议日程(略) · 会议日期:2026.05.11 · 项目名称:小牛血清创新应用研讨会</div>
<p>尊敬的 XX 教授:</p>
<p>您好!由衷感谢您长期以来对北京整合医学学会工作的鼎力支持。您报名参与的 <strong>【项目名称】</strong>,现定于【会议日期】正式举办。我们诚挚邀请担任【专家角色】。</p>
<p>再次感谢您的支持与参与,期待与您相会!</p>
<p style="margin-top:16px">北京整合医学学会</p>
<p>XXXX 年 XX 月 XX 日</p>
<div style="text-align:center;margin-top:20px">
<span class="confirm-btn">确认参加</span>
</div>
</div>
<div style="margin-top:14px;font-size:13px">
<a class="file-link" href="javascript:void(0)" onclick="alert('预览红头文件')">查看邀请文件</a>
<a class="file-link" href="javascript:void(0)" onclick="alert('下载红头文件')">下载邀请文件</a>
</div>
<div class="hint-grey" style="margin-top:8px">*点击查看邀请文件,可预览上传的红头文件</div>
<div class="hint-grey" style="margin-top:4px">*发送邀请为链接,点击链接后,打开页面</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-default" style="min-width:80px;height:32px;padding:0 16px" onclick="closeModal('modalPreview')">关闭预览</button>
</div>
</div>
</div>
<!-- 弹窗3:发送成功(对应原型 u6308 动态面板) -->
<div class="modal-mask" id="modalSent">
<div class="modal" style="width:420px">
<div class="modal-body" style="text-align:center;padding:32px 24px">
<p style="font-size:16px;color:#1a1a1a;margin-bottom:8px">邀请已发送,请点击专家数查看发送详情</p>
</div>
<div class="modal-footer" style="justify-content:center">
<button type="button" class="btn btn-primary" style="min-width:80px;height:32px;padding:0 16px" onclick="closeModal('modalSent');goBack()">确定</button>
</div>
</div>
</div>
<script>
// 再次发送 入口(resend=1)——按原型说法"跳转至新建邀请页面,会将之前邀请的信息带入"
// 页面与新建邀请完全一致,仅把上次内容预填到表单里
(function() {
var params = new URLSearchParams(location.search);
if (params.get('resend') !== '1') return;
var meeting = params.get('meeting') || '小牛血清创新应用研讨会';
document.getElementById('f_meetingName').value = meeting;
document.getElementById('f_projectNo').value = 'ZH-2026-658';
document.getElementById('f_period').value = '3';
document.getElementById('f_meetingTime').value = '2026.05.11';
document.getElementById('f_sendTime').value = new Date().toISOString().slice(0,10);
document.getElementById('f_content').value = '北京整合医学学会诚挚邀请您参加【' + meeting + '】,请您点击确认。';
})();
// 弹窗控制
function openExpertModal() { openModal('modalExpert'); }
function openPreview() { openModal('modalPreview'); }
function openModal(id) { document.getElementById(id).classList.add('show'); }
function closeModal(id) { document.getElementById(id).classList.remove('show'); }
function confirmExpert() {
var cbs = document.querySelectorAll('#modalExpert input[type="checkbox"]:checked');
var names = Array.from(cbs).map(function(c){return c.value;});
document.getElementById('f_expert').value = names.join(', ') || '';
closeModal('modalExpert');
}
function send() {
var required = ['f_meetingName','f_projectNo','f_period','f_meetingTime','f_sendTime','f_expert','f_content'];
for (var i = 0; i < required.length; i++) {
var el = document.getElementById(required[i]);
if (!el.value.trim()) { alert('请填写完整必填项:' + el.previousElementSibling.textContent); el.focus(); return; }
}
// 发送时间不能晚于会议时间
var mt = document.getElementById('f_meetingTime').value;
var st = document.getElementById('f_sendTime').value;
if (st && mt && st > mt) {
alert('发送时间不能晚于会议时间');
return;
}
openModal('modalSent');
}
function goBack() {
try {
if (window.parent && window.parent.document.getElementById('contentFrame')) {
window.parent.document.getElementById('contentFrame').src = 'send-invitation.html';
} else {
history.back();
}
} catch (e) { history.back(); }
}
// 点击遮罩关闭
document.querySelectorAll('.modal-mask').forEach(function(m){
m.addEventListener('click', function(e){
if (e.target === m) m.classList.remove('show');
});
});
</script>
</body>
</html>
+431
View File
@@ -0,0 +1,431 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>发送邀请 - 项目管理系统</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, "PingFang SC", "Microsoft YaHei", sans-serif;
color: #333;
background: #fafafa;
min-height: 100vh;
}
.content {
padding: 24px 32px;
}
.breadcrumb {
font-size: 13px;
color: #8c8c8c;
margin-bottom: 12px;
}
.breadcrumb a {
color: #1890ff;
text-decoration: none;
}
.breadcrumb a:hover { text-decoration: underline; }
.page-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
margin-bottom: 20px;
}
.page-title {
font-size: 22px;
font-weight: 600;
color: #1a1a1a;
margin-bottom: 4px;
}
.page-subtitle {
font-size: 13px;
color: #8c8c8c;
}
/* ============= 卡片 ============= */
.card {
background: #fff;
border-radius: 8px;
padding: 20px 24px;
margin-bottom: 16px;
border: 1px solid #f0f0f0;
}
.card-title {
font-size: 15px;
font-weight: 600;
color: #1a1a1a;
margin-bottom: 16px;
padding-left: 10px;
border-left: 3px solid #1890ff;
line-height: 1;
}
/* ============= 筛选区 ============= */
.filter-row {
display: flex;
flex-wrap: wrap;
gap: 12px;
align-items: center;
}
.filter-field {
display: flex;
align-items: center;
gap: 8px;
}
.filter-label {
font-size: 14px;
color: #595959;
white-space: nowrap;
}
.filter-input, .filter-select {
width: 180px;
height: 32px;
padding: 0 10px;
border: 1px solid #d9d9d9;
border-radius: 4px;
font-size: 13px;
color: #333;
background: #fff;
outline: none;
}
.filter-input:focus, .filter-select:focus {
border-color: #1890ff;
box-shadow: 0 0 0 3px rgba(24,144,255,0.1);
}
.filter-select {
cursor: pointer;
appearance: none;
background-image: url("data:image/svg+xml;charset=UTF-8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23999' stroke-width='2'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 10px center;
background-size: 14px;
padding-right: 24px;
}
.filter-btn {
height: 32px;
padding: 0 20px;
background: #1890ff;
color: #fff;
border: none;
border-radius: 4px;
font-size: 13px;
cursor: pointer;
}
.filter-btn:hover { background: #096dd9; }
.btn-group {
display: flex;
gap: 8px;
}
/* ============= 主按钮(新建邀请) ============= */
.header-actions {
display: flex;
gap: 8px;
}
.primary-btn {
height: 32px;
padding: 0 20px;
background: #1890ff;
color: #fff;
border: none;
border-radius: 4px;
font-size: 13px;
cursor: pointer;
}
.primary-btn:hover { background: #096dd9; }
/* ============= 表格 ============= */
.data-table {
width: 100%;
border-collapse: collapse;
font-size: 12px;
}
.data-table thead th {
background: #fafafa;
padding: 12px 12px;
text-align: left;
color: #1a1a1a;
font-weight: 600;
border-bottom: 1px solid #f0f0f0;
white-space: nowrap;
}
.data-table tbody td {
padding: 12px 12px;
border-bottom: 1px solid #f5f5f5;
color: #595959;
vertical-align: middle;
white-space: nowrap;
}
.data-table tbody tr:hover { background: #fafcff; }
.col-code {
color: #1890ff;
font-family: ui-monospace, "Courier New", monospace;
}
.col-name { color: #1a1a1a; }
.col-num {
color: #1890ff;
font-weight: 600;
}
.op-link {
color: #1890ff;
text-decoration: none;
margin-right: 8px;
font-size: 12px;
}
.op-link:last-child { margin-right: 0; }
.op-link:hover { text-decoration: underline; }
.op-link.resend {
color: #fa8c16;
}
/* ============= 提示说明 ============= */
.tip-line {
font-size: 12px;
color: #8c8c8c;
margin-top: 10px;
}
.tip-line .icon {
color: #fa8c16;
margin-right: 4px;
}
@media (max-width: 768px) {
.content { padding: 16px; }
.data-table { display: block; overflow-x: auto; white-space: nowrap; }
}
</style>
</head>
<body>
<main class="content">
<div class="breadcrumb">
<a href="javascript:void(0)">工作台</a> &gt; 发送邀请
</div>
<div class="page-header">
<div>
<h1 class="page-title">发送邀请</h1>
<p class="page-subtitle">为已立项的会议批量发送专家邀请函,支持再次发送</p>
</div>
<div class="header-actions">
<button class="primary-btn" onclick="goNew()">+ 新建邀请</button>
</div>
</div>
<!-- 筛选区(对应原型 u5921-u5931, 只有 3 个字段) -->
<div class="card">
<div class="filter-row">
<div class="filter-field">
<span class="filter-label">项目编号:</span>
<input type="text" class="filter-input" placeholder="输入项目编号">
</div>
<div class="filter-field">
<span class="filter-label">会议名称:</span>
<input type="text" class="filter-input" placeholder="输入会议名称">
</div>
<div class="filter-field">
<span class="filter-label">期数:</span>
<input type="text" class="filter-input" placeholder="输入期数" style="width:120px">
</div>
<div class="btn-group">
<button class="filter-btn">查找</button>
<button class="filter-btn" style="background:#fff;color:#595959;border:1px solid #d9d9d9">重置</button>
</div>
</div>
</div>
<!-- 表格 -->
<div class="card" style="padding: 0; overflow-x: auto;">
<table class="data-table">
<thead>
<tr>
<th>项目编号</th>
<th>会议名称</th>
<th>会议时间</th>
<th>期数</th>
<th>邀请专家数</th>
<th>已确认数</th>
<th>发送时间</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr>
<td><a class="col-code" href="javascript:void(0)">ZH-2026-658</a></td>
<td><a class="col-name" href="javascript:void(0)">小牛血清创新应用研讨会</a></td>
<td>2026.05.11 11:30</td>
<td>第 3 期</td>
<td><span class="col-num">26</span></td>
<td><span class="col-num">18</span></td>
<td>2026.09.03 14:20</td>
<td>
<a class="op-link" href="javascript:void(0)" onclick="view(this)">查看</a>
<a class="op-link resend" href="javascript:void(0)" onclick="resend(this)">再次发送</a>
</td>
</tr>
<tr>
<td><a class="col-code" href="javascript:void(0)">ZH-2026-658</a></td>
<td><a class="col-name" href="javascript:void(0)">小牛血清创新应用研讨会</a></td>
<td>2026.05.11 11:30</td>
<td>第 3 期</td>
<td><span class="col-num">26</span></td>
<td><span class="col-num">26</span></td>
<td>2026.09.03 14:20</td>
<td>
<a class="op-link" href="javascript:void(0)" onclick="view(this)">查看</a>
<a class="op-link resend" href="javascript:void(0)" onclick="resend(this)">再次发送</a>
</td>
</tr>
<tr>
<td><a class="col-code" href="javascript:void(0)">ZH-2026-658</a></td>
<td><a class="col-name" href="javascript:void(0)">小牛血清创新应用研讨会</a></td>
<td>2026.05.11 11:30</td>
<td>第 3 期</td>
<td><span class="col-num">26</span></td>
<td><span class="col-num">12</span></td>
<td>2026.09.03 14:20</td>
<td>
<a class="op-link" href="javascript:void(0)" onclick="view(this)">查看</a>
<a class="op-link resend" href="javascript:void(0)" onclick="resend(this)">再次发送</a>
</td>
</tr>
<tr>
<td><a class="col-code" href="javascript:void(0)">ZH-2026-658</a></td>
<td><a class="col-name" href="javascript:void(0)">小牛血清创新应用研讨会</a></td>
<td>2026.05.11 11:30</td>
<td>第 3 期</td>
<td><span class="col-num">26</span></td>
<td><span class="col-num">20</span></td>
<td>2026.09.03 14:20</td>
<td>
<a class="op-link" href="javascript:void(0)" onclick="view(this)">查看</a>
<a class="op-link resend" href="javascript:void(0)" onclick="resend(this)">再次发送</a>
</td>
</tr>
<tr>
<td><a class="col-code" href="javascript:void(0)">ZH-2026-658</a></td>
<td><a class="col-name" href="javascript:void(0)">小牛血清创新应用研讨会</a></td>
<td>2026.05.11 11:30</td>
<td>第 3 期</td>
<td><span class="col-num">26</span></td>
<td><span class="col-num">26</span></td>
<td>2026.09.03 14:20</td>
<td>
<a class="op-link" href="javascript:void(0)" onclick="view(this)">查看</a>
<a class="op-link resend" href="javascript:void(0)" onclick="resend(this)">再次发送</a>
</td>
</tr>
<tr>
<td><a class="col-code" href="javascript:void(0)">ZH-2026-658</a></td>
<td><a class="col-name" href="javascript:void(0)">小牛血清创新应用研讨会</a></td>
<td>2026.05.11 11:30</td>
<td>第 3 期</td>
<td><span class="col-num">26</span></td>
<td><span class="col-num">9</span></td>
<td>2026.09.03 14:20</td>
<td>
<a class="op-link" href="javascript:void(0)" onclick="view(this)">查看</a>
<a class="op-link resend" href="javascript:void(0)" onclick="resend(this)">再次发送</a>
</td>
</tr>
<tr>
<td><a class="col-code" href="javascript:void(0)">ZH-2026-658</a></td>
<td><a class="col-name" href="javascript:void(0)">小牛血清创新应用研讨会</a></td>
<td>2026.05.11 11:30</td>
<td>第 3 期</td>
<td><span class="col-num">26</span></td>
<td><span class="col-num">15</span></td>
<td>2026.09.03 14:20</td>
<td>
<a class="op-link" href="javascript:void(0)" onclick="view(this)">查看</a>
<a class="op-link resend" href="javascript:void(0)" onclick="resend(this)">再次发送</a>
</td>
</tr>
<tr>
<td><a class="col-code" href="javascript:void(0)">ZH-2026-658</a></td>
<td><a class="col-name" href="javascript:void(0)">小牛血清创新应用研讨会</a></td>
<td>2026.05.11 11:30</td>
<td>第 3 期</td>
<td><span class="col-num">26</span></td>
<td><span class="col-num">22</span></td>
<td>2026.09.03 14:20</td>
<td>
<a class="op-link" href="javascript:void(0)" onclick="view(this)">查看</a>
<a class="op-link resend" href="javascript:void(0)" onclick="resend(this)">再次发送</a>
</td>
</tr>
</tbody>
</table>
<p class="tip-line" style="padding: 0 24px 20px;">
<span class="icon">*</span>点击「再次发送」,跳转至新建邀请页面,会将之前邀请的信息带入,与复制相同
</p>
</div>
</main>
<script>
// 跳转到父级 manager.html 的 iframe(iframe 内导航)
function navTo(page) {
try {
if (window.parent && window.parent.document.getElementById('contentFrame')) {
window.parent.document.getElementById('contentFrame').src = page;
} else {
window.location.href = page;
}
} catch (e) {
window.location.href = page;
}
}
function goNew() {
navTo('new-invitation.html');
}
function resend(btn) {
const row = btn.closest('tr');
const code = row.querySelector('.col-code').textContent.trim();
const name = row.querySelector('.col-name').textContent.trim();
// 再次发送 → 直接跳到 新建邀请 表单(原型对应 #p=新建邀请)
navTo('new-invitation.html?resend=1&meeting=' + encodeURIComponent(name));
}
function view(btn) {
const row = btn.closest('tr');
const code = row.querySelector('.col-code').textContent.trim();
const name = row.querySelector('.col-name').textContent.trim();
// 查看 → 邀请详情 页(原型对应 #p=专家列表,页面标题即"邀请详情")
navTo('invite-detail.html?meeting=' + encodeURIComponent(name));
}
</script>
</body>
</html>
+1
View File
@@ -210,6 +210,7 @@
<li><a href="#" data-page="components/support-unit-manage.html" class="menu-link">支持单位管理</a></li> <li><a href="#" data-page="components/support-unit-manage.html" class="menu-link">支持单位管理</a></li>
<li><a href="#" data-page="components/support-intent.html" class="menu-link">支持意向</a></li> <li><a href="#" data-page="components/support-intent.html" class="menu-link">支持意向</a></li>
<li><a href="#" data-page="components/execution-intent.html" class="menu-link">执行意向</a></li> <li><a href="#" data-page="components/execution-intent.html" class="menu-link">执行意向</a></li>
<li><a href="#" data-page="components/send-invitation.html" class="menu-link">发送邀请</a></li>
<li><a href="#" data-page="components/account-manage.html" class="menu-link">账号管理</a></li> <li><a href="#" data-page="components/account-manage.html" class="menu-link">账号管理</a></li>
</ul> </ul>
</aside> </aside>
@@ -16,6 +16,16 @@ import com.ruoyi.common.config.RuoYiConfig;
import com.ruoyi.common.config.RuoYiConfig.OssProperties; import com.ruoyi.common.config.RuoYiConfig.OssProperties;
import com.ruoyi.common.core.domain.AjaxResult; import com.ruoyi.common.core.domain.AjaxResult;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.nio.charset.Charset;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.zip.ZipEntry;
import java.util.zip.ZipInputStream;
/** /**
* 阿里云 OSS 签名服务 * 阿里云 OSS 签名服务
* *
@@ -167,4 +177,180 @@ public class OssController
response.getWriter().write("FILE_NOT_FOUND"); response.getWriter().write("FILE_NOT_FOUND");
} }
} }
// ==================== zip 在线查看 (方案 B: 服务端解压, 前端只拉清单 + 按需取单文件) ====================
/**
* 读取 OSS 上某个 zip 的文件清单 (服务端解压, 前端按需拉单文件, 不整包下载到浏览器).
* 公开访问 (SecurityConfig /common/oss/zip/** 公开), 仅代理 hwtossbamlorgcn bucket 防 SSRF.
* 返回: [{ name, path, size, kind }], kind ∈ image/pdf/docx/excel/pptx/txt/other.
*/
@GetMapping("/zip/entries")
public AjaxResult zipEntries(@RequestParam String url) throws Exception
{
if (url == null || !url.contains("hwtossbamlorgcn.oss-cn-beijing.aliyuncs.com"))
{
return AjaxResult.error("非法 URL");
}
byte[] zipBytes = fetchOssBytes(url);
List<Map<String, Object>> entries;
try
{
entries = readZipEntries(zipBytes, StandardCharsets.UTF_8);
}
catch (Exception e)
{
// GBK 文件名被 UTF-8 解码会抛非法字节序列异常 → 回退 GBK 重读
entries = readZipEntries(zipBytes, Charset.forName("GBK"));
}
return AjaxResult.success(entries);
}
/**
* 读取 OSS 上某个 zip 里的单个文件字节, 按扩展名设置 Content-Type 返回.
* download=true 时 Content-Disposition 为 attachment (前端"下载单个文件"), 否则 inline (页内预览).
*/
@GetMapping("/zip/file")
public void zipFile(@RequestParam String url, @RequestParam String entry,
@RequestParam(required = false, defaultValue = "false") boolean download,
HttpServletResponse response) throws Exception
{
if (url == null || !url.contains("hwtossbamlorgcn.oss-cn-beijing.aliyuncs.com"))
{
response.setStatus(HttpServletResponse.SC_FORBIDDEN);
return;
}
byte[] zipBytes = fetchOssBytes(url);
byte[] data;
try
{
data = readZipEntry(zipBytes, entry, StandardCharsets.UTF_8);
}
catch (Exception e)
{
data = readZipEntry(zipBytes, entry, Charset.forName("GBK"));
}
if (data == null)
{
response.setStatus(HttpServletResponse.SC_NOT_FOUND);
response.setContentType("text/plain;charset=utf-8");
response.getWriter().write("ENTRY_NOT_FOUND");
return;
}
String filename = entry.substring(entry.lastIndexOf('/') + 1);
response.setContentType(contentTypeOf(filename));
response.setHeader("Content-Disposition", (download ? "attachment" : "inline") + "; filename=\"" + filename + "\"");
response.setHeader("Cache-Control", "public, max-age=3600");
response.getOutputStream().write(data);
}
/** 下载 OSS 文件字节到内存 (zip 场景专用, 与 /proxy 同源但返回 byte[]) */
private byte[] fetchOssBytes(String url) throws Exception
{
URI uri = URI.create(url);
URLConnection conn = uri.toURL().openConnection();
conn.setConnectTimeout(10000);
conn.setReadTimeout(60000);
try (InputStream in = conn.getInputStream())
{
ByteArrayOutputStream out = new ByteArrayOutputStream();
byte[] buf = new byte[8192];
int n;
while ((n = in.read(buf)) > 0)
{
out.write(buf, 0, n);
}
return out.toByteArray();
}
}
/** 解压 zip, 返回文件清单 (跳过目录/__MACOSX/.DS_Store, 上限 500 个防 zip 炸弹) */
private List<Map<String, Object>> readZipEntries(byte[] zipBytes, Charset charset) throws Exception
{
List<Map<String, Object>> entries = new ArrayList<>();
int count = 0;
try (ZipInputStream zis = new ZipInputStream(new ByteArrayInputStream(zipBytes), charset))
{
ZipEntry entry;
while ((entry = zis.getNextEntry()) != null)
{
if (entry.isDirectory()) continue;
String path = entry.getName();
if (path.contains("__MACOSX") || path.endsWith(".DS_Store")) continue;
count++;
if (count > 500)
{
Map<String, Object> overflow = new HashMap<>();
overflow.put("name", "…(文件过多, 仅显示前 500 个)");
overflow.put("path", "");
overflow.put("size", 0L);
overflow.put("kind", "other");
entries.add(overflow);
break;
}
Map<String, Object> m = new HashMap<>();
m.put("name", path.substring(path.lastIndexOf('/') + 1));
m.put("path", path);
m.put("size", entry.getSize());
m.put("kind", kindOf(path));
entries.add(m);
}
}
return entries;
}
/** 从 zip 里取单个 entry 的字节; 找不到返回 null */
private byte[] readZipEntry(byte[] zipBytes, String targetPath, Charset charset) throws Exception
{
try (ZipInputStream zis = new ZipInputStream(new ByteArrayInputStream(zipBytes), charset))
{
ZipEntry entry;
while ((entry = zis.getNextEntry()) != null)
{
if (entry.isDirectory()) continue;
if (targetPath.equals(entry.getName()))
{
ByteArrayOutputStream out = new ByteArrayOutputStream();
byte[] buf = new byte[8192];
int n;
while ((n = zis.read(buf)) > 0)
{
out.write(buf, 0, n);
}
return out.toByteArray();
}
}
}
return null;
}
private static String kindOf(String path)
{
String lower = path.toLowerCase();
if (lower.endsWith(".png") || lower.endsWith(".jpg") || lower.endsWith(".jpeg")
|| lower.endsWith(".gif") || lower.endsWith(".webp") || lower.endsWith(".bmp") || lower.endsWith(".svg")) return "image";
if (lower.endsWith(".pdf")) return "pdf";
if (lower.endsWith(".docx")) return "docx";
if (lower.endsWith(".xlsx") || lower.endsWith(".xls")) return "excel";
if (lower.endsWith(".pptx")) return "pptx";
if (lower.endsWith(".txt")) return "txt";
return "other";
}
private static String contentTypeOf(String filename)
{
String lower = filename.toLowerCase();
if (lower.endsWith(".pdf")) return "application/pdf";
if (lower.endsWith(".png")) return "image/png";
if (lower.endsWith(".jpg") || lower.endsWith(".jpeg")) return "image/jpeg";
if (lower.endsWith(".gif")) return "image/gif";
if (lower.endsWith(".webp")) return "image/webp";
if (lower.endsWith(".svg")) return "image/svg+xml";
if (lower.endsWith(".docx")) return "application/vnd.openxmlformats-officedocument.wordprocessingml.document";
if (lower.endsWith(".xlsx")) return "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
if (lower.endsWith(".xls")) return "application/vnd.ms-excel";
if (lower.endsWith(".pptx")) return "application/vnd.openxmlformats-officedocument.presentationml.presentation";
if (lower.endsWith(".txt")) return "text/plain;charset=utf-8";
return "application/octet-stream";
}
} }
@@ -25,7 +25,10 @@ import com.ruoyi.system.service.ISysConfigService;
import com.ruoyi.system.service.ISysMenuService; import com.ruoyi.system.service.ISysMenuService;
import com.ruoyi.system.service.ISysUserService; import com.ruoyi.system.service.ISysUserService;
import com.ruoyi.business.domain.BizExpert; import com.ruoyi.business.domain.BizExpert;
import com.ruoyi.business.domain.BizPerson;
import com.ruoyi.business.service.ComplianceLoginService;
import com.ruoyi.business.service.IBizExpertService; import com.ruoyi.business.service.IBizExpertService;
import com.ruoyi.business.service.IBizPersonService;
/** /**
* 登录验证 * 登录验证
@@ -56,6 +59,12 @@ public class SysLoginController
@Autowired @Autowired
private IBizExpertService expertService; private IBizExpertService expertService;
@Autowired
private IBizPersonService personService;
@Autowired
private ComplianceLoginService complianceLoginService;
/** /**
* 登录方法 * 登录方法
* *
@@ -70,6 +79,12 @@ public class SysLoginController
{ {
return AjaxResult.error("您的信息正在审核中"); return AjaxResult.error("您的信息正在审核中");
} }
// 合规人员/项目负责人门禁: 校验其仍存在于 ecology 视图, 角色变更则软删重建
String complianceGate = complianceGate(loginBody.getUsername());
if (complianceGate != null)
{
return AjaxResult.error(complianceGate);
}
AjaxResult ajax = AjaxResult.success(); AjaxResult ajax = AjaxResult.success();
// 生成令牌 // 生成令牌
String token = loginService.login(loginBody.getUsername(), loginBody.getPassword(), loginBody.getCode(), String token = loginService.login(loginBody.getUsername(), loginBody.getPassword(), loginBody.getCode(),
@@ -101,6 +116,28 @@ public class SysLoginController
return expert != null && "1".equals(expert.getAuditStatus()); return expert != null && "1".equals(expert.getAuditStatus());
} }
/**
* 密码登录门禁: 按登录用户的手机号查 ecology 视图, 命中即校验身份 (不限于 manager/leader).
* 返回 null=放行, 非 null=错误提示 (直接报错, 不走密码登录).
*/
private String complianceGate(String username)
{
if (username == null || username.isEmpty())
{
return null;
}
SysUser probe = userService.selectUserByUserName(username);
if (probe == null && username.contains("@"))
{
probe = userService.selectUserByEmail(username);
}
if (probe == null || probe.getUserId() == null)
{
return null;
}
return complianceLoginService.gateForPasswordLogin(probe);
}
/** /**
* 获取用户信息 * 获取用户信息
* *
@@ -122,17 +159,45 @@ public class SysLoginController
} }
AjaxResult ajax = AjaxResult.success(); AjaxResult ajax = AjaxResult.success();
ajax.put("user", user); ajax.put("user", user);
// 姓名单一可信源: biz_person.name (sponsor/executor) / biz_expert.name (doctor), 其余角色前端回退 nick_name
ajax.put("name", resolveDisplayName(user));
ajax.put("roles", roles); ajax.put("roles", roles);
ajax.put("permissions", permissions); ajax.put("permissions", permissions);
ajax.put("pwdChrtype", getSysAccountChrtype()); ajax.put("pwdChrtype", getSysAccountChrtype());
ajax.put("isDefaultModifyPwd", initPasswordIsModify(user.getPwdUpdateDate())); ajax.put("isDefaultModifyPwd", initPasswordIsModify(user.getPwdUpdateDate()));
ajax.put("isPasswordExpired", passwordIsExpiration(user.getPwdUpdateDate())); ajax.put("isPasswordExpired", passwordIsExpiration(user.getPwdUpdateDate()));
ajax.put("isPasswordEmpty", isPasswordEmpty(user.getUserId()));
return ajax; return ajax;
} }
/**
* 解析当前登录用户姓名 (navbar 显示用).
* 单一可信源: doctor → biz_expert.name, sponsor/executor → biz_person.name;
* 其余角色 (admin/manager/leader) 返回 null, 前端回退 sys_user.nick_name.
*/
private String resolveDisplayName(SysUser user)
{
if (user == null || user.getUserId() == null)
{
return null;
}
String roleType = user.getRoleType();
if ("doctor".equals(roleType))
{
BizExpert expert = expertService.getByUserId(user.getUserId());
return expert != null ? expert.getName() : null;
}
if ("sponsor".equals(roleType) || "executor".equals(roleType))
{
BizPerson person = personService.getByUserId(user.getUserId());
return person != null ? person.getName() : null;
}
return null;
}
/** /**
* 获取路由信息 * 获取路由信息
* *
* @return 路由信息 * @return 路由信息
*/ */
@GetMapping("getRouters") @GetMapping("getRouters")
@@ -156,6 +221,21 @@ public class SysLoginController
return initPasswordModify != null && initPasswordModify == 1 && pwdUpdateDate == null; return initPasswordModify != null && initPasswordModify == 1 && pwdUpdateDate == null;
} }
// 密码是否为空 (password 与 password2 均为空 → 无任何可用密码, 仅短信登录).
// 注意: 不能读缓存 LoginUser 里的 password/password2 —— 二者标注 @JsonProperty(WRITE_ONLY),
// 序列化进 Redis 时会被清空, 读回恒为 null; 必须回查 DB 取真实值, 否则"设置密码后仍弹窗".
public boolean isPasswordEmpty(Long userId)
{
if (userId == null)
{
return false;
}
SysUser fresh = userService.selectUserById(userId);
return fresh != null
&& StringUtils.isEmpty(fresh.getPassword())
&& StringUtils.isEmpty(fresh.getPassword2());
}
// 检查密码是否过期 // 检查密码是否过期
public boolean passwordIsExpiration(Date pwdUpdateDate) public boolean passwordIsExpiration(Date pwdUpdateDate)
{ {
@@ -103,11 +103,13 @@ public class SysProfileController extends BaseController
Long userId = loginUser.getUserId(); Long userId = loginUser.getUserId();
SysUser user = userService.selectUserById(userId); SysUser user = userService.selectUserById(userId);
String password = user.getPassword(); String password = user.getPassword();
if (!SecurityUtils.matchesPassword(oldPassword, password)) // 密码为空 (password 与 password2 均为空) 时, 跳过旧密码校验 (首次设置密码场景, 仅短信登录用户)
boolean passwordEmpty = StringUtils.isEmpty(password) && StringUtils.isEmpty(user.getPassword2());
if (!passwordEmpty && !SecurityUtils.matchesPassword(oldPassword, password))
{ {
return error("修改密码失败,旧密码错误"); return error("修改密码失败,旧密码错误");
} }
if (SecurityUtils.matchesPassword(newPassword, password)) if (!passwordEmpty && SecurityUtils.matchesPassword(newPassword, password))
{ {
return error("新密码不能与旧密码相同"); return error("新密码不能与旧密码相同");
} }
@@ -4,10 +4,7 @@ spring:
type: com.alibaba.druid.pool.DruidDataSource type: com.alibaba.druid.pool.DruidDataSource
driverClassName: com.mysql.cj.jdbc.Driver driverClassName: com.mysql.cj.jdbc.Driver
druid: druid:
master: # master 数据源 url/username/password 按环境拆分到 application-test.yml / application-prod.yml
url: jdbc:mysql://mmos.uvwcloud.com:3306/guoju0808?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=true&serverTimezone=GMT%2B8
username: root
password: Qljl1rh_
slave: slave:
enabled: false enabled: false
url: url:
@@ -0,0 +1,45 @@
# 生产环境数据源 (47.95.12.80 / hegui)
# 生产端口 8082 (测试端口 8080 为 application.yml 默认值)
server:
port: 8082
spring:
datasource:
druid:
master:
url: jdbc:mysql://47.95.12.80:3306/hegui?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=true&serverTimezone=GMT%2B8
username: hegui
password: C55zwt5L2TeB4kLB
# 合规(泛微 ecology)外部只读库, 登录校验合规人员/项目负责人用
ecology:
url: jdbc:mysql://oagm228.guojustar.com:3306/ecology?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=false&serverTimezone=GMT%2B8
username: user_compliance
password: 'ComplexPass@2026'
driver-class-name: com.mysql.cj.jdbc.Driver
# 生产环境外部接口地址
ruoyi:
sms:
# 生产环境: aliyun 真发短信
mock-enabled: false
esignBaseUrl: https://hegui.bahim.org.cn
# 发送邀请 公开邀请链接 (生产域名, 待确认)
invite:
baseUrl: https://hegui.bahim.org.cn
# 扫码拍照 (ry-h5 相机网页地址, 前端二维码目标 URL)
camera:
base-url: https://hegui.bahim.org.cn/camera/
supplier-account-api:
base-url: https://zbsupplier.guojustar.com/supplier-api/bidding/supplier/openapi/accounts
# base-url: https://zbsuppliertest.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://zbpublic.guojustar.com/portal-api/bidding/public/notice/list
detail-url: https://zbpublic.guojustar.com/#/detail/
# notice-list-url: https://zbpublictest.guojustar.com/portal-api/bidding/public/notice/list
# detail-url: https://zbpublictest.guojustar.com/#/detail/
@@ -0,0 +1,38 @@
# 测试环境数据源 (mmos.uvwcloud.com / guoju0808)
spring:
datasource:
druid:
master:
url: jdbc:mysql://mmos.uvwcloud.com:3306/guoju0808?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=true&serverTimezone=GMT%2B8
username: root
password: Qljl1rh_
# 合规(泛微 ecology)外部只读库, 登录校验合规人员/项目负责人用
ecology:
url: jdbc:mysql://101.254.99.156:11120/ecology?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=false&serverTimezone=GMT%2B8
username: user_compliance
password: 'ComplexPass@2026'
driver-class-name: com.mysql.cj.jdbc.Driver
# 测试环境外部接口地址
ruoyi:
sms:
# 测试环境: 验证码固定 1234 且不真发短信
mock-enabled: true
esignBaseUrl: https://risingdoctor.com/hg
# 发送邀请 公开邀请链接 (测试域名)
invite:
baseUrl: https://risingdoctor.com/hg
# 扫码拍照 (ry-h5 相机网页地址, 前端二维码目标 URL)
camera:
base-url: https://risingdoctor.com/camera/
supplier-account-api:
base-url: https://zbsuppliertest8rwiphmrenrtsh35.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://zbpublic.guojustar.com/portal-api/bidding/public/notice/list
detail-url: https://zbpublic.guojustar.com/#/detail/
@@ -1,100 +1,3 @@
# 项目相关配置
ruoyi:
# 名称
name: RuoYi
# 版本
version: 3.9.2
# 版权年份
copyrightYear: 2026
# 文件路径 示例( Windows配置D:/ruoyi/uploadPathLinux配置 /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
esignBaseUrl: https://hegui.bahim.org.cn
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
# 扫码拍照 (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
# 每页条数
page-size: 20
# 每次拉取最近 N 分钟更新的数据
pull-minutes: 5
# 供应商账号接口 AES 密钥 (数据解密用, 测试环境密钥)
supplier-account-api-aes:
key-id: supplier-api-key
key: 'kY0+oIO/laeaaaYDd+9TCYbCQR0b/vqMeJ9sUnSVd9U='
# 开发环境配置
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配置
spring: spring:
@@ -103,14 +6,18 @@ spring:
# 国际化资源文件路径 # 国际化资源文件路径
basename: i18n/messages basename: i18n/messages
profiles: profiles:
active: druid # 默认 prod 环境; 本地测试用 --spring.profiles.active=test (group 自动展开为 druid,test)
active: prod
group:
test: druid,test
prod: druid,prod
# 文件上传 # 文件上传
servlet: servlet:
multipart: multipart:
# 单个文件大小 # 单个文件大小
max-file-size: 10MB max-file-size: 200MB
# 设置总上传的文件大小 # 设置总上传的文件大小
max-request-size: 20MB max-request-size: 200MB
jackson: jackson:
time-zone: GMT+8 time-zone: GMT+8
date-format: yyyy-MM-dd HH:mm:ss date-format: yyyy-MM-dd HH:mm:ss
@@ -142,6 +49,21 @@ spring:
max-active: 8 max-active: 8
# #连接池最大阻塞等待时间(使用负值表示没有限制) # #连接池最大阻塞等待时间(使用负值表示没有限制)
max-wait: -1ms max-wait: -1ms
# 邮件发送 (spring-boot-starter-mail, 参考 xxl-job EmailJobAlarm)
# host/port/password 待确认后回填 (username 已按原型稿 contactus@bahim.org.cn 占位)
mail:
host: 待确认
port: 465
username: contactus@bahim.org.cn
password: 待确认
protocol: smtp
default-encoding: UTF-8
properties:
mail:
smtp:
auth: true
ssl:
enable: true
# token配置 # token配置
token: token:
@@ -195,4 +117,104 @@ xss:
# 排除链接(多个用逗号分隔) # 排除链接(多个用逗号分隔)
excludes: /system/notice excludes: /system/notice
# 匹配链接 # 匹配链接
urlPatterns: /system/*,/monitor/*,/tool/* urlPatterns: /system/*,/monitor/*,/tool/*
# 项目相关配置
ruoyi:
# 名称
name: RuoYi
# 版本
version: 3.9.2
# 版权年份
copyrightYear: 2026
# 文件路径 示例( Windows配置D:/ruoyi/uploadPathLinux配置 /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
# 发送邀请短信模板 (新增独立模板, 不复用 esignTemplate; 占位符 ${link}=邀请链接) — 待确认
inviteTemplate: 待填
endpoint: dysmsapi.aliyuncs.com
regionId: cn-hangzhou
# 发送邀请 公开邀请链接 baseUrl (按环境拆分到 application-test/prod.yml)
invite:
baseUrl: https://risingdoctor.com/hg
# 发票 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
+5
View File
@@ -34,6 +34,11 @@
<artifactId>aliyun-java-sdk-dysmsapi</artifactId> <artifactId>aliyun-java-sdk-dysmsapi</artifactId>
<version>2.2.1</version> <version>2.2.1</version>
</dependency> </dependency>
<!-- 邮件发送 (发送邀请 email 渠道, 参考 xxl-job EmailJobAlarm) — 需 mvn install -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-mail</artifactId>
</dependency>
<dependency> <dependency>
<groupId>com.aliyun</groupId> <groupId>com.aliyun</groupId>
<artifactId>aliyun-java-sdk-core</artifactId> <artifactId>aliyun-java-sdk-core</artifactId>
@@ -0,0 +1,42 @@
package com.ruoyi.business.config;
import com.alibaba.druid.pool.DruidDataSource;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.env.Environment;
import org.springframework.jdbc.core.JdbcTemplate;
/**
* 合规(泛微 ecology)外部只读库数据源.
* <p>
* 用于登录时校验合规人员(manager) / 项目负责人(leader) 是否仍存在于
* {@code v_compliance_project_manager} 视图 (按 mobile 手机号匹配).
* <p>
* 独立 JdbcTemplate (bean 名 ecologyJdbcTemplate), 不接入主库事务 / MyBatis,
* 避免与 DruidConfig 的主从动态数据源 (dynamicDataSource @Primary) 产生二义性.
*/
@Configuration
public class EcologyDataSourceConfig
{
@Bean(name = "ecologyJdbcTemplate")
public JdbcTemplate ecologyJdbcTemplate(Environment env)
{
String url = env.getProperty("spring.datasource.ecology.url");
String username = env.getProperty("spring.datasource.ecology.username");
String password = env.getProperty("spring.datasource.ecology.password");
String driver = env.getProperty("spring.datasource.ecology.driver-class-name", "com.mysql.cj.jdbc.Driver");
DruidDataSource ds = new DruidDataSource();
ds.setUrl(url);
ds.setUsername(username);
ds.setPassword(password);
ds.setDriverClassName(driver);
// 只读小连接池, 登录校验用
ds.setInitialSize(1);
ds.setMinIdle(1);
ds.setMaxActive(3);
ds.setMaxWait(3000);
ds.setValidationQuery("SELECT 1");
return new JdbcTemplate(ds);
}
}
@@ -38,4 +38,15 @@ public class OcrExecutorConfig
{ {
return Executors.newFixedThreadPool(8); return Executors.newFixedThreadPool(8);
} }
/**
* zip 批量上传执行器 (劳务协议 / 专家照片 / 会务材料): 解压 zip + 逐文件上传 OSS (网络 IO 密集).
* <p>
* 上传接口改为"提交即返回 jobId" (处理移后台), 前端轮询进度; 4 线程并发处理不同用户的上传任务.
*/
@Bean(name = "zipUploadExecutor", destroyMethod = "shutdown")
public ExecutorService zipUploadExecutor()
{
return Executors.newFixedThreadPool(4);
}
} }
@@ -19,7 +19,9 @@ import com.ruoyi.business.dto.SmsValidForm;
import com.ruoyi.business.mapper.BizPersonMapper; import com.ruoyi.business.mapper.BizPersonMapper;
import com.ruoyi.business.service.IBizExpertService; import com.ruoyi.business.service.IBizExpertService;
import com.ruoyi.business.service.IBizOrgService; import com.ruoyi.business.service.IBizOrgService;
import com.ruoyi.business.service.ComplianceLoginService;
import com.ruoyi.business.service.SysSmsService; import com.ruoyi.business.service.SysSmsService;
import com.ruoyi.common.exception.ServiceException;
import com.ruoyi.common.utils.id.SnowflakeId; import com.ruoyi.common.utils.id.SnowflakeId;
import com.ruoyi.common.constant.Constants; import com.ruoyi.common.constant.Constants;
import com.ruoyi.common.core.controller.BaseController; import com.ruoyi.common.core.controller.BaseController;
@@ -69,6 +71,9 @@ public class BizAuthController extends BaseController {
@Autowired @Autowired
private IBizExpertService expertService; private IBizExpertService expertService;
@Autowired
private ComplianceLoginService complianceLoginService;
@Autowired @Autowired
private BizPersonMapper bizPersonMapper; private BizPersonMapper bizPersonMapper;
@@ -101,8 +106,8 @@ public class BizAuthController extends BaseController {
if (phone == null || !phone.matches("^1\\d{10}$")) { if (phone == null || !phone.matches("^1\\d{10}$")) {
return error("请输入正确的手机号"); return error("请输入正确的手机号");
} }
// 登录反义: 手机号未注册则不发 // 登录反义: 手机号未注册则不发; 但合规人员/项目负责人可从 ecology 视图自动建号, 放行发码
if (!userService.isPhoneRegistered(phone)) { if (!userService.isPhoneRegistered(phone) && !complianceLoginService.existsInEcology(phone)) {
return error("该手机号未注册, 请先注册账号"); return error("该手机号未注册, 请先注册账号");
} }
String uuid = smsService.sendCode(phone); String uuid = smsService.sendCode(phone);
@@ -138,15 +143,15 @@ public class BizAuthController extends BaseController {
return error("验证码错误或已过期: " + e.getMessage()); return error("验证码错误或已过期: " + e.getMessage());
} }
// 3. 查用户 (checkPhoneUnique 只 select user_id+phonenumber, 缺 role_type/user_name/status, // 3. 查用户 + 合规人员/项目负责人身份校验 (可能从 ecology 视图自动建号)
// 需再按 userId 查完整 user, 否则 LoginUser 里 roleType=null 导致前端拿不到角色而跳回登录页) SysUser user;
SysUser probe = userMapper.checkPhoneUnique(phone); try {
if (probe == null || probe.getUserId() == null) { user = complianceLoginService.reconcileForLogin(phone);
return error("该手机号未注册"); } catch (ServiceException e) {
return error(e.getMessage());
} }
SysUser user = userMapper.selectUserById(probe.getUserId());
if (user == null) { if (user == null) {
return error("该手机号未注册"); return error("用户不存在");
} }
// 4. 校验用户状态 // 4. 校验用户状态
@@ -276,7 +281,7 @@ public class BizAuthController extends BaseController {
self.setUpdateBy(username); self.setUpdateBy(username);
bizPersonMapper.insert(self); bizPersonMapper.insert(self);
return success("注册成功, 请等待审核").put("userId", userId).put("orgId", orgId); return success("注册成功").put("userId", userId).put("orgId", orgId);
} }
/** /**
@@ -379,6 +384,6 @@ public class BizAuthController extends BaseController {
self.setUpdateBy(username); self.setUpdateBy(username);
bizPersonMapper.insert(self); bizPersonMapper.insert(self);
return success("注册成功, 请等待审核").put("userId", userId).put("orgId", orgId); return success("注册成功").put("userId", userId).put("orgId", orgId);
} }
} }
@@ -0,0 +1,55 @@
package com.ruoyi.business.controller;
import java.util.List;
import java.util.Map;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import com.ruoyi.business.service.EcologyProjectService;
import com.ruoyi.common.core.controller.BaseController;
import com.ruoyi.common.core.domain.AjaxResult;
import com.ruoyi.common.exception.ServiceException;
/**
* ecology 项目信息拉取 (admin/projects/new 按项目编号自动回填).
* <p>
* 读接口开放给已登录用户 (admin/manager 新建项目用), 与 /business/org/*Options 下拉一致, 不加 @PreAuthorize.
*/
@RestController
@RequestMapping("/business/ecology")
public class BizEcologyController extends BaseController
{
@Autowired
private EcologyProjectService ecologyProjectService;
/**
* 项目编号模糊搜索 (下拉选项): GET /business/ecology/project/search?xmbh=xxx
* 返回 [{projectNo, projectName}].
*/
@GetMapping("/project/search")
public AjaxResult searchProjects(@RequestParam(value = "xmbh", required = false) String xmbh)
{
List<Map<String, Object>> list = ecologyProjectService.searchProjectNos(xmbh);
return success(list);
}
/**
* 按项目编号拉取完整信息 + 负责人静默建档: GET /business/ecology/project/byNo?projectNo=xxx
* 返回 {projectNo, projectName, projectForm, startTime, endTime, totalAmount, manageFee,
* leaderResolved, leaderName, leaderPhone, leaderUserId}.
*/
@GetMapping("/project/byNo")
public AjaxResult fetchProject(@RequestParam(value = "projectNo", required = false) String projectNo)
{
try
{
return success(ecologyProjectService.fetchProjectByNo(projectNo));
}
catch (ServiceException e)
{
return error(e.getMessage());
}
}
}
@@ -14,9 +14,11 @@ import com.ruoyi.common.enums.BusinessType;
import com.ruoyi.common.utils.SecurityUtils; import com.ruoyi.common.utils.SecurityUtils;
import com.ruoyi.common.utils.poi.ExcelUtil; import com.ruoyi.common.utils.poi.ExcelUtil;
import com.ruoyi.system.mapper.SysUserMapper; import com.ruoyi.system.mapper.SysUserMapper;
import com.ruoyi.business.domain.BizExpert;
import com.ruoyi.business.domain.BizExecutionIntent; import com.ruoyi.business.domain.BizExecutionIntent;
import com.ruoyi.business.domain.BizProject; import com.ruoyi.business.domain.BizProject;
import com.ruoyi.business.domain.vo.BizSignupExpertExportVo; import com.ruoyi.business.domain.vo.BizSignupExpertExportVo;
import com.ruoyi.business.service.IBizExpertService;
import com.ruoyi.business.service.IBizExecutionIntentService; import com.ruoyi.business.service.IBizExecutionIntentService;
import com.ruoyi.business.service.IBizProjectService; import com.ruoyi.business.service.IBizProjectService;
@@ -33,6 +35,8 @@ public class BizExecutionIntentController extends BaseController
private IBizExecutionIntentService bizExecutionIntentService; private IBizExecutionIntentService bizExecutionIntentService;
@Autowired @Autowired
private IBizProjectService bizProjectService; private IBizProjectService bizProjectService;
@Autowired
private IBizExpertService bizExpertService;
/** /**
* 公开门户点击"立即报名" — 写入意向 (user_id = 当前登录用户) * 公开门户点击"立即报名" — 写入意向 (user_id = 当前登录用户)
@@ -60,6 +64,8 @@ public class BizExecutionIntentController extends BaseController
q.setProjectNo(proj.getProjectNo()); q.setProjectNo(proj.getProjectNo());
List<BizExecutionIntent> exists = bizExecutionIntentService.selectList(q); List<BizExecutionIntent> exists = bizExecutionIntentService.selectList(q);
if (exists != null && !exists.isEmpty()) return error("您已报名过该项目,无需重复报名"); if (exists != null && !exists.isEmpty()) return error("您已报名过该项目,无需重复报名");
// 真报名: 先物理删掉幽灵占位记录 (del_flag='1')
bizExecutionIntentService.hardDeletePlaceholder(SecurityUtils.getUserId(), proj.getProjectNo());
bizExecutionIntent.setProjectNo(proj.getProjectNo()); bizExecutionIntent.setProjectNo(proj.getProjectNo());
bizExecutionIntent.setProjectName(proj.getProjectName()); bizExecutionIntent.setProjectName(proj.getProjectName());
Long uid = SecurityUtils.getUserId(); Long uid = SecurityUtils.getUserId();
@@ -136,16 +142,28 @@ public class BizExecutionIntentController extends BaseController
if (bizExecutionIntent.getProjectNo() == null || bizExecutionIntent.getProjectNo().isEmpty()) { if (bizExecutionIntent.getProjectNo() == null || bizExecutionIntent.getProjectNo().isEmpty()) {
throw new IllegalArgumentException("projectNo 不能为空"); throw new IllegalArgumentException("projectNo 不能为空");
} }
List<BizExecutionIntent> list = bizExecutionIntentService.selectList(bizExecutionIntent); List<BizExecutionIntent> list = bizExecutionIntentService.selectListIncludingDeleted(bizExecutionIntent);
List<BizSignupExpertExportVo> exportList = new ArrayList<>(list.size()); List<BizSignupExpertExportVo> exportList = new ArrayList<>(list.size());
for (BizExecutionIntent e : list) { for (BizExecutionIntent e : list) {
BizSignupExpertExportVo v = new BizSignupExpertExportVo(); BizSignupExpertExportVo v = new BizSignupExpertExportVo();
v.setProjectNo(e.getProjectNo());
v.setProjectName(e.getProjectName());
v.setName(e.getName()); v.setName(e.getName());
v.setDepartment(e.getDepartment());
v.setWorkUnit(e.getWorkUnit());
v.setPosition(e.getPosition());
v.setPhone(e.getPhone()); v.setPhone(e.getPhone());
v.setCreateTime(e.getCreateTime()); v.setCreateTime(e.getCreateTime());
// 医生档案: 报名专家 userId → biz_expert (科室/医院/职称 报名表常为空, 从档案兜底补全)
BizExpert expert = e.getUserId() != null ? bizExpertService.getByUserId(e.getUserId()) : null;
String department = e.getDepartment();
String workUnit = e.getWorkUnit();
String position = e.getPosition();
if (expert != null) {
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.setDepartment(department);
v.setWorkUnit(workUnit);
v.setPosition(position);
exportList.add(v); exportList.add(v);
} }
ExcelUtil<BizSignupExpertExportVo> util = new ExcelUtil<>(BizSignupExpertExportVo.class); ExcelUtil<BizSignupExpertExportVo> util = new ExcelUtil<>(BizSignupExpertExportVo.class);
@@ -0,0 +1,119 @@
package com.ruoyi.business.controller;
import java.util.List;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import com.ruoyi.common.annotation.Log;
import com.ruoyi.common.core.controller.BaseController;
import com.ruoyi.common.core.domain.AjaxResult;
import com.ruoyi.common.core.page.TableDataInfo;
import com.ruoyi.common.enums.BusinessType;
import com.ruoyi.common.utils.poi.ExcelUtil;
import com.ruoyi.business.domain.BizHospital;
import com.ruoyi.business.domain.dto.ImportResult;
import com.ruoyi.business.domain.vo.BizHospitalExportVo;
import com.ruoyi.business.domain.vo.BizHospitalImportVo;
import com.ruoyi.business.service.IBizHospitalService;
/**
* 医院字典 Controller (admin 资料库管理 / 医院管理)
*
* <p>CRUD + 导入/导出走 admin 路由 (前端 AdminLayout 角色控制);
* 远程搜索 /search 为匿名公开 (医生注册下拉).
* 注: 不加 @PreAuthorize — 与 BizExpert/BizOrg 等业务 controller 一致 (业务角色不走 RuoYi RBAC).
*
* @author guoju
*/
@RestController
@RequestMapping("/business/hospital")
public class BizHospitalController extends BaseController {
@Autowired
private IBizHospitalService bizHospitalService;
/** 分页列表 (admin 后台, 支持 hospital/province/city/hospitalCode/militaryHospital/type 筛选) */
@GetMapping("/list")
public TableDataInfo list(BizHospital entity) {
startPage();
List<BizHospital> rows = bizHospitalService.selectList(entity);
return getDataTable(rows);
}
/** 详情 */
@GetMapping("/{hospitalId}")
public AjaxResult getInfo(@PathVariable("hospitalId") Long hospitalId) {
return success(bizHospitalService.getById(hospitalId));
}
/**
* 远程搜索 (匿名公开, 医生注册下拉"边输入边查询")
* GET /business/hospital/search?keyword=xxx → [{hospitalId, province, city, hospital, ...}] LIMIT 30
*/
@GetMapping("/search")
public AjaxResult search(@RequestParam(value = "keyword", required = false) String keyword) {
return success(bizHospitalService.searchByKeyword(keyword));
}
/** 删除前引用统计: 返回医院名被业务表 work_unit 引用的条数 */
@GetMapping("/countUsage")
public AjaxResult countUsage(@RequestParam("hospital") String hospital) {
return success(bizHospitalService.countUsageByHospital(hospital));
}
@Log(title = "医院管理", businessType = BusinessType.INSERT)
@PostMapping
public AjaxResult add(@RequestBody BizHospital entity) {
return toAjax(bizHospitalService.insert(entity));
}
@Log(title = "医院管理", businessType = BusinessType.UPDATE)
@PutMapping
public AjaxResult edit(@RequestBody BizHospital entity) {
return toAjax(bizHospitalService.updateByPrimaryKey(entity));
}
@Log(title = "医院管理", businessType = BusinessType.DELETE)
@DeleteMapping("/{hospitalIds}")
public AjaxResult remove(@PathVariable Long[] hospitalIds) {
return toAjax(bizHospitalService.deleteByPrimaryKeys(hospitalIds));
}
/** 导出 (中文列头, 跟随当前筛选条件) */
@Log(title = "医院管理", businessType = BusinessType.EXPORT)
@PostMapping("/export")
public void export(HttpServletResponse response, BizHospital entity) {
List<BizHospital> list = bizHospitalService.selectList(entity);
List<BizHospitalExportVo> exportList = new java.util.ArrayList<>(list.size());
for (BizHospital h : list) {
BizHospitalExportVo v = new BizHospitalExportVo();
v.setHospitalId(h.getHospitalId());
v.setProvince(h.getProvince());
v.setCity(h.getCity());
v.setHospital(h.getHospital());
v.setOriginalHospital(h.getOriginalHospital());
v.setHospitalCode(h.getHospitalCode());
v.setMilitaryHospital(h.getMilitaryHospital());
v.setType(h.getType());
exportList.add(v);
}
ExcelUtil<BizHospitalExportVo> util = new ExcelUtil<>(BizHospitalExportVo.class);
util.exportExcel(response, exportList, "医院数据");
}
/** 下载导入模板 */
@GetMapping("/importTemplate")
public void importTemplate(HttpServletResponse response) {
ExcelUtil<BizHospitalImportVo> util = new ExcelUtil<>(BizHospitalImportVo.class);
util.importTemplateExcel(response, "医院数据");
}
/** 批量导入 (Excel → biz_hospital, 去重, 返回 ImportResult) */
@Log(title = "医院管理", businessType = BusinessType.IMPORT)
@PostMapping("/importData")
public AjaxResult importData(MultipartFile file) throws Exception {
ImportResult result = bizHospitalService.importHospitals(file);
return success(result);
}
}
@@ -0,0 +1,90 @@
package com.ruoyi.business.controller;
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 com.ruoyi.common.annotation.Log;
import com.ruoyi.common.core.controller.BaseController;
import com.ruoyi.common.core.domain.AjaxResult;
import com.ruoyi.common.core.page.TableDataInfo;
import com.ruoyi.common.enums.BusinessType;
import com.ruoyi.business.domain.BizInvite;
import com.ruoyi.business.domain.BizInviteRecipient;
import com.ruoyi.business.service.IBizInviteService;
/**
* 发送邀请 Controller (manager 工作台).
* <p>独立于项目/会议, 项目编号为手输文本. manager 是业务角色, 不走 RuoYi RBAC 权限.
*/
@RestController
@RequestMapping("/business/invite")
public class BizInviteController extends BaseController
{
@Autowired
private IBizInviteService inviteService;
@GetMapping("/list")
public TableDataInfo list(BizInvite bizInvite)
{
startPage();
List<BizInvite> list = inviteService.selectList(bizInvite);
return getDataTable(list);
}
@GetMapping("/{id}")
public AjaxResult getInfo(@PathVariable("id") Long id)
{
BizInvite invite = inviteService.getById(id);
if (invite == null)
{
return error("邀请不存在");
}
Map<String, Object> data = new HashMap<>();
data.put("invite", invite);
data.put("recipients", inviteService.listRecipients(id));
return success(data);
}
/**
* 新建邀请 (仅保存草稿, 不发送).
* 返回 {id}, 前端拿 data.id 继续编辑或调用 /{id}/send 发送.
*/
@Log(title = "发送邀请", businessType = BusinessType.INSERT)
@PostMapping
public AjaxResult add(@RequestBody BizInvite bizInvite)
{
Long id = inviteService.create(bizInvite, false);
Map<String, Object> data = new HashMap<>();
data.put("id", id);
return success(data);
}
/**
* 发送邀请 (对 remark=1 的收件人触发短信/邮件), 返回实际发送条数.
*/
@Log(title = "发送邀请", businessType = BusinessType.UPDATE)
@PostMapping("/{id}/send")
public AjaxResult send(@PathVariable("id") Long id)
{
try
{
int sent = inviteService.send(id);
Map<String, Object> data = new HashMap<>();
data.put("sent", sent);
return success(data);
}
catch (RuntimeException e)
{
return error(e.getMessage());
}
}
@Log(title = "发送邀请", businessType = BusinessType.DELETE)
@DeleteMapping("/{ids}")
public AjaxResult remove(@PathVariable Long[] ids)
{
return toAjax(inviteService.deleteByPrimaryKeys(ids));
}
}
@@ -1,14 +1,18 @@
package com.ruoyi.business.controller; package com.ruoyi.business.controller;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile; import org.springframework.web.multipart.MultipartFile;
import com.ruoyi.common.annotation.Log; import com.ruoyi.common.annotation.Log;
import com.ruoyi.common.core.controller.BaseController; import com.ruoyi.common.core.controller.BaseController;
import com.ruoyi.common.core.domain.AjaxResult; import com.ruoyi.common.core.domain.AjaxResult;
import com.ruoyi.common.core.page.TableDataInfo;
import com.ruoyi.common.enums.BusinessType; import com.ruoyi.common.enums.BusinessType;
import com.ruoyi.common.exception.ServiceException;
import com.ruoyi.common.utils.SecurityUtils; import com.ruoyi.common.utils.SecurityUtils;
import com.ruoyi.common.utils.poi.ExcelUtil; import com.ruoyi.common.utils.poi.ExcelUtil;
import com.ruoyi.business.domain.BizMeeting; import com.ruoyi.business.domain.BizMeeting;
@@ -16,6 +20,7 @@ import com.ruoyi.business.domain.BizMeetingAttendee;
import com.ruoyi.business.domain.dto.ImportResult; import com.ruoyi.business.domain.dto.ImportResult;
import com.ruoyi.business.domain.vo.BizMeetingAttendeeImportVo; import com.ruoyi.business.domain.vo.BizMeetingAttendeeImportVo;
import com.ruoyi.business.notify.BizNotifyService; import com.ruoyi.business.notify.BizNotifyService;
import com.ruoyi.business.service.IBizExecutionIntentService;
import com.ruoyi.business.service.IBizMeetingAttendeeService; import com.ruoyi.business.service.IBizMeetingAttendeeService;
import com.ruoyi.business.service.IBizMeetingService; import com.ruoyi.business.service.IBizMeetingService;
@@ -44,17 +49,21 @@ public class BizMeetingAttendeeController extends BaseController {
@Autowired @Autowired
private IBizMeetingService bizMeetingService; private IBizMeetingService bizMeetingService;
@Autowired @Autowired
private IBizExecutionIntentService bizExecutionIntentService;
@Autowired
private BizNotifyService bizNotifyService; private BizNotifyService bizNotifyService;
/** /**
* 当前登录用户的"待签署协议"列表 (已推送电子签 is_esigned=1 且 任一未签) * 当前登录用户的"待签署协议"列表 (已推送电子签 is_esigned=1 且 任一未签)
* 用于 /doctor/home 工作台 * 用于 /doctor/home 工作台, 支持分页 (前端按 5 条/页)
* pageNum / pageSize 从 request param 读 (RuoYi 标准做法, TableSupport 自动解析)
*/ */
@GetMapping("/unsigned") @GetMapping("/unsigned")
public AjaxResult listUnsigned() { public TableDataInfo listUnsigned() {
Long userId = SecurityUtils.getUserId(); Long userId = SecurityUtils.getUserId();
startPage(); // PageHelper 仅拦截同一线程的下一条 select, 所以放 service.list() 之前
List<BizMeetingAttendee> rows = attendeeService.selectUnsignedByUserId(userId); List<BizMeetingAttendee> rows = attendeeService.selectUnsignedByUserId(userId);
return success(rows); return getDataTable(rows);
} }
/** /**
@@ -68,13 +77,55 @@ public class BizMeetingAttendeeController extends BaseController {
return success(rows); return success(rows);
} }
/**
* 会议邀请函详情 (参会人/医生点开站内信里的邀请链接后查看).
* <p>安全: 仅参会人本人可见 (attendee.userId == 当前登录用户), 防止横向越权.
* <p>返回 {attendeeName, title, laborForm, projectName, meetingName, meetingTime, scheduleUrl, invitationUrl}.
*/
@GetMapping("/invitation/{attendeeId}")
public AjaxResult invitationDetail(@PathVariable("attendeeId") Long attendeeId) {
if (attendeeId == null) return error("缺少参会记录");
BizMeetingAttendee a = attendeeService.selectById(attendeeId);
if (a == null) return error("参会记录不存在");
Long uid = SecurityUtils.getUserId();
if (a.getUserId() == null || !a.getUserId().equals(uid)) {
return error("无权限查看该邀请函");
}
BizMeeting m = a.getMeetingId() == null ? null : bizMeetingService.getById(a.getMeetingId());
// 点开邀请函 = 立即报名: 确保该专家已"报名"该项目 (幂等, 已报名则跳过)
if (m != null && m.getProjectNo() != null && !m.getProjectNo().trim().isEmpty()) {
bizExecutionIntentService.signupByProject(m.getProjectNo(), m.getProjectName());
}
Map<String, Object> data = new HashMap<>();
data.put("attendeeName", a.getName());
data.put("title", a.getTitle());
data.put("laborForm", a.getLaborForm());
data.put("projectName", m != null ? m.getProjectName() : null);
data.put("meetingName", m != null ? m.getMeetingName() : null);
data.put("meetingTime", m != null ? m.getStartTime() : null);
data.put("scheduleUrl", m != null ? m.getScheduleUrl() : null);
// 优先参会人个性化邀请函 (盖章+姓名); 未生成回退会议级模板
data.put("invitationUrl", a.getInvitationUrl() != null && !a.getInvitationUrl().trim().isEmpty()
? a.getInvitationUrl()
: (m != null ? m.getInvitationUrl() : null));
return success(data);
}
/** /**
* 管理端: 某会议的全部参会人 (MeetingDetail 参会人 CRUD 用). * 管理端: 某会议的全部参会人 (MeetingDetail 参会人 CRUD 用).
* 返回全字段, 前端按需展示. * 返回全字段, 前端按需展示.
* <p>sponsor (监督方) 只读且敏感信息脱敏: 姓名/手机号/银行卡号/账户名称/身份证号在后端打码,
* 明文不下发 (前端遮罩只是显示层, 不是安全边界).
*/ */
@GetMapping("/list/{meetingId}") @GetMapping("/list/{meetingId}")
public AjaxResult listByMeetingId(@PathVariable("meetingId") Long meetingId) { public AjaxResult listByMeetingId(@PathVariable("meetingId") Long meetingId) {
return success(attendeeService.selectByMeetingId(meetingId)); List<BizMeetingAttendee> list = attendeeService.selectByMeetingId(meetingId);
if (isSponsor()) {
for (BizMeetingAttendee a : list) {
maskForSponsor(a);
}
}
return success(list);
} }
/** /**
@@ -93,6 +144,8 @@ public class BizMeetingAttendeeController extends BaseController {
@Log(title = "参会人", businessType = BusinessType.INSERT) @Log(title = "参会人", businessType = BusinessType.INSERT)
@PostMapping @PostMapping
public AjaxResult add(@RequestBody BizMeetingAttendee body) { public AjaxResult add(@RequestBody BizMeetingAttendee body) {
// 参会人名单写操作拦截: executor 劳务提交后禁止 (新增)
checkAttendeeEditable(body.getMeetingId());
Long attendeeId = attendeeService.insertByPhoneWithProfile(body); Long attendeeId = attendeeService.insertByPhoneWithProfile(body);
// 人员变化 → 立即重算劳务费 (不碰会务费/不置统计中) // 人员变化 → 立即重算劳务费 (不碰会务费/不置统计中)
bizMeetingService.recomputeLaborFee(body.getMeetingId()); bizMeetingService.recomputeLaborFee(body.getMeetingId());
@@ -111,6 +164,8 @@ public class BizMeetingAttendeeController extends BaseController {
return error("id 不能为空"); return error("id 不能为空");
} }
BizMeetingAttendee before = attendeeService.selectById(body.getId()); BizMeetingAttendee before = attendeeService.selectById(body.getId());
// 参会人名单写操作拦截: executor 劳务提交后禁止 (编辑)
checkAttendeeEditable(before != null ? before.getMeetingId() : null);
body.setUpdateBy(SecurityUtils.getUsername()); body.setUpdateBy(SecurityUtils.getUsername());
int rows = attendeeService.updateProfile(body); int rows = attendeeService.updateProfile(body);
// 人员变化 → 立即重算劳务费 (不碰会务费/不置统计中) // 人员变化 → 立即重算劳务费 (不碰会务费/不置统计中)
@@ -120,6 +175,15 @@ public class BizMeetingAttendeeController extends BaseController {
return toAjax(rows); return toAjax(rows);
} }
/**
* 新增/编辑参会人保存前的预检: 比对表单与专家表 (biz_expert) 档案, 返回不一致字段列表.
* <p>前端据此弹窗 + 标红 + 二次确认, 确认后才真正 POST/PUT. 无差异返回空数组.
*/
@PostMapping("/diff")
public AjaxResult diff(@RequestBody BizMeetingAttendee body) {
return success(attendeeService.diffAgainstExpert(body));
}
/** /**
* 管理端: 按 id 删除参会人 (单条). * 管理端: 按 id 删除参会人 (单条).
* 注意: 仅删 biz_meeting_attendee 行, 不删 sys_user (参会人可能是真实用户, 不能因参会关系删号). * 注意: 仅删 biz_meeting_attendee 行, 不删 sys_user (参会人可能是真实用户, 不能因参会关系删号).
@@ -128,6 +192,8 @@ public class BizMeetingAttendeeController extends BaseController {
@DeleteMapping("/{id}") @DeleteMapping("/{id}")
public AjaxResult remove(@PathVariable("id") Long id) { public AjaxResult remove(@PathVariable("id") Long id) {
BizMeetingAttendee before = attendeeService.selectById(id); BizMeetingAttendee before = attendeeService.selectById(id);
// 参会人名单写操作拦截: executor 劳务提交后禁止 (删除)
checkAttendeeEditable(before != null ? before.getMeetingId() : null);
int rows = attendeeService.deleteByPrimaryKey(id); int rows = attendeeService.deleteByPrimaryKey(id);
// 人员变化 → 立即重算劳务费 (不碰会务费/不置统计中) // 人员变化 → 立即重算劳务费 (不碰会务费/不置统计中)
if (before != null) { if (before != null) {
@@ -179,19 +245,28 @@ public class BizMeetingAttendeeController extends BaseController {
} }
/** /**
* 批量导入参会人 — 上传 Excel + 解析入库. * 批量导入参会人 — 两段式: 默认 dry-run (force 缺省=false) 只解析 + 比对专家表, 不落库;
* force=true 才真正入库.
* *
* <p>调 {@link IBizMeetingAttendeeService#importFromExcel} 逐行处理, 失败的进 ngList. * <p>dry-run 返回 { total, mismatches: [{rowNo, phone, name, fields:[{field,label,expertValue,inputValue}]}] },
* 邀请参会已改为手动, 此处不再自动推"会议邀请". * 前端据此弹「二次确认」; 确认后带 force=true 重发同一文件.
* *
* <p>返回 ImportResult { okNum, ngNum, ngList: [{rowNum, message}] }, 前端 ImportResultDialog 直接渲染. * <p>force=true 调 {@link IBizMeetingAttendeeService#importFromExcel} 逐行入库, 失败进 ngList,
* 返回 ImportResult { okNum, ngNum, ngList }.
* *
* @param meetingId 会议 id (query 传, 决定导入到哪个会议) * @param meetingId 会议 id (query 传, 决定导入到哪个会议)
*/ */
@Log(title = "参会人批量导入", businessType = BusinessType.IMPORT) @Log(title = "参会人批量导入", businessType = BusinessType.IMPORT)
@PostMapping("/importData") @PostMapping("/importData")
public AjaxResult importData(@RequestParam("file") MultipartFile file, 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()); ImportResult result = attendeeService.importFromExcel(file, meetingId, SecurityUtils.getUsername());
// 人员变化 → 立即重算劳务费 (有成功导入才需重算) // 人员变化 → 立即重算劳务费 (有成功导入才需重算)
@@ -270,8 +345,10 @@ public class BizMeetingAttendeeController extends BaseController {
@PostMapping("/uploadAgreements") @PostMapping("/uploadAgreements")
public AjaxResult uploadAgreements(@RequestParam("file") MultipartFile file, public AjaxResult uploadAgreements(@RequestParam("file") MultipartFile file,
@RequestParam("meetingId") Long meetingId) throws Exception { @RequestParam("meetingId") Long meetingId) throws Exception {
int updated = attendeeService.uploadAgreements(file, meetingId); String jobId = attendeeService.submitAgreementUpload(file, meetingId);
return success(updated); Map<String, Object> result = new HashMap<>();
result.put("jobId", jobId);
return success(result);
} }
/** /**
@@ -295,8 +372,73 @@ public class BizMeetingAttendeeController extends BaseController {
@PostMapping("/uploadExpertPhotos") @PostMapping("/uploadExpertPhotos")
public AjaxResult uploadExpertPhotos(@RequestParam("file") MultipartFile file, public AjaxResult uploadExpertPhotos(@RequestParam("file") MultipartFile file,
@RequestParam("meetingId") Long meetingId) throws Exception { @RequestParam("meetingId") Long meetingId) throws Exception {
int updated = attendeeService.uploadExpertPhotos(file, meetingId); String jobId = attendeeService.submitExpertPhotoUpload(file, meetingId);
return success(updated); Map<String, Object> result = new HashMap<>();
result.put("jobId", jobId);
return success(result);
}
/**
* 参会人名单写操作拦截: executor 在劳务提交后 (labor_audit_stage 非 NOT_SUBMITTED/REJECTED) 禁止新增/导入/编辑/删除.
* 退回 (REJECTED) 视为可重新编辑, 与前端 laborEditable 口径一致; manager/admin 不受限.
*/
private void checkAttendeeEditable(Long meetingId) {
if (meetingId == null) return;
String roleType = SecurityUtils.getLoginUser().getUser().getRoleType();
if (!"executor".equals(roleType)) return;
BizMeeting m = bizMeetingService.getById(meetingId);
if (m == null) return;
String stage = m.getLaborAuditStage();
if (stage != null && !"NOT_SUBMITTED".equals(stage) && !"REJECTED".equals(stage)) {
throw new ServiceException("劳务已提交, 不能修改参会人");
}
}
/** 当前登录用户是否为 sponsor (监督方只读, 敏感信息需脱敏) */
private boolean isSponsor() {
try {
return "sponsor".equals(SecurityUtils.getLoginUser().getUser().getRoleType());
} catch (Exception e) {
return false;
}
}
/** sponsor 脱敏: 姓名/手机号/银行卡号/账户名称/身份证号 (对齐 desensitizeHtml 口径) */
private void maskForSponsor(BizMeetingAttendee a) {
if (a == null) return;
a.setName(maskName(a.getName()));
a.setPhone(maskPhone(a.getPhone()));
a.setBankCard(maskBankCard(a.getBankCard()));
a.setAccountName(maskName(a.getAccountName()));
a.setIdCard(maskIdCard(a.getIdCard()));
}
/** 姓名脱敏: 保留首尾, 中间打码 (张三 → 张*, 李小明 → 李*明) */
private String maskName(String v) {
if (v == null) return null;
String s = v.trim();
if (s.isEmpty()) return v;
if (s.length() == 1) return "*";
if (s.length() == 2) return s.charAt(0) + "*";
return s.charAt(0) + "*".repeat(s.length() - 2) + s.charAt(s.length() - 1);
}
/** 手机号脱敏: 前3 + **** + 后4 */
private String maskPhone(String v) {
if (v == null || v.length() != 11) return v;
return v.substring(0, 3) + "****" + v.substring(7);
}
/** 银行卡号脱敏: 前4 + ******** + 后4 */
private String maskBankCard(String v) {
if (v == null || v.length() < 8) return v;
return v.substring(0, 4) + "********" + v.substring(v.length() - 4);
}
/** 身份证号脱敏: 前6 + ******** + 后4 */
private String maskIdCard(String v) {
if (v == null || v.length() != 18) return v;
return v.substring(0, 6) + "********" + v.substring(14);
} }
} }
@@ -1,5 +1,6 @@
package com.ruoyi.business.controller; package com.ruoyi.business.controller;
import java.text.SimpleDateFormat;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Calendar; import java.util.Calendar;
import java.util.Date; import java.util.Date;
@@ -73,11 +74,18 @@ public class BizMeetingController extends BaseController {
private PosterService posterService; private PosterService posterService;
@GetMapping("/list") @GetMapping("/list")
public TableDataInfo list(BizMeeting bizMeeting) { public TableDataInfo list(BizMeeting bizMeeting,
@RequestParam(value = "signedStatus", required = false) String signedStatus) {
Long uid = SecurityUtils.getUserId(); Long uid = SecurityUtils.getUserId();
String roleType = SecurityUtils.getLoginUser().getUser().getRoleType(); String roleType = SecurityUtils.getLoginUser().getUser().getRoleType();
// 医生/专家角色: 后端兜底只查"我参加的会议" (走 biz_meeting_attendee 中间表) // doctor 列表页"签署状态"下拉: unsigned=待签署 / signed=已签署 / 其他=不过滤
if ("doctor".equals(roleType) || "expert".equals(roleType)) { // URL 不会自动绑到 BaseEntity.params, controller 显式注入供 mapper 读取
if (signedStatus != null && !signedStatus.isEmpty()
&& ("unsigned".equals(signedStatus) || "signed".equals(signedStatus))) {
bizMeeting.getParams().put("signedStatus", signedStatus);
}
// 医生角色: 后端兜底只查"我参加的会议" (走 biz_meeting_attendee 中间表)
if ("doctor".equals(roleType)) {
bizMeeting.setUserId(uid); bizMeeting.setUserId(uid);
} }
// sponsor 数据权限: 只看"我的项目"下的会议 (MAIN 走 sponsor_org_id, SUB 走 sponsor_assign.monitor_user_id). // sponsor 数据权限: 只看"我的项目"下的会议 (MAIN 走 sponsor_org_id, SUB 走 sponsor_assign.monitor_user_id).
@@ -110,6 +118,10 @@ public class BizMeetingController extends BaseController {
else if ("manager".equals(roleType)) { else if ("manager".equals(roleType)) {
bizMeeting.getParams().put("managerCreateUserId", uid); bizMeeting.getParams().put("managerCreateUserId", uid);
} }
// 项目负责人(leader) 数据权限: 只看"本人负责的项目"下的会议 (project_id ∈ lead_user_id = 自己)
else if ("leader".equals(roleType)) {
bizMeeting.getParams().put("leaderUserId", uid);
}
startPage(); startPage();
List<BizMeeting> list = bizMeetingService.selectList(bizMeeting); List<BizMeeting> list = bizMeetingService.selectList(bizMeeting);
return getDataTable(list); return getDataTable(list);
@@ -187,8 +199,12 @@ public class BizMeetingController extends BaseController {
bizMeeting.setTotalPeriods(proj.getTotalSessions()); bizMeeting.setTotalPeriods(proj.getTotalSessions());
} }
bizMeeting.setProjectForm(proj.getProjectForm()); bizMeeting.setProjectForm(proj.getProjectForm());
// 会议时间区间必须在项目起止时间区间内 (闭区间; 项目未设起止则跳过对应边界)
validateMeetingWithinProject(bizMeeting, proj);
} }
} }
// 物理阶段初值: 建会即落 current_stage (NOT_STARTED / IN_PROGRESS), 否则 DB 默认 '0' 使项目列表「未执行」统计永远 0
bizMeeting.setCurrentStage(computePhysicalStage(bizMeeting));
int rows = bizMeetingService.insert(bizMeeting); int rows = bizMeetingService.insert(bizMeeting);
Long[] attendeeUserIds = bizMeeting.getAttendeeUserIds(); Long[] attendeeUserIds = bizMeeting.getAttendeeUserIds();
if (attendeeUserIds != null && attendeeUserIds.length > 0) { if (attendeeUserIds != null && attendeeUserIds.length > 0) {
@@ -198,6 +214,39 @@ public class BizMeetingController extends BaseController {
return toAjax(rows); return toAjax(rows);
} }
/** 会议时间区间必须在项目起止时间区间内 (闭区间, 等于允许); 项目未设起止则跳过对应边界. */
private void validateMeetingWithinProject(BizMeeting meeting, BizProject proj) {
SimpleDateFormat fmt = new SimpleDateFormat("yyyy-MM-dd HH:mm");
if (meeting.getStartTime() != null && proj.getStartTime() != null
&& meeting.getStartTime().before(proj.getStartTime())) {
throw new ServiceException("会议开始时间不能早于项目开始时间 " + fmt.format(proj.getStartTime()));
}
if (meeting.getEndTime() != null && proj.getEndTime() != null
&& meeting.getEndTime().after(proj.getEndTime())) {
throw new ServiceException("会议结束时间不能晚于项目结束时间 " + fmt.format(proj.getEndTime()));
}
}
/**
* 会议物理阶段 (含时间窗口): 事实推导 + start_time~end_time 窗口补 IN_PROGRESS.
* <p>用于建会/编辑时落 current_stage 初值 — 否则建会落 DB 默认 '0'、编辑时间后残留旧值,
* 项目列表「未执行」统计 (current_stage='NOT_STARTED') 会与会议列表「未执行」展示 (时间实时推导) 错位.
* <p>{@link StageDeriver#derivePhysicalStage} 只产 NOT_STARTED/RUNNING 不产 IN_PROGRESS
* (时间窗口态原由 scheduler 写), 此处补上窗口, 让 add/edit 一落地即准确.
*/
private String computePhysicalStage(BizMeeting m) {
String stage = stageDeriver.derivePhysicalStage(m);
// 仅「未执行 + 无材料」这一物理态需要补时间窗口: start_time 已到且 end_time 未到 → 执行中
if ("NOT_STARTED".equals(stage)) {
Date now = new Date();
if (m.getStartTime() != null && !m.getStartTime().after(now)
&& (m.getEndTime() == null || m.getEndTime().after(now))) {
stage = "IN_PROGRESS";
}
}
return stage;
}
@Log(title = "会议", businessType = BusinessType.UPDATE) @Log(title = "会议", businessType = BusinessType.UPDATE)
@PutMapping @PutMapping
public AjaxResult edit(@RequestBody BizMeeting bizMeeting) { public AjaxResult edit(@RequestBody BizMeeting bizMeeting) {
@@ -234,6 +283,26 @@ public class BizMeetingController extends BaseController {
BizProject proj = bizProjectService.getById(bizMeeting.getProjectId()); BizProject proj = bizProjectService.getById(bizMeeting.getProjectId());
if (proj != null) { if (proj != null) {
bizMeeting.setProjectForm(proj.getProjectForm()); bizMeeting.setProjectForm(proj.getProjectForm());
// 会议时间区间必须在项目起止时间区间内 (闭区间; 项目未设起止则跳过对应边界)
validateMeetingWithinProject(bizMeeting, proj);
}
}
// 编辑: 时间变更后重算物理阶段 (加载完整事实, 用请求中的新时间覆盖后再推导),
// 否则 start_time 推到未来后 current_stage 残留 IN_PROGRESS 等旧值, 项目列表「未执行」统计错位.
BizMeeting existing = bizMeetingService.getById(bizMeeting.getMeetingId());
if (existing != null) {
Date oldEndTime = existing.getEndTime();
if (bizMeeting.getStartTime() != null) existing.setStartTime(bizMeeting.getStartTime());
if (bizMeeting.getEndTime() != null) existing.setEndTime(bizMeeting.getEndTime());
bizMeeting.setCurrentStage(computePhysicalStage(existing));
// 结束时间变更 → 重算提交截止时间 (end_time + 项目天数), 让冻结判定跟着新时间走 (否则改时间后 deadline 仍是建会时的旧值, 冻结不触发).
// 已冻结 / 已退回 (锚点=退回时刻) 的不重算, 避免错误缩短整改窗口.
boolean endChanged = bizMeeting.getEndTime() != null && !bizMeeting.getEndTime().equals(oldEndTime);
boolean frozen = existing.getIsFrozen() != null && existing.getIsFrozen() == 1;
boolean rejected = "REJECTED".equals(existing.getLaborAuditStage())
|| "REJECTED".equals(existing.getServiceAuditStage());
if (endChanged && !frozen && !rejected) {
bizMeeting.setSubmitDeadline(computeSubmitDeadline(existing.getProjectId(), bizMeeting.getEndTime()));
} }
} }
int rows = bizMeetingService.updateByPrimaryKey(bizMeeting); int rows = bizMeetingService.updateByPrimaryKey(bizMeeting);
@@ -285,7 +354,7 @@ public class BizMeetingController extends BaseController {
* <li>校验 1: 当前用户是该会议执行方 (项目级归属, 强校验)</li> * <li>校验 1: 当前用户是该会议执行方 (项目级归属, 强校验)</li>
* <li>校验 2: 已执行 (is_executed=1) 且未冻结</li> * <li>校验 2: 已执行 (is_executed=1) 且未冻结</li>
* <li>校验 3: 所选轨 audit_stage ∈ {NOT_SUBMITTED, REJECTED}</li> * <li>校验 3: 所选轨 audit_stage ∈ {NOT_SUBMITTED, REJECTED}</li>
* <li>校验 4: 所选轨在 biz_meeting_material 至少 1 条 (劳务 L_* / 会务 M_*)</li> * <li>校验 4: 会务轨在 biz_meeting_material 至少 1 条 (M_*); 劳务轨 = 参会人员列表至少 1 人 (非上传文件)</li>
* </ul> * </ul>
* body: { "types": ["LABOR","SERVICE"] } * body: { "types": ["LABOR","SERVICE"] }
* 通过后该轨 → SUBMITTED (compliance_approved=0), 每轨记一条 audit_log. * 通过后该轨 → SUBMITTED (compliance_approved=0), 每轨记一条 audit_log.
@@ -310,8 +379,9 @@ public class BizMeetingController extends BaseController {
} }
List<BizMeetingMaterial> mats = bizMeetingMaterialService.selectByMeetingId(meetingId); List<BizMeetingMaterial> mats = bizMeetingMaterialService.selectByMeetingId(meetingId);
boolean hasLabor = mats.stream().anyMatch(x -> x.getSubType() != null && x.getSubType().startsWith("L_"));
boolean hasService = mats.stream().anyMatch(x -> x.getSubType() != null && x.getSubType().startsWith("M_")); boolean hasService = mats.stream().anyMatch(x -> x.getSubType() != null && x.getSubType().startsWith("M_"));
// 劳务材料 = 参会人员列表 (不是上传的文件), 提交门槛 = 至少 1 名参会人
boolean hasLabor = !attendeeService.selectByMeetingId(meetingId).isEmpty();
for (String type : types) { for (String type : types) {
String label = "LABOR".equals(type) ? "劳务材料" : "会务材料"; String label = "LABOR".equals(type) ? "劳务材料" : "会务材料";
@@ -319,8 +389,11 @@ public class BizMeetingController extends BaseController {
if (!"NOT_SUBMITTED".equals(stage) && !"REJECTED".equals(stage)) { if (!"NOT_SUBMITTED".equals(stage) && !"REJECTED".equals(stage)) {
throw new ServiceException(label + "当前阶段 (" + stage + ") 不允许提交"); throw new ServiceException(label + "当前阶段 (" + stage + ") 不允许提交");
} }
boolean has = "LABOR".equals(type) ? hasLabor : hasService; if ("LABOR".equals(type)) {
if (!has) throw new ServiceException(label + "至少上传一条"); if (!hasLabor) throw new ServiceException("请先添加参会人员 (劳务材料)");
} else if (!hasService) {
throw new ServiceException("会务材料至少上传一条");
}
} }
for (String type : types) { for (String type : types) {
@@ -572,8 +645,14 @@ public class BizMeetingController extends BaseController {
List<BizMeetingMaterial> existingMaterials = bizMeetingMaterialService.selectByMeetingId(meetingId); List<BizMeetingMaterial> existingMaterials = bizMeetingMaterialService.selectByMeetingId(meetingId);
boolean hasLv = existingMaterials != null && existingMaterials.stream().anyMatch(v -> "LV_PAYMENT".equals(v.getSubType()) && v.getOssUrl() != null && !v.getOssUrl().isEmpty()); boolean hasLv = existingMaterials != null && existingMaterials.stream().anyMatch(v -> "LV_PAYMENT".equals(v.getSubType()) && v.getOssUrl() != null && !v.getOssUrl().isEmpty());
boolean hasSv = existingMaterials != null && existingMaterials.stream().anyMatch(v -> "SV_PAYMENT".equals(v.getSubType()) && v.getOssUrl() != null && !v.getOssUrl().isEmpty()); boolean hasSv = existingMaterials != null && existingMaterials.stream().anyMatch(v -> "SV_PAYMENT".equals(v.getSubType()) && v.getOssUrl() != null && !v.getOssUrl().isEmpty());
if (!hasLv || !hasSv) { if (!hasLv && !hasSv) {
throw new ServiceException("请先上传付款凭证"); throw new ServiceException("请先上传劳务凭证与会务凭证");
}
if (!hasLv) {
throw new ServiceException("请先上传劳务凭证");
}
if (!hasSv) {
throw new ServiceException("请先上传会务凭证");
} }
m.setIsSettled(1); m.setIsSettled(1);
@@ -685,8 +764,12 @@ public class BizMeetingController extends BaseController {
log.setAuditResult(result); log.setAuditResult(result);
log.setOpinion(opinion); log.setOpinion(opinion);
log.setMaterialType(materialType); log.setMaterialType(materialType);
log.setCreateTime(new Date()); Date now = new Date();
log.setAuditTime(new Date()); log.setCreateTime(now);
log.setAuditTime(now);
// 本次动作所在轨「最近时间」记为 now, 供 deriveDisplay 按最近时间合并两轨 (4 列阶段快照)
if ("LABOR".equals(materialType)) m.setLaborAuditTime(now);
else if ("SERVICE".equals(materialType)) m.setServiceAuditTime(now);
log.setExecutorStage(stageDeriver.deriveDisplay("executor", m)); log.setExecutorStage(stageDeriver.deriveDisplay("executor", m));
log.setSponsorStage(stageDeriver.deriveDisplay("sponsor", m)); log.setSponsorStage(stageDeriver.deriveDisplay("sponsor", m));
log.setManagerStage(stageDeriver.deriveDisplay("manager", m)); log.setManagerStage(stageDeriver.deriveDisplay("manager", m));
@@ -4,14 +4,16 @@ import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import com.ruoyi.common.core.controller.BaseController; import com.ruoyi.common.core.controller.BaseController;
import com.ruoyi.common.core.domain.AjaxResult; import com.ruoyi.common.core.domain.AjaxResult;
import com.ruoyi.business.service.IBizMeetingInvoiceService;
import com.ruoyi.business.service.impl.InvoiceOcrService; import com.ruoyi.business.service.impl.InvoiceOcrService;
import com.ruoyi.common.utils.SecurityUtils;
/** /**
* 会议发票识别 Controller (v3) * 会议发票识别 Controller (v3)
* <p> * <p>
* v3 变更: * v3 变更:
* - 端点改为"提交即返回 SUBMITTED", OCR 在后台 ExecutorService 跑 * - 端点改为"提交即返回 SUBMITTED", OCR 在后台 ExecutorService 跑
* - 新增 isZip 字段: true → zip 路径(解压 → 重传 OSS → 写多行 invoice) * - zip 由后端按 ossUrl 后缀判断 (解压 → 重传 OSS → 写多行 invoice), 前端无需传 isZip
* - 新增 oldMaterialId 字段: 替换场景, 后端先清旧 invoice + material.amount=0 * - 新增 oldMaterialId 字段: 替换场景, 后端先清旧 invoice + material.amount=0
* - 不再走旧的同步 recognizeAndSave, 已被 submitRecognition 取代 * - 不再走旧的同步 recognizeAndSave, 已被 submitRecognition 取代
*/ */
@@ -23,14 +25,16 @@ public class BizMeetingInvoiceController extends BaseController
@Autowired @Autowired
private InvoiceOcrService invoiceOcrService; private InvoiceOcrService invoiceOcrService;
@Autowired
private IBizMeetingInvoiceService invoiceService;
/** /**
* 提交发票识别 (后台执行, 立即返回) * 提交发票识别 (后台执行, 立即返回)
* <p> * <p>
* body: { * body: {
* "materialId": 123, * "materialId": 123,
* "meetingId": 456, * "meetingId": 456,
* "ossUrl": "https://...", * "ossUrl": "https://...", // .zip 结尾走 zip 解压, 否则按单文件
* "isZip": false, // 新增
* "oldMaterialId": null // 新增, 替换时携带原 materialId * "oldMaterialId": null // 新增, 替换时携带原 materialId
* } * }
*/ */
@@ -41,19 +45,29 @@ public class BizMeetingInvoiceController extends BaseController
body.getMaterialId(), body.getMaterialId(),
body.getMeetingId(), body.getMeetingId(),
body.getOssUrl(), body.getOssUrl(),
body.isZip(), body.getOldMaterialId(),
body.getOldMaterialId()); SecurityUtils.getUserId());
return success(r); return success(r);
} }
/**
* 查询某会议下所有发票明细 (会务材料 tab 顶部「发票表」展示)
* <p>
* 返回 BizMeetingInvoice 列表, 含 materialSubType / materialFileName (join biz_meeting_material 带出),
* 供前端按所属材料类型渲染发票表.
*/
@GetMapping("/list")
public AjaxResult list(@RequestParam("meetingId") Long meetingId)
{
return success(invoiceService.selectByMeetingId(meetingId));
}
/** request body wrapper */ /** request body wrapper */
public static class RecognizeBody public static class RecognizeBody
{ {
private Long materialId; private Long materialId;
private Long meetingId; private Long meetingId;
private String ossUrl; private String ossUrl;
/** v3 新增: true=zip 路径(解压遍历), false=单文件路径 */
private boolean isZip;
/** v3 新增: 替换场景携带的旧 materialId, 后端先 DELETE invoice WHERE material_id=old + material.amount=0 */ /** v3 新增: 替换场景携带的旧 materialId, 后端先 DELETE invoice WHERE material_id=old + material.amount=0 */
private Long oldMaterialId; private Long oldMaterialId;
@@ -63,8 +77,6 @@ public class BizMeetingInvoiceController extends BaseController
public void setMeetingId(Long meetingId) { this.meetingId = meetingId; } public void setMeetingId(Long meetingId) { this.meetingId = meetingId; }
public String getOssUrl() { return ossUrl; } public String getOssUrl() { return ossUrl; }
public void setOssUrl(String ossUrl) { this.ossUrl = ossUrl; } public void setOssUrl(String ossUrl) { this.ossUrl = ossUrl; }
public boolean isZip() { return isZip; }
public void setZip(boolean zip) { isZip = zip; }
public Long getOldMaterialId() { return oldMaterialId; } public Long getOldMaterialId() { return oldMaterialId; }
public void setOldMaterialId(Long oldMaterialId) { this.oldMaterialId = oldMaterialId; } public void setOldMaterialId(Long oldMaterialId) { this.oldMaterialId = oldMaterialId; }
} }
@@ -1,7 +1,9 @@
package com.ruoyi.business.controller; package com.ruoyi.business.controller;
import java.io.InputStream; import java.io.InputStream;
import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile; import org.springframework.web.multipart.MultipartFile;
@@ -92,12 +94,12 @@ public class BizMeetingMaterialController extends BaseController {
/** /**
* 扫码拍照回传 (公开端点, ry-h5 手机端拍照直传 OSS 后回传 URL). * 扫码拍照回传 (公开端点, ry-h5 手机端拍照直传 OSS 后回传 URL).
* <p> * <p>
* body: { meetingId, subType, ossUrl }. 后端白名单 subType + 会议存在校验. * body: { meetingId, subType, ossUrls[], extraOssUrls[] }. 后端白名单 subType + 会议存在校验.
* 照片类 NON_OCR, 不影响会议费用. * 签到表连拍多张传多个 URL, 前后全景传单个. 照片类 NON_OCR, 不影响会议费用.
*/ */
@PostMapping("/cameraUpload") @PostMapping("/cameraUpload")
public AjaxResult cameraUpload(@RequestBody BizMeetingMaterial body) { public AjaxResult cameraUpload(@RequestBody BizMeetingMaterial body) {
bizMeetingMaterialService.upsertFromCamera(body.getMeetingId(), body.getSubType(), body.getOssUrl(), body.getExtraOssUrl()); bizMeetingMaterialService.upsertFromCamera(body.getMeetingId(), body.getSubType(), body.getOssUrls(), body.getExtraOssUrls());
return success(); return success();
} }
@@ -197,12 +199,25 @@ public class BizMeetingMaterialController extends BaseController {
@PostMapping("/uploadServiceMaterials") @PostMapping("/uploadServiceMaterials")
public AjaxResult uploadServiceMaterials(@RequestParam("file") MultipartFile file, public AjaxResult uploadServiceMaterials(@RequestParam("file") MultipartFile file,
@RequestParam("meetingId") Long meetingId) throws Exception { @RequestParam("meetingId") Long meetingId) throws Exception {
int updated = bizMeetingMaterialService.uploadServiceMaterials(file, meetingId); String jobId = bizMeetingMaterialService.submitServiceMaterialsUpload(file, meetingId);
// 仅当真有材料被替换 (内容变化, updated>0) 才立即重算会务费; 全量未变不置"统计中" Map<String, Object> result = new HashMap<>();
if (updated > 0) { result.put("jobId", jobId);
bizMeetingService.recomputeMeetingFee(meetingId); return success(result);
} }
return success(updated);
/**
* 电子签到表 (L_ESIGN_IN) 多文件/zip 上传 (同步, 追加式保存).
* <p>
* file 为单个 Excel (xls/xlsx/csv) 或 zip (后台解压并分析其中所有 Excel).
* 后端逐个解析脱敏 + 直传 OSS, 单行 L_ESIGN_IN 存多文件 (oss_url 存 JSON 数组).
* 已有文件保留, 新文件追加到末尾. 任一文件解析失败 → 整体失败. 仅材料可编辑角色可上传.
*/
@Log(title = "电子签到表上传", businessType = BusinessType.UPDATE)
@PostMapping("/uploadEsignIn")
public AjaxResult uploadEsignIn(@RequestParam("file") MultipartFile file,
@RequestParam("meetingId") Long meetingId) throws Exception {
Map<String, Object> result = bizMeetingMaterialService.uploadEsignIn(file, meetingId);
return success(result);
} }
/** request body for batch download endpoints */ /** request body for batch download endpoints */
@@ -31,26 +31,37 @@ public class BizMeetingStageStatsController extends BaseController
private SysUserMapper sysUserMapper; private SysUserMapper sysUserMapper;
/** /**
* 按 current_stage 分组计数, 做 sponsor 数据权限隔离 (MAIN=本支持单位全部, SUB=自己负责的项目). * 按 current_stage 分组计数, 做 sponsor / leader 数据权限隔离 (sponsor MAIN=本支持单位全部, SUB=自己负责的项目; leader=本人负责的项目).
* 返回 { stage: cnt, ... }, 前端据此算: 未执行=NOT_STARTED, 已执行=排除 NOT_STARTED/IN_PROGRESS 的其余 stage (含 FROZEN). * 返回 { stage: cnt, ... }, 前端据此算: 未执行=NOT_STARTED, 已执行=排除 NOT_STARTED/IN_PROGRESS 的其余 stage (含 FROZEN).
* 其他角色的统计端点单独实现, 此端点非 sponsor 调用返回空 map (不泄露全量数据). * 其他角色的统计端点单独实现, 此端点非 sponsor/leader 调用返回空 map (不泄露全量数据).
*/ */
@GetMapping("/stageStats") @GetMapping("/stageStats")
public AjaxResult stageStats() { public AjaxResult stageStats() {
Map<String, Long> result = new HashMap<>(); Map<String, Long> result = new HashMap<>();
if (!"sponsor".equals(SecurityUtils.getLoginUser().getUser().getRoleType())) { String roleType = SecurityUtils.getLoginUser().getUser().getRoleType();
if (!"sponsor".equals(roleType) && !"leader".equals(roleType)) {
return success(result); return success(result);
} }
BizMeeting q = new BizMeeting(); BizMeeting q = new BizMeeting();
Long uid = SecurityUtils.getUserId(); Long uid = SecurityUtils.getUserId();
if ("leader".equals(roleType)) {
// 项目负责人: 统计范围 = 本人负责的项目 (lead_user_id = 自己)
q.getParams().put("leaderUserId", uid);
return success(doStats(q));
}
SysUser current = uid == null ? null : sysUserMapper.selectUserById(uid); SysUser current = uid == null ? null : sysUserMapper.selectUserById(uid);
if (current != null && "SUB".equals(current.getAccountType())) { if (current != null && "SUB".equals(current.getAccountType())) {
q.getParams().put("monitorUserId", uid); q.getParams().put("monitorUserId", uid);
} else { } else {
q.getParams().put("sponsorAdminUserId", uid); q.getParams().put("sponsorAdminUserId", uid);
} }
return success(doStats(q));
}
/** 按 current_stage 分组计数, 结果 key 大小写随 JDBC 驱动, 做大小写不敏感匹配, 避免"统计恒 0". */
private Map<String, Long> doStats(BizMeeting q) {
Map<String, Long> result = new HashMap<>();
for (Map<String, Object> r : bizMeetingService.selectStageStats(q)) { for (Map<String, Object> r : bizMeetingService.selectStageStats(q)) {
// resultType=map 的 key 大小写随 JDBC 驱动, 做大小写不敏感匹配, 避免"统计恒 0"
String stage = null; String stage = null;
long cnt = 0L; long cnt = 0L;
for (Map.Entry<String, Object> e : r.entrySet()) { for (Map.Entry<String, Object> e : r.entrySet()) {
@@ -66,6 +77,6 @@ public class BizMeetingStageStatsController extends BaseController
result.put(stage, cnt); result.put(stage, cnt);
} }
} }
return success(result); return result;
} }
} }
@@ -69,14 +69,15 @@ public class BizOrgController extends BaseController {
} }
/** /**
* 当前登录 sponsor 的所属公司 (供 /sponsor/account 页面回显) * 当前登录账号的所属公司 (sponsor + executor 共用, 供 /sponsor/account + /executor/account 页面回显)
* 返回 { orgId, orgName, isOwner: 1|0 } * 根据 sys_user.role_type 自动选 sponsor/executor
* 主账号 isOwner=1 可改名, 子账号 isOwner=0 只读 * 返回 { orgId, orgName, orgType, isOwner: 1|0 }
* 注: 账号信息页只读展示, 主账号也不可改单位名称
*/ */
@GetMapping("/myCompany") @GetMapping("/myCompany")
public AjaxResult myCompany() { public AjaxResult myCompany() {
Long uid = SecurityUtils.getUserId(); Long uid = SecurityUtils.getUserId();
Map<String, Object> data = bizOrgService.selectMySponsorCompany(uid); Map<String, Object> data = bizOrgService.selectMyCompany(uid);
return success(data); return success(data);
} }
@@ -48,7 +48,7 @@ public class BizPersonController extends BaseController
@GetMapping("/list") @GetMapping("/list")
public TableDataInfo list(BizPerson bizPerson) public TableDataInfo list(BizPerson bizPerson)
{ {
// 当前登录用户是 MAIN 业务账号 → 自动注入 subUserIds, 隔离到本主账号子账号对应人员 // 当前登录用户是 MAIN 业务账号 → 自动注入 subUserIds, 隔离到本主账号自己 + 其子账号对应人员
Long currentUserId = getUserId(); Long currentUserId = getUserId();
SysUser currentUser = currentUserId == null ? null : sysUserMapper.selectUserById(currentUserId); SysUser currentUser = currentUserId == null ? null : sysUserMapper.selectUserById(currentUserId);
if (currentUser != null if (currentUser != null
@@ -56,10 +56,14 @@ public class BizPersonController extends BaseController
&& currentUser.getRoleType() != null && currentUser.getRoleType() != null
&& BUSINESS_MAIN_ROLES.contains(currentUser.getRoleType())) { && BUSINESS_MAIN_ROLES.contains(currentUser.getRoleType())) {
List<Long> subUserIds = sysUserMapper.selectUserIdsByParent(currentUserId); List<Long> subUserIds = sysUserMapper.selectUserIdsByParent(currentUserId);
// 即使没子账号也强制注入, 避免漏出 sys_user.parent_user_id=NULL 的孤立 person // 隔离范围 = 主账号自己 + 子账号. 主账号自己也要能看到自己的 biz_person
bizPerson.getParams().put("subUserIds", subUserIds == null || subUserIds.isEmpty() // (公益支持/项目执行意向回显 工作单位/部门/职务 用), 同时仍不漏出 parent_user_id=NULL 的孤立 person
? java.util.Collections.singletonList(-1L) List<Long> visibleUserIds = new ArrayList<>();
: subUserIds); visibleUserIds.add(currentUserId);
if (subUserIds != null) {
visibleUserIds.addAll(subUserIds);
}
bizPerson.getParams().put("subUserIds", visibleUserIds);
} }
startPage(); startPage();
List<BizPerson> list = bizPersonService.selectList(bizPerson); List<BizPerson> list = bizPersonService.selectList(bizPerson);
@@ -9,6 +9,7 @@ import java.util.Map;
import java.util.Objects; import java.util.Objects;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import jakarta.servlet.http.HttpServletResponse;
import com.ruoyi.common.annotation.Log; import com.ruoyi.common.annotation.Log;
import com.ruoyi.common.core.controller.BaseController; import com.ruoyi.common.core.controller.BaseController;
import com.ruoyi.common.core.domain.AjaxResult; import com.ruoyi.common.core.domain.AjaxResult;
@@ -16,14 +17,21 @@ import com.ruoyi.common.core.page.TableDataInfo;
import com.ruoyi.common.enums.BusinessType; import com.ruoyi.common.enums.BusinessType;
import com.ruoyi.common.exception.ServiceException; import com.ruoyi.common.exception.ServiceException;
import com.ruoyi.common.utils.SecurityUtils; import com.ruoyi.common.utils.SecurityUtils;
import com.ruoyi.common.utils.poi.ExcelUtil;
import com.ruoyi.business.domain.BizExpert;
import com.ruoyi.business.domain.BizExecutionIntent; import com.ruoyi.business.domain.BizExecutionIntent;
import com.ruoyi.business.domain.BizOrg; import com.ruoyi.business.domain.BizOrg;
import com.ruoyi.business.domain.BizProject; import com.ruoyi.business.domain.BizProject;
import com.ruoyi.business.domain.BizProjectAssign; import com.ruoyi.business.domain.BizProjectAssign;
import com.ruoyi.business.domain.BizProjectRating; import com.ruoyi.business.domain.BizProjectRating;
import com.ruoyi.business.domain.vo.BizProjectExportVo;
import com.ruoyi.business.domain.vo.BizProjectRatingExportVo;
import com.ruoyi.business.domain.vo.BizSignupExpertExportVo;
import com.ruoyi.business.service.IBizExpertService;
import com.ruoyi.business.service.IBizOrgService; import com.ruoyi.business.service.IBizOrgService;
import com.ruoyi.business.service.IBizProjectService; import com.ruoyi.business.service.IBizProjectService;
import com.ruoyi.business.service.IBizExecutionIntentService; import com.ruoyi.business.service.IBizExecutionIntentService;
import com.ruoyi.business.service.IBizMeetingService;
import com.ruoyi.business.domain.BizProjectSponsorAssign; import com.ruoyi.business.domain.BizProjectSponsorAssign;
import com.ruoyi.business.domain.BizProjectExecutorAssign; import com.ruoyi.business.domain.BizProjectExecutorAssign;
import com.ruoyi.business.notify.BizNotifyService; import com.ruoyi.business.notify.BizNotifyService;
@@ -66,6 +74,10 @@ public class BizProjectController extends BaseController
private SysUserMapper sysUserMapper; private SysUserMapper sysUserMapper;
@Autowired @Autowired
private IBizOrgService bizOrgService; private IBizOrgService bizOrgService;
@Autowired
private IBizExpertService bizExpertService;
@Autowired
private IBizMeetingService bizMeetingService;
/** /**
* 我报名的项目 (当前用户在 biz_execution_intent 里有意向的项目) * 我报名的项目 (当前用户在 biz_execution_intent 里有意向的项目)
@@ -97,10 +109,12 @@ public class BizProjectController extends BaseController
@GetMapping("/list") @GetMapping("/list")
public TableDataInfo list(BizProject bizProject) public TableDataInfo list(BizProject bizProject)
{ {
// 合规人员(manager): 只看本人创建的项目 (create_user_id = 当前用户); admin 不限制 // 数据权限: manager 只看本人创建(create_user_id), leader 只看本人负责(lead_user_id), admin 不限制
String roleType = SecurityUtils.getLoginUser().getUser().getRoleType(); String roleType = SecurityUtils.getLoginUser().getUser().getRoleType();
if ("manager".equals(roleType)) { if ("manager".equals(roleType)) {
bizProject.getParams().put("createUserId", SecurityUtils.getUserId()); bizProject.getParams().put("createUserId", SecurityUtils.getUserId());
} else if ("leader".equals(roleType)) {
bizProject.getParams().put("leaderUserId", SecurityUtils.getUserId());
} }
startPage(); startPage();
List<BizProject> list = bizProjectService.selectList(bizProject); List<BizProject> list = bizProjectService.selectList(bizProject);
@@ -183,6 +197,7 @@ public class BizProjectController extends BaseController
List<BizProject> list = bizProjectService.selectExecutorList(bizProject); List<BizProject> list = bizProjectService.selectExecutorList(bizProject);
return getDataTable(list); return getDataTable(list);
} }
@GetMapping("/{projectId}") @GetMapping("/{projectId}")
public AjaxResult getInfo(@PathVariable("projectId") Long projectId) public AjaxResult getInfo(@PathVariable("projectId") Long projectId)
{ {
@@ -218,7 +233,31 @@ public class BizProjectController extends BaseController
@PutMapping @PutMapping
public AjaxResult edit(@RequestBody BizProject bizProject) public AjaxResult edit(@RequestBody BizProject bizProject)
{ {
return toAjax(bizProjectService.updateByPrimaryKey(bizProject)); BizProject old = bizProject.getProjectId() != null ? bizProjectService.getById(bizProject.getProjectId()) : null;
// submit_deadline_days 变更 → 级联重算该项目存量「从未退回、且仍有轨未提交」会议的提交截止时间.
// 建会时 deadline 一次性落库, 改项目天数本不影响已有会议; 这里补上级联, 避免"改了天数会议却不按新天数冻结".
Integer newDays = bizProject.getSubmitDeadlineDays();
boolean daysChanged = bizProject.getProjectId() != null && newDays != null
&& !Objects.equals(old == null ? null : old.getSubmitDeadlineDays(), newDays);
// sponsor_org_id 变更 (合规把项目分配给支持方) → 通知新支持方主账号 (待办: 去查看并分配监察员)
Long newSponsorOrgId = bizProject.getSponsorOrgId();
boolean sponsorChanged = bizProject.getProjectId() != null && newSponsorOrgId != null
&& !Objects.equals(old == null ? null : old.getSponsorOrgId(), newSponsorOrgId);
int rows = bizProjectService.updateByPrimaryKey(bizProject);
if (daysChanged)
{
bizMeetingService.recomputeSubmitDeadlinesByProject(bizProject.getProjectId(), newDays);
}
if (sponsorChanged)
{
Long sponsorMainUserId = resolveMainUserIdByOrgId(newSponsorOrgId);
bizNotifyService.projectAssignedToSupportOrg(sponsorMainUserId, bizProject.getProjectId(),
old == null ? null : old.getProjectName());
}
return toAjax(rows);
} }
@Log(title = "项目", businessType = BusinessType.DELETE) @Log(title = "项目", businessType = BusinessType.DELETE)
@DeleteMapping("/{ids}") @DeleteMapping("/{ids}")
@@ -346,7 +385,7 @@ public class BizProjectController extends BaseController
if (projectId == null) throw new ServiceException("项目不存在"); if (projectId == null) throw new ServiceException("项目不存在");
BizProject p = bizProjectService.getById(projectId); BizProject p = bizProjectService.getById(projectId);
if (p == null) throw new ServiceException("项目不存在"); if (p == null) throw new ServiceException("项目不存在");
if ("1".equals(p.getIsFinished())) throw new ServiceException("已结题项目不能分配"); if ("Y".equals(p.getIsFinished())) throw new ServiceException("已结题项目不能分配");
return p; return p;
} }
@@ -519,7 +558,7 @@ public class BizProjectController extends BaseController
return error("projectId 必填"); return error("projectId 必填");
} }
// 已结题项目不能分配 // 已结题项目不能分配
requireAssignableProject(parseProjectId(body.getProjectId())); requireAssignableProject(body.getProjectId());
java.util.List<Long> sids = body.getStaffUserIds(); java.util.List<Long> sids = body.getStaffUserIds();
if (sids == null || sids.isEmpty()) { if (sids == null || sids.isEmpty()) {
// 向后兼容: 单值 staffUserId // 向后兼容: 单值 staffUserId
@@ -539,11 +578,13 @@ public class BizProjectController extends BaseController
/** /**
* 查询项目已分配的执行人列表 (供前端 dialog 重开时回显) * 查询项目已分配的执行人列表 (供前端 dialog 重开时回显)
* GET /business/project/{projectId}/executorAssigns * GET /business/project/{projectId}/executorAssigns
* 按当前登录执行方的 executor_org_id 隔离, 只回显本执行方自己的执行人 (避免串读别家执行人显示成裸 user_id)
*/ */
@GetMapping("/{projectId}/executorAssigns") @GetMapping("/{projectId}/executorAssigns")
public AjaxResult listExecutorAssigns(@PathVariable("projectId") String projectId) public AjaxResult listExecutorAssigns(@PathVariable("projectId") Long projectId)
{ {
return success(bizProjectExecutorAssignService.listByProjectId(projectId)); Long executorOrgId = bizOrgService.selectOrgIdByUserId(SecurityUtils.getUserId());
return success(bizProjectExecutorAssignService.listByProjectId(projectId, executorOrgId));
} }
/** /**
@@ -563,6 +604,29 @@ public class BizProjectController extends BaseController
return getDataTable(list); return getDataTable(list);
} }
/**
* 结题前置检查: 查若干项目里还有未结算会议(is_settled<>1) 的项目.
* GET /business/project/meetingSettlement?projectIds=1,2,3
* 返回 [{projectId, projectNo, unsettledCount}], 仅含 unsettledCount>0
* (0 会议或全部已结算不返回). 前端结题弹警告据此判定.
* 接收逗号分隔字符串 (跨 axios 数组序列化版本安全), 内部 split 转 List<Long>.
*/
@GetMapping("/meetingSettlement")
public AjaxResult checkMeetingSettled(@RequestParam("projectIds") String projectIdsStr)
{
if (projectIdsStr == null || projectIdsStr.trim().isEmpty()) {
return success(new ArrayList<>());
}
List<Long> ids = new ArrayList<>();
for (String s : projectIdsStr.split(",")) {
String t = s.trim();
if (!t.isEmpty()) {
try { ids.add(Long.parseLong(t)); } catch (NumberFormatException ignore) {}
}
}
return success(bizProjectService.checkMeetingSettled(ids));
}
/** /**
* 支持方批量分配监察员 (多个项目, 同一个监察员 + 同一份说明) * 支持方批量分配监察员 (多个项目, 同一个监察员 + 同一份说明)
* POST /business/project/sponsorAssignBatch * POST /business/project/sponsorAssignBatch
@@ -616,4 +680,153 @@ public class BizProjectController extends BaseController
ajax.put("errors", errors); ajax.put("errors", errors);
return ajax; return ajax;
} }
// ============== 导出 (后端 ExcelUtil 写 .xlsx, 跟随当前筛选) ==============
/**
* 导出项目列表 (跟随当前筛选条件, 与 list() 同一口径, 无 startPage 全量导出)
* POST /business/project/export
* admin 导出全部; manager 只导出本人创建的 (与 list 一致)
*/
@Log(title = "导出项目", businessType = BusinessType.EXPORT)
@PostMapping("/export")
public void export(HttpServletResponse response, BizProject bizProject)
{
applyManagerScope(bizProject);
List<BizProject> list = bizProjectService.selectList(bizProject);
List<BizProjectExportVo> exportList = new ArrayList<>(list.size());
for (BizProject p : list) {
BizProjectExportVo v = new BizProjectExportVo();
v.setProjectNo(p.getProjectNo());
v.setProjectName(p.getProjectName());
v.setTotalSessions(p.getTotalSessions());
v.setDoneSessions(p.getDoneSessions());
v.setTodoSessions(p.getTodoSessions());
v.setTotalAmount(p.getTotalAmount());
v.setAvailableAmount(p.getAvailableAmount());
v.setPaidLaborAmount(p.getPaidLaborAmount());
v.setPaidMeetingAmount(p.getPaidMeetingAmount());
v.setManagerScore(p.getManagerScore());
v.setSponsorScore(p.getSponsorScore());
v.setSponsorOrgName(p.getSponsorOrgName());
v.setExecOrgNames(p.getExecOrgNames());
v.setProjectForm(p.getProjectForm());
v.setIsFinished("Y".equals(p.getIsFinished()) ? "已结题" : "未结题");
v.setStartTime(p.getStartTime());
v.setEndTime(p.getEndTime());
exportList.add(v);
}
ExcelUtil<BizProjectExportVo> util = new ExcelUtil<>(BizProjectExportVo.class);
util.exportExcel(response, exportList, "项目列表");
}
/**
* 导出项目评价 (4 维度评分明细, 跟随当前筛选的项目范围)
* POST /business/project/ratingExport
* 先按筛选取项目 → 收集 projectId → 查 biz_project_rating 明细
*/
@Log(title = "导出项目评价", businessType = BusinessType.EXPORT)
@PostMapping("/ratingExport")
public void exportRating(HttpServletResponse response, BizProject bizProject)
{
applyManagerScope(bizProject);
List<BizProject> projects = bizProjectService.selectList(bizProject);
// projectId → project 反查表 (评分明细表 project_no/project_name 可能为空, 用主表补齐)
Map<Long, BizProject> projectById = new HashMap<>();
List<Long> projectIds = new ArrayList<>();
for (BizProject p : projects) {
if (p.getProjectId() != null) {
projectIds.add(p.getProjectId());
projectById.put(p.getProjectId(), p);
}
}
List<BizProjectRating> ratings = new ArrayList<>();
if (!projectIds.isEmpty()) {
BizProjectRating q = new BizProjectRating();
q.getParams().put("projectIds", projectIds);
ratings = bizProjectRatingService.selectList(q);
}
List<BizProjectRatingExportVo> exportList = new ArrayList<>(ratings.size());
for (BizProjectRating r : ratings) {
BizProject p = projectById.get(r.getProjectId());
BizProjectRatingExportVo v = new BizProjectRatingExportVo();
v.setProjectNo(p != null ? p.getProjectNo() : r.getProjectNo());
v.setProjectName(p != null ? p.getProjectName() : r.getProjectName());
v.setRaterRole("sponsor".equals(r.getRaterRole()) ? "支持方" : "合规管理员");
v.setQualityScore(r.getQualityScore());
v.setResponseScore(r.getResponseScore());
v.setCooperationScore(r.getCooperationScore());
v.setComplianceScore(r.getComplianceScore());
v.setCreateTime(r.getCreateTime());
exportList.add(v);
}
ExcelUtil<BizProjectRatingExportVo> util = new ExcelUtil<>(BizProjectRatingExportVo.class);
util.exportExcel(response, exportList, "项目评价");
}
/**
* 导出已报名专家 (跟随当前筛选的项目范围, manager + admin)
* POST /business/project/signupExpertExport
* 先按筛选取项目 → 收集 projectNo → 查 biz_execution_intent 报名专家
*/
@Log(title = "导出报名专家", businessType = BusinessType.EXPORT)
@PostMapping("/signupExpertExport")
public void exportSignupExpert(HttpServletResponse response, BizProject bizProject)
{
String roleType = SecurityUtils.getLoginUser().getUser().getRoleType();
if (!"manager".equals(roleType) && !"admin".equals(roleType)) {
throw new ServiceException("只有合规管理员或后台管理员可导出报名专家");
}
applyManagerScope(bizProject);
List<BizProject> projects = bizProjectService.selectList(bizProject);
List<String> projectNos = new ArrayList<>();
for (BizProject p : projects) {
if (p.getProjectNo() != null && !p.getProjectNo().isEmpty()) projectNos.add(p.getProjectNo());
}
List<BizExecutionIntent> intents = new ArrayList<>();
if (!projectNos.isEmpty()) {
BizExecutionIntent q = new BizExecutionIntent();
q.getParams().put("projectNos", projectNos);
// 已报名专家导出: 唯一一处导出"含幽灵占位(del_flag='1')"的报名记录, 不标记哪些是幽灵
intents = bizExecutionIntentService.selectListIncludingDeleted(q);
}
List<BizSignupExpertExportVo> exportList = new ArrayList<>(intents.size());
for (BizExecutionIntent e : intents) {
BizSignupExpertExportVo v = new BizSignupExpertExportVo();
v.setProjectNo(e.getProjectNo());
v.setProjectName(e.getProjectName());
v.setName(e.getName());
v.setPhone(e.getPhone());
v.setCreateTime(e.getCreateTime());
// 医生档案: 报名专家 userId → biz_expert (科室/医院/职称 报名表常为空, 从档案兜底补全)
BizExpert expert = e.getUserId() != null ? bizExpertService.getByUserId(e.getUserId()) : null;
String department = e.getDepartment();
String workUnit = e.getWorkUnit();
String position = e.getPosition();
if (expert != null) {
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.setDepartment(department);
v.setWorkUnit(workUnit);
v.setPosition(position);
exportList.add(v);
}
ExcelUtil<BizSignupExpertExportVo> util = new ExcelUtil<>(BizSignupExpertExportVo.class);
util.exportExcel(response, exportList, "报名专家");
}
/** 与 list() 一致的导出数据范围: manager 只看本人创建的项目; 勾选时按 projectIds 过滤 */
private void applyManagerScope(BizProject bizProject)
{
String roleType = SecurityUtils.getLoginUser().getUser().getRoleType();
if ("manager".equals(roleType)) {
bizProject.getParams().put("createUserId", SecurityUtils.getUserId());
}
// 勾选的项目: 前端勾选时传 projectIds, 转 params.projectIds 做 IN 过滤
if (bizProject.getProjectIds() != null && !bizProject.getProjectIds().isEmpty()) {
bizProject.getParams().put("projectIds", bizProject.getProjectIds());
}
}
} }
@@ -3,24 +3,33 @@ package com.ruoyi.business.controller;
import java.util.HashMap; import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.net.URLEncoder;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.*;
import org.springframework.web.bind.annotation.PathVariable; import org.springframework.core.env.Environment;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import com.ruoyi.common.core.controller.BaseController; import com.ruoyi.common.core.controller.BaseController;
import com.ruoyi.common.core.domain.AjaxResult; import com.ruoyi.common.core.domain.AjaxResult;
import com.ruoyi.common.core.page.TableDataInfo;
import com.ruoyi.common.utils.http.HttpUtils;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import com.ruoyi.business.domain.BizArticle; import com.ruoyi.business.domain.BizArticle;
import com.ruoyi.business.domain.BizProject; import com.ruoyi.business.domain.BizProject;
import com.ruoyi.business.domain.BizSupportLetter; import com.ruoyi.business.domain.BizSupportLetter;
import com.ruoyi.business.domain.BizInvitation; import com.ruoyi.business.domain.BizInvitation;
import com.ruoyi.business.domain.BizProjectPlan; import com.ruoyi.business.domain.BizProjectPlan;
import com.ruoyi.business.domain.BizInvite;
import com.ruoyi.business.domain.BizInviteRecipient;
import com.ruoyi.business.service.IBizArticleService; import com.ruoyi.business.service.IBizArticleService;
import com.ruoyi.business.service.IBizProjectService; import com.ruoyi.business.service.IBizProjectService;
import com.ruoyi.business.service.IBizSupportLetterService; import com.ruoyi.business.service.IBizSupportLetterService;
import com.ruoyi.business.service.IBizInvitationService; import com.ruoyi.business.service.IBizInvitationService;
import com.ruoyi.business.service.IBizInviteService;
import com.ruoyi.business.service.IBizProjectPlanService; import com.ruoyi.business.service.IBizProjectPlanService;
import com.ruoyi.business.service.IBizSpecialPlanService; import com.ruoyi.business.service.IBizSpecialPlanService;
import com.ruoyi.business.service.WxShareService;
import com.ruoyi.business.domain.BizSpecialPlan; import com.ruoyi.business.domain.BizSpecialPlan;
/** /**
@@ -33,9 +42,15 @@ public class BizPublicController extends BaseController {
@Autowired private IBizProjectService projectService; @Autowired private IBizProjectService projectService;
@Autowired private IBizSupportLetterService supportLetterService; @Autowired private IBizSupportLetterService supportLetterService;
@Autowired private IBizInvitationService invitationService; @Autowired private IBizInvitationService invitationService;
@Autowired private IBizInviteService inviteService;
@Autowired private IBizProjectPlanService projectPlanService; @Autowired private IBizProjectPlanService projectPlanService;
@Autowired private IBizArticleService articleService; @Autowired private IBizArticleService articleService;
@Autowired private IBizSpecialPlanService specialPlanService; @Autowired private IBizSpecialPlanService specialPlanService;
@Autowired private WxShareService wxShareService;
@Autowired private Environment environment;
private static final Logger log = LoggerFactory.getLogger(BizPublicController.class);
private final ObjectMapper objectMapper = new ObjectMapper();
/** /**
* 按类型获取已发布的协议/政策 (供注册页底部链接打开) * 按类型获取已发布的协议/政策 (供注册页底部链接打开)
@@ -71,7 +86,7 @@ public class BizPublicController extends BaseController {
public AjaxResult index() { public AjaxResult index() {
Map<String, Object> map = new HashMap<>(); Map<String, Object> map = new HashMap<>();
BizProject publishedQuery = new BizProject(); BizProject publishedQuery = new BizProject();
publishedQuery.setIsPublished("1"); publishedQuery.setIsPublished("Y");
List<BizProject> announcements = projectService.selectList(publishedQuery); List<BizProject> announcements = projectService.selectList(publishedQuery);
List<BizProjectPlan> plans = projectPlanService.selectList(new BizProjectPlan()); List<BizProjectPlan> plans = projectPlanService.selectList(new BizProjectPlan());
map.put("announcements", announcements); map.put("announcements", announcements);
@@ -81,10 +96,11 @@ public class BizPublicController extends BaseController {
} }
@GetMapping("/announcements") @GetMapping("/announcements")
public AjaxResult announcements() { public TableDataInfo announcements(@RequestParam(required = false) String keyword)
// 公示列表按发布时间倒序 (后端排序), 只返回已发布项目 {
List<BizProject> list = projectService.selectPublicAnnouncements(); // 公示列表: 只查公示页展示列 (不跑关联子查询), PageHelper 分页, keyword 按项目名模糊
return success(list); startPage();
return getDataTable(projectService.selectPublicAnnouncements(keyword));
} }
@GetMapping("/project/{projectId}") @GetMapping("/project/{projectId}")
@@ -92,6 +108,69 @@ public class BizPublicController extends BaseController {
return success(projectService.getById(projectId)); return success(projectService.getById(projectId));
} }
/**
* 招标公告查询: 按项目编号查外部招标平台的 noticeId, 返回详情跳转 URL.
* <p>数据源: GET {bidding-api.notice-list-url}?pageNum=1&pageSize=200&keyword={projectNo}&projectType=10&noticeType=10
* 取响应 rows[0].noticeId, 拼接 {bidding-api.detail-url}{noticeId} 返回, 前端 window.open 跳转.
*/
@GetMapping("/biddingNotice")
public AjaxResult biddingNotice(@RequestParam String projectNo)
{
String listUrl = environment.getProperty("bidding-api.notice-list-url",
"https://zbpublic.guojustar.com/portal-api/bidding/public/notice/list");
String detailUrl = environment.getProperty("bidding-api.detail-url",
"https://zbpublic.guojustar.com/#/detail/");
if (projectNo == null || projectNo.trim().isEmpty())
{
return error("项目编号不能为空");
}
try
{
String param = "pageNum=1&pageSize=200&keyword=" + URLEncoder.encode(projectNo.trim(), "UTF-8")
+ "&projectType=10&noticeType=10";
String resp = HttpUtils.sendGet(listUrl, param);
if (resp == null || resp.isEmpty())
{
return error("招标公告查询失败");
}
JsonNode root = objectMapper.readTree(resp);
JsonNode rows = root.path("rows");
if (!rows.isArray() || rows.size() == 0)
{
return error("未查到该项目的招标公告");
}
long noticeId = rows.get(0).path("noticeId").asLong(0L);
if (noticeId == 0L)
{
return error("招标公告 noticeId 缺失");
}
// 注意: 不能用 success(url) —— BaseController.success(String) 会把 URL 当 msg、data 为 null
// 用 Map 承载, 前端取 data.detailUrl
Map<String, Object> data = new HashMap<>();
data.put("detailUrl", detailUrl + noticeId);
return success(data);
}
catch (Exception e)
{
log.error("招标公告查询失败, projectNo={}", projectNo, e);
return error("招标公告查询异常");
}
}
/**
* 微信 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}") @GetMapping("/supportLetter/{annId}")
public AjaxResult supportLetterDetail(@PathVariable String annId) { public AjaxResult supportLetterDetail(@PathVariable String annId) {
return success(supportLetterService.getById(annId)); return success(supportLetterService.getById(annId));
@@ -106,4 +185,38 @@ public class BizPublicController extends BaseController {
public AjaxResult apply(String annId) { public AjaxResult apply(String annId) {
return success(); return success();
} }
/**
* 公开邀请函 (发送邀请 短信/邮件里的链接): 按 token 查邀请内容 + 收件人确认状态.
* 无需登录, token 即唯一凭证.
*/
@GetMapping("/invite/{token}")
public AjaxResult inviteByToken(@PathVariable("token") String token) {
BizInviteRecipient recipient = inviteService.getRecipientByToken(token);
if (recipient == null) {
return error("邀请链接无效");
}
BizInvite invite = inviteService.getById(recipient.getInviteId());
if (invite == null) {
return error("邀请不存在");
}
Map<String, Object> data = new HashMap<>();
data.put("invite", invite);
data.put("recipient", recipient);
return success(data);
}
/**
* 公开确认 (粘在底部的确认按钮): 置确认状态=已确认, 不可逆.
* 幂等: 重复确认返回成功 (已确认态不再翻转).
*/
@PostMapping("/invite/{token}/confirm")
public AjaxResult inviteConfirm(@PathVariable("token") String token) {
BizInviteRecipient recipient = inviteService.getRecipientByToken(token);
if (recipient == null) {
return error("邀请链接无效");
}
inviteService.confirmByToken(token);
return success("确认成功");
}
} }
@@ -0,0 +1,48 @@
package com.ruoyi.business.controller;
import java.util.HashMap;
import java.util.Map;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import com.ruoyi.business.upload.UploadProgressRegistry;
import com.ruoyi.common.core.controller.BaseController;
import com.ruoyi.common.core.domain.AjaxResult;
/**
* 批量上传进度轮询端点 (zip 上传 → 后台解压/回填 → 前端轮询)
*/
@RestController
@RequestMapping("/business/upload")
public class UploadProgressController extends BaseController
{
@Autowired
private UploadProgressRegistry uploadProgressRegistry;
/**
* 查询某上传任务的实时进度.
* <p>
* 返回 { jobId, done, total, finished, error, message, result };
* 任务不存在 (尚未登记或已清理) 返回 success() (data=null), 前端视为"准备中".
*/
@GetMapping("/progress/{jobId}")
public AjaxResult progress(@PathVariable("jobId") String jobId)
{
UploadProgressRegistry.Progress p = uploadProgressRegistry.get(jobId);
if (p == null)
{
return success();
}
Map<String, Object> out = new HashMap<>();
out.put("jobId", p.jobId);
out.put("done", p.done);
out.put("total", p.total);
out.put("finished", p.finished);
out.put("error", p.error);
out.put("message", p.message);
out.put("result", p.result);
return success(out);
}
}
@@ -24,8 +24,8 @@ public class BizArticle extends BaseEntity
@Excel(name = "正文") @Excel(name = "正文")
private String content; private String content;
/** 状态 0启用 1停用 */ /** 状态 Y启用 N停用 */
@Excel(name = "状态", readConverterExp = "0=启用,1=停用") @Excel(name = "状态", readConverterExp = "Y=启用,N=停用")
private String status; private String status;
public Long getId() { return id; } public Long getId() { return id; }
@@ -17,8 +17,8 @@ public class BizDepartment extends BaseEntity
@Excel(name = "排序") @Excel(name = "排序")
private Integer sort; private Integer sort;
/** 0=启用 1=停用 */ /** Y=启用 N=停用 */
@Excel(name = "状态", readConverterExp = "0=启用,1=停用") @Excel(name = "状态", readConverterExp = "Y=启用,N=停用")
private String status; private String status;
public Long getDeptId() { return deptId; } public Long getDeptId() { return deptId; }
@@ -51,6 +51,8 @@ public class BizExecutionIntent extends BaseEntity {
@Excel(name = "update_time") @Excel(name = "update_time")
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date updateTime; private Date updateTime;
/** 删除标记 (0正常 1已删除, 幽灵占位记录用) */
private String delFlag;
public String getIntentId() { return intentId; } public String getIntentId() { return intentId; }
public void setIntentId(String intentId) { this.intentId = intentId; } public void setIntentId(String intentId) { this.intentId = intentId; }
public Long getUserId() { return userId; } public Long getUserId() { return userId; }
@@ -79,4 +81,6 @@ public class BizExecutionIntent extends BaseEntity {
public void setUpdateBy(String updateBy) { this.updateBy = updateBy; } public void setUpdateBy(String updateBy) { this.updateBy = updateBy; }
public Date getUpdateTime() { return updateTime; } public Date getUpdateTime() { return updateTime; }
public void setUpdateTime(Date updateTime) { this.updateTime = updateTime; } public void setUpdateTime(Date updateTime) { this.updateTime = updateTime; }
public String getDelFlag() { return delFlag; }
public void setDelFlag(String delFlag) { this.delFlag = delFlag; }
} }
@@ -0,0 +1,84 @@
package com.ruoyi.business.domain;
import com.ruoyi.common.annotation.Excel;
/**
* 医院字典 (biz_hospital)
*
* <p>admin 用户在"资料库管理 / 医院管理"下维护; 医生注册时通过 /business/hospital/search 远程搜索选择.
*
* <p>字段说明:
* <ul>
* <li>hospitalId — 雪花 ID (Long), 由 SnowflakeId.injectIfEmpty 生成; 53-bit, JS Number 安全</li>
* <li>province/city — 省/市 (varchar 100)</li>
* <li>hospital — 医院名称 (varchar 1000, 主显示名)</li>
* <li>originalHospital — 原名称 (varchar 100)</li>
* <li>hospitalCode — 医院 CODE (varchar 100, 如 H3301006)</li>
* <li>militaryHospital — 是否军医院 (varchar 100, 现存数据为 '0'/'1')</li>
* <li>type — 类型 (varchar 100, 现存数据为 '0'/'1')</li>
* </ul>
*
* <p>说明: 原表 schema 中 id 为 int NOT NULL 无 auto_increment, 改 BIGINT 由应用生成雪花 ID (与项目其他表风格一致).
*
* @author guoju
*/
public class BizHospital {
private static final long serialVersionUID = 1L;
/** 医院 ID (雪花 ID, BIGINT) */
@Excel(name = "医院ID")
private Long hospitalId;
/** 省 */
@Excel(name = "")
private String province;
/** 市 */
@Excel(name = "")
private String city;
/** 医院名称 (主显示名) */
@Excel(name = "医院名称")
private String hospital;
/** 原名称 */
@Excel(name = "原名称")
private String originalHospital;
/** 医院 CODE */
@Excel(name = "医院CODE")
private String hospitalCode;
/** 是否军医院 */
@Excel(name = "是否军医院")
private String militaryHospital;
/** 类型 */
@Excel(name = "类型")
private String type;
public Long getHospitalId() { return hospitalId; }
public void setHospitalId(Long hospitalId) { this.hospitalId = hospitalId; }
public String getProvince() { return province; }
public void setProvince(String province) { this.province = province; }
public String getCity() { return city; }
public void setCity(String city) { this.city = city; }
public String getHospital() { return hospital; }
public void setHospital(String hospital) { this.hospital = hospital; }
public String getOriginalHospital() { return originalHospital; }
public void setOriginalHospital(String originalHospital) { this.originalHospital = originalHospital; }
public String getHospitalCode() { return hospitalCode; }
public void setHospitalCode(String hospitalCode) { this.hospitalCode = hospitalCode; }
public String getMilitaryHospital() { return militaryHospital; }
public void setMilitaryHospital(String militaryHospital) { this.militaryHospital = militaryHospital; }
public String getType() { return type; }
public void setType(String type) { this.type = type; }
}
@@ -0,0 +1,68 @@
package com.ruoyi.business.domain;
import java.util.Date;
import java.util.List;
import com.fasterxml.jackson.annotation.JsonFormat;
import com.ruoyi.common.core.domain.BaseEntity;
/**
* 发送邀请 主表对象 biz_invite.
* <p>独立于项目/会议: 此时项目在 OA 尚未创建, 项目编号/会议名称/期数/会议时间 均为手输文本,
* 不关联 biz_project / biz_meeting, 也不复用 biz_invitation (那是另一套邀请函逻辑).
*/
public class BizInvite extends BaseEntity
{
private static final long serialVersionUID = 1L;
/** 主键 (雪花 ID, 调用方生成) */
private Long id;
/** 项目编号 (手输) */
private String projectNo;
/** 会议名称 (手输) */
private String meetingName;
/** 期数 (手输) */
private String periodNo;
/** 会议时间 (手输) */
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date meetingTime;
/** 发送时间 (发送动作回填) */
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date sendTime;
/** 邀请内容 (富文本 HTML) */
private String content;
/** 红头文件/附件 URL */
private String redheadFileUrl;
/** 发送渠道: sms / email / both */
private String sendChannel;
/** 邀请专家数 (聚合, 非库列) */
private Integer recipientCount;
/** 已确认数 (聚合, 非库列) */
private Integer confirmedCount;
/** 收件人明细 (前端提交用, 非库列) */
private List<BizInviteRecipient> recipients;
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public String getProjectNo() { return projectNo; }
public void setProjectNo(String projectNo) { this.projectNo = projectNo; }
public String getMeetingName() { return meetingName; }
public void setMeetingName(String meetingName) { this.meetingName = meetingName; }
public String getPeriodNo() { return periodNo; }
public void setPeriodNo(String periodNo) { this.periodNo = periodNo; }
public Date getMeetingTime() { return meetingTime; }
public void setMeetingTime(Date meetingTime) { this.meetingTime = meetingTime; }
public Date getSendTime() { return sendTime; }
public void setSendTime(Date sendTime) { this.sendTime = sendTime; }
public String getContent() { return content; }
public void setContent(String content) { this.content = content; }
public String getRedheadFileUrl() { return redheadFileUrl; }
public void setRedheadFileUrl(String redheadFileUrl) { this.redheadFileUrl = redheadFileUrl; }
public String getSendChannel() { return sendChannel; }
public void setSendChannel(String sendChannel) { this.sendChannel = sendChannel; }
public Integer getRecipientCount() { return recipientCount; }
public void setRecipientCount(Integer recipientCount) { this.recipientCount = recipientCount; }
public Integer getConfirmedCount() { return confirmedCount; }
public void setConfirmedCount(Integer confirmedCount) { this.confirmedCount = confirmedCount; }
public List<BizInviteRecipient> getRecipients() { return recipients; }
public void setRecipients(List<BizInviteRecipient> recipients) { this.recipients = recipients; }
}
@@ -0,0 +1,64 @@
package com.ruoyi.business.domain;
import java.util.Date;
import com.fasterxml.jackson.annotation.JsonFormat;
import com.ruoyi.common.core.domain.BaseEntity;
/**
* 发送邀请 收件人明细对象 biz_invite_recipient.
*/
public class BizInviteRecipient extends BaseEntity
{
private static final long serialVersionUID = 1L;
/** 主键 (雪花 ID, 调用方生成) */
private Long id;
/** 邀请主表 id */
private Long inviteId;
/** 专家姓名 */
private String name;
/** 手机号 */
private String phone;
/** 邮箱 */
private String email;
/** 来源: E=专家库 / I=导入 */
private String source;
/** 备注: 1=发送 / 空=不发送 */
private String remark;
/** 发送状态: 0=未发送 / 1=成功 / 2=失败 */
private String sendStatus;
/** 发送失败原因 */
private String failReason;
/** 确认状态: 0=未确认 / 1=已确认 */
private String confirmStatus;
/** 确认时间 */
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date confirmedAt;
/** 公开邀请链接 token (uuid) */
private String token;
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public Long getInviteId() { return inviteId; }
public void setInviteId(Long inviteId) { this.inviteId = inviteId; }
public String getName() { return name; }
public void setName(String name) { this.name = name; }
public String getPhone() { return phone; }
public void setPhone(String phone) { this.phone = phone; }
public String getEmail() { return email; }
public void setEmail(String email) { this.email = email; }
public String getSource() { return source; }
public void setSource(String source) { this.source = source; }
public String getRemark() { return remark; }
public void setRemark(String remark) { this.remark = remark; }
public String getSendStatus() { return sendStatus; }
public void setSendStatus(String sendStatus) { this.sendStatus = sendStatus; }
public String getFailReason() { return failReason; }
public void setFailReason(String failReason) { this.failReason = failReason; }
public String getConfirmStatus() { return confirmStatus; }
public void setConfirmStatus(String confirmStatus) { this.confirmStatus = confirmStatus; }
public Date getConfirmedAt() { return confirmedAt; }
public void setConfirmedAt(Date confirmedAt) { this.confirmedAt = confirmedAt; }
public String getToken() { return token; }
public void setToken(String token) { this.token = token; }
}
@@ -99,6 +99,12 @@ public class BizMeeting extends BaseEntity {
/** 材料最近一次审核动作时间 */ /** 材料最近一次审核动作时间 */
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date materialAuditTime; private Date materialAuditTime;
/** 劳务材料最后一次动作时间 (派生字段, 标量子查询 max(audit_time) where material_type='LABOR', 不落库) */
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date laborAuditTime;
/** 会务材料最后一次动作时间 (派生字段, 标量子查询 max(audit_time) where material_type='SERVICE', 不落库) */
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date serviceAuditTime;
/** 劳务材料合规是否已通过 0否1是 (区分 SUBMITTED 内合规审/支持方审) */ /** 劳务材料合规是否已通过 0否1是 (区分 SUBMITTED 内合规审/支持方审) */
private Integer laborComplianceApproved; private Integer laborComplianceApproved;
/** 会务材料合规是否已通过 0否1是 (区分 SUBMITTED 内合规审/支持方审) */ /** 会务材料合规是否已通过 0否1是 (区分 SUBMITTED 内合规审/支持方审) */
@@ -114,7 +120,7 @@ public class BizMeeting extends BaseEntity {
private String scheduleUrl; private String scheduleUrl;
/** 生成的海报URL (生成海报按钮产出) */ /** 生成的海报URL (生成海报按钮产出) */
private String posterUrl; private String posterUrl;
/** 签署劳务 0未签 1已签 */ /** 签署劳务 N未签 Y已签 */
private String laborSigned; private String laborSigned;
/** 参会人 user_id (非持久化字段, 仅用于 mapper WHERE 过滤; controller 注入, 配合 biz_meeting_attendee 中间表) */ /** 参会人 user_id (非持久化字段, 仅用于 mapper WHERE 过滤; controller 注入, 配合 biz_meeting_attendee 中间表) */
private transient Long userId; private transient Long userId;
@@ -219,6 +225,10 @@ public class BizMeeting extends BaseEntity {
public void setFreezeTime(Date freezeTime) { this.freezeTime = freezeTime; } public void setFreezeTime(Date freezeTime) { this.freezeTime = freezeTime; }
public Date getMaterialAuditTime() { return materialAuditTime; } public Date getMaterialAuditTime() { return materialAuditTime; }
public void setMaterialAuditTime(Date materialAuditTime) { this.materialAuditTime = materialAuditTime; } public void setMaterialAuditTime(Date materialAuditTime) { this.materialAuditTime = materialAuditTime; }
public Date getLaborAuditTime() { return laborAuditTime; }
public void setLaborAuditTime(Date laborAuditTime) { this.laborAuditTime = laborAuditTime; }
public Date getServiceAuditTime() { return serviceAuditTime; }
public void setServiceAuditTime(Date serviceAuditTime) { this.serviceAuditTime = serviceAuditTime; }
public Integer getLaborComplianceApproved() { return laborComplianceApproved; } public Integer getLaborComplianceApproved() { return laborComplianceApproved; }
public void setLaborComplianceApproved(Integer laborComplianceApproved) { this.laborComplianceApproved = laborComplianceApproved; } public void setLaborComplianceApproved(Integer laborComplianceApproved) { this.laborComplianceApproved = laborComplianceApproved; }
public Integer getServiceComplianceApproved() { return serviceComplianceApproved; } public Integer getServiceComplianceApproved() { return serviceComplianceApproved; }
@@ -54,6 +54,8 @@ public class BizMeetingAttendee extends BaseEntity {
private String laborProtocol; private String laborProtocol;
/** 脱敏版协议 URL (身份证/银行卡用 * 替换) */ /** 脱敏版协议 URL (身份证/银行卡用 * 替换) */
private String laborProtocolMasked; private String laborProtocolMasked;
/** 盖章后个性化邀请函 URL (OSS, 发送邀请函时生成; null=未生成, 回退会议级模板) */
private String invitationUrl;
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date createTime; private Date createTime;
/* ====== 非持久化字段, 用于 selectUnsignedByUserId 联表查询 ====== */ /* ====== 非持久化字段, 用于 selectUnsignedByUserId 联表查询 ====== */
@@ -121,6 +123,8 @@ public class BizMeetingAttendee extends BaseEntity {
public void setLaborProtocol(String laborProtocol) { this.laborProtocol = laborProtocol; } public void setLaborProtocol(String laborProtocol) { this.laborProtocol = laborProtocol; }
public String getLaborProtocolMasked() { return laborProtocolMasked; } public String getLaborProtocolMasked() { return laborProtocolMasked; }
public void setLaborProtocolMasked(String laborProtocolMasked) { this.laborProtocolMasked = 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 Date getCreateTime() { return createTime; }
public void setCreateTime(Date createTime) { this.createTime = createTime; } public void setCreateTime(Date createTime) { this.createTime = createTime; }
public String getMeetingName() { return meetingName; } public String getMeetingName() { return meetingName; }
@@ -58,6 +58,18 @@ public class BizMeetingInvoice {
/** 原始文件名 (zip 解压时记录, 单文件路径可空) */ /** 原始文件名 (zip 解压时记录, 单文件路径可空) */
private String sourceFilename; private String sourceFilename;
/** 所属材料类型 sub_type (join biz_meeting_material 带出, 发票表展示用) */
private String materialSubType;
/** 所属材料原始文件名 (join biz_meeting_material 带出, 单文件发票 source_filename 为空时兜底) */
private String materialFileName;
/** 发票号码 (OCR 识别) */
private String invoiceNo;
/** 开票日期 (OCR 识别, YYYY-MM-DD) */
private String invoiceDate;
public Long getId() { return id; } public Long getId() { return id; }
public void setId(Long id) { this.id = id; } public void setId(Long id) { this.id = id; }
@@ -93,4 +105,16 @@ public class BizMeetingInvoice {
public String getSourceFilename() { return sourceFilename; } public String getSourceFilename() { return sourceFilename; }
public void setSourceFilename(String sourceFilename) { this.sourceFilename = sourceFilename; } public void setSourceFilename(String sourceFilename) { this.sourceFilename = sourceFilename; }
public String getMaterialSubType() { return materialSubType; }
public void setMaterialSubType(String materialSubType) { this.materialSubType = materialSubType; }
public String getMaterialFileName() { return materialFileName; }
public void setMaterialFileName(String materialFileName) { this.materialFileName = materialFileName; }
public String getInvoiceNo() { return invoiceNo; }
public void setInvoiceNo(String invoiceNo) { this.invoiceNo = invoiceNo; }
public String getInvoiceDate() { return invoiceDate; }
public void setInvoiceDate(String invoiceDate) { this.invoiceDate = invoiceDate; }
} }
@@ -2,7 +2,9 @@ package com.ruoyi.business.domain;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.util.Date; import java.util.Date;
import java.util.List;
import com.fasterxml.jackson.annotation.JsonFormat; import com.fasterxml.jackson.annotation.JsonFormat;
import com.fasterxml.jackson.annotation.JsonProperty;
/** /**
* 会议材料对象 biz_meeting_material (单表) * 会议材料对象 biz_meeting_material (单表)
@@ -10,7 +12,7 @@ import com.fasterxml.jackson.annotation.JsonFormat;
* 包含 4 大类 17 子类: * 包含 4 大类 17 子类:
* <ul> * <ul>
* <li>material_type: SERVICE=会务材料, LABOR=劳务材料, SERVICE_VOUCHER=会务凭证, LABOR_VOUCHER=劳务凭证</li> * <li>material_type: SERVICE=会务材料, LABOR=劳务材料, SERVICE_VOUCHER=会务凭证, LABOR_VOUCHER=劳务凭证</li>
* <li>sub_type: M_MATERIAL / M_HOTEL / M_TRAFFIC_BIG / M_TRAFFIC_SMALL / M_EXECUTION / M_DESIGN / M_OTHER / M_SETTLEMENT / M_INVOICE / L_DETAIL / L_AGREEMENT / L_ENTERPRISE_BENEFIT / L_SIGN_IN / L_PANORAMA / L_EXPERT_PHOTO / SV_PAYMENT / LV_PAYMENT</li> * <li>sub_type: M_MATERIAL / M_HOTEL / M_TRAFFIC_BIG / M_TRAFFIC_SMALL / M_EXECUTION / M_DESIGN / M_OTHER / M_SETTLEMENT / M_INVOICE / L_DETAIL / L_AGREEMENT / L_ENTERPRISE_BENEFIT / L_ESIGN_IN / L_SIGN_IN / L_PANORAMA / L_EXPERT_PHOTO / SV_PAYMENT / LV_PAYMENT</li>
* </ul> * </ul>
* <p> * <p>
* 注意: 不继承 BaseEntity — 不要 create_by / update_by / update_time 字段. * 注意: 不继承 BaseEntity — 不要 create_by / update_by / update_time 字段.
@@ -41,6 +43,15 @@ public class BizMeetingMaterial {
/** 脱敏版 OSS URL (签到表拍照时额外生成的高斯模糊版, sponsor 只看这个以隐藏手机号/身份证号) */ /** 脱敏版 OSS URL (签到表拍照时额外生成的高斯模糊版, sponsor 只看这个以隐藏手机号/身份证号) */
private String extraOssUrl; private String extraOssUrl;
/** 电子签到表解析后的脱敏 JSON (表头+行, sponsor 预览用; 保存时后端解析生成) */
private String maskedJson;
/** 扫码拍照回传的 OSS URL 列表 (仅 cameraUpload 端点入参; 签到表连拍多张, 前后全景单张). 存库时 L_SIGN_IN 序列化为 JSON 数组字符串写 ossUrl */
private List<String> ossUrls;
/** 扫码拍照回传的脱敏版 OSS URL 列表 (与 ossUrls 一一对应, 仅 cameraUpload 端点入参) */
private List<String> extraOssUrls;
/** 金额 (发票专用, 其他类型 = 0) */ /** 金额 (发票专用, 其他类型 = 0) */
private BigDecimal amount; private BigDecimal amount;
@@ -78,6 +89,17 @@ public class BizMeetingMaterial {
public String getExtraOssUrl() { return extraOssUrl; } public String getExtraOssUrl() { return extraOssUrl; }
public void setExtraOssUrl(String extraOssUrl) { this.extraOssUrl = extraOssUrl; } public void setExtraOssUrl(String extraOssUrl) { this.extraOssUrl = extraOssUrl; }
public String getMaskedJson() { return maskedJson; }
public void setMaskedJson(String maskedJson) { this.maskedJson = maskedJson; }
@JsonProperty(access = JsonProperty.Access.WRITE_ONLY)
public List<String> getOssUrls() { return ossUrls; }
public void setOssUrls(List<String> ossUrls) { this.ossUrls = ossUrls; }
@JsonProperty(access = JsonProperty.Access.WRITE_ONLY)
public List<String> getExtraOssUrls() { return extraOssUrls; }
public void setExtraOssUrls(List<String> extraOssUrls) { this.extraOssUrls = extraOssUrls; }
public BigDecimal getAmount() { return amount; } public BigDecimal getAmount() { return amount; }
public void setAmount(BigDecimal amount) { this.amount = amount; } public void setAmount(BigDecimal amount) { this.amount = amount; }
@@ -50,6 +50,10 @@ public class BizOrg extends BaseEntity {
/** update_time */ /** update_time */
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date updateTime; private Date updateTime;
/** del_flag 删除标志 0正常 1删除 */
private String delFlag;
/** is_synced 同步来源标记 0注册 1供应商同步 */
private Integer isSynced;
public Long getOrgId() { return orgId; } public Long getOrgId() { return orgId; }
public void setOrgId(Long orgId) { this.orgId = orgId; } public void setOrgId(Long orgId) { this.orgId = orgId; }
@@ -77,4 +81,8 @@ public class BizOrg extends BaseEntity {
public void setCreateTime(Date createTime) { this.createTime = createTime; } public void setCreateTime(Date createTime) { this.createTime = createTime; }
public Date getUpdateTime() { return updateTime; } public Date getUpdateTime() { return updateTime; }
public void setUpdateTime(Date updateTime) { this.updateTime = updateTime; } public void setUpdateTime(Date updateTime) { this.updateTime = updateTime; }
public String getDelFlag() { return delFlag; }
public void setDelFlag(String delFlag) { this.delFlag = delFlag; }
public Integer getIsSynced() { return isSynced; }
public void setIsSynced(Integer isSynced) { this.isSynced = isSynced; }
} }
@@ -10,7 +10,7 @@ import com.ruoyi.common.core.domain.BaseEntity;
/** 项目对象 BizProject */ /** 项目对象 BizProject */
public class BizProject extends BaseEntity { public class BizProject extends BaseEntity {
private static final long serialVersionUID = 1L; private static final long serialVersionUID = 1L;
/** projectId (与 DB bigint 对齐, AUTO_INCREMENT) */ /** projectId (雪花 ID, 与 DB bigint 对齐) */
private Long projectId; private Long projectId;
/** project_no */ /** project_no */
@Excel(name = "project_no") @Excel(name = "project_no")
@@ -56,6 +56,8 @@ public class BizProject extends BaseEntity {
private String sponsorAdminUserName; private String sponsorAdminUserName;
/** 支持单位名称 (列表展示列, JOIN biz_org 取 org_name, org_type='sponsor') */ /** 支持单位名称 (列表展示列, JOIN biz_org 取 org_name, org_type='sponsor') */
private String sponsorOrgName; private String sponsorOrgName;
/** 项目ID筛选 (导出时勾选项目透传, IN 查询) */
private List<Long> projectIds;
/** 支持单位筛选 (biz_org.org_id 列表, IN 查询, 多选 select 透传) */ /** 支持单位筛选 (biz_org.org_id 列表, IN 查询, 多选 select 透传) */
private List<Long> sponsorOrgIds; private List<Long> sponsorOrgIds;
/** 服务机构名称列表, 多个用英文逗号连接 (GROUP_CONCAT 派生, 不入库, 仅展示) */ /** 服务机构名称列表, 多个用英文逗号连接 (GROUP_CONCAT 派生, 不入库, 仅展示) */
@@ -124,7 +126,7 @@ public class BizProject extends BaseEntity {
private String publishUrl; private String publishUrl;
/** 日程文件URL */ /** 日程文件URL */
private String scheduleUrl; private String scheduleUrl;
/** 是否已发布公示 01是 */ /** 是否已发布公示 NY是 */
private String isPublished; private String isPublished;
/** 发布时间 */ /** 发布时间 */
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
@@ -181,6 +183,8 @@ public class BizProject extends BaseEntity {
public void setSponsorAdminUserName(String sponsorAdminUserName) { this.sponsorAdminUserName = sponsorAdminUserName; } public void setSponsorAdminUserName(String sponsorAdminUserName) { this.sponsorAdminUserName = sponsorAdminUserName; }
public String getSponsorOrgName() { return sponsorOrgName; } public String getSponsorOrgName() { return sponsorOrgName; }
public void setSponsorOrgName(String sponsorOrgName) { this.sponsorOrgName = sponsorOrgName; } public void setSponsorOrgName(String sponsorOrgName) { this.sponsorOrgName = sponsorOrgName; }
public List<Long> getProjectIds() { return projectIds; }
public void setProjectIds(List<Long> projectIds) { this.projectIds = projectIds; }
public List<Long> getSponsorOrgIds() { return sponsorOrgIds; } public List<Long> getSponsorOrgIds() { return sponsorOrgIds; }
public void setSponsorOrgIds(List<Long> sponsorOrgIds) { this.sponsorOrgIds = sponsorOrgIds; } public void setSponsorOrgIds(List<Long> sponsorOrgIds) { this.sponsorOrgIds = sponsorOrgIds; }
public String getExecOrgNames() { return execOrgNames; } public String getExecOrgNames() { return execOrgNames; }
@@ -8,7 +8,7 @@ import com.ruoyi.common.core.domain.BaseEntity;
public class BizProjectExecutorAssign extends BaseEntity { public class BizProjectExecutorAssign extends BaseEntity {
private static final long serialVersionUID = 1L; private static final long serialVersionUID = 1L;
private Long id; private Long id;
private String projectId; private Long projectId;
/** 分配方企业 org_id (biz_org.org_id, 分配人审计; 原 executor_user_id) */ /** 分配方企业 org_id (biz_org.org_id, 分配人审计; 原 executor_user_id) */
private Long executorOrgId; private Long executorOrgId;
/** 执行人 user_id (被分配) */ /** 执行人 user_id (被分配) */
@@ -26,15 +26,15 @@ public class BizProjectExecutorAssign extends BaseEntity {
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date createTime; private Date createTime;
/** 软删除标记 0否1是 (项目级联删除时按 project_id (String) 置1, 查询需 WHERE is_deleted=0) */ /** 软删除标记 0否1是 (项目级联删除时按 project_id 置1, 查询需 WHERE is_deleted=0) */
private Integer isDeleted; private Integer isDeleted;
public Integer getIsDeleted() { return isDeleted; } public Integer getIsDeleted() { return isDeleted; }
public void setIsDeleted(Integer isDeleted) { this.isDeleted = isDeleted; } public void setIsDeleted(Integer isDeleted) { this.isDeleted = isDeleted; }
public Long getId() { return id; } public Long getId() { return id; }
public void setId(Long id) { this.id = id; } public void setId(Long id) { this.id = id; }
public String getProjectId() { return projectId; } public Long getProjectId() { return projectId; }
public void setProjectId(String projectId) { this.projectId = projectId; } public void setProjectId(Long projectId) { this.projectId = projectId; }
public Long getExecutorOrgId() { return executorOrgId; } public Long getExecutorOrgId() { return executorOrgId; }
public void setExecutorOrgId(Long executorOrgId) { this.executorOrgId = executorOrgId; } public void setExecutorOrgId(Long executorOrgId) { this.executorOrgId = executorOrgId; }
public Long getStaffUserId() { return staffUserId; } public Long getStaffUserId() { return staffUserId; }
@@ -0,0 +1,61 @@
package com.ruoyi.business.domain.vo;
import com.ruoyi.common.annotation.Excel;
/**
* 医院字典导出 VO
*
* <p>仅用于 Excel 导出, 不参与业务逻辑.
*
* @author guoju
*/
public class BizHospitalExportVo {
@Excel(name = "医院ID", sort = 1)
private Long hospitalId;
@Excel(name = "", sort = 2)
private String province;
@Excel(name = "", sort = 3)
private String city;
@Excel(name = "医院名称", sort = 4)
private String hospital;
@Excel(name = "原名称", sort = 5)
private String originalHospital;
@Excel(name = "医院CODE", sort = 6)
private String hospitalCode;
@Excel(name = "是否军医院", sort = 7)
private String militaryHospital;
@Excel(name = "类型", sort = 8)
private String type;
public Long getHospitalId() { return hospitalId; }
public void setHospitalId(Long hospitalId) { this.hospitalId = hospitalId; }
public String getProvince() { return province; }
public void setProvince(String province) { this.province = province; }
public String getCity() { return city; }
public void setCity(String city) { this.city = city; }
public String getHospital() { return hospital; }
public void setHospital(String hospital) { this.hospital = hospital; }
public String getOriginalHospital() { return originalHospital; }
public void setOriginalHospital(String originalHospital) { this.originalHospital = originalHospital; }
public String getHospitalCode() { return hospitalCode; }
public void setHospitalCode(String hospitalCode) { this.hospitalCode = hospitalCode; }
public String getMilitaryHospital() { return militaryHospital; }
public void setMilitaryHospital(String militaryHospital) { this.militaryHospital = militaryHospital; }
public String getType() { return type; }
public void setType(String type) { this.type = type; }
}
@@ -0,0 +1,66 @@
package com.ruoyi.business.domain.vo;
import com.ruoyi.common.annotation.Excel;
/**
* 医院字典导入 VO
*
* <p>仅用于 Excel 批量导入 / 模板下载, 不参与业务逻辑.
* 字段顺序与 Excel 列一致; hospitalId 不在导入范围 (由 SnowflakeId 生成).
*
* <p>必填: hospital (医院名称).
* 其余字段允许空.
*
* @author guoju
*/
public class BizHospitalImportVo {
/** 省 */
@Excel(name = "", sort = 1)
private String province;
/** 市 */
@Excel(name = "", sort = 2)
private String city;
/** 医院名称 (必填) */
@Excel(name = "医院名称", sort = 3)
private String hospital;
/** 原名称 */
@Excel(name = "原名称", sort = 4)
private String originalHospital;
/** 医院 CODE */
@Excel(name = "医院CODE", sort = 5)
private String hospitalCode;
/** 是否军医院 (0=否, 1=是) */
@Excel(name = "是否军医院", sort = 6)
private String militaryHospital;
/** 类型 */
@Excel(name = "类型", sort = 7)
private String type;
public String getProvince() { return province; }
public void setProvince(String province) { this.province = province; }
public String getCity() { return city; }
public void setCity(String city) { this.city = city; }
public String getHospital() { return hospital; }
public void setHospital(String hospital) { this.hospital = hospital; }
public String getOriginalHospital() { return originalHospital; }
public void setOriginalHospital(String originalHospital) { this.originalHospital = originalHospital; }
public String getHospitalCode() { return hospitalCode; }
public void setHospitalCode(String hospitalCode) { this.hospitalCode = hospitalCode; }
public String getMilitaryHospital() { return militaryHospital; }
public void setMilitaryHospital(String militaryHospital) { this.militaryHospital = militaryHospital; }
public String getType() { return type; }
public void setType(String type) { this.type = type; }
}
@@ -93,8 +93,8 @@ public class BizMeetingAttendeeImportVo {
@Excel(name = "银行详细地址", sort = 18) @Excel(name = "银行详细地址", sort = 18)
private String bankAddress; private String bankAddress;
/** 身份证附件(正反面 URL CSV) */ /** 身份证附件(正反面 URL CSV)— 仅导出,不进入导入模板(附件文件无法通过 Excel 导入) */
@Excel(name = "身份证附件", sort = 19) @Excel(name = "身份证附件", sort = 19, type = Excel.Type.EXPORT)
private String idCardAttachments; private String idCardAttachments;
public String getPhone() { return phone; } public String getPhone() { return phone; }
@@ -0,0 +1,105 @@
package com.ruoyi.business.domain.vo;
import java.math.BigDecimal;
import java.util.Date;
import com.fasterxml.jackson.annotation.JsonFormat;
import com.ruoyi.common.annotation.Excel;
/**
* 项目列表导出 VO (中文列头, 与 manager/Projects.vue 列表列一致)
*
* <p>数据源: BizProject.selectList 派生字段 (含会议子查询聚合列).
* 仅用于 Excel 导出, 不参与业务逻辑.
*
* @author guoju
*/
public class BizProjectExportVo {
@Excel(name = "项目编号", sort = 1)
private String projectNo;
@Excel(name = "项目名称", sort = 2)
private String projectName;
@Excel(name = "总场次/总期数", sort = 3)
private Long totalSessions;
@Excel(name = "已执行", sort = 4)
private Long doneSessions;
@Excel(name = "未执行", sort = 5)
private Long todoSessions;
@Excel(name = "总金额", sort = 6)
private BigDecimal totalAmount;
@Excel(name = "可用金额", sort = 7)
private BigDecimal availableAmount;
@Excel(name = "已支付劳务费", sort = 8)
private BigDecimal paidLaborAmount;
@Excel(name = "已支付会务费", sort = 9)
private BigDecimal paidMeetingAmount;
@Excel(name = "执行单位评分(合规)", sort = 10)
private BigDecimal managerScore;
@Excel(name = "执行单位评分(支持)", sort = 11)
private BigDecimal sponsorScore;
@Excel(name = "支持单位", sort = 12)
private String sponsorOrgName;
@Excel(name = "服务机构", sort = 13)
private String execOrgNames;
@Excel(name = "项目形式", sort = 14)
private String projectForm;
@Excel(name = "是否结题", sort = 15)
private String isFinished;
@Excel(name = "项目开始时间", sort = 16, dateFormat = "yyyy-MM-dd HH:mm:ss")
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date startTime;
@Excel(name = "项目结束时间", sort = 17, dateFormat = "yyyy-MM-dd HH:mm:ss")
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date endTime;
public String getProjectNo() { return projectNo; }
public void setProjectNo(String projectNo) { this.projectNo = projectNo; }
public String getProjectName() { return projectName; }
public void setProjectName(String projectName) { this.projectName = projectName; }
public Long getTotalSessions() { return totalSessions; }
public void setTotalSessions(Long totalSessions) { this.totalSessions = totalSessions; }
public Long getDoneSessions() { return doneSessions; }
public void setDoneSessions(Long doneSessions) { this.doneSessions = doneSessions; }
public Long getTodoSessions() { return todoSessions; }
public void setTodoSessions(Long todoSessions) { this.todoSessions = todoSessions; }
public BigDecimal getTotalAmount() { return totalAmount; }
public void setTotalAmount(BigDecimal totalAmount) { this.totalAmount = totalAmount; }
public BigDecimal getAvailableAmount() { return availableAmount; }
public void setAvailableAmount(BigDecimal availableAmount) { this.availableAmount = availableAmount; }
public BigDecimal getPaidLaborAmount() { return paidLaborAmount; }
public void setPaidLaborAmount(BigDecimal paidLaborAmount) { this.paidLaborAmount = paidLaborAmount; }
public BigDecimal getPaidMeetingAmount() { return paidMeetingAmount; }
public void setPaidMeetingAmount(BigDecimal paidMeetingAmount) { this.paidMeetingAmount = paidMeetingAmount; }
public BigDecimal getManagerScore() { return managerScore; }
public void setManagerScore(BigDecimal managerScore) { this.managerScore = managerScore; }
public BigDecimal getSponsorScore() { return sponsorScore; }
public void setSponsorScore(BigDecimal sponsorScore) { this.sponsorScore = sponsorScore; }
public String getSponsorOrgName() { return sponsorOrgName; }
public void setSponsorOrgName(String sponsorOrgName) { this.sponsorOrgName = sponsorOrgName; }
public String getExecOrgNames() { return execOrgNames; }
public void setExecOrgNames(String execOrgNames) { this.execOrgNames = execOrgNames; }
public String getProjectForm() { return projectForm; }
public void setProjectForm(String projectForm) { this.projectForm = projectForm; }
public String getIsFinished() { return isFinished; }
public void setIsFinished(String isFinished) { this.isFinished = isFinished; }
public Date getStartTime() { return startTime; }
public void setStartTime(Date startTime) { this.startTime = startTime; }
public Date getEndTime() { return endTime; }
public void setEndTime(Date endTime) { this.endTime = endTime; }
}
@@ -0,0 +1,59 @@
package com.ruoyi.business.domain.vo;
import java.util.Date;
import com.fasterxml.jackson.annotation.JsonFormat;
import com.ruoyi.common.annotation.Excel;
/**
* 项目评价导出 VO (中文列头, 4 维度评分明细)
*
* <p>数据源: biz_project_rating (每个项目 × 每个评分角色 一条评分记录).
* 项目编号/项目名称由导出时用 project_id 反查 biz_project 主表补齐 (明细表这两列可能为空).
* 仅用于 Excel 导出, 不参与业务逻辑.
*
* @author guoju
*/
public class BizProjectRatingExportVo {
@Excel(name = "项目编号", sort = 1)
private String projectNo;
@Excel(name = "项目名称", sort = 2)
private String projectName;
@Excel(name = "评分角色", sort = 3)
private String raterRole;
@Excel(name = "履约质量", sort = 4)
private Long qualityScore;
@Excel(name = "时效响应", sort = 5)
private Long responseScore;
@Excel(name = "配合度", sort = 6)
private Long cooperationScore;
@Excel(name = "合规安全", sort = 7)
private Long complianceScore;
@Excel(name = "评分时间", sort = 8, dateFormat = "yyyy-MM-dd HH:mm:ss")
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date createTime;
public String getProjectNo() { return projectNo; }
public void setProjectNo(String projectNo) { this.projectNo = projectNo; }
public String getProjectName() { return projectName; }
public void setProjectName(String projectName) { this.projectName = projectName; }
public String getRaterRole() { return raterRole; }
public void setRaterRole(String raterRole) { this.raterRole = raterRole; }
public Long getQualityScore() { return qualityScore; }
public void setQualityScore(Long qualityScore) { this.qualityScore = qualityScore; }
public Long getResponseScore() { return responseScore; }
public void setResponseScore(Long responseScore) { this.responseScore = responseScore; }
public Long getCooperationScore() { return cooperationScore; }
public void setCooperationScore(Long cooperationScore) { this.cooperationScore = cooperationScore; }
public Long getComplianceScore() { return complianceScore; }
public void setComplianceScore(Long complianceScore) { this.complianceScore = complianceScore; }
public Date getCreateTime() { return createTime; }
public void setCreateTime(Date createTime) { this.createTime = createTime; }
}
@@ -7,32 +7,45 @@ import com.ruoyi.common.annotation.Excel;
/** /**
* 报名专家导出 VO (中文列头) * 报名专家导出 VO (中文列头)
* *
* <p>数据源: biz_execution_intent (公开门户"立即报名"写入的执行意向表, 即已报名专家). * <p>数据源: biz_execution_intent (公开门户"立即报名"写入的执行意向表, 即已报名专家)
* + biz_expert (医生档案, 报名专家 userId → biz_expert.userId 反查补全科室/医院/职称).
* 仅用于 Excel 导出, 不参与业务逻辑. * 仅用于 Excel 导出, 不参与业务逻辑.
* *
* <p>字段: 项目编号 项目名称 专家姓名 科室 医院 职称 报名时间 手机号
*
* @author guoju * @author guoju
*/ */
public class BizSignupExpertExportVo { public class BizSignupExpertExportVo {
@Excel(name = "专家姓名", sort = 1) @Excel(name = "项目编号", sort = 1)
private String projectNo;
@Excel(name = "项目名称", sort = 2)
private String projectName;
@Excel(name = "专家姓名", sort = 3)
private String name; private String name;
@Excel(name = "科室", sort = 2) @Excel(name = "科室", sort = 4)
private String department; private String department;
@Excel(name = "医院", sort = 3) @Excel(name = "医院", sort = 5)
private String workUnit; private String workUnit;
@Excel(name = "职称", sort = 4) @Excel(name = "职称", sort = 6)
private String position; private String position;
@Excel(name = "报名时间", sort = 5, 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") @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date createTime; private Date createTime;
@Excel(name = "手机号", sort = 6) @Excel(name = "手机号", sort = 8)
private String phone; private String phone;
public String getProjectNo() { return projectNo; }
public void setProjectNo(String projectNo) { this.projectNo = projectNo; }
public String getProjectName() { return projectName; }
public void setProjectName(String projectName) { this.projectName = projectName; }
public String getName() { return name; } public String getName() { return name; }
public void setName(String name) { this.name = name; } public void setName(String name) { this.name = name; }
public String getDepartment() { return department; } public String getDepartment() { return department; }
@@ -45,4 +58,4 @@ public class BizSignupExpertExportVo {
public void setCreateTime(Date createTime) { this.createTime = createTime; } public void setCreateTime(Date createTime) { this.createTime = createTime; }
public String getPhone() { return phone; } public String getPhone() { return phone; }
public void setPhone(String phone) { this.phone = phone; } public void setPhone(String phone) { this.phone = phone; }
} }
@@ -0,0 +1,32 @@
package com.ruoyi.business.domain.vo;
/**
* 参会人表单字段与专家表 (biz_expert) 档案的不一致项.
*
* <p>用于「新增/编辑参会人」和「批量导入」保存前的二次确认:
* 后端按 手机号→sys_user→biz_expert 解析出专家档案, 与用户输入比对,
* 专家表已有值且与输入不一致时生成一条 mismatch.
*/
public class FieldMismatch {
/** 字段 key (name/workUnit/...) */
private String field;
/** 字段中文名 (姓名/医院/...) */
private String label;
/** 专家表当前值 */
private String expertValue;
/** 用户输入值 */
private String inputValue;
public String getField() { return field; }
public void setField(String field) { this.field = field; }
public String getLabel() { return label; }
public void setLabel(String label) { this.label = label; }
public String getExpertValue() { return expertValue; }
public void setExpertValue(String expertValue) { this.expertValue = expertValue; }
public String getInputValue() { return inputValue; }
public void setInputValue(String inputValue) { this.inputValue = inputValue; }
}
@@ -0,0 +1,28 @@
package com.ruoyi.business.domain.vo;
import java.util.List;
/**
* 批量导入 dry-run 中某一行与专家表 (biz_expert) 档案的不一致 (导入二次确认用).
*/
public class ImportMismatch {
/** Excel 行号 (1=表头) */
private int rowNo;
private String phone;
private String name;
/** 该行不一致的字段列表 */
private List<FieldMismatch> fields;
public int getRowNo() { return rowNo; }
public void setRowNo(int rowNo) { this.rowNo = rowNo; }
public String getPhone() { return phone; }
public void setPhone(String phone) { this.phone = phone; }
public String getName() { return name; }
public void setName(String name) { this.name = name; }
public List<FieldMismatch> getFields() { return fields; }
public void setFields(List<FieldMismatch> fields) { this.fields = fields; }
}
@@ -0,0 +1,56 @@
package com.ruoyi.business.mail;
import jakarta.mail.internet.MimeMessage;
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.mail.javamail.JavaMailSender;
import org.springframework.mail.javamail.MimeMessageHelper;
import org.springframework.stereotype.Service;
/**
* 邀请邮件发送器.
* <p>参考 xxl-job {@code EmailJobAlarm} 模式, 走 spring-boot-starter-mail (JavaMailSender + MimeMessageHelper).
* 发件账号/主题/正文由调用方传入; 发件人 from 取 spring.mail.username (SMTP 账号本身).
*/
@Service
public class InviteMailSender
{
private static final Logger log = LoggerFactory.getLogger(InviteMailSender.class);
@Autowired(required = false)
private JavaMailSender mailSender;
@Value("${spring.mail.username:}")
private String emailFrom;
/**
* 发送 HTML 邀请邮件. 成功返回 true, 失败返回 false (不抛, 由调用方回写发送状态).
*/
public boolean sendInvite(String to, String subject, String html)
{
if (mailSender == null)
{
log.warn("[邮件] 未配置 spring.mail (JavaMailSender 不存在), 跳过发送 to={}", to);
return false;
}
try
{
MimeMessage mimeMessage = mailSender.createMimeMessage();
MimeMessageHelper helper = new MimeMessageHelper(mimeMessage, true, "UTF-8");
helper.setFrom(emailFrom);
helper.setTo(to);
helper.setSubject(subject == null ? "" : subject);
helper.setText(html == null ? "" : html, true);
mailSender.send(mimeMessage);
log.info("[邮件] 邀请邮件发送成功 to={}", to);
return true;
}
catch (Exception e)
{
log.error("[邮件] 邀请邮件发送失败 to={}", to, e);
return false;
}
}
}
@@ -1,5 +1,6 @@
package com.ruoyi.business.mapper; package com.ruoyi.business.mapper;
import java.util.List; import java.util.List;
import org.apache.ibatis.annotations.Param;
import com.ruoyi.business.domain.BizExecutionIntent; import com.ruoyi.business.domain.BizExecutionIntent;
/** /**
@@ -9,8 +10,14 @@ public interface BizExecutionIntentMapper
{ {
BizExecutionIntent selectByPrimaryKey(String intentId); BizExecutionIntent selectByPrimaryKey(String intentId);
List<BizExecutionIntent> selectList(BizExecutionIntent entity); List<BizExecutionIntent> selectList(BizExecutionIntent entity);
/** 查询全部记录 (含 del_flag='1' 幽灵占位), 仅"已报名专家导出"用 */
List<BizExecutionIntent> selectListIncludingDeleted(BizExecutionIntent entity);
int insert(BizExecutionIntent entity); int insert(BizExecutionIntent entity);
int updateByPrimaryKey(BizExecutionIntent entity); int updateByPrimaryKey(BizExecutionIntent entity);
int deleteByPrimaryKey(String intentId); int deleteByPrimaryKey(String intentId);
int deleteByPrimaryKeys(String[] intentIds); 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);
} }
@@ -0,0 +1,52 @@
package com.ruoyi.business.mapper;
import java.util.List;
import org.apache.ibatis.annotations.Param;
import com.ruoyi.business.domain.BizHospital;
/**
* 医院字典 Mapper (biz_hospital)
*
* <p>无 del_flag 列 — 删除走硬删 (admin 字典, workUnit 字符串引用, 删除不会破坏业务数据完整性).
*
* @author guoju
*/
public interface BizHospitalMapper {
/** 按主键查询 */
BizHospital selectByPrimaryKey(@Param("hospitalId") Long hospitalId);
/** 分页 + 多条件模糊筛选 (admin 后台用, 支持 hospital/province/type/militaryHospital) */
List<BizHospital> selectList(BizHospital entity);
/** 新增 */
int insert(BizHospital entity);
/** 按主键更新 (动态 trim SET) */
int updateByPrimaryKey(BizHospital entity);
/** 批量硬删 */
int deleteByPrimaryKeys(@Param("hospitalIds") Long[] hospitalIds);
/**
* 远程搜索: 按关键字模糊匹配 hospital / originalHospital / hospitalCode,
* LIMIT 30 (医生注册下拉防爆).
* keyword 为空时返回空集 (前端按"边输入边查询"约定不主动出建议).
*/
List<BizHospital> searchByKeyword(@Param("keyword") String keyword);
/**
* 唯一性检查: 按 trim(hospital) + province + city 精确匹配 (case-insensitive).
* hospitalId 非空时排除自身 (改名/同名异市用); 用于新增/编辑前的"同名医院"提示.
* 返回匹配条数 (0 或 1).
*/
int countByTrimmedHospital(BizHospital entity);
/**
* 统计指定医院名称被业务表 work_unit 字段引用的总条数.
* 用于删除前给 admin 二次确认: "该医院已被 N 条专家/参会人引用".
* 4 表 union: biz_expert / biz_signup_expert / biz_meeting_attendee / biz_execution_intent (有 work_unit 列)
* 注: biz_support_intent 也有 work_unit 列, 一起并入.
*/
int countUsageByHospital(@Param("hospital") String hospital);
}
@@ -0,0 +1,22 @@
package com.ruoyi.business.mapper;
import java.util.Date;
import java.util.List;
import org.apache.ibatis.annotations.Param;
import com.ruoyi.business.domain.BizInvite;
/**
* 发送邀请主表 Mapper 接口
*/
public interface BizInviteMapper
{
BizInvite selectByPrimaryKey(Long id);
/** 列表 (含聚合列 recipient_count / confirmed_count), 不含富文本 content */
List<BizInvite> selectList(BizInvite entity);
int insert(BizInvite entity);
int updateByPrimaryKey(BizInvite entity);
int deleteByPrimaryKey(Long id);
int deleteByPrimaryKeys(Long[] ids);
/** 发送后回填 send_time */
int updateSendTime(@Param("id") Long id, @Param("sendTime") Date sendTime);
}
@@ -0,0 +1,21 @@
package com.ruoyi.business.mapper;
import java.util.List;
import org.apache.ibatis.annotations.Param;
import com.ruoyi.business.domain.BizInviteRecipient;
/**
* 发送邀请收件人明细 Mapper 接口
*/
public interface BizInviteRecipientMapper
{
List<BizInviteRecipient> selectByInviteId(Long inviteId);
BizInviteRecipient selectByToken(String token);
int insert(BizInviteRecipient entity);
int insertBatch(List<BizInviteRecipient> list);
int deleteByInviteId(Long inviteId);
/** 公开确认: 置 confirm_status=1 + confirmed_at=sysdate() */
int updateConfirmByToken(String token);
/** 发送结果回写: send_status + fail_reason */
int updateSendStatus(@Param("id") Long id, @Param("sendStatus") String sendStatus, @Param("failReason") String failReason);
}
@@ -20,6 +20,8 @@ public interface BizMeetingAttendeeMapper {
/** 提交签字: 一次性存 handsign + labor_protocol + signed_at + signed_ip */ /** 提交签字: 一次性存 handsign + labor_protocol + signed_at + signed_ip */
int updateSign(BizMeetingAttendee entity); int updateSign(BizMeetingAttendee entity);
int updateLaborProtocol(BizMeetingAttendee entity); int updateLaborProtocol(BizMeetingAttendee entity);
/** 发送邀请函后回写盖章个性化邀请函 URL (单条) */
int updateInvitationUrl(@Param("id") Long id, @Param("invitationUrl") String invitationUrl);
/** 推送电子签后置 is_esigned=1 (单条) */ /** 推送电子签后置 is_esigned=1 (单条) */
int markEsignedById(@Param("id") Long id); int markEsignedById(@Param("id") Long id);
/** 邀请参会后置 is_invited=1 (单条) */ /** 邀请参会后置 is_invited=1 (单条) */
@@ -33,6 +35,11 @@ public interface BizMeetingAttendeeMapper {
List<BizMeetingAttendee> selectByMeetingId(Long meetingId); List<BizMeetingAttendee> selectByMeetingId(Long meetingId);
List<BizMeetingAttendee> selectByUserId(Long userId); List<BizMeetingAttendee> selectByUserId(Long userId);
BizMeetingAttendee selectById(Long id); BizMeetingAttendee selectById(Long id);
/**
* 按 (meetingId, userId) 查参会人行 id (导入覆盖更新用: 手机号已在该会议时拿旧行 id).
* 无匹配返回 null.
*/
Long selectIdByMeetingIdAndUserId(@Param("meetingId") Long meetingId, @Param("userId") Long userId);
List<BizMeetingAttendee> selectUnsignedByUserId(Long userId); List<BizMeetingAttendee> selectUnsignedByUserId(Long userId);
/** 当前用户的"待参加"会议 (已邀请参会 is_invited=1), 联表取会议名/时间 */ /** 当前用户的"待参加"会议 (已邀请参会 is_invited=1), 联表取会议名/时间 */
List<BizMeetingAttendee> selectInvitedByUserId(Long userId); List<BizMeetingAttendee> selectInvitedByUserId(Long userId);
@@ -51,6 +51,22 @@ public interface BizMeetingInvoiceMapper {
int updateAmountByMaterial(@org.apache.ibatis.annotations.Param("materialId") Long materialId, int updateAmountByMaterial(@org.apache.ibatis.annotations.Param("materialId") Long materialId,
@org.apache.ibatis.annotations.Param("amount") java.math.BigDecimal amount); @org.apache.ibatis.annotations.Param("amount") java.math.BigDecimal amount);
/**
* 单文件 OCR 完成: 更新金额 + 发票号 + 开票日期 (按 material_id, 与状态更新分开调用)
*/
int updateOcrResultByMaterial(@org.apache.ibatis.annotations.Param("materialId") Long materialId,
@org.apache.ibatis.annotations.Param("amount") java.math.BigDecimal amount,
@org.apache.ibatis.annotations.Param("invoiceNo") String invoiceNo,
@org.apache.ibatis.annotations.Param("invoiceDate") String invoiceDate);
/**
* 兜底 OCR 完成: 更新状态(RECOGNIZED) + 金额 + 发票号 + 开票日期 (按主键)
*/
int updateOcrResultByPrimaryKey(@org.apache.ibatis.annotations.Param("id") Long id,
@org.apache.ibatis.annotations.Param("amount") java.math.BigDecimal amount,
@org.apache.ibatis.annotations.Param("invoiceNo") String invoiceNo,
@org.apache.ibatis.annotations.Param("invoiceDate") String invoiceDate);
/** /**
* 兜底 OCR: 按主键更新 (状态 + 金额 + 错误信息) * 兜底 OCR: 按主键更新 (状态 + 金额 + 错误信息)
*/ */
@@ -58,4 +74,10 @@ public interface BizMeetingInvoiceMapper {
@org.apache.ibatis.annotations.Param("recognizeStatus") String recognizeStatus, @org.apache.ibatis.annotations.Param("recognizeStatus") String recognizeStatus,
@org.apache.ibatis.annotations.Param("amount") java.math.BigDecimal amount, @org.apache.ibatis.annotations.Param("amount") java.math.BigDecimal amount,
@org.apache.ibatis.annotations.Param("errorMsg") String errorMsg); @org.apache.ibatis.annotations.Param("errorMsg") String errorMsg);
/**
* 材料全删全插后, 未变材料的发票 material_id 需从旧 id 迁移到新 id (否则发票表 join 不到材料类型)
*/
int updateInvoiceMaterialId(@org.apache.ibatis.annotations.Param("oldMaterialId") Long oldMaterialId,
@org.apache.ibatis.annotations.Param("newMaterialId") Long newMaterialId);
} }
@@ -48,6 +48,12 @@ public interface BizMeetingMapper
* <p>由 MeetingStageScheduler 每分钟触发. * <p>由 MeetingStageScheduler 每分钟触发.
*/ */
int markFrozen(); int markFrozen();
/**
* 自动流转 (反向纠偏): start_time 被改到未来、但 current_stage 仍残留 IN_PROGRESS 的会议 → 置回 NOT_STARTED.
* <p>markInProgress 只做单向 (NOT_STARTED→IN_PROGRESS), 编辑把 start_time 推到未来时无人回退; 本方法由
* MeetingStageScheduler 每分钟触发, 与 markInProgress 互为逆操作, 保证时间窗口态双向一致.
*/
int markNotStarted();
/** /**
* 费用汇总调度器用: 查 fee_calc_status=0 且未软删的会议 id 列表. * 费用汇总调度器用: 查 fee_calc_status=0 且未软删的会议 id 列表.
*/ */
@@ -71,4 +77,11 @@ public interface BizMeetingMapper
int updateLaborFee(@Param("meetingId") Long meetingId, int updateLaborFee(@Param("meetingId") Long meetingId,
@Param("laborFee") BigDecimal laborFee, @Param("laborFee") BigDecimal laborFee,
@Param("totalFee") BigDecimal totalFee); @Param("totalFee") BigDecimal totalFee);
/**
* 项目 submit_deadline_days 变更时级联重算: 该项目下「从未退回、且仍有轨未提交」的会议 (未软删/未冻结),
* 提交截止时间 = end_time + days 天 (days 为 null 则置 NULL → 永不冻结).
* <p>排除 REJECTED 轨: 退回时截止时间已被重置为 now+天数 (锚点=退回时刻), 按 end_time 重算会错误缩短整改窗口.
* 已冻结/两轨均已提交的会议不动 (其截止时间已无意义).
*/
int recomputeSubmitDeadlinesByProject(@Param("projectId") Long projectId, @Param("days") Integer days);
} }
@@ -10,6 +10,8 @@ public interface BizOrgMapper {
int insert(BizOrg entity); int insert(BizOrg entity);
int updateByPrimaryKey(BizOrg entity); int updateByPrimaryKey(BizOrg entity);
int deleteByPrimaryKeys(Long[] orgIds); int deleteByPrimaryKeys(Long[] orgIds);
/** 级联软删机构下所有账号 (主 + 子), 删除机构时调用 */
int softDeleteUsersByOrgId(Long orgId);
/** 支持方下拉选项 (JOIN sys_user.user_name), 用于 manager 项目分配弹窗, 返回 orgId/orgName/userName */ /** 支持方下拉选项 (JOIN sys_user.user_name), 用于 manager 项目分配弹窗, 返回 orgId/orgName/userName */
List<Map<String, Object>> selectSponsorOrgOptions(BizOrg entity); List<Map<String, Object>> selectSponsorOrgOptions(BizOrg entity);
/** 执行方下拉选项 (JOIN sys_user MAIN 账号 user_name), 用于 manager 项目分配弹窗 /** 执行方下拉选项 (JOIN sys_user MAIN 账号 user_name), 用于 manager 项目分配弹窗
@@ -22,15 +24,20 @@ public interface BizOrgMapper {
* 与 selectSponsorOrgOptions 区别: 不 JOIN sys_user (无主账号的 org 也可选), 返回 orgId + mainUserId * 与 selectSponsorOrgOptions 区别: 不 JOIN sys_user (无主账号的 org 也可选), 返回 orgId + mainUserId
* 返回 Map: orgId / orgName / mainUserId (= biz_org.user_id, 可为 null 表示该企业暂无主账号) */ * 返回 Map: orgId / orgName / mainUserId (= biz_org.user_id, 可为 null 表示该企业暂无主账号) */
List<Map<String, Object>> selectSponsorRegisterOptions(BizOrg entity); List<Map<String, Object>> selectSponsorRegisterOptions(BizOrg entity);
/** 当前登录 sponsor 的所属公司 /** 当前登录账号的所属公司 (sponsor + executor 共用)
* 主账号 (sys_user.parent_user_id IS NULL): biz_org.user_id = #{userId} * 根据 sys_user.role_type 自动选 sponsor/executor
* 账号 (有 biz_person 记录): biz_org.org_id = biz_person.org_id * 账号 (biz_org.user_id = #{userId}): 走 own
* 返回 Map: orgId / orgName / isOwner (1=主账号, 0=子账号) * 子账号 (有 biz_person 记录): biz_org.org_id = biz_person.org_id
* 用于 /sponsor/account 页面回显 + 主账号修改 org_name */ * 返回 Map: orgId / orgName / orgType / isOwner (1=主账号, 0=子账号)
Map<String, Object> selectMySponsorCompany(Long userId); * 用于 /sponsor/account + /executor/account 页面只读回显单位名称 (主账号也不可改) */
Map<String, Object> selectMyCompany(Long userId);
/** user_id → org_id 单一可信源反查: COALESCE(biz_org.user_id, biz_person.user_id) /** user_id → org_id 单一可信源反查: COALESCE(biz_org.user_id, biz_person.user_id)
* MAIN 主账号走 biz_org.user_id; SUB 子账号走 biz_person.org_id; 都没有返回 null */ * MAIN 主账号走 biz_org.user_id; SUB 子账号走 biz_person.org_id; 都没有返回 null */
Long selectOrgIdByUserId(Long userId); Long selectOrgIdByUserId(Long userId);
/** 单位名称查重: 同 orgType 下 org_name 精确匹配的条数 (新增单位前判重) */ /** 单位名称查重: 同 orgType 下 org_name 精确匹配的条数 (新增单位前判重) */
int countByOrgName(BizOrg entity); int countByOrgName(BizOrg entity);
/** 供应商同步 org 去重: 按税号精确定位 executor org (取 org_id 最小) */
BizOrg selectByTaxNo(BizOrg entity);
/** 供应商同步 org 去重兜底: 税号缺失时按企业名精确定位 executor org (取 org_id 最小) */
BizOrg selectByNameAndType(BizOrg entity);
} }
@@ -1,13 +1,18 @@
package com.ruoyi.business.mapper; package com.ruoyi.business.mapper;
import java.util.List; import java.util.List;
import org.apache.ibatis.annotations.Param;
import com.ruoyi.business.domain.BizProjectExecutorAssign; import com.ruoyi.business.domain.BizProjectExecutorAssign;
public interface BizProjectExecutorAssignMapper { public interface BizProjectExecutorAssignMapper {
int insertAssign(BizProjectExecutorAssign entity); int insertAssign(BizProjectExecutorAssign entity);
List<BizProjectExecutorAssign> selectByProjectId(String projectId); List<BizProjectExecutorAssign> selectByProjectId(Long projectId);
/** 按 project_id 全删 (执行方分配: 先删后插策略) */ /** 按 project_id 全删 (执行方分配: 先删后插策略) */
int deleteByProjectId(String projectId); int deleteByProjectId(Long projectId);
/** 软删除: 项目级联删除时按 project_id (String) 置 is_deleted=1 */ /** 软删除: 项目级联删除时按 project_id 置 is_deleted=1 */
int softDeleteByProjectId(String projectId); int softDeleteByProjectId(Long projectId);
/** 按 (project_id + executor_org_id) 物理删除本执行方的执行人分配 (替代按 project 全删, 避免多执行方互相覆盖) */
int deleteByProjectIdAndOrg(@Param("projectId") Long projectId, @Param("executorOrgId") Long executorOrgId);
/** 按 (project_id + executor_org_id) 查本执行方的执行人分配 (回显隔离, 避免串读别家执行人) */
List<BizProjectExecutorAssign> selectByProjectIdAndOrg(@Param("projectId") Long projectId, @Param("executorOrgId") Long executorOrgId);
} }
@@ -1,5 +1,6 @@
package com.ruoyi.business.mapper; package com.ruoyi.business.mapper;
import java.util.List; import java.util.List;
import java.util.Map;
import com.ruoyi.business.domain.BizProject; import com.ruoyi.business.domain.BizProject;
/** /**
@@ -9,8 +10,9 @@ public interface BizProjectMapper
{ {
BizProject selectByPrimaryKey(Long projectId); BizProject selectByPrimaryKey(Long projectId);
List<BizProject> selectList(BizProject entity); List<BizProject> selectList(BizProject entity);
/** 公开门户公示列表: is_published='1' 且未删除, 按 publish_time 倒序 (最新在前, 空值排最后) */ /** 公开门户公示列表: is_published='Y' 且未删除, 按 publish_time 倒序 (最新在前, 空值排最后).
List<BizProject> selectPublicAnnouncements(); * 仅查公示页展示的少量标量列, keyword 按项目名模糊过滤, 分页由 controller startPage 注入. */
List<BizProject> selectPublicAnnouncements(@org.apache.ibatis.annotations.Param("keyword") String keyword);
/** sponsor 端专属: projectIds + LEFT JOIN 当前 login 用户评分, 用于评分回显 */ /** sponsor 端专属: projectIds + LEFT JOIN 当前 login 用户评分, 用于评分回显 */
List<BizProject> selectSponsorList(BizProject entity); List<BizProject> selectSponsorList(BizProject entity);
/** executor 端专属: JOIN biz_project_assign 过滤, 只返回分给当前 user 的项目 */ /** executor 端专属: JOIN biz_project_assign 过滤, 只返回分给当前 user 的项目 */
@@ -35,4 +37,6 @@ public interface BizProjectMapper
int clearAnnouncement(@org.apache.ibatis.annotations.Param("projectId") Long projectId); int clearAnnouncement(@org.apache.ibatis.annotations.Param("projectId") Long projectId);
/** 开通到期回收: 到期(open_deadline <= 今天)的 open_status='Y' 置回 'N' */ /** 开通到期回收: 到期(open_deadline <= 今天)的 open_status='Y' 置回 'N' */
int closeExpiredOpenStatus(); int closeExpiredOpenStatus();
/** 结题前置检查: 返回还有未结算会议(is_settled<>1)的项目 [{projectId,projectNo,unsettledCount}], 仅含 unsettledCount>0 */
List<Map<String, Object>> checkMeetingSettled(@org.apache.ibatis.annotations.Param("projectIds") List<Long> projectIds);
} }
@@ -59,6 +59,8 @@ public class BizNotifyService
public static final String BIZ_PROJECT = "project"; public static final String BIZ_PROJECT = "project";
public static final String BIZ_PROJECT_PLAN = "projectPlan"; public static final String BIZ_PROJECT_PLAN = "projectPlan";
public static final String BIZ_MEETING = "meeting"; 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_AGREEMENT = "agreement";
public static final String BIZ_PUBLICITY = "publicity"; public static final String BIZ_PUBLICITY = "publicity";
public static final String BIZ_ORG = "org"; public static final String BIZ_ORG = "org";
@@ -166,11 +168,12 @@ public class BizNotifyService
* 业务去重由调用方保证 (差集), 本方法不再判断. * 业务去重由调用方保证 (差集), 本方法不再判断.
* *
* @param userId 被邀请人 sys_user.user_id * @param userId 被邀请人 sys_user.user_id
* @param attendeeId biz_meeting_attendee.id (存 bizId, 前端点击通知跳转到邀请函页)
* @param meetingId biz_meeting.meeting_id * @param meetingId biz_meeting.meeting_id
* @param meetingName 会议名 (可空, 兜底 "会议 #ID") * @param meetingName 会议名 (可空, 兜底 "会议 #ID")
* @param startTime 会议开始时间 (可空, 不展示具体时间) * @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) { if (userId == null) {
log.warn("[notify] meetingInvitation: userId 为空, 跳过 (meetingId={})", meetingId); log.warn("[notify] meetingInvitation: userId 为空, 跳过 (meetingId={})", meetingId);
@@ -181,18 +184,18 @@ public class BizNotifyService
if (startTime != null) { if (startTime != null) {
content.append(", 时间: ").append(new java.text.SimpleDateFormat("yyyy-MM-dd HH:mm").format(startTime)); content.append(", 时间: ").append(new java.text.SimpleDateFormat("yyyy-MM-dd HH:mm").format(startTime));
} }
content.append("。请登录系统查看详情"); content.append("。请点击查看邀请函");
BizMessage msg = new BizMessage(); BizMessage msg = new BizMessage();
msg.setReceiverUserId(userId); msg.setReceiverUserId(userId);
msg.setMsgType(TYPE_NOTIFY); // 会议邀请 = 普通通知 (医生只需知道被邀请, 不是必须立即操作) msg.setMsgType(TYPE_NOTIFY); // 会议邀请 = 普通通知 (医生只需知道被邀请, 不是必须立即操作)
msg.setTitle("会议邀请: " + name); msg.setTitle("会议邀请: " + name);
msg.setContent(content.toString()); msg.setContent(content.toString());
msg.setBizType(BIZ_MEETING); msg.setBizType(BIZ_MEETING_INVITATION); // 独立类型: 前端点击直接跳邀请函页, 不弹 dialog
msg.setBizId(meetingId); msg.setBizId(attendeeId);
msg.setCreateBy("system"); msg.setCreateBy("system");
bizMessageService.insert(msg); bizMessageService.insert(msg);
log.info("[notify] meetingInvitation 已发 uid={} meetingId={}", userId, meetingId); log.info("[notify] meetingInvitation 已发 uid={} meetingId={} attendeeId={}", userId, meetingId, attendeeId);
} }
/** /**
@@ -375,4 +378,36 @@ public class BizNotifyService
bizMessageService.insert(msg); bizMessageService.insert(msg);
log.info("[notify] projectAssignedToSponsor 已发 uid={} projectId={}", monitorUserId, projectId); log.info("[notify] projectAssignedToSponsor 已发 uid={} projectId={}", monitorUserId, projectId);
} }
/**
* #8 项目分配给支持方 → 通知支持方主账号 (待办: 去查看项目并分配监察员).
*
* <p>调用方: {@link com.ruoyi.business.controller.BizProjectController#edit},
* 检测到 biz_project.sponsor_org_id 真正变了 (null→org / A→B) 才调. 去掉支持方 (置 null) 不发.
*
* <p>与 {@link #projectAssignedToSponsor} 的区别: 那条是通知被分配的监察员 (SUB sponsor),
* 本条是通知支持单位的主账号 (biz_org.user_id, MAIN sponsor).
*
* @param sponsorUserId 支持方主账号 sys_user.user_id (biz_org.user_id, nullable, 跳过)
* @param projectId biz_project.project_id (Long, 用于 bizId)
* @param projectName 项目名 (可空, 兜底)
*/
public void projectAssignedToSupportOrg(Long sponsorUserId, Long projectId, String projectName)
{
if (sponsorUserId == null) {
log.warn("[notify] projectAssignedToSupportOrg: sponsorUserId 为空, 跳过 (projectId={})", projectId);
return;
}
String name = projectName != null ? projectName : ("项目 #" + projectId);
BizMessage msg = new BizMessage();
msg.setReceiverUserId(sponsorUserId);
msg.setMsgType(TYPE_TODO);
msg.setTitle("项目分配: " + name);
msg.setContent("您单位被分配为项目【" + name + "】的支持方,请登录系统查看并分配监察员。");
msg.setBizType(BIZ_PROJECT);
msg.setBizId(projectId);
msg.setCreateBy("system");
bizMessageService.insert(msg);
log.info("[notify] projectAssignedToSupportOrg 已发 uid={} projectId={}", sponsorUserId, projectId);
}
} }
@@ -7,6 +7,8 @@ import java.io.File;
import java.io.FileInputStream; import java.io.FileInputStream;
import java.io.FileOutputStream; import java.io.FileOutputStream;
import java.io.IOException; import java.io.IOException;
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.zip.ZipEntry; import java.util.zip.ZipEntry;
@@ -39,31 +41,17 @@ public class ZipExtractor
if (size <= 0) throw new IOException("OSS zip 下载失败: " + zipUrl); if (size <= 0) throw new IOException("OSS zip 下载失败: " + zipUrl);
log.info("zip 下载: url={} size={}B tmp={}", zipUrl, size, zipFile.getAbsolutePath()); log.info("zip 下载: url={} size={}B tmp={}", zipUrl, size, zipFile.getAbsolutePath());
List<File> out = new ArrayList<>(); // 优先 UTF-8 解包; 若 entry 名非 UTF-8 (常见 GBK, Windows 打包) 会抛
try (ZipInputStream zin = new ZipInputStream(new FileInputStream(zipFile))) // IllegalArgumentException (malformed input) → 回退 GBK 重试
try
{ {
ZipEntry e; return doExtract(zipFile, tmpRoot, StandardCharsets.UTF_8);
while ((e = zin.getNextEntry()) != null) }
{ catch (IllegalArgumentException e)
if (e.isDirectory()) continue; {
String name = e.getName(); log.warn("zip 文件名非 UTF-8, 回退 GBK 重试: {}", e.getMessage());
String lower = name.toLowerCase(); return doExtract(zipFile, tmpRoot, Charset.forName("GBK"));
if (!(lower.endsWith(".png") || lower.endsWith(".jpg")
|| lower.endsWith(".jpeg") || lower.endsWith(".pdf")))
{
continue;
}
// 子目录展平: a/b/c.png → tmpRoot/a_b_c.png
File outFile = new File(tmpRoot, name.replace("/", "_"));
try (FileOutputStream fos = new FileOutputStream(outFile))
{
zin.transferTo(fos);
}
out.add(outFile);
}
} }
log.info("zip 解压完成: 文件数={}", out.size());
return out;
} }
finally finally
{ {
@@ -72,6 +60,38 @@ public class ZipExtractor
} }
} }
/**
* 按指定 charset 解压 zip, 返回图片/PDF 文件列表
*/
private static List<File> doExtract(File zipFile, File tmpRoot, Charset charset) throws IOException
{
List<File> out = new ArrayList<>();
try (ZipInputStream zin = new ZipInputStream(new FileInputStream(zipFile), charset))
{
ZipEntry e;
while ((e = zin.getNextEntry()) != null)
{
if (e.isDirectory()) continue;
String name = e.getName();
String lower = name.toLowerCase();
if (!(lower.endsWith(".png") || lower.endsWith(".jpg")
|| lower.endsWith(".jpeg") || lower.endsWith(".pdf")))
{
continue;
}
// 子目录展平: a/b/c.png → tmpRoot/a_b_c.png
File outFile = new File(tmpRoot, name.replace("/", "_"));
try (FileOutputStream fos = new FileOutputStream(outFile))
{
zin.transferTo(fos);
}
out.add(outFile);
}
}
log.info("zip 解压完成: charset={} 文件数={}", charset, out.size());
return out;
}
/** 清理整个解压临时目录 */ /** 清理整个解压临时目录 */
public static void cleanup(File tmpRoot) public static void cleanup(File tmpRoot)
{ {
@@ -9,6 +9,7 @@ import java.io.ByteArrayInputStream;
import java.text.SimpleDateFormat; import java.text.SimpleDateFormat;
import java.util.Date; import java.util.Date;
import java.util.UUID; import java.util.UUID;
import java.util.concurrent.ThreadLocalRandom;
/** /**
* 服务端 OSS 上传器 (copy 自 hwt-serve/ruoyi-common-base/.../third/AliOssService,精简) * 服务端 OSS 上传器 (copy 自 hwt-serve/ruoyi-common-base/.../third/AliOssService,精简)
@@ -70,6 +71,57 @@ public class OssUploader
return url; return url;
} }
/**
* 上传字节到 OSS, key 保留原文件名 (原名_13位时间戳_6位随机.扩展名), 供前端从 URL 还原原名显示.
* 与前端 uploadToOss 的 key 命名规则对齐, 用于电子签到表 zip 解压后逐个文件上传.
*/
public String uploadWithOriginalName(byte[] data, String originalFilename, String subDir)
{
if (data == null || data.length == 0) throw new IllegalArgumentException("上传字节为空");
if (originalFilename == null) throw new IllegalArgumentException("originalFilename 不能为空");
log.info("OSS 上传开始(原名): name={} size={}B subDir={}", originalFilename, data.length, subDir);
int dot = originalFilename.lastIndexOf('.');
String ext = dot > 0 ? originalFilename.substring(dot) : "";
String base = dot > 0 ? originalFilename.substring(0, dot) : originalFilename;
// 与前端 uploadToOss 一致: 扩展名剔除危险字符, 基础名危险字符替换为下划线
ext = ext.replaceAll("[\\\\/:*?\"<>|%\\s]+", "");
base = base.replaceAll("[\\\\/:*?\"<>|%\\s]+", "_");
if (base.isEmpty()) base = "file";
String date = new SimpleDateFormat("yyyyMM").format(new Date());
String rand = random6();
String key = (subDir == null ? "" : subDir + "/") + date + "/" + base + "_" + System.currentTimeMillis() + "_" + rand + ext;
OSSClient client = new OSSClient(
ossConfMeta.getEndpoint(),
ossConfMeta.getAccessKeyId(),
ossConfMeta.getAccessKeySecret());
try
{
ObjectMetadata meta = buildObjectMeta(originalFilename, ext);
client.putObject(ossConfMeta.getBucket(), key, new ByteArrayInputStream(data), meta);
}
finally
{
client.shutdown();
}
String url = "https://" + ossConfMeta.getBucket() + "." + ossConfMeta.getEndpoint() + "/" + key;
log.info("OSS 上传完成(原名): {}", url);
return url;
}
/** 6 位小写字母数字随机串 (对应前端 Math.random().toString(36).slice(2,8)) */
private static String random6()
{
String alphabet = "abcdefghijklmnopqrstuvwxyz0123456789";
char[] chars = new char[6];
ThreadLocalRandom rnd = ThreadLocalRandom.current();
for (int i = 0; i < 6; i++) chars[i] = alphabet.charAt(rnd.nextInt(alphabet.length()));
return new String(chars);
}
private static ObjectMetadata buildObjectMeta(String filename, String ext) private static ObjectMetadata buildObjectMeta(String filename, String ext)
{ {
ObjectMetadata meta = new ObjectMetadata(); ObjectMetadata meta = new ObjectMetadata();
@@ -0,0 +1,84 @@
package com.ruoyi.business.pdf;
import com.itextpdf.io.image.ImageDataFactory;
import com.itextpdf.kernel.events.Event;
import com.itextpdf.kernel.events.IEventHandler;
import com.itextpdf.kernel.events.PdfDocumentEvent;
import com.itextpdf.kernel.geom.Rectangle;
import com.itextpdf.kernel.pdf.PdfDocument;
import com.itextpdf.kernel.pdf.PdfPage;
import com.itextpdf.kernel.pdf.canvas.PdfCanvas;
import com.itextpdf.layout.Canvas;
import com.itextpdf.layout.element.Image;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* 劳务协议盖章 (固定定位)
*
* <p>在指定页 (默认第 2 页) 固定坐标叠加公章: x 固定, y = 页面垂直中心偏上 yOffset.
* 使用 {@link PdfDocumentEvent#END_PAGE} 事件叠加在页面内容流之后, 公章 z 序在正文之上.
*
* <p>容错: 印章图片缺失 / 加载失败时只记日志并跳过, 不阻断 PDF 生成.
*/
public class SealStamper implements IEventHandler {
private static final Logger log = LoggerFactory.getLogger(SealStamper.class);
/** 印章图片相对路径 (运行目录, 与 simsun.ttc 同级) */
private final String sealImagePath;
/** 印章水平坐标 (iText 坐标 x 从左往右) */
private final float x;
/** 印章垂直: 页面垂直中心偏上的偏移量 (正=往上) */
private final float yOffsetFromCenter;
/** 印章尺寸 (宽高, px) */
private final float width;
private final float height;
/** 盖章页码 (从 1 起) */
private final int pageNumber;
public SealStamper(String sealImagePath, float x, float yOffsetFromCenter,
float width, float height, int pageNumber) {
this.sealImagePath = sealImagePath;
this.x = x;
this.yOffsetFromCenter = yOffsetFromCenter;
this.width = width;
this.height = height;
this.pageNumber = pageNumber;
}
@Override
public void handleEvent(Event event) {
PdfDocumentEvent documentEvent = (PdfDocumentEvent) event;
PdfDocument document = documentEvent.getDocument();
int n = document.getPageNumber(documentEvent.getPage());
// 只盖指定页
if (n != pageNumber) {
return;
}
Image image;
try {
image = new Image(ImageDataFactory.create(sealImagePath));
} catch (Exception e) {
log.warn("[seal] 印章图片加载失败, 跳过盖章: {}", sealImagePath, e);
return;
}
image.setWidth(width);
image.setHeight(height);
PdfPage page = documentEvent.getPage();
Rectangle pageSize = page.getPageSize();
// y = 页面垂直中心偏上 yOffsetFromCenter (iText 坐标 y 从下往上)
float y = pageSize.getHeight() / 2 + yOffsetFromCenter;
image.setFixedPosition(x, y);
PdfCanvas pdfCanvas = new PdfCanvas(page.newContentStreamAfter(), page.getResources(), document);
Canvas canvas = new Canvas(pdfCanvas, pageSize);
try {
canvas.add(image);
} finally {
canvas.close();
}
}
}
@@ -87,4 +87,26 @@ public class MeetingStageScheduler
log.warn("[MeetingStageScheduler] 冻结异常 (跳过, 下分钟再试)", e); log.warn("[MeetingStageScheduler] 冻结异常 (跳过, 下分钟再试)", e);
} }
} }
/**
* 每分钟: start_time 被改到未来、但 current_stage 仍残留 IN_PROGRESS → 置回 NOT_STARTED.
* <p>{@code markInProgress} 只做单向 NOT_STARTED→IN_PROGRESS, 编辑把 start_time 推到未来时无人回退,
* 由这里兜底自愈 (与 {@code markInProgress} 互为逆操作).
*/
@Scheduled(fixedRate = 60_000, initialDelay = 30_000)
public void markNotStarted()
{
try
{
int affected = meetingMapper.markNotStarted();
if (affected > 0)
{
log.info("[MeetingStageScheduler] 自动置回未执行: 本次更新 {} 行", affected);
}
}
catch (Exception e)
{
log.warn("[MeetingStageScheduler] 置回未执行异常 (跳过, 下分钟再试)", e);
}
}
} }
@@ -10,7 +10,7 @@ import lombok.extern.slf4j.Slf4j;
* 项目"开通"到期回收调度器 (每天 00:05 一次). * 项目"开通"到期回收调度器 (每天 00:05 一次).
* <p> * <p>
* 开通状态流转: 默认 N → 经理"开通"置 Y + open_deadline → 到期(open_deadline &lt;= 今天)回收为 N. * 开通状态流转: 默认 N → 经理"开通"置 Y + open_deadline → 到期(open_deadline &lt;= 今天)回收为 N.
* 结题(is_finished=1)且 open_status=N 的项目, sponsor 不可见 * 结题(is_finished=Y)且 open_status=N 的项目, sponsor 不可见
* (见 BizProjectMapper.selectSponsorList / BizMeetingMapper.selectList 的过滤). * (见 BizProjectMapper.selectSponsorList / BizMeetingMapper.selectList 的过滤).
* <p> * <p>
* 需要启动类 {@code @EnableScheduling} 才会生效 (RuoYiApplication 已有). * 需要启动类 {@code @EnableScheduling} 才会生效 (RuoYiApplication 已有).
@@ -4,6 +4,10 @@ import java.net.URLEncoder;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter; import java.time.format.DateTimeFormatter;
import java.util.List; import java.util.List;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import jakarta.annotation.PostConstruct;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.env.Environment; import org.springframework.core.env.Environment;
@@ -21,12 +25,17 @@ import com.ruoyi.common.utils.http.HttpUtils;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
/** /**
* 供应商账号数据拉取调度器: 每分钟拉取最近 N 分钟更新的账号, 解密后同步到执行方侧. * 供应商账号数据拉取调度器.
* <p> * <p>
* 数据源: {@code GET /supplier-api/bidding/supplier/openapi/accounts} * 数据源: {@code GET /supplier-api/bidding/supplier/openapi/accounts}
* 入参 lastUpdatedTime(最后更新时间) / pageNum / pageSize, 按更新时间倒序返回. * 入参 lastUpdatedTime(最后更新时间) / pageNum / pageSize, 按更新时间倒序返回.
* 返回 data 字段为 AES-256-GCM 加密串, 用 {@link SupplierAccountApiCodec} 解密. * 返回 data 字段为 AES-256-GCM 加密串, 用 {@link SupplierAccountApiCodec} 解密.
* <p> * <p>
* 三个触发点:
* 1. 启动后立即 (异步线程池) 全量拉 1 个月 ({@code full-pull-days}, 默认 30 天)
* 2. 每分钟增量拉最近 5 分钟 ({@code incremental-minutes}, 默认 5)
* 3. 每晚 2 点全量拉 1 个月 ({@code full-pull-days})
* <p>
* 解密后按邮箱 upsert 到 sys_user / biz_org / biz_person (见 {@link SupplierAccountSyncService}). * 解密后按邮箱 upsert 到 sys_user / biz_org / biz_person (见 {@link SupplierAccountSyncService}).
*/ */
@Slf4j @Slf4j
@@ -36,10 +45,11 @@ public class SupplierAccountPullScheduler
private static final String KEY = "supplier-account-api-aes.key"; private static final String KEY = "supplier-account-api-aes.key";
private static final String BASE_URL = "supplier-account-api.base-url"; private static final String BASE_URL = "supplier-account-api.base-url";
private static final String PAGE_SIZE = "supplier-account-api.page-size"; private static final String PAGE_SIZE = "supplier-account-api.page-size";
private static final String PULL_MINUTES = "supplier-account-api.pull-minutes"; private static final String INCREMENTAL_MINUTES = "supplier-account-api.incremental-minutes";
private static final String FULL_PULL_DAYS = "supplier-account-api.full-pull-days";
private static final String DEFAULT_BASE_URL = private static final String DEFAULT_BASE_URL =
"https://zbsuppliertest.guojustar.com/supplier-api/bidding/supplier/openapi/accounts"; "https://zbsupplier.guojustar.com/supplier-api/bidding/supplier/openapi/accounts";
private static final DateTimeFormatter TIME_FMT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); private static final DateTimeFormatter TIME_FMT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
@@ -51,8 +61,46 @@ public class SupplierAccountPullScheduler
@Autowired @Autowired
private SupplierAccountSyncService supplierAccountSyncService; private SupplierAccountSyncService supplierAccountSyncService;
@Scheduled(fixedRate = 60_000, initialDelay = 30_000) /** 启动全量拉取专用线程池 (daemon 单线程, 不阻塞启动, 也不阻塞 JVM 退出) */
public void pullAccounts() private final ExecutorService startupExecutor = Executors.newSingleThreadExecutor(r -> {
Thread t = new Thread(r, "supplier-account-pull-startup");
t.setDaemon(true);
return t;
});
/** 启动后立即全量拉取 1 个月数据 (异步, 不阻塞 Spring 启动) */
@PostConstruct
public void init()
{
startupExecutor.submit(() -> {
try
{
log.info("[SupplierAccountPull] 启动全量拉取开始 (近 {} 天)", fullPullDays());
pull(fullPullDays() * 24 * 60);
}
catch (Exception e)
{
log.warn("[SupplierAccountPull] 启动全量拉取失败", e);
}
});
}
/** 每分钟增量拉最近 N 分钟 (默认 5 分钟) 的账号 */
@Scheduled(fixedRate = 60_000, initialDelay = 60_000)
public void pullIncremental()
{
pull(incrementalMinutes());
}
/** 每晚 2 点全量拉取 1 个月数据 */
@Scheduled(cron = "0 0 2 * * ?")
public void pullFullNightly()
{
pull(fullPullDays() * 24 * 60);
}
/** 拉取最近 minutes 分钟更新的账号并同步 */
private void pull(int minutes)
{ {
try try
{ {
@@ -64,8 +112,7 @@ public class SupplierAccountPullScheduler
} }
String baseUrl = env.getProperty(BASE_URL, DEFAULT_BASE_URL); String baseUrl = env.getProperty(BASE_URL, DEFAULT_BASE_URL);
int pageSize = env.getProperty(PAGE_SIZE, Integer.class, 20); int pageSize = env.getProperty(PAGE_SIZE, Integer.class, 20);
int pullMinutes = env.getProperty(PULL_MINUTES, Integer.class, 5*24*60*60); String lastUpdatedTime = LocalDateTime.now().minusMinutes(minutes).format(TIME_FMT);
String lastUpdatedTime = LocalDateTime.now().minusMinutes(pullMinutes).format(TIME_FMT);
int pageNum = 1; int pageNum = 1;
int fetched = 0; int fetched = 0;
@@ -109,11 +156,21 @@ public class SupplierAccountPullScheduler
} }
pageNum++; pageNum++;
} }
log.info("[SupplierAccountPull] 本轮完成, 共 {} 条", fetched); log.info("[SupplierAccountPull] 本轮完成, 窗口={}分钟, 共 {} 条", minutes, fetched);
} }
catch (Exception e) catch (Exception e)
{ {
log.warn("[SupplierAccountPull] 拉取失败 (跳过, 下分钟再试)", e); log.warn("[SupplierAccountPull] 拉取失败 (跳过, 下再试)", e);
} }
} }
private int incrementalMinutes()
{
return env.getProperty(INCREMENTAL_MINUTES, Integer.class, 5);
}
private int fullPullDays()
{
return env.getProperty(FULL_PULL_DAYS, Integer.class, 30);
}
} }
@@ -0,0 +1,259 @@
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;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import com.ruoyi.business.domain.BizPerson;
import com.ruoyi.business.mapper.BizPersonMapper;
import com.ruoyi.common.core.domain.entity.SysUser;
import com.ruoyi.common.exception.ServiceException;
import com.ruoyi.common.utils.id.SnowflakeId;
import com.ruoyi.system.mapper.SysUserMapper;
import com.ruoyi.system.service.ISysUserService;
/**
* 合规人员(manager) / 项目负责人(leader) 登录身份校验 + 自动建号.
* <p>
* 单一可信源 = 泛微 ecology 的 {@code v_compliance_project_manager} 视图 (按 mobile 手机号):
* - type=1 → 合规人员 → role_type=manager
* - type=2 → 项目负责人 → role_type=leader
* <p>
* 登录决策 (手机号 vs 视图 vs sys_user):
* - 白名单 (manager01/leader01) → 直接放行
* - 每次手机号登录都先查视图 (视图是合规/负责人身份的权威来源)
* - 视图未命中:
* - 未注册 → 报"用户不存在"
* - 已注册且为 manager/leader (已不在视图) → 报"用户不存在"
* - 其他角色 → 放行 (走原逻辑)
* - 视图命中:
* - sys_user 没有 → 入库(sys_user + biz_person, 登录名=手机号, 密码留空) 后放行
* - sys_user 有 + 角色匹配 → 放行
* - sys_user 有 + 角色不匹配 (含 manager/leader 之外的角色被命中) → 软删旧 + 建新 后放行
*/
@Service
public class ComplianceLoginService
{
private static final Logger log = LoggerFactory.getLogger(ComplianceLoginService.class);
private static final String ECOLOGY_LOOKUP_SQL =
"SELECT lastname, type FROM v_compliance_project_manager WHERE mobile = ? LIMIT 1";
/** 登录白名单: 这些账号绕过 ecology 视图校验直接放行 (测试账号 manager01 / leader01) */
private static final Set<String> LOGIN_WHITELIST = Set.of("manager01", "leader01");
@Autowired
@Qualifier("ecologyJdbcTemplate")
private JdbcTemplate ecologyJdbcTemplate;
@Autowired
private SysUserMapper userMapper;
@Autowired
private ISysUserService userService;
@Autowired
private BizPersonMapper bizPersonMapper;
/**
* 短信登录主路径: 查用户 + 合规/负责人校验, 返回最终要登录的 SysUser.
* 无资格时抛 {@link ServiceException}("用户不存在").
*/
@Transactional(rollbackFor = Exception.class)
public SysUser reconcileForLogin(String phone)
{
SysUser user = findUserByPhone(phone);
if (isWhitelisted(user))
{
return user;
}
// 视图查询提前: 每次手机号登录都先查 ecology 视图, 命中即按视图权威身份处理
EcologyPerson p = queryEcology(phone);
if (p == null)
{
// 视图未命中: 未注册 → 拦; 已注册的 manager/leader (已不在视图) → 拦; 其他角色正常放行
if (user == null)
{
throw new ServiceException("用户不存在");
}
String role = user.getRoleType();
if ("manager".equals(role) || "leader".equals(role))
{
throw new ServiceException("用户不存在");
}
return user;
}
String expectedRole = p.type == 1 ? "manager" : "leader";
if (user == null)
{
// sys_user 没有 → 自动入库
return provision(p, phone, expectedRole);
}
if (expectedRole.equals(user.getRoleType()))
{
// 角色匹配 → 直接放行
return user;
}
// 角色不匹配 (含 manager/leader 之外的角色被视图命中) → 软删旧 + 建新
userService.deleteUserByIds(new Long[] { user.getUserId() });
return provision(p, phone, expectedRole);
}
/**
* 密码登录门禁: 按登录用户的手机号查 ecology 视图, 命中即按视图权威身份处理.
* 返回 null=放行 (继续正常密码登录); 非 null=错误提示 (此时调用方应直接报错, 不再走密码登录).
*/
@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)
{
// 视图未命中: manager/leader 已不在视图 → 拦; 其他角色正常密码登录
String role = user.getRoleType();
if ("manager".equals(role) || "leader".equals(role))
{
return "用户不存在";
}
return null;
}
String expectedRole = p.type == 1 ? "manager" : "leader";
if (expectedRole.equals(user.getRoleType()))
{
return null;
}
// 角色不匹配 (含 manager/leader 之外的角色被视图命中) → 软删旧 + 建新 (新账号密码留空, 只能短信登录)
userService.deleteUserByIds(new Long[] { user.getUserId() });
provision(p, phone, expectedRole);
return "账号角色已更新, 请使用手机验证码登录";
}
/**
* 手机号是否存在于 ecology 视图 (短信发码前放行合规/负责人未注册手机号用).
*/
public boolean existsInEcology(String phone)
{
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);
if (probe == null || probe.getUserId() == null)
{
return null;
}
return userMapper.selectUserById(probe.getUserId());
}
private EcologyPerson queryEcology(String phone)
{
if (phone == null || phone.isEmpty())
{
return null;
}
try
{
List<Map<String, Object>> rows = ecologyJdbcTemplate.queryForList(ECOLOGY_LOOKUP_SQL, phone);
if (rows == null || rows.isEmpty())
{
return null;
}
Map<String, Object> row = rows.get(0);
Object typeObj = row.get("type");
if (typeObj == null)
{
return null;
}
EcologyPerson p = new EcologyPerson();
p.type = ((Number) typeObj).intValue();
p.name = row.get("lastname") == null ? null : row.get("lastname").toString();
return p;
}
catch (Exception e)
{
// ecology 库不可达时 fail-closed: 视作视图没有, 合规/负责人被拦, 普通用户不受影响
log.warn("查询 ecology 合规视图失败: {}", e.getMessage());
return null;
}
}
private SysUser provision(EcologyPerson p, String phone, String role)
{
String name = (p.name == null || p.name.isEmpty()) ? phone : p.name;
SysUser u = new SysUser();
u.setUserName(phone); // 登录名 = 手机号
u.setNickName(name);
u.setPhonenumber(phone);
u.setRoleType(role); // 显式写, 避免 DB 默认 'executor'
u.setStatus("0");
// 密码留空 (只能短信登录)
u.setCreateBy(phone);
userService.insertUser(u);
Long userId = u.getUserId();
BizPerson person = new BizPerson();
SnowflakeId.injectIfEmpty(person, "personId");
person.setName(name);
person.setPhone(phone);
person.setUnitType(role);
person.setDepartment("manager".equals(role) ? "合规部" : "项目组");
person.setPosition("manager".equals(role) ? "合规员" : "项目负责人");
person.setUserId(userId);
person.setCreateBy(phone);
person.setUpdateBy(phone);
bizPersonMapper.insert(person);
return u;
}
private static class EcologyPerson
{
int type;
String name;
}
}
@@ -0,0 +1,230 @@
package com.ruoyi.business.service;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import com.ruoyi.common.exception.ServiceException;
/**
* 从泛微 ecology 只读视图拉取项目信息 (admin/projects/new 按项目编号自动回填).
* <p>
* 数据源: {@code v_compliance_project_list} (项目列表) + {@code v_compliance_project_manager} (负责人姓名/手机号).
* <p>
* 字段映射:
* xmbh→项目编号, xmmc→项目名称, xmqssj/xmjssj→开始/结束时间(补全 00:00:00 / 23:59:59),
* xmlx_name→项目形式(线上加线下→线上+线下), yqsry→总金额, glfsfbl→管理费=总金额×比例%,
* xmfzr→项目负责人(通过 v_compliance_project_manager 解析姓名+手机号, 再查/建 sys_user 静默建档).
*/
@Service
public class EcologyProjectService
{
private static final Logger log = LoggerFactory.getLogger(EcologyProjectService.class);
private static final String PROJECT_BY_NO_SQL =
"SELECT id, xmbh, xmmc, xmfzr, yqsry, xmqssj, xmjssj, glfsfbl, xmlx_name " +
"FROM v_compliance_project_list WHERE xmbh = ? ORDER BY id LIMIT 1";
private static final String PROJECT_SEARCH_SQL =
"SELECT xmbh, xmmc FROM v_compliance_project_list WHERE xmbh LIKE ? " +
"GROUP BY xmbh, xmmc ORDER BY xmbh LIMIT 50";
private static final String LEADER_BY_ID_SQL =
"SELECT lastname, mobile FROM v_compliance_project_manager WHERE id = ? LIMIT 1";
@Autowired
@Qualifier("ecologyJdbcTemplate")
private JdbcTemplate ecologyJdbcTemplate;
@Autowired
private ComplianceLoginService complianceLoginService;
/**
* 项目编号模糊搜索 (下拉选项用): 返回 [{projectNo, projectName}], 空关键词返回空列表.
*/
public List<Map<String, Object>> searchProjectNos(String kw)
{
if (kw == null || kw.trim().isEmpty())
{
return new ArrayList<>();
}
String like = "%" + escapeLike(kw.trim()) + "%";
try
{
List<Map<String, Object>> rows = ecologyJdbcTemplate.queryForList(PROJECT_SEARCH_SQL, like);
List<Map<String, Object>> result = new ArrayList<>();
for (Map<String, Object> row : rows)
{
Map<String, Object> m = new LinkedHashMap<>();
m.put("projectNo", str(row, "xmbh"));
m.put("projectName", str(row, "xmmc"));
result.add(m);
}
return result;
}
catch (Exception e)
{
log.warn("查询 ecology 项目编号失败: {}", e.getMessage());
return new ArrayList<>();
}
}
/**
* 按项目编号拉取完整项目信息 + 负责人静默查号/建号.
* 返回字段: projectNo/projectName/projectForm/startTime/endTime/totalAmount/manageFee/
* leaderResolved/leaderName/leaderPhone/leaderUserId.
* 项目不存在或 ecology 不可达抛 {@link ServiceException}.
*/
public Map<String, Object> fetchProjectByNo(String projectNo)
{
if (projectNo == null || projectNo.trim().isEmpty())
{
throw new ServiceException("项目编号不能为空");
}
Map<String, Object> row;
try
{
List<Map<String, Object>> rows = ecologyJdbcTemplate.queryForList(PROJECT_BY_NO_SQL, projectNo.trim());
if (rows == null || rows.isEmpty())
{
throw new ServiceException("ecology 中未找到该项目编号");
}
row = rows.get(0);
}
catch (ServiceException e)
{
throw e;
}
catch (Exception e)
{
log.warn("查询 ecology 项目信息失败: {}", e.getMessage());
throw new ServiceException("ecology 数据源不可用");
}
BigDecimal yqsry = decimal(row, "yqsry");
BigDecimal ratio = decimal(row, "glfsfbl");
Map<String, Object> result = new LinkedHashMap<>();
result.put("projectNo", str(row, "xmbh"));
result.put("projectName", str(row, "xmmc"));
result.put("projectForm", mapProjectForm(str(row, "xmlx_name")));
result.put("startTime", toStartTime(str(row, "xmqssj")));
result.put("endTime", toEndTime(str(row, "xmjssj")));
result.put("totalAmount", yqsry);
result.put("manageFee", calcManageFee(yqsry, ratio));
resolveLeader(row, result);
return result;
}
/**
* 负责人解析: xmfzr(int, hrmresource.id) → v_compliance_project_manager 查姓名/手机号 →
* 静默查/建 sys_user(role_type=leader). 解析不到(负责人不在视图/无手机号)则 leaderResolved=false.
*/
private void resolveLeader(Map<String, Object> row, Map<String, Object> result)
{
Object xmfzr = row.get("xmfzr");
result.put("leaderResolved", false);
result.put("leaderName", null);
result.put("leaderPhone", null);
result.put("leaderUserId", null);
if (xmfzr == null)
{
return;
}
try
{
List<Map<String, Object>> lr = ecologyJdbcTemplate.queryForList(
LEADER_BY_ID_SQL, ((Number) xmfzr).longValue());
if (lr == null || lr.isEmpty())
{
return;
}
String name = str(lr.get(0), "lastname");
String phone = str(lr.get(0), "mobile");
if (phone == null || phone.isEmpty())
{
return;
}
Long userId = complianceLoginService.ensureLeaderAccount(name, phone);
result.put("leaderResolved", true);
result.put("leaderName", name);
result.put("leaderPhone", phone);
result.put("leaderUserId", userId);
}
catch (Exception e)
{
log.warn("解析 ecology 项目负责人失败 (xmfzr={}): {}", xmfzr, e.getMessage());
}
}
private String str(Map<String, Object> row, String key)
{
Object v = row.get(key);
if (v == null)
{
return null;
}
String s = v.toString().trim();
return s.isEmpty() ? null : s;
}
private BigDecimal decimal(Map<String, Object> row, String key)
{
Object v = row.get(key);
if (v == null)
{
return null;
}
try
{
return new BigDecimal(v.toString());
}
catch (NumberFormatException e)
{
return null;
}
}
/** 项目形式: 线上加线下 → 线上+线下 (表单口径), 其余原样返回. */
private String mapProjectForm(String xmlxName)
{
if (xmlxName == null)
{
return null;
}
return "线上加线下".equals(xmlxName) ? "线上+线下" : xmlxName;
}
private String toStartTime(String day)
{
return day == null ? null : day + " 00:00:00";
}
private String toEndTime(String day)
{
return day == null ? null : day + " 23:59:59";
}
/** 管理费 = 总金额 × 比例 / 100, 保留 2 位小数. */
private BigDecimal calcManageFee(BigDecimal yqsry, BigDecimal ratio)
{
if (yqsry == null || ratio == null)
{
return null;
}
return yqsry.multiply(ratio).divide(BigDecimal.valueOf(100), 2, RoundingMode.HALF_UP);
}
private String escapeLike(String s)
{
return s.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_");
}
}
@@ -10,8 +10,22 @@ public interface IBizExecutionIntentService
{ {
BizExecutionIntent getById(String intentId); BizExecutionIntent getById(String intentId);
List<BizExecutionIntent> selectList(BizExecutionIntent entity); List<BizExecutionIntent> selectList(BizExecutionIntent entity);
/** 查询全部记录 (含 del_flag='1' 幽灵占位), 仅"已报名专家导出"用 */
List<BizExecutionIntent> selectListIncludingDeleted(BizExecutionIntent entity);
int insert(BizExecutionIntent entity); int insert(BizExecutionIntent entity);
int updateByPrimaryKey(BizExecutionIntent entity); int updateByPrimaryKey(BizExecutionIntent entity);
int deleteByPrimaryKey(String intentId); int deleteByPrimaryKey(String intentId);
int deleteByPrimaryKeys(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);
} }
@@ -0,0 +1,41 @@
package com.ruoyi.business.service;
import java.util.List;
import org.springframework.web.multipart.MultipartFile;
import com.ruoyi.business.domain.BizHospital;
import com.ruoyi.business.domain.dto.ImportResult;
/**
* 医院字典 Service 接口 (biz_hospital)
*
* @author guoju
*/
public interface IBizHospitalService {
/** 按主键查询 */
BizHospital getById(Long hospitalId);
/** 分页 + 多条件筛选 */
List<BizHospital> selectList(BizHospital entity);
/** 新增 (医院名查重 + 雪花 ID) */
int insert(BizHospital entity);
/** 编辑 (医院名查重) */
int updateByPrimaryKey(BizHospital entity);
/** 批量硬删 */
int deleteByPrimaryKeys(Long[] hospitalIds);
/** 远程搜索 (医生注册下拉, LIMIT 30) */
List<BizHospital> searchByKeyword(String keyword);
/** 唯一性检查: 同 name + province + city 是否已存在 (排除自身) */
int countByTrimmedHospital(BizHospital entity);
/** 统计医院名被业务表 work_unit 引用的条数 (删除前二次确认用) */
int countUsageByHospital(String hospital);
/** 批量导入 (Excel → biz_hospital, 批内/DB 去重, 返回 ImportResult) */
ImportResult importHospitals(MultipartFile file) throws Exception;
}
@@ -0,0 +1,34 @@
package com.ruoyi.business.service;
import java.util.List;
import com.ruoyi.business.domain.BizInvite;
import com.ruoyi.business.domain.BizInviteRecipient;
/**
* 发送邀请 Service 接口
*/
public interface IBizInviteService
{
List<BizInvite> selectList(BizInvite entity);
BizInvite getById(Long id);
List<BizInviteRecipient> listRecipients(Long inviteId);
/**
* 新建邀请: 写主表 + 收件人明细 (生成 token).
* @param invite 主表字段 + recipients 明细
* @param sendNow true=保存后立即发送, false=仅保存草稿
* @return 邀请 id
*/
Long create(BizInvite invite, boolean sendNow);
/** 发送已保存的邀请 (对 remark=1 的收件人触发短信/邮件), 返回实际发送条数 */
int send(Long inviteId);
int deleteByPrimaryKeys(Long[] ids);
/** 公开: 按 token 查收件人 */
BizInviteRecipient getRecipientByToken(String token);
/** 公开: 确认 (不可逆), 返回受影响行数 (0=已确认过或 token 无效) */
int confirmByToken(String token);
}
@@ -1,9 +1,12 @@
package com.ruoyi.business.service; package com.ruoyi.business.service;
import java.util.List; import java.util.List;
import java.util.Map;
import org.springframework.web.multipart.MultipartFile; import org.springframework.web.multipart.MultipartFile;
import com.ruoyi.business.domain.BizMeetingAttendee; import com.ruoyi.business.domain.BizMeetingAttendee;
import com.ruoyi.business.domain.dto.ImportResult; import com.ruoyi.business.domain.dto.ImportResult;
import com.ruoyi.business.domain.vo.FieldMismatch;
public interface IBizMeetingAttendeeService { public interface IBizMeetingAttendeeService {
int insert(BizMeetingAttendee entity); int insert(BizMeetingAttendee entity);
@@ -38,6 +41,14 @@ public interface IBizMeetingAttendeeService {
* @throws ServiceException phone 为空 / 用户已在会议中 * @throws ServiceException phone 为空 / 用户已在会议中
*/ */
Long insertByPhoneWithProfile(BizMeetingAttendee body); Long insertByPhoneWithProfile(BizMeetingAttendee body);
/**
* 导入参会人单行 upsert: 手机号已在该会议 → 覆盖更新已有行 (走 updateProfile 修改逻辑);
* 否则新增 (复用 insertByPhoneWithProfile 的查/建号 + 写档案 + 建报名占位).
* 与 {@link #insertByPhoneWithProfile} 的区别: 手机号重复不抛异常, 而是更新.
*/
Long upsertByPhoneWithProfile(BizMeetingAttendee body);
List<BizMeetingAttendee> selectByMeetingId(Long meetingId); List<BizMeetingAttendee> selectByMeetingId(Long meetingId);
List<BizMeetingAttendee> selectByUserId(Long userId); List<BizMeetingAttendee> selectByUserId(Long userId);
BizMeetingAttendee selectById(Long id); BizMeetingAttendee selectById(Long id);
@@ -63,6 +74,27 @@ public interface IBizMeetingAttendeeService {
*/ */
ImportResult importFromExcel(MultipartFile file, Long meetingId, String operName) throws Exception; ImportResult importFromExcel(MultipartFile file, Long meetingId, String operName) throws Exception;
/**
* 新增/编辑参会人前, 比对表单与专家表 (biz_expert) 档案, 返回不一致字段列表.
*
* <p>解析口径与 {@link #insertByPhoneWithProfile} 一致: 编辑按 body.id 反查 userId,
* 新增按 body.phone 查 sys_user 得 userId (不建号), 再 userId→biz_expert. 查不到专家返回空列表.
*
* @param body 参会人表单 (含 id 或 phone 定位)
* @return 不一致字段 (专家表已有值且与输入不同); 无差异/无专家 → 空列表
*/
List<FieldMismatch> diffAgainstExpert(BizMeetingAttendee body);
/**
* 批量导入 dry-run: 解析 Excel + 逐行比对专家表, 不落库.
*
* @param file Excel 文件
* @param meetingId 会议 id
* @return { total: 数据行数, mismatches: [ImportMismatch] }
* @throws Exception 文件为空 / 解析异常
*/
Map<String, Object> previewImport(MultipartFile file, Long meetingId) throws Exception;
/** /**
* 推送电子签 (批量/单条通用): 对给定 attendee.id 列表逐个 * 推送电子签 (批量/单条通用): 对给定 attendee.id 列表逐个
* 发短信 (电子签模板) + 推站内信 + 置 is_esigned=1. * 发短信 (电子签模板) + 推站内信 + 置 is_esigned=1.
@@ -106,15 +138,15 @@ public interface IBizMeetingAttendeeService {
byte[] buildAgreementTemplateZip(Long meetingId); byte[] buildAgreementTemplateZip(Long meetingId);
/** /**
* 上传"劳务协议" zip, 解压后按 劳务协议/{序号}_{姓名}/ 目录匹配参会人, * 提交"劳务协议" zip 上传任务 (异步): 请求线程读字节 + 登记进度后立即返回 jobId,
* 取目录内第一个文件上传 OSS 并回填 labor_protocol (覆盖式). * 后台线程解压匹配参会人并回填 labor_protocol, 前端轮询 /business/upload/progress/{jobId} 拿进度与结果.
* *
* @param file 用户重新压缩的 zip * @param file 用户重新压缩的 zip
* @param meetingId 会议 id (决定 OSS 子目录 + 参会人列表) * @param meetingId 会议 id (决定 OSS 子目录 + 参会人列表)
* @return 成功回填的参会人数 * @return jobId (进度/结果轮询键)
* @throws Exception 文件为空 / 无参会人 / zip 解析异常 * @throws Exception 文件为空 / 读字节失败
*/ */
int uploadAgreements(MultipartFile file, Long meetingId) throws Exception; String submitAgreementUpload(MultipartFile file, Long meetingId) throws Exception;
/** /**
* 生成"专家照片"空目录模板 zip (专家照片 "下载目录模板" 按钮). * 生成"专家照片"空目录模板 zip (专家照片 "下载目录模板" 按钮).
@@ -126,13 +158,13 @@ public interface IBizMeetingAttendeeService {
byte[] buildExpertPhotoTemplateZip(Long meetingId); byte[] buildExpertPhotoTemplateZip(Long meetingId);
/** /**
* 上传"专家照片" zip, 解压后按 专家照片/{序号}_{姓名}/ 目录匹配参会人, * 提交"专家照片" zip 上传任务 (异步): 请求线程读字节 + 登记进度后立即返回 jobId,
* 目录内所有文件上传 OSS 后逗号拼接, 覆盖式回填 on_site_photos. * 后台线程解压匹配参会人并回填 on_site_photos, 前端轮询 /business/upload/progress/{jobId} 拿进度与结果.
* *
* @param file 用户重新压缩的 zip * @param file 用户重新压缩的 zip
* @param meetingId 会议 id * @param meetingId 会议 id
* @return 成功回填的参会人数 * @return jobId (进度/结果轮询键)
* @throws Exception 文件为空 / 无参会人 / zip 解析异常 * @throws Exception 文件为空 / 读字节失败
*/ */
int uploadExpertPhotos(MultipartFile file, Long meetingId) throws Exception; String submitExpertPhotoUpload(MultipartFile file, Long meetingId) throws Exception;
} }
@@ -1,6 +1,8 @@
package com.ruoyi.business.service; package com.ruoyi.business.service;
import java.util.List; import java.util.List;
import java.util.Map;
import org.springframework.web.multipart.MultipartFile; import org.springframework.web.multipart.MultipartFile;
import com.ruoyi.business.domain.BizMeetingMaterial; import com.ruoyi.business.domain.BizMeetingMaterial;
@@ -52,10 +54,11 @@ public interface IBizMeetingMaterialService {
/** /**
* 扫码拍照回传: ry-h5 手机端拍照直传 OSS 后, 回传 URL 到此存库. * 扫码拍照回传: ry-h5 手机端拍照直传 OSS 后, 回传 URL 到此存库.
* 按 (meetingId, subType) upsert 单行 (存在改 ossUrl, 不存在 insert). * 按 (meetingId, subType) upsert 单行 (存在改 ossUrl, 不存在 insert).
* extraOssUrl: 签到表拍照时额外生成的高斯模糊版 URL (sponsor 只看这个), 其他 subType 传空. * 签到表 (L_SIGN_IN) 连拍多张: ossUrls/extraOssUrls 为多张, 存库时序列化为 JSON 数组字符串;
* 前后全景单张: 取第一个. extraOssUrls 是高斯模糊版 (sponsor 只看这个), 其他 subType 传空.
* 白名单 subType + 会议存在校验 (公开端点防滥用). * 白名单 subType + 会议存在校验 (公开端点防滥用).
*/ */
void upsertFromCamera(Long meetingId, String subType, String ossUrl, String extraOssUrl); void upsertFromCamera(Long meetingId, String subType, List<String> ossUrls, List<String> extraOssUrls);
/** /**
* 结算时保存付款凭证 (LV_PAYMENT + SV_PAYMENT, 合规人员上传). * 结算时保存付款凭证 (LV_PAYMENT + SV_PAYMENT, 合规人员上传).
@@ -114,13 +117,26 @@ public interface IBizMeetingMaterialService {
byte[] buildServiceTemplateZip(Long meetingId); byte[] buildServiceTemplateZip(Long meetingId);
/** /**
* 上传"会务材料" zip, 解压后按 会务材料/{材料类型中文名}/ 目录匹配 subType, * 提交"会务材料" zip 上传任务 (异步): 请求线程读字节 + 登记进度后立即返回 jobId,
* 单文件直接上传 OSS, 多文件先打 zip 再上传 OSS, 按 (meetingId, SERVICE, subType) upsert 回填. * 后台线程解压按 会务材料/{材料类型中文名}/ 目录匹配 subType 并回填, 前端轮询
* /business/upload/progress/{jobId} 拿进度与结果 (result = 回填的材料类数).
* *
* @param file 用户重新压缩的 zip * @param file 用户重新压缩的 zip
* @param meetingId 会议 id (决定 OSS 子目录) * @param meetingId 会议 id (决定 OSS 子目录)
* @return 成功回填的材料类数 * @return jobId (进度/结果轮询键)
* @throws Exception 文件为空 / zip 解析异常 / 未找到有效目录 * @throws Exception 文件为空 / 读字节失败
*/ */
int uploadServiceMaterials(MultipartFile file, Long meetingId) throws Exception; String submitServiceMaterialsUpload(MultipartFile file, Long meetingId) throws Exception;
/**
* 电子签到表 (L_ESIGN_IN) 多文件/zip 上传 (同步, 追加式保存).
* <p>
* file 为单个 Excel (xls/xlsx/csv) 或 zip (后台解压并分析其中所有 Excel).
* 逐个解析脱敏 + 直传 OSS; 单行 L_ESIGN_IN 存多文件 (oss_url 存单 URL / JSON 数组,
* masked_json 存 {headers,rows} / {sheets:[...]}). 已有文件保留, 新文件追加到末尾.
* 任一文件解析失败 → 整体失败回滚.
*
* @return { count: 本次新增文件数, total: 合并后总文件数 }
*/
Map<String, Object> uploadEsignIn(MultipartFile file, Long meetingId) throws Exception;
} }
@@ -49,4 +49,9 @@ public interface IBizMeetingService
* (参会人变化不影响会务费, 也不触发整体重算, 避免"统计中"等待 60s 调度器). * (参会人变化不影响会务费, 也不触发整体重算, 避免"统计中"等待 60s 调度器).
*/ */
void recomputeLaborFee(Long meetingId); void recomputeLaborFee(Long meetingId);
/**
* 项目 submit_deadline_days 变更时级联重算: 该项目下「从未退回、且仍有轨未提交」会议的 submit_deadline = end_time + days 天.
* <p>days 为 null → 置 NULL (永不冻结). 已冻结/两轨均已提交的会议不动; REJECTED 轨按退回时刻为锚点, 不在此重算.
*/
void recomputeSubmitDeadlinesByProject(Long projectId, Integer days);
} }
@@ -19,8 +19,9 @@ public interface IBizOrgService {
List<Map<String, Object>> selectExecutorOrgOptions(BizOrg entity); List<Map<String, Object>> selectExecutorOrgOptions(BizOrg entity);
/** 支持方注册下拉选项 (匿名公开, 含无主账号 org): 返回 orgId / orgName / mainUserId */ /** 支持方注册下拉选项 (匿名公开, 含无主账号 org): 返回 orgId / orgName / mainUserId */
List<Map<String, Object>> selectSponsorRegisterOptions(BizOrg entity); List<Map<String, Object>> selectSponsorRegisterOptions(BizOrg entity);
/** 当前登录 sponsor 的所属公司 (主账号可改, 子账号只读) */ /** 当前登录账号的所属公司 (sponsor + executor 共用, 只读展示)
Map<String, Object> selectMySponsorCompany(Long userId); * 返回 Map: orgId / orgName / orgType / isOwner (1=主账号, 0=子账号) */
Map<String, Object> selectMyCompany(Long userId);
/** user_id → org_id 单一可信源反查 (MAIN 走 biz_org, SUB 走 biz_person), 找不到返回 null */ /** user_id → org_id 单一可信源反查 (MAIN 走 biz_org, SUB 走 biz_person), 找不到返回 null */
Long selectOrgIdByUserId(Long userId); Long selectOrgIdByUserId(Long userId);
@@ -14,6 +14,8 @@ import com.ruoyi.common.core.domain.entity.SysUser;
public interface IBizPersonService public interface IBizPersonService
{ {
BizPerson getById(String personId); BizPerson getById(String personId);
/** 按 user_id 反查人员档案 (登录态拿姓名/手机号, navbar 显示姓名用) */
BizPerson getByUserId(Long userId);
List<BizPerson> selectList(BizPerson entity); List<BizPerson> selectList(BizPerson entity);
/** sponsor 专属: 走 BizPersonMapper.selectSponsorList, 用 sys_user.parent_user_id 做归属过滤 */ /** sponsor 专属: 走 BizPersonMapper.selectSponsorList, 用 sys_user.parent_user_id 做归属过滤 */
List<BizPerson> selectSponsorList(BizPerson entity); List<BizPerson> selectSponsorList(BizPerson entity);
@@ -8,6 +8,6 @@ public interface IBizProjectExecutorAssignService {
int insertAssign(BizProjectExecutorAssign entity); int insertAssign(BizProjectExecutorAssign entity);
/** 执行方多条分配 (一个项目 ↔ N 执行人: 先按 project_id 删, 再逐个 insert, 不会循环 delete) */ /** 执行方多条分配 (一个项目 ↔ N 执行人: 先按 project_id 删, 再逐个 insert, 不会循环 delete) */
int assignStaffForProject(BizProjectExecutorAssign body, List<Long> staffUserIds); int assignStaffForProject(BizProjectExecutorAssign body, List<Long> staffUserIds);
List<BizProjectExecutorAssign> listByProjectId(String projectId); List<BizProjectExecutorAssign> listByProjectId(Long projectId, Long executorOrgId);
int deleteByProjectId(String projectId); int deleteByProjectId(Long projectId);
} }
@@ -1,6 +1,7 @@
package com.ruoyi.business.service; package com.ruoyi.business.service;
import java.util.List; import java.util.List;
import java.util.Map;
import com.ruoyi.business.domain.BizProject; import com.ruoyi.business.domain.BizProject;
/** /**
@@ -10,8 +11,8 @@ public interface IBizProjectService
{ {
BizProject getById(Long projectId); BizProject getById(Long projectId);
List<BizProject> selectList(BizProject entity); List<BizProject> selectList(BizProject entity);
/** 公开门户公示列表: 已发布, 按发布时间倒序 */ /** 公开门户公示列表: 已发布, 按发布时间倒序, keyword 按项目名模糊过滤 (分页由 controller startPage 注入) */
List<BizProject> selectPublicAnnouncements(); List<BizProject> selectPublicAnnouncements(String keyword);
/** sponsor 端专属: LEFT JOIN 当前 login 用户评分回显 */ /** sponsor 端专属: LEFT JOIN 当前 login 用户评分回显 */
List<BizProject> selectSponsorList(BizProject entity); List<BizProject> selectSponsorList(BizProject entity);
/** executor 端专属: JOIN biz_project_assign 过滤, 只返回分给当前 user 的项目 */ /** executor 端专属: JOIN biz_project_assign 过滤, 只返回分给当前 user 的项目 */
@@ -47,4 +48,6 @@ public interface IBizProjectService
/** 开通到期回收: 到期(open_deadline <= 今天)的 open_status='Y' 置回 'N' */ /** 开通到期回收: 到期(open_deadline <= 今天)的 open_status='Y' 置回 'N' */
int closeExpiredOpenStatus(); int closeExpiredOpenStatus();
/** 结题前置检查: 返回还有未结算会议的项目 [{projectId,projectNo,unsettledCount}], 仅含 unsettledCount>0 */
List<Map<String, Object>> checkMeetingSettled(List<Long> projectIds);
} }
@@ -0,0 +1,292 @@
package com.ruoyi.business.service;
import cn.hutool.http.HttpUtil;
import com.itextpdf.io.font.PdfEncodings;
import com.itextpdf.io.image.ImageData;
import com.itextpdf.io.image.ImageDataFactory;
import com.itextpdf.kernel.colors.DeviceRgb;
import com.itextpdf.kernel.font.PdfFont;
import com.itextpdf.kernel.font.PdfFontFactory;
import com.itextpdf.kernel.geom.LineSegment;
import com.itextpdf.kernel.geom.Rectangle;
import com.itextpdf.kernel.geom.Vector;
import com.itextpdf.kernel.pdf.PdfDocument;
import com.itextpdf.kernel.pdf.PdfPage;
import com.itextpdf.kernel.pdf.PdfReader;
import com.itextpdf.kernel.pdf.PdfWriter;
import com.itextpdf.kernel.pdf.canvas.PdfCanvas;
import com.itextpdf.kernel.pdf.canvas.parser.EventType;
import com.itextpdf.kernel.pdf.canvas.parser.PdfTextExtractor;
import com.itextpdf.kernel.pdf.canvas.parser.data.IEventData;
import com.itextpdf.kernel.pdf.canvas.parser.data.TextRenderInfo;
import com.itextpdf.kernel.pdf.canvas.parser.listener.ITextExtractionStrategy;
import com.ruoyi.business.oss.OssUploader;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Locale;
import java.util.Set;
/**
* 会议邀请函个性化 PDF 生成服务.
*
* <p>流程 (「发送邀请函」触发): 下载会议邀请函模板 PDF → 把模板里的 {@code xxx}/{@code XXX}
* 替换为医生姓名 → 找到<strong>最后一个</strong>「北京整合医学学会」在其位置盖公章 → 上传 OSS.
*
* <p>文本定位用 iText7 的 {@link PdfTextExtractor} + 自定义 {@link ITextExtractionStrategy},
* 逐字形拿到 baseline / ascent / descent / 字号, 白底盖住占位符后在原 baseline 处写姓名.
* 模板若为扫描图片 PDF (无文本层), 抽取不到目标文本 → 仅记 warn、原样返回 (不盖名章).
*/
@Slf4j
@Service
public class InvitationPdfService {
@Autowired
private OssUploader ossUploader;
/** 公章图片相对路径 (运行目录, 与 simsun.ttc / 劳务协议盖章同图) */
private static final String SEAL_IMAGE_PATH = "zhenghe.png";
/** 公章尺寸 (宽高, px), 复用 PdfService 劳务协议章口径 */
private static final float SEAL_WIDTH = 120f;
private static final float SEAL_HEIGHT = 120f;
/** 需盖章的落款机构名 (取全文最后一个) */
private static final String ORG_TEXT = "北京整合医学学会";
/** 姓名占位符 (忽略大小写, 兼容 xxx / XXX) */
private static final String PLACEHOLDER = "xxx";
/** 按 URL 下载邀请函模板 PDF 字节 (批量发送时对同一会议只调用一次) */
public byte[] downloadTemplate(String url) {
log.info("[invitation] 下载邀请函模板: {}", url);
try {
return HttpUtil.downloadBytes(url);
} catch (Exception e) {
throw new RuntimeException("下载邀请函模板失败: " + e.getMessage(), e);
}
}
/** 生成个性化 PDF 并上传 OSS, 返回完整 URL */
public String generateAndUpload(byte[] template, String name) {
if (template == null || template.length == 0) {
throw new IllegalArgumentException("邀请函模板为空");
}
if (name == null || name.trim().isEmpty()) {
name = "专家";
}
byte[] out = generate(template, name);
String filename = System.currentTimeMillis() + "_" + (int) (Math.random() * 1000) + ".pdf";
return ossUploader.upload(out, filename, "meeting/invitation");
}
/** 模板 PDF → 个性化 PDF (替换姓名 + 盖章) */
public byte[] generate(byte[] template, String name) {
try {
ByteArrayOutputStream baos = new ByteArrayOutputStream();
PdfDocument doc = new PdfDocument(
new PdfReader(new ByteArrayInputStream(template)),
new PdfWriter(baos));
try {
render(doc, name);
} finally {
doc.close();
}
return baos.toByteArray();
} catch (Exception e) {
throw new RuntimeException("生成邀请函 PDF 失败: " + e.getMessage(), e);
}
}
/** 第一遍抽取文本定位 → 第二遍叠加姓名 + 公章 */
private void render(PdfDocument doc, String name) throws IOException {
List<NameTarget> nameTargets = new ArrayList<>();
SealTarget seal = null;
int pageCount = doc.getNumberOfPages();
// 第一遍: 逐页抽取文本, 记录 xxx 位置 + 最后一个「北京整合医学学会」位置
for (int p = 1; p <= pageCount; p++) {
PdfPage page = doc.getPage(p);
TextPositionStrategy strategy = new TextPositionStrategy();
PdfTextExtractor.getTextFromPage(page, strategy);
// 展平为「字符 → 字形」列表, 供子串定位 (要求 1 字符 = 1 字形, CJK/拉丁模板恒成立)
List<TextRenderInfo> charInfos = new ArrayList<>();
StringBuilder sb = new StringBuilder();
for (TextRenderInfo info : strategy.infos()) {
List<TextRenderInfo> chars = info.getCharacterRenderInfos();
if (chars != null && !chars.isEmpty()) {
for (TextRenderInfo c : chars) {
String ch = c.getText();
if (ch != null && !ch.isEmpty()) {
charInfos.add(c);
sb.append(ch);
}
}
} else {
String t = info.getText();
if (t != null && !t.isEmpty()) {
charInfos.add(info);
sb.append(t);
}
}
}
String full = sb.toString();
if (full.isEmpty()) continue;
if (charInfos.size() != full.length()) {
log.warn("[invitation] 第{}页字形/字符数不一致 ({}!={}), 跳过定位", p, charInfos.size(), full.length());
continue;
}
String lower = full.toLowerCase(Locale.ROOT);
int i = 0;
while ((i = lower.indexOf(PLACEHOLDER, i)) >= 0) {
nameTargets.add(buildNameTarget(charInfos, i, PLACEHOLDER.length(), p));
i += PLACEHOLDER.length();
}
int j = 0;
while ((j = full.indexOf(ORG_TEXT, j)) >= 0) {
seal = buildSealTarget(charInfos, j, ORG_TEXT.length(), p);
j += ORG_TEXT.length();
}
}
// 第二遍: 叠加
if (nameTargets.isEmpty()) {
log.warn("[invitation] 未找到占位符 xxx, 跳过姓名替换");
} else {
PdfFont cjkFont = PdfFontFactory.createFont("simsun.ttc,0", PdfEncodings.IDENTITY_H, false);
for (NameTarget t : nameTargets) {
drawName(doc, cjkFont, t, name);
}
}
if (seal == null) {
log.warn("[invitation] 未找到 '{}', 跳过盖章", ORG_TEXT);
} else {
drawSeal(doc, seal);
}
}
private NameTarget buildNameTarget(List<TextRenderInfo> charInfos, int start, int len, int page) {
TextRenderInfo first = charInfos.get(start);
LineSegment firstBase = first.getBaseline();
float x = firstBase.getStartPoint().get(Vector.I1);
float baselineY = firstBase.getStartPoint().get(Vector.I2);
float fontSize = first.getFontSize();
float width = 0f;
for (int k = start; k < start + len && k < charInfos.size(); k++) {
width += charInfos.get(k).getBaseline().getLength();
}
return new NameTarget(page, x, baselineY, fontSize, width);
}
private SealTarget buildSealTarget(List<TextRenderInfo> charInfos, int start, int len, int page) {
TextRenderInfo first = charInfos.get(start);
TextRenderInfo last = charInfos.get(start + len - 1);
float x = first.getBaseline().getStartPoint().get(Vector.I1);
float endX = last.getBaseline().getEndPoint().get(Vector.I1);
float ascent = first.getAscentLine().getStartPoint().get(Vector.I2);
float descent = first.getDescentLine().getStartPoint().get(Vector.I2);
float cx = (x + endX) / 2f;
float cy = (ascent + descent) / 2f;
return new SealTarget(page, cx, cy);
}
/** 白底盖住 xxx, 在原 baseline 处写医生姓名 (左对齐) */
private void drawName(PdfDocument doc, PdfFont font, NameTarget t, String name) {
PdfPage page = doc.getPage(t.page);
PdfCanvas canvas = new PdfCanvas(page.newContentStreamAfter(), page.getResources(), doc);
float pad = t.fontSize * 0.1f;
float y = t.baselineY - t.fontSize * 0.28f;
float h = t.fontSize * 1.2f;
canvas.setFillColor(DeviceRgb.WHITE);
canvas.rectangle(t.x - pad, y, t.width + 2 * pad, h);
canvas.fill();
canvas.setFillColor(DeviceRgb.BLACK);
canvas.beginText();
canvas.setFontAndSize(font, t.fontSize);
canvas.setTextMatrix(t.x, t.baselineY);
canvas.showText(name);
canvas.endText();
}
/** 公章居中叠加到最后一个「北京整合医学学会」文本中心 */
private void drawSeal(PdfDocument doc, SealTarget s) {
PdfPage page = doc.getPage(s.page);
PdfCanvas canvas = new PdfCanvas(page.newContentStreamAfter(), page.getResources(), doc);
try {
ImageData imageData = ImageDataFactory.create(SEAL_IMAGE_PATH);
float x = s.cx - SEAL_WIDTH / 2f;
// 公章中心相对落款文本中心再往下偏 1/2 个公章半径 (半径 = SEAL_HEIGHT/2, 即下移 SEAL_HEIGHT/4)
// PDF 坐标系 Y 轴向上为正, "往下" 即减小 Y
float y = s.cy - SEAL_HEIGHT / 2f - SEAL_HEIGHT / 4f;
canvas.addImage(imageData, new Rectangle(x, y, SEAL_WIDTH, SEAL_HEIGHT), false);
} catch (Exception e) {
log.warn("[invitation] 公章图片加载失败, 跳过盖章: {}", SEAL_IMAGE_PATH, e);
}
}
/** 收集所有 RENDER_TEXT 事件的 TextRenderInfo (字形级定位) */
private static class TextPositionStrategy implements ITextExtractionStrategy {
private final List<TextRenderInfo> infos = new ArrayList<>();
@Override
public void eventOccurred(IEventData data, EventType type) {
if (type == EventType.RENDER_TEXT && data instanceof TextRenderInfo) {
TextRenderInfo info = (TextRenderInfo) data;
// 必须保留 graphics state, 否则抽取结束后 getBaseline()/getFontSize()/
// getCharacterRenderInfos() 等会抛 IllegalStateException (iText7 默认丢弃)
info.preserveGraphicsState();
infos.add(info);
}
}
@Override
public Set<EventType> getSupportedEvents() {
return Collections.singleton(EventType.RENDER_TEXT);
}
@Override
public String getResultantText() {
return "";
}
List<TextRenderInfo> infos() {
return infos;
}
}
private static class NameTarget {
final int page;
final float x;
final float baselineY;
final float fontSize;
final float width;
NameTarget(int page, float x, float baselineY, float fontSize, float width) {
this.page = page;
this.x = x;
this.baselineY = baselineY;
this.fontSize = fontSize;
this.width = width;
}
}
private static class SealTarget {
final int page;
final float cx;
final float cy;
SealTarget(int page, float cx, float cy) {
this.page = page;
this.cx = cx;
this.cy = cy;
}
}
}
@@ -3,6 +3,7 @@ package com.ruoyi.business.service;
import com.itextpdf.html2pdf.ConverterProperties; import com.itextpdf.html2pdf.ConverterProperties;
import com.itextpdf.html2pdf.HtmlConverter; import com.itextpdf.html2pdf.HtmlConverter;
import com.itextpdf.io.font.PdfEncodings; import com.itextpdf.io.font.PdfEncodings;
import com.itextpdf.kernel.events.PdfDocumentEvent;
import com.itextpdf.kernel.font.PdfFont; import com.itextpdf.kernel.font.PdfFont;
import com.itextpdf.kernel.font.PdfFontFactory; import com.itextpdf.kernel.font.PdfFontFactory;
import com.itextpdf.kernel.geom.PageSize; import com.itextpdf.kernel.geom.PageSize;
@@ -10,6 +11,7 @@ import com.itextpdf.kernel.pdf.PdfDocument;
import com.itextpdf.kernel.pdf.PdfWriter; import com.itextpdf.kernel.pdf.PdfWriter;
import com.itextpdf.layout.font.FontProvider; import com.itextpdf.layout.font.FontProvider;
import com.ruoyi.business.oss.OssUploader; import com.ruoyi.business.oss.OssUploader;
import com.ruoyi.business.pdf.SealStamper;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
@@ -28,6 +30,18 @@ public class PdfService {
@Autowired @Autowired
private OssUploader ossUploader; private OssUploader ossUploader;
/** 公章图片相对路径 (运行目录, 与 simsun.ttc 同级) */
private static final String SEAL_IMAGE_PATH = "zhenghe.png";
/** 公章水平坐标 (保持不动) */
private static final float SEAL_X = 80f;
/** 公章垂直: 页面正中间偏上 15 */
private static final float SEAL_Y_OFFSET = 15f;
/** 公章尺寸 (宽高, px) */
private static final float SEAL_WIDTH = 120f;
private static final float SEAL_HEIGHT = 120f;
/** 盖章页码 (第 2 页) */
private static final int SEAL_PAGE = 2;
/** /**
* HTML 字符串 → PDF 字节流 * HTML 字符串 → PDF 字节流
*/ */
@@ -48,6 +62,10 @@ public class PdfService {
fontProvider.addFont(cjkFont.getFontProgram(), PdfEncodings.IDENTITY_H); fontProvider.addFont(cjkFont.getFontProgram(), PdfEncodings.IDENTITY_H);
properties.setFontProvider(fontProvider); properties.setFontProvider(fontProvider);
// 盖章: 第 2 页固定坐标叠加公章 (END_PAGE 事件, 必须在 convertToPdf 之前注册, 否则 document 已关闭)
pdfDocument.addEventHandler(PdfDocumentEvent.END_PAGE,
new SealStamper(SEAL_IMAGE_PATH, SEAL_X, SEAL_Y_OFFSET, SEAL_WIDTH, SEAL_HEIGHT, SEAL_PAGE));
HtmlConverter.convertToPdf(fullHtml, pdfDocument, properties); HtmlConverter.convertToPdf(fullHtml, pdfDocument, properties);
pdfDocument.close(); pdfDocument.close();
return baos.toByteArray(); return baos.toByteArray();
@@ -88,8 +88,42 @@ public class StageDeriver
if (t(m.getIsFinished())) return "已完结"; if (t(m.getIsFinished())) return "已完结";
if (t(m.getIsSettled())) return "已结算"; if (t(m.getIsSettled())) return "已结算";
int chosen = chooseState(role, laborState(m), serviceState(m)); int phase = executionPhase(m);
return render(role, chosen, executionPhase(m)); int labor = laborState(m);
int service = serviceState(m);
// 中性角色 (admin/doctor/expert): 维持原逻辑 (最小进度 + 单轨措辞), 不加前缀
if (!"executor".equals(role) && !"sponsor".equals(role) && !"manager".equals(role))
{
return render(role, Math.min(labor, service), phase);
}
// 三流程角色: 两轨合并 + 劳务/会务前缀
String laborLabel = render(role, labor, phase);
String serviceLabel = render(role, service, phase);
if (laborLabel.equals(serviceLabel)) return laborLabel;
// 待办态最高优先: manager=C0(2), sponsor=C1(3), executor=R(0)
int urgent = "manager".equals(role) ? 2 : "sponsor".equals(role) ? 3 : 0;
String track;
String label;
if (labor == urgent) { track = "labor"; label = laborLabel; }
else if (service == urgent) { track = "service"; label = serviceLabel; }
else
{
Date lt = m.getLaborAuditTime();
Date st = m.getServiceAuditTime();
boolean laborNewer = lt != null && (st == null || !lt.before(st));
if (laborNewer) { track = "labor"; label = laborLabel; }
else { track = "service"; label = serviceLabel; }
}
// 仅 待审核/审核通过/待整改/待结算 四类加前缀
if ("待审核".equals(label) || "审核通过".equals(label) || "待整改".equals(label) || "待结算".equals(label) || "待监察".equals(label))
{
return ("labor".equals(track) ? "劳务" : "会务") + label;
}
return label;
} }
/** 按角色优先级选代表轨 (两轨中优先级更高的那轨). */ /** 按角色优先级选代表轨 (两轨中优先级更高的那轨). */
@@ -139,17 +173,17 @@ public class StageDeriver
switch (s) switch (s)
{ {
case 0: // R 退回 case 0: // R 退回
return "executor".equals(role) ? "已退回" : "待整改"; return "待整改";
case 1: // N 未提交 (时间驱动三态: 未执行 → 执行中 → 已执行, 所有角色统一) case 1: // N 未提交 (时间驱动三态: 未执行 → 执行中 → 已执行, 所有角色统一)
if (phase == 0) return "未执行"; if (phase == 0) return "未执行";
if (phase == 1) return "执行中"; if (phase == 1) return "执行中";
return "已执行"; return "已执行";
case 2: // C0 合规审中 case 2: // C0 合规审中
if ("sponsor".equals(role)) return "已执行未传材料"; // 只读 return "待审核"; // executor / manager / sponsor / admin
return "待审核"; // executor / manager / admin
case 3: // C1 支持方审中 case 3: // C1 支持方审中
if ("manager".equals(role)) return "审核通过"; if ("manager".equals(role)) return "审核通过";
return "审核"; // executor / sponsor / admin if ("sponsor".equals(role)) return "监察";
return "待审核"; // executor / admin
default: // 4 = A 已通过 default: // 4 = A 已通过
return "待结算"; return "待结算";
} }
@@ -6,6 +6,7 @@ import com.ruoyi.common.core.redis.RedisCache;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import java.util.UUID; import java.util.UUID;
@@ -38,12 +39,13 @@ public class SysSmsService {
@Autowired @Autowired
private AliyunSmsSender aliyunSmsSender; private AliyunSmsSender aliyunSmsSender;
/** /** 测试环境短信开关: true=固定码 1234 且不真发 (test), false=aliyun 真发 (prod) */
* dev 模式判断: 手机号以 "10" 开头视为开发测试 (固定码 1234), 其它走 aliyun 真发 @Value("${ruoyi.sms.mock-enabled:false}")
* 该逻辑不依赖 application.yml 配置, 确保开发/生产两种手机号能并存 private boolean mockEnabled;
*/
private boolean isDev(String phone) { /** 生产测试账号: 手机号以 "10" 开头视为测试号, 固定码 1234 且不真发 (生产环境测试用) */
return phone.startsWith("10"); private boolean isTestAccount(String phone) {
return phone != null && phone.startsWith("10");
} }
/** 发送短信验证码, 返回 uuid (前端提交验证时回传) */ /** 发送短信验证码, 返回 uuid (前端提交验证时回传) */
@@ -56,12 +58,12 @@ public class SysSmsService {
} }
String code; String code;
if (isDev(phone)) { if (mockEnabled || isTestAccount(phone)) {
// dev 模式 (10 开头): 固定码 1234 (供内部测试) // 测试环境 / 生产测试账号(手机号 10 开头): 固定码 1234 (不真发)
code = "1234"; code = "1234";
log.info("[SMS][DEV][phone-prefix=10] 向 {} 发送验证码: {}", phone, code); log.info("[SMS][MOCK] 向 {} 发送验证码(测试固定码): {}", phone, code);
} else { } else {
// prod 模式: aliyun 真发 // 生产环境: aliyun 真发
code = generateCode(4); code = generateCode(4);
log.info("[SMS][PROD] 向 {} 发送验证码: {}", phone, code); log.info("[SMS][PROD] 向 {} 发送验证码: {}", phone, code);
boolean ok = aliyunSmsSender.sendCode(phone, code); boolean ok = aliyunSmsSender.sendCode(phone, code);
@@ -0,0 +1,153 @@
package com.ruoyi.business.service;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.env.Environment;
import org.springframework.stereotype.Service;
import com.alibaba.fastjson2.JSONObject;
import com.ruoyi.common.core.redis.RedisCache;
import com.ruoyi.common.utils.http.HttpUtils;
/**
* 公众号 JS-SDK 分享签名服务
* <p>链路: appId + appSecret → access_token → jsapi_ticket → signature(sha1)
* <p>参考 hwt-serve WxService.getAccessToken/getTicket, 但签名改在后端完整计算,
* 不把 jsapi_ticket 暴露给前端.
*/
@Service
public class WxShareService
{
private static final Logger log = LoggerFactory.getLogger(WxShareService.class);
/** access_token / jsapi_ticket 缓存有效期 (官方 7200s, 提前 200s 刷新) */
private static final int CACHE_TTL_SECONDS = 7000;
@Autowired
private Environment environment;
@Autowired
private RedisCache redisCache;
private String appId()
{
return environment.getProperty("wx.app-id", "wx2414cddf4bd56092");
}
private String appSecret()
{
return environment.getProperty("wx.app-secret");
}
/**
* 获取公众号 access_token (Redis 缓存, 避免频繁调用被限流 2000 次/日)
*/
private String getAccessToken()
{
String appId = appId();
String key = "wx:access_token:" + appId;
String token = redisCache.getCacheObject(key);
if (token != null && !token.isEmpty())
{
return token;
}
String url = "https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid="
+ appId + "&secret=" + appSecret();
String resp = HttpUtils.sendGet(url);
JSONObject obj = (resp == null || resp.isEmpty()) ? null : JSONObject.parseObject(resp);
if (obj == null || obj.getString("access_token") == null)
{
log.error("获取微信 access_token 失败, resp={}", resp);
throw new RuntimeException("获取微信 access_token 失败: "
+ (obj == null ? resp : obj.getString("errmsg")));
}
token = obj.getString("access_token");
redisCache.setCacheObject(key, token, CACHE_TTL_SECONDS, TimeUnit.SECONDS);
return token;
}
/**
* 获取 jsapi_ticket (Redis 缓存)
*/
private String getJsapiTicket(String accessToken)
{
String appId = appId();
String key = "wx:jsapi_ticket:" + appId;
String ticket = redisCache.getCacheObject(key);
if (ticket != null && !ticket.isEmpty())
{
return ticket;
}
String url = "https://api.weixin.qq.com/cgi-bin/ticket/getticket?access_token="
+ accessToken + "&type=jsapi";
String resp = HttpUtils.sendGet(url);
JSONObject obj = (resp == null || resp.isEmpty()) ? null : JSONObject.parseObject(resp);
if (obj == null || !"0".equals(obj.getString("errcode")) || obj.getString("ticket") == null)
{
log.error("获取微信 jsapi_ticket 失败, resp={}", resp);
throw new RuntimeException("获取微信 jsapi_ticket 失败: "
+ (obj == null ? resp : obj.getString("errmsg")));
}
ticket = obj.getString("ticket");
redisCache.setCacheObject(key, ticket, CACHE_TTL_SECONDS, TimeUnit.SECONDS);
return ticket;
}
/**
* 生成前端 wx.config 所需参数
*
* @param url 当前页面地址 (必须不含 # 及之后 fragment, 微信签名校验会忽略 fragment)
* @return {appId, timestamp, nonceStr, signature}
*/
public Map<String, Object> buildShareConfig(String url)
{
String ticket = getJsapiTicket(getAccessToken());
long timestamp = System.currentTimeMillis() / 1000;
String nonceStr = UUID.randomUUID().toString().replace("-", "").substring(0, 16);
// 官方签名算法: key 按字典序 (jsapi_ticket < noncestr < timestamp < url) 拼接后 sha1
String string1 = "jsapi_ticket=" + ticket + "&noncestr=" + nonceStr
+ "&timestamp=" + timestamp + "&url=" + url;
String signature = sha1(string1);
Map<String, Object> ret = new HashMap<>();
ret.put("appId", appId());
ret.put("timestamp", timestamp);
ret.put("nonceStr", nonceStr);
ret.put("signature", signature);
return ret;
}
/**
* SHA-1 十六进制小写摘要
*/
private static String sha1(String str)
{
try
{
MessageDigest digest = MessageDigest.getInstance("SHA-1");
byte[] bytes = digest.digest(str.getBytes(StandardCharsets.UTF_8));
StringBuilder sb = new StringBuilder();
for (byte b : bytes)
{
String hex = Integer.toHexString(b & 0xFF);
if (hex.length() < 2)
{
sb.append('0');
}
sb.append(hex);
}
return sb.toString();
}
catch (NoSuchAlgorithmException e)
{
throw new RuntimeException("SHA-1 算法不可用", e);
}
}
}
@@ -26,7 +26,7 @@ import com.ruoyi.system.service.ISysUserService;
@Service @Service
public class BizAdminUserServiceImpl implements IBizAdminUserService { public class BizAdminUserServiceImpl implements IBizAdminUserService {
private static final Set<String> ROLE_WHITELIST = Set.of("admin", "manager", "doctor", "executor", "sponsor"); private static final Set<String> ROLE_WHITELIST = Set.of("admin", "manager", "doctor", "executor", "sponsor", "leader");
@Autowired @Autowired
private ISysUserService sysUserService; private ISysUserService sysUserService;
@@ -79,6 +79,7 @@ public class BizAdminUserServiceImpl implements IBizAdminUserService {
switch (role) { switch (role) {
case "admin": case "admin":
case "manager": case "manager":
case "leader": // 项目负责人: 纯 sys_user (姓名落 sys_user.nick_name, 供 lead_user_name 反查兜底)
return insertPlain(b, role, encPwd); return insertPlain(b, role, encPwd);
case "doctor": case "doctor":
return insertDoctor(b, encPwd); return insertDoctor(b, encPwd);
@@ -6,12 +6,17 @@ import org.springframework.stereotype.Service;
import com.ruoyi.business.domain.BizExecutionIntent; import com.ruoyi.business.domain.BizExecutionIntent;
import com.ruoyi.business.mapper.BizExecutionIntentMapper; import com.ruoyi.business.mapper.BizExecutionIntentMapper;
import com.ruoyi.business.service.IBizExecutionIntentService; 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 @Service
public class BizExecutionIntentServiceImpl implements IBizExecutionIntentService public class BizExecutionIntentServiceImpl implements IBizExecutionIntentService
{ {
@Autowired @Autowired
private BizExecutionIntentMapper bizExecutionIntentMapper; private BizExecutionIntentMapper bizExecutionIntentMapper;
@Autowired
private SysUserMapper sysUserMapper;
@Override @Override
public BizExecutionIntent getById(String intentId) public BizExecutionIntent getById(String intentId)
@@ -20,6 +25,9 @@ public class BizExecutionIntentServiceImpl implements IBizExecutionIntentService
public List<BizExecutionIntent> selectList(BizExecutionIntent entity) public List<BizExecutionIntent> selectList(BizExecutionIntent entity)
{ return bizExecutionIntentMapper.selectList(entity); } { return bizExecutionIntentMapper.selectList(entity); }
@Override @Override
public List<BizExecutionIntent> selectListIncludingDeleted(BizExecutionIntent entity)
{ return bizExecutionIntentMapper.selectListIncludingDeleted(entity); }
@Override
public int insert(BizExecutionIntent entity) { com.ruoyi.common.utils.id.SnowflakeId.injectIfEmpty(entity, "intentId"); return bizExecutionIntentMapper.insert(entity); } public int insert(BizExecutionIntent entity) { com.ruoyi.common.utils.id.SnowflakeId.injectIfEmpty(entity, "intentId"); return bizExecutionIntentMapper.insert(entity); }
@Override @Override
public int updateByPrimaryKey(BizExecutionIntent entity) public int updateByPrimaryKey(BizExecutionIntent entity)
@@ -30,4 +38,47 @@ public class BizExecutionIntentServiceImpl implements IBizExecutionIntentService
@Override @Override
public int deleteByPrimaryKeys(String[] intentId) public int deleteByPrimaryKeys(String[] intentId)
{ return bizExecutionIntentMapper.deleteByPrimaryKeys(intentId); } { return bizExecutionIntentMapper.deleteByPrimaryKeys(intentId); }
@Override
public void hardDeletePlaceholder(Long userId, String projectNo)
{
if (userId == null || projectNo == null || projectNo.trim().isEmpty()) return;
bizExecutionIntentMapper.hardDeletePlaceholder(userId, projectNo);
}
@Override
public boolean existsAny(Long userId, String projectNo)
{
if (userId == null || projectNo == null || projectNo.trim().isEmpty()) return false;
return bizExecutionIntentMapper.countByUserAndProject(userId, projectNo) > 0;
}
@Override
public BizExecutionIntent signupByProject(String projectNo, String projectName)
{
if (projectNo == null || projectNo.trim().isEmpty()) return null;
Long uid = SecurityUtils.getUserId();
BizExecutionIntent q = new BizExecutionIntent();
q.setUserId(uid);
q.setProjectNo(projectNo);
List<BizExecutionIntent> exists = bizExecutionIntentMapper.selectList(q);
if (exists != null && !exists.isEmpty()) return null;
// 真报名: 先物理删掉幽灵占位记录 (del_flag='1'), 避免残留
hardDeletePlaceholder(uid, projectNo);
BizExecutionIntent e = new BizExecutionIntent();
e.setUserId(uid);
e.setProjectNo(projectNo);
e.setProjectName(projectName);
SysUser u = sysUserMapper.selectUserById(uid);
if (u != null) {
e.setName(u.getNickName());
e.setPhone(u.getPhonenumber());
}
e.setOnboardStatus("未入库");
e.setCreateBy(SecurityUtils.getUsername());
insert(e);
return e;
}
} }
@@ -0,0 +1,193 @@
package com.ruoyi.business.service.impl;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
import com.ruoyi.business.domain.BizHospital;
import com.ruoyi.business.domain.dto.ImportResult;
import com.ruoyi.business.domain.vo.BizHospitalImportVo;
import com.ruoyi.business.mapper.BizHospitalMapper;
import com.ruoyi.business.service.IBizHospitalService;
import com.ruoyi.common.exception.ServiceException;
import com.ruoyi.common.utils.id.SnowflakeId;
import com.ruoyi.common.utils.poi.ExcelUtil;
/**
* 医院字典 Service 实现 (biz_hospital)
*
* <p>要点:
* <ul>
* <li>所有字符串字段入库前 trim (防首尾空白脏数据)</li>
* <li>医院名 + province + city 判重 (trim + case-insensitive)</li>
* <li>主键走雪花 ID (SnowflakeId.injectIfEmpty) — 前提: id 列已 ALTER 为 BIGINT</li>
* <li>删除走硬删 (字典表, workUnit 为字符串无外键, 删除不破坏业务完整性)</li>
* </ul>
*
* @author guoju
*/
@Service
public class BizHospitalServiceImpl implements IBizHospitalService {
@Autowired
private BizHospitalMapper bizHospitalMapper;
@Override
public BizHospital getById(Long hospitalId) {
return bizHospitalMapper.selectByPrimaryKey(hospitalId);
}
@Override
public List<BizHospital> selectList(BizHospital entity) {
return bizHospitalMapper.selectList(entity);
}
@Override
public int insert(BizHospital entity) {
String hospital = trimToNull(entity.getHospital());
if (hospital == null) {
throw new ServiceException("医院名称不能为空");
}
entity.setHospital(hospital);
normalize(entity);
// 同名医院查重 (trim + case-insensitive + province/city 匹配)
BizHospital probe = new BizHospital();
probe.setHospital(hospital);
probe.setProvince(entity.getProvince());
probe.setCity(entity.getCity());
if (bizHospitalMapper.countByTrimmedHospital(probe) > 0) {
throw new ServiceException("医院「" + hospital + "」已存在");
}
SnowflakeId.injectIfEmpty(entity, "hospitalId");
return bizHospitalMapper.insert(entity);
}
@Override
public int updateByPrimaryKey(BizHospital entity) {
if (entity.getHospitalId() == null) {
throw new ServiceException("医院 ID 不能为空");
}
String hospital = trimToNull(entity.getHospital());
if (hospital == null) {
throw new ServiceException("医院名称不能为空");
}
entity.setHospital(hospital);
normalize(entity);
// 改名时同样查重 (排除自身)
BizHospital probe = new BizHospital();
probe.setHospital(hospital);
probe.setProvince(entity.getProvince());
probe.setCity(entity.getCity());
probe.setHospitalId(entity.getHospitalId());
if (bizHospitalMapper.countByTrimmedHospital(probe) > 0) {
throw new ServiceException("医院「" + hospital + "」已存在");
}
return bizHospitalMapper.updateByPrimaryKey(entity);
}
@Override
public int deleteByPrimaryKeys(Long[] hospitalIds) {
return bizHospitalMapper.deleteByPrimaryKeys(hospitalIds);
}
@Override
public List<BizHospital> searchByKeyword(String keyword) {
return bizHospitalMapper.searchByKeyword(keyword);
}
@Override
public int countByTrimmedHospital(BizHospital entity) {
return bizHospitalMapper.countByTrimmedHospital(entity);
}
@Override
public int countUsageByHospital(String hospital) {
if (hospital == null || hospital.trim().isEmpty()) {
return 0;
}
return bizHospitalMapper.countUsageByHospital(hospital.trim());
}
/**
* 批量导入: Excel → biz_hospital.
* <ol>
* <li>ExcelUtil 反序列化</li>
* <li>行级校验 (医院名必填) + 批内去重 + DB 去重</li>
* <li>逐行 insert (雪花 ID), 失败记 ngList 不影响其它行</li>
* </ol>
*/
@Override
public ImportResult importHospitals(MultipartFile file) throws Exception {
ExcelUtil<BizHospitalImportVo> util = new ExcelUtil<>(BizHospitalImportVo.class);
List<BizHospitalImportVo> importList = util.importExcel(file.getInputStream());
if (importList == null || importList.isEmpty()) {
throw new ServiceException("导入数据不能为空");
}
ImportResult result = new ImportResult();
Set<String> seenHospital = new HashSet<>();
for (int i = 0; i < importList.size(); i++) {
BizHospitalImportVo vo = importList.get(i);
int rowNo = i + 2; // Excel 行号 (1=表头)
try {
String hospital = trimToNull(vo.getHospital());
if (hospital == null) {
throw new ServiceException("医院名称不能为空");
}
// 批内去重 (case-insensitive)
String key = hospital.toLowerCase();
if (!seenHospital.add(key)) {
throw new ServiceException("医院「" + hospital + "」在文件中重复");
}
// DB 去重
BizHospital probe = new BizHospital();
probe.setHospital(hospital);
probe.setProvince(trimToNull(vo.getProvince()));
probe.setCity(trimToNull(vo.getCity()));
if (bizHospitalMapper.countByTrimmedHospital(probe) > 0) {
throw new ServiceException("医院「" + hospital + "」已存在");
}
BizHospital entity = new BizHospital();
entity.setHospital(hospital);
entity.setProvince(trimToNull(vo.getProvince()));
entity.setCity(trimToNull(vo.getCity()));
entity.setOriginalHospital(trimToNull(vo.getOriginalHospital()));
entity.setHospitalCode(trimToNull(vo.getHospitalCode()));
entity.setMilitaryHospital(trimToNull(vo.getMilitaryHospital()));
entity.setType(trimToNull(vo.getType()));
SnowflakeId.injectIfEmpty(entity, "hospitalId");
bizHospitalMapper.insert(entity);
result.ok();
} catch (Exception e) {
result.fail(rowNo, e.getMessage() == null ? "导入失败" : e.getMessage());
}
}
return result;
}
/** 除 hospital 外的字段统一 trim (把空串压成 null, 避免脏数据) */
private void normalize(BizHospital entity) {
entity.setProvince(trimToNull(entity.getProvince()));
entity.setCity(trimToNull(entity.getCity()));
entity.setOriginalHospital(trimToNull(entity.getOriginalHospital()));
entity.setHospitalCode(trimToNull(entity.getHospitalCode()));
entity.setMilitaryHospital(trimToNull(entity.getMilitaryHospital()));
entity.setType(trimToNull(entity.getType()));
}
private String trimToNull(String s) {
if (s == null) return null;
String t = s.trim();
return t.isEmpty() ? null : t;
}
}
@@ -0,0 +1,186 @@
package com.ruoyi.business.service.impl;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import com.ruoyi.common.utils.SecurityUtils;
import com.ruoyi.common.utils.id.IdGenerator;
import com.ruoyi.business.domain.BizInvite;
import com.ruoyi.business.domain.BizInviteRecipient;
import com.ruoyi.business.mapper.BizInviteMapper;
import com.ruoyi.business.mapper.BizInviteRecipientMapper;
import com.ruoyi.business.mail.InviteMailSender;
import com.ruoyi.business.service.IBizInviteService;
import com.ruoyi.business.sms.AliyunSmsSender;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* 发送邀请 Service 实现.
* <p>独立于项目/会议: 项目编号等均为手输文本, 短信/邮件带公开邀请链接 (富文本内容 + 底部确认按钮).
*/
@Service
public class BizInviteServiceImpl implements IBizInviteService
{
private static final Logger log = LoggerFactory.getLogger(BizInviteServiceImpl.class);
@Autowired private BizInviteMapper inviteMapper;
@Autowired private BizInviteRecipientMapper recipientMapper;
@Autowired private AliyunSmsSender smsSender;
@Autowired private InviteMailSender mailSender;
@Override
public List<BizInvite> selectList(BizInvite entity)
{
return inviteMapper.selectList(entity);
}
@Override
public BizInvite getById(Long id)
{
return inviteMapper.selectByPrimaryKey(id);
}
@Override
public List<BizInviteRecipient> listRecipients(Long inviteId)
{
return recipientMapper.selectByInviteId(inviteId);
}
@Override
@Transactional
public Long create(BizInvite invite, boolean sendNow)
{
String username = SecurityUtils.getUsername();
invite.setId(IdGenerator.generateId());
invite.setCreateBy(username);
inviteMapper.insert(invite);
if (invite.getRecipients() != null && !invite.getRecipients().isEmpty())
{
List<BizInviteRecipient> list = new ArrayList<>(invite.getRecipients().size());
for (BizInviteRecipient r : invite.getRecipients())
{
r.setId(IdGenerator.generateId());
r.setInviteId(invite.getId());
r.setToken(IdGenerator.uuid());
if (r.getSendStatus() == null) r.setSendStatus("0");
if (r.getConfirmStatus() == null) r.setConfirmStatus("0");
r.setCreateBy(username);
list.add(r);
}
recipientMapper.insertBatch(list);
}
if (sendNow)
{
send(invite.getId());
}
return invite.getId();
}
@Override
public int send(Long inviteId)
{
BizInvite invite = inviteMapper.selectByPrimaryKey(inviteId);
if (invite == null)
{
throw new RuntimeException("邀请不存在");
}
List<BizInviteRecipient> recipients = recipientMapper.selectByInviteId(inviteId);
int sent = 0;
for (BizInviteRecipient r : recipients)
{
// 只发备注=1 的收件人, 且未成功过
if (r.getRemark() == null || !"1".equals(r.getRemark()))
{
continue;
}
String link = smsSender.inviteLink(r.getToken());
String failReason = sendToRecipient(invite, r, link);
boolean ok = failReason == null;
recipientMapper.updateSendStatus(r.getId(), ok ? "1" : "2", failReason);
if (ok) sent++;
}
inviteMapper.updateSendTime(inviteId, new Date());
return sent;
}
/** 发送单条收件人, 返回 null=成功, 非空=失败原因 */
private String sendToRecipient(BizInvite invite, BizInviteRecipient r, String link)
{
String channel = invite.getSendChannel() == null ? "both" : invite.getSendChannel();
boolean wantSms = channel.contains("sms");
boolean wantEmail = channel.contains("email");
boolean ok = false;
List<String> fails = new ArrayList<>();
if (wantEmail)
{
if (r.getEmail() != null && !r.getEmail().trim().isEmpty())
{
String subject = "诚邀您参加\"" + (invite.getMeetingName() == null ? "" : invite.getMeetingName()) + "\"项目";
String html = buildInviteEmail(invite, r, link);
if (mailSender.sendInvite(r.getEmail(), subject, html)) ok = true;
else fails.add("邮件发送失败");
}
else
{
fails.add("缺少邮箱");
}
}
if (wantSms)
{
if (r.getPhone() != null && !r.getPhone().trim().isEmpty())
{
if (smsSender.sendInvite(r.getPhone(), link)) ok = true;
else fails.add("短信发送失败");
}
else
{
fails.add("缺少手机号");
}
}
return ok ? null : String.join("", fails);
}
/** 邮件正文 (待定稿): 简版提示 + 邀请链接; 完整富文本内容放在公开邀请页渲染 */
private String buildInviteEmail(BizInvite invite, BizInviteRecipient r, String link)
{
String name = r.getName() == null ? "" : r.getName();
return "<div style='font-size:14px;color:#333;'>"
+ "<p>" + name + " 您好:</p>"
+ "<p>诚邀您参加相关项目,请点击以下链接查看邀请内容并确认:</p>"
+ "<p><a href='" + link + "'>" + link + "</a></p>"
+ "</div>";
}
@Override
public int deleteByPrimaryKeys(Long[] ids)
{
int rows = 0;
for (Long id : ids)
{
recipientMapper.deleteByInviteId(id);
rows += inviteMapper.deleteByPrimaryKey(id);
}
return rows;
}
@Override
public BizInviteRecipient getRecipientByToken(String token)
{
return recipientMapper.selectByToken(token);
}
@Override
public int confirmByToken(String token)
{
return recipientMapper.updateConfirmByToken(token);
}
}
@@ -8,12 +8,20 @@ import java.math.BigDecimal;
import java.math.RoundingMode; import java.math.RoundingMode;
import java.nio.charset.Charset; import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets; 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.ArrayList;
import java.util.Collections; import java.util.Collections;
import java.util.Date; import java.util.Date;
import java.util.HashMap; import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.ThreadLocalRandom;
import java.util.zip.ZipEntry; import java.util.zip.ZipEntry;
import java.util.zip.ZipInputStream; import java.util.zip.ZipInputStream;
import java.util.zip.ZipOutputStream; import java.util.zip.ZipOutputStream;
@@ -21,25 +29,32 @@ import org.apache.poi.xssf.streaming.SXSSFWorkbook;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile; import org.springframework.web.multipart.MultipartFile;
import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import com.ruoyi.business.domain.BizExecutionIntent;
import com.ruoyi.business.domain.BizExpert; import com.ruoyi.business.domain.BizExpert;
import com.ruoyi.business.domain.BizMeeting; import com.ruoyi.business.domain.BizMeeting;
import com.ruoyi.business.domain.BizMeetingAttendee; import com.ruoyi.business.domain.BizMeetingAttendee;
import com.ruoyi.business.domain.BizProject; import com.ruoyi.business.domain.BizProject;
import com.ruoyi.business.domain.dto.ImportResult; import com.ruoyi.business.domain.dto.ImportResult;
import com.ruoyi.business.domain.vo.BizMeetingAttendeeImportVo; 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.BizExpertMapper;
import com.ruoyi.business.mapper.BizMeetingAttendeeMapper; import com.ruoyi.business.mapper.BizMeetingAttendeeMapper;
import com.ruoyi.business.mapper.BizMeetingMapper; import com.ruoyi.business.mapper.BizMeetingMapper;
import com.ruoyi.business.mapper.BizProjectMapper; import com.ruoyi.business.mapper.BizProjectMapper;
import com.ruoyi.business.notify.BizNotifyService; import com.ruoyi.business.notify.BizNotifyService;
import com.ruoyi.business.oss.OssUploader; import com.ruoyi.business.oss.OssUploader;
import com.ruoyi.business.service.IBizExecutionIntentService;
import com.ruoyi.business.service.IBizMeetingAttendeeService; import com.ruoyi.business.service.IBizMeetingAttendeeService;
import com.ruoyi.business.service.IBizMeetingService; import com.ruoyi.business.service.IBizMeetingService;
import com.ruoyi.business.service.InvitationPdfService;
import com.ruoyi.business.sms.AliyunSmsSender; import com.ruoyi.business.sms.AliyunSmsSender;
import com.ruoyi.business.upload.UploadProgressRegistry;
import com.ruoyi.common.core.domain.entity.SysUser; import com.ruoyi.common.core.domain.entity.SysUser;
import com.ruoyi.common.exception.ServiceException; import com.ruoyi.common.exception.ServiceException;
import com.ruoyi.common.utils.SecurityUtils; import com.ruoyi.common.utils.SecurityUtils;
@@ -65,6 +80,8 @@ public class BizMeetingAttendeeServiceImpl implements IBizMeetingAttendeeService
private ISysUserService sysUserService; private ISysUserService sysUserService;
@Autowired @Autowired
private BizExpertMapper bizExpertMapper; private BizExpertMapper bizExpertMapper;
@Autowired
private IBizExecutionIntentService bizExecutionIntentService;
/** Jackson (Spring Boot 自带), 解析 biz_project.role_labor JSON 数组 [{role, customName, amount}] */ /** Jackson (Spring Boot 自带), 解析 biz_project.role_labor JSON 数组 [{role, customName, amount}] */
private final ObjectMapper objectMapper = new ObjectMapper(); private final ObjectMapper objectMapper = new ObjectMapper();
@@ -77,6 +94,13 @@ public class BizMeetingAttendeeServiceImpl implements IBizMeetingAttendeeService
private IBizMeetingService bizMeetingService; private IBizMeetingService bizMeetingService;
@Autowired @Autowired
private OssUploader ossUploader; private OssUploader ossUploader;
@Autowired
private InvitationPdfService invitationPdfService;
@Autowired
private UploadProgressRegistry uploadProgressRegistry;
@Autowired
@Qualifier("zipUploadExecutor")
private ExecutorService zipUploadExecutor;
@Override @Override
public int insert(BizMeetingAttendee entity) { public int insert(BizMeetingAttendee entity) {
@@ -114,120 +138,145 @@ public class BizMeetingAttendeeServiceImpl implements IBizMeetingAttendeeService
*/ */
@Override @Override
public Long insertByPhoneWithProfile(BizMeetingAttendee body) { public Long insertByPhoneWithProfile(BizMeetingAttendee body) {
String phone = body.getPhone(); String phone = requirePhone(body);
if (phone == null || phone.trim().isEmpty()) { Long userId = resolveUserIdByPhone(phone, body.getName());
throw new ServiceException("手机号不能为空");
}
phone = phone.trim();
// 校验实发金额不超所选角色劳务金额合计 (与手动新增弹窗 MeetingDetail.validateFee 一致; // 检查 (meetingId, userId) 是否已存在 → 友好提示
// 无角色/无金额/项目无 role_labor 时跳过)
validateFeeAgainstRole(loadRoleLabor(body.getMeetingId()), body.getLaborForm(), body.getFee());
// 1. 按 phone 查 sys_user (单条 IN 查, selectByPhoneList 接受 List<String>)
List<SysUser> hits = sysUserMapper.selectByPhoneList(Collections.singletonList(phone));
Long userId;
SysUser existed = (hits != null && !hits.isEmpty()) ? hits.get(0) : null;
if (existed != null) {
userId = existed.getUserId();
} else {
// 2. 查不到 → 建 sys_user (用户名=phone, 密码=phone, role_type='doctor')
SysUser newUser = new SysUser();
newUser.setUserName(phone);
newUser.setNickName(body.getName() != null && !body.getName().isEmpty() ? body.getName() : phone);
newUser.setPhonenumber(phone);
newUser.setPassword(SecurityUtils.encryptPassword(phone));
newUser.setStatus("0");
newUser.setDelFlag("0");
newUser.setCreateBy(SecurityUtils.getUsername());
sysUserService.insertUser(newUser);
userId = newUser.getUserId();
if (userId == null) {
// 兜底: insertUser 用了 useGeneratedKeys, 正常能拿到; 拿不到时按 phone 再查一次
SysUser re = sysUserMapper.selectUserByUserName(phone);
if (re == null) throw new ServiceException("建账号失败, 请重试");
userId = re.getUserId();
}
// 显式设 role_type='doctor' (DB 默认 'executor', 参会人应为 doctor 视角)
sysUserService.updateRoleType(userId, "doctor");
log.info("[attendee] 新建 sys_user (phone={}, userId={}, roleType=doctor)", phone, userId);
}
// 3. 检查 (meetingId, userId) 是否已存在 → 友好提示
// 仅 SELECT, 不 DELETE (deleteByMeetingIdAndUserId 是破坏性的, 不能误用做探测) // 仅 SELECT, 不 DELETE (deleteByMeetingIdAndUserId 是破坏性的, 不能误用做探测)
java.util.Set<Long> existing = new java.util.HashSet<>(mapper.selectUserIdsByMeetingId(body.getMeetingId())); java.util.Set<Long> existing = new java.util.HashSet<>(mapper.selectUserIdsByMeetingId(body.getMeetingId()));
if (existing.contains(userId)) { if (existing.contains(userId)) {
throw new ServiceException("该手机号参会人已在会议中, 无需重复添加"); throw new ServiceException("该手机号参会人已在会议中, 无需重复添加");
} }
// 3.5 确保专家档案存在 (不存在则新建, 存在则补齐空字段), 让参会人在专家库可见 return insertNewAttendee(body, userId, phone);
ensureExpertProfile(body, userId); }
// 4. 写完整档案行 (attendee.id 用雪花 ID, 不走 DB 自增) /**
* 导入参会人单行 upsert: 手机号已在该会议 → 覆盖更新已有行 (走 updateProfile 修改逻辑),
* 不抛"已在会议中"重复异常; 否则与 insertByPhoneWithProfile 一致地新增.
*/
@Override
public Long upsertByPhoneWithProfile(BizMeetingAttendee body) {
String phone = requirePhone(body);
Long userId = resolveUserIdByPhone(phone, body.getName());
Long existingId = mapper.selectIdByMeetingIdAndUserId(body.getMeetingId(), userId);
if (existingId != null) {
// 覆盖已有行: 复用编辑逻辑 updateProfile (选择性更新, 未填字段保留旧值)
body.setId(existingId);
body.setUpdateBy(SecurityUtils.getUsername());
mapper.updateProfile(body);
log.info("[attendee] 覆盖更新参会人 meetingId={} userId={} attendeeId={}", body.getMeetingId(), userId, existingId);
return existingId;
}
return insertNewAttendee(body, userId, phone);
}
/** 校验手机号非空并 trim 后返回, 空则抛 */
private String requirePhone(BizMeetingAttendee body) {
String phone = body.getPhone();
if (phone == null || phone.trim().isEmpty()) {
throw new ServiceException("手机号不能为空");
}
return phone.trim();
}
/**
* 按 phone 定位/新建 sys_user, 返回 userId (用户名=phone, 密码=phone, roleType='doctor').
* 查到复用, 查不到新建.
*/
private Long resolveUserIdByPhone(String phone, String name) {
List<SysUser> hits = sysUserMapper.selectByPhoneList(Collections.singletonList(phone));
SysUser existed = (hits != null && !hits.isEmpty()) ? hits.get(0) : null;
if (existed != null) {
return existed.getUserId();
}
SysUser newUser = new SysUser();
newUser.setUserName(phone);
newUser.setNickName(name != null && !name.isEmpty() ? name : phone);
newUser.setPhonenumber(phone);
newUser.setPassword(SecurityUtils.encryptPassword(phone));
newUser.setStatus("0");
newUser.setDelFlag("0");
newUser.setCreateBy(SecurityUtils.getUsername());
sysUserService.insertUser(newUser);
Long userId = newUser.getUserId();
if (userId == null) {
// 兜底: insertUser 用了 useGeneratedKeys, 正常能拿到; 拿不到时按 phone 再查一次
SysUser re = sysUserMapper.selectUserByUserName(phone);
if (re == null) throw new ServiceException("建账号失败, 请重试");
userId = re.getUserId();
}
// 显式设 role_type='doctor' (DB 默认 'executor', 参会人应为 doctor 视角)
sysUserService.updateRoleType(userId, "doctor");
log.info("[attendee] 新建 sys_user (phone={}, userId={}, roleType=doctor)", phone, userId);
return userId;
}
/** 新增参会人行: 写完整档案 + 同步报名占位记录, 返回新 attendee.id */
private Long insertNewAttendee(BizMeetingAttendee body, Long userId, String phone) {
// 写完整档案行 (attendee.id 用雪花 ID, 不走 DB 自增)
body.setUserId(userId); body.setUserId(userId);
body.setCreateBy(SecurityUtils.getUsername()); body.setCreateBy(SecurityUtils.getUsername());
body.setId(IdGenerator.generateId()); body.setId(IdGenerator.generateId());
mapper.insertWithProfile(body); mapper.insertWithProfile(body);
Long newId = body.getId(); Long newId = body.getId();
log.info("[attendee] 新增参会人 meetingId={} userId={} attendeeId={}", body.getMeetingId(), userId, newId); log.info("[attendee] 新增参会人 meetingId={} userId={} attendeeId={}", body.getMeetingId(), userId, newId);
// 同步一条"报名项目"幽灵占位记录 (del_flag='1'): 新增/导入都走这里, 一处覆盖两入口
createFakeSignup(body.getMeetingId(), userId, phone, body);
return newId; return newId;
} }
/** /**
* 确保参会人对应的 biz_expert 记录存在且字段尽量完整: * 新增/导入参会人时, 同步给该医生建一条"报名项目"幽灵占位记录 (del_flag='1'):
* - 不存在 → 新建 (status='Y' 正常, auditStatus='2' 通过, 与 importExpert 约定一致) * - 纯占位, 报名列表/导出/报名查重/参会人 has_intent 均按 del_flag='0' 过滤, 不会露出;
* - 存在但部分字段为空 → 只用参会人档案里的非空值补齐空字段, 不覆盖已有值 * - 报名时间随机: 日期 ∈ [项目开始日, 今天], 时间 ∈ [07:00, 21:00);
* - 真报名(报名/邀请函)时会先物理删除这条占位, 再写真实报名.
* 失败只记日志, 不阻断参会人新增/导入.
*/ */
private void ensureExpertProfile(BizMeetingAttendee body, Long userId) { private void createFakeSignup(Long meetingId, Long userId, String phone, BizMeetingAttendee body) {
if (userId == null) return; try {
BizExpert existed = bizExpertMapper.selectByUserId(userId); if (meetingId == null || userId == null) return;
if (existed == null) { BizMeeting meeting = meetingMapper.selectByPrimaryKey(meetingId);
BizExpert e = new BizExpert(); if (meeting == null || meeting.getProjectId() == null) return;
e.setExpertId(IdGenerator.generateId()); 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.setUserId(userId);
e.setName(isBlank(body.getName()) ? body.getPhone() : body.getName()); e.setProjectNo(projectNo);
e.setPhone(body.getPhone()); e.setProjectName(project.getProjectName());
e.setWorkUnit(body.getWorkUnit()); e.setName(name);
e.setDepartment(body.getDepartment()); e.setPhone(phone);
e.setTitle(body.getTitle()); e.setOnboardStatus("未入库");
e.setIdCard(body.getIdCard()); e.setDelFlag("1");
e.setBankCard(body.getBankCard()); e.setCreateTime(randomSignupTime(project.getStartTime()));
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.setCreateBy(SecurityUtils.getUsername()); e.setCreateBy(SecurityUtils.getUsername());
e.setCreateTime(new Date()); bizExecutionIntentService.insert(e);
bizExpertMapper.insert(e); log.info("[attendee] 建报名占位记录 userId={} projectNo={}", userId, projectNo);
return; } catch (Exception ex) {
} log.warn("[attendee] 建报名占位记录失败 meetingId={} userId={}: {}", meetingId, userId, ex.getMessage());
// 补充: 只填 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);
} }
} }
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 @Override
public int updateProfile(BizMeetingAttendee entity) { public int updateProfile(BizMeetingAttendee entity) {
@@ -236,8 +285,6 @@ public class BizMeetingAttendeeServiceImpl implements IBizMeetingAttendeeService
BizMeetingAttendee old = mapper.selectById(entity.getId()); BizMeetingAttendee old = mapper.selectById(entity.getId());
if (old != null) meetingId = old.getMeetingId(); if (old != null) meetingId = old.getMeetingId();
} }
// 校验实发金额不超所选角色劳务金额合计 (与手动新增弹窗 MeetingDetail.validateFee 一致)
validateFeeAgainstRole(loadRoleLabor(meetingId), entity.getLaborForm(), entity.getFee());
return mapper.updateProfile(entity); return mapper.updateProfile(entity);
} }
@@ -377,24 +424,6 @@ public class BizMeetingAttendeeServiceImpl implements IBizMeetingAttendeeService
return fee; return fee;
} }
/**
* 解析某会议的 project.role_labor JSON 数组, 失败/无数据返回 null.
* 供"实发金额 ≤ 角色设置金额"校验复用 (单条 add/edit 各自加载).
*/
private JsonNode loadRoleLabor(Long meetingId) {
if (meetingId == null) return null;
try {
BizMeeting meeting = meetingMapper.selectByPrimaryKey(meetingId);
if (meeting == null || meeting.getProjectId() == null) return null;
BizProject project = projectMapper.selectByPrimaryKey(meeting.getProjectId());
if (project == null || project.getRoleLabor() == null || project.getRoleLabor().trim().isEmpty()) return null;
return objectMapper.readTree(project.getRoleLabor());
} catch (Exception e) {
log.warn("[attendee] 解析项目角色劳务失败 meetingId={}", meetingId, e);
return null;
}
}
/** /**
* 在项目角色劳务 JSON 数组 [{role, customName, amount}] 里按单个角色名匹配劳务金额. * 在项目角色劳务 JSON 数组 [{role, customName, amount}] 里按单个角色名匹配劳务金额.
* 匹配规则与前端 ProjectRoleSelect 一致: role === '其他' 时用 customName 作 label, 否则用 role. * 匹配规则与前端 ProjectRoleSelect 一致: role === '其他' 时用 customName 作 label, 否则用 role.
@@ -434,46 +463,13 @@ public class BizMeetingAttendeeServiceImpl implements IBizMeetingAttendeeService
return matchRoleAmount(nodes, laborForm); return matchRoleAmount(nodes, laborForm);
} }
/**
* 参会人角色 (laborForm, 可能逗号分隔多选) 对应的角色劳务金额合计.
* 与前端 MeetingDetail.roleAmountSum 一致: 按逗号拆分逐个匹配求和.
*
* @return 金额合计; nodes 为空/非数组 → null (无 role_labor 数据, 无法比较)
*/
private BigDecimal sumRoleAmount(JsonNode nodes, String laborForm) {
if (nodes == null || !nodes.isArray() || laborForm == null || laborForm.trim().isEmpty()) {
return null;
}
BigDecimal sum = BigDecimal.ZERO;
for (String item : laborForm.split(",")) {
BigDecimal a = matchRoleAmount(nodes, item.trim());
if (a != null) sum = sum.add(a);
}
return sum;
}
/**
* 校验实发金额(fee)不超所选角色劳务金额合计 (与前端 MeetingDetail.validateFee 一致).
* laborForm 为空 / fee 为空 / 项目无 role_labor 数据时跳过 (无"设置金额"可比较).
*/
private void validateFeeAgainstRole(JsonNode roleLaborNodes, String laborForm, BigDecimal fee) {
if (laborForm == null || laborForm.trim().isEmpty()) return;
if (fee == null) return;
BigDecimal sum = sumRoleAmount(roleLaborNodes, laborForm);
if (sum == null) return;
if (fee.compareTo(sum) > 0) {
throw new ServiceException("实发金额不能超过角色金额 "
+ sum.setScale(2, RoundingMode.HALF_UP).toPlainString() + "");
}
}
/** /**
* 批量导入参会人 (Excel → biz_meeting_attendee). * 批量导入参会人 (Excel → biz_meeting_attendee).
* *
* <p>复用 {@link #insertByPhoneWithProfile} 做单行处理, 让"按手机号查/建 sys_user + 写 attendee" * <p>复用 {@link #upsertByPhoneWithProfile} 做单行处理: 手机号已在该会议 → 覆盖更新已有行,
* 逻辑和单条新增一致. 单行失败只计入 ngList, 不中断其它行. * 否则按手机号查/建 sys_user + 写 attendee. 单行失败只计入 ngList, 不中断其它行.
* *
* <p>注意: 本方法只插数据, 不发 #5 通知. controller 在 import 前快照 userIds, import 后 diff, * <p>注意: 本方法只落库(新增或覆盖更新), 不发 #5 通知. controller 在 import 前快照 userIds, import 后 diff,
* 只对"新加入"的 userId 调 notify, 避免给已存在的参会人重发邀请. * 只对"新加入"的 userId 调 notify, 避免给已存在的参会人重发邀请.
*/ */
@Override @Override
@@ -515,6 +511,19 @@ public class BizMeetingAttendeeServiceImpl implements IBizMeetingAttendeeService
result.fail(rowNo, "手机号格式不正确: " + phone); result.fail(rowNo, "手机号格式不正确: " + phone);
continue; 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;
}
// 角色(laborForm) 必填校验 (与前端 dialog 必填规则保持一致: 角色单选 radio 不能为空;
// 2026-09-08 executor 在 2609010002 会议导入时漏校验, 单条无角色行被静默入库)
String laborForm = vo.getLaborForm() == null ? null : vo.getLaborForm().trim();
if (laborForm == null || laborForm.isEmpty()) {
result.fail(rowNo, "角色不能为空");
continue;
}
// 构造 attendee 实体, 复用 insertByPhoneWithProfile (内部查/建 sys_user + 写 attendee) // 构造 attendee 实体, 复用 insertByPhoneWithProfile (内部查/建 sys_user + 写 attendee)
BizMeetingAttendee body = new BizMeetingAttendee(); BizMeetingAttendee body = new BizMeetingAttendee();
@@ -524,7 +533,7 @@ public class BizMeetingAttendeeServiceImpl implements IBizMeetingAttendeeService
body.setWorkUnit(vo.getWorkUnit()); body.setWorkUnit(vo.getWorkUnit());
body.setDepartment(vo.getDepartment()); body.setDepartment(vo.getDepartment());
body.setTitle(vo.getTitle()); body.setTitle(vo.getTitle());
body.setIdCard(vo.getIdCard()); body.setIdCard(idCard);
body.setBankName(vo.getBankName()); body.setBankName(vo.getBankName());
body.setBankCard(vo.getBankCard()); body.setBankCard(vo.getBankCard());
body.setBankBranch(vo.getBankBranch()); body.setBankBranch(vo.getBankBranch());
@@ -532,7 +541,7 @@ public class BizMeetingAttendeeServiceImpl implements IBizMeetingAttendeeService
body.setBankRegion(vo.getBankRegion()); body.setBankRegion(vo.getBankRegion());
body.setBankAddress(vo.getBankAddress()); body.setBankAddress(vo.getBankAddress());
body.setIdCardAttachments(vo.getIdCardAttachments()); body.setIdCardAttachments(vo.getIdCardAttachments());
body.setLaborForm(vo.getLaborForm()); body.setLaborForm(laborForm);
// 金额联动补算 (照搬 hwt importLaborData): 已有值优先, 空白才按链补算, 避免覆盖人工填写 // 金额联动补算 (照搬 hwt importLaborData): 已有值优先, 空白才按链补算, 避免覆盖人工填写
BigDecimal fee = vo.getFee(); BigDecimal fee = vo.getFee();
BigDecimal tax = vo.getTax(); BigDecimal tax = vo.getTax();
@@ -567,7 +576,7 @@ public class BizMeetingAttendeeServiceImpl implements IBizMeetingAttendeeService
body.setFee(fee); body.setFee(fee);
body.setSummary(vo.getSummary()); body.setSummary(vo.getSummary());
insertByPhoneWithProfile(body); // 失败抛 ServiceException, 被 catch upsertByPhoneWithProfile(body); // 重复手机号 → 覆盖更新; 其它失败抛 ServiceException, 被 catch
result.ok(); result.ok();
} catch (Exception e) { } catch (Exception e) {
String msg = e.getMessage(); String msg = e.getMessage();
@@ -579,6 +588,111 @@ public class BizMeetingAttendeeServiceImpl implements IBizMeetingAttendeeService
return result; return result;
} }
@Override
public List<FieldMismatch> diffAgainstExpert(BizMeetingAttendee body) {
Long userId = null;
if (body.getId() != null) {
BizMeetingAttendee existing = mapper.selectById(body.getId());
if (existing != null) userId = existing.getUserId();
} else if (body.getPhone() != null && !body.getPhone().trim().isEmpty()) {
String phone = body.getPhone().trim();
List<SysUser> hits = sysUserMapper.selectByPhoneList(Collections.singletonList(phone));
if (hits != null && !hits.isEmpty()) userId = hits.get(0).getUserId();
}
if (userId == null) return Collections.emptyList();
BizExpert expert = bizExpertMapper.selectByUserId(userId);
if (expert == null) return Collections.emptyList();
return compareExpertProfile(expert, body);
}
@Override
public Map<String, Object> previewImport(MultipartFile file, Long meetingId) throws Exception {
if (meetingId == null) throw new ServiceException("meetingId 不能为空");
ExcelUtil<BizMeetingAttendeeImportVo> util = new ExcelUtil<>(BizMeetingAttendeeImportVo.class);
List<BizMeetingAttendeeImportVo> rows = util.importExcel(file.getInputStream());
if (rows == null || rows.isEmpty()) {
throw new ServiceException("导入数据不能为空");
}
List<ImportMismatch> mismatches = new ArrayList<>();
for (int i = 0; i < rows.size(); i++) {
BizMeetingAttendeeImportVo vo = rows.get(i);
int rowNo = i + 2; // Excel 行号: 1=表头, 2=第一行数据
String phone = vo.getPhone() == null ? null : vo.getPhone().trim();
// 手机号非法/缺失的行走真导入时会被拦截, dry-run 不参与比对
if (phone == null || phone.isEmpty() || !phone.matches("^1\\d{10}$")) continue;
List<SysUser> hits = sysUserMapper.selectByPhoneList(Collections.singletonList(phone));
Long userId = (hits != null && !hits.isEmpty()) ? hits.get(0).getUserId() : null;
if (userId == null) continue;
BizExpert expert = bizExpertMapper.selectByUserId(userId);
if (expert == null) continue;
BizMeetingAttendee body = new BizMeetingAttendee();
body.setPhone(phone);
body.setName(vo.getName());
body.setWorkUnit(vo.getWorkUnit());
body.setDepartment(vo.getDepartment());
body.setTitle(vo.getTitle());
body.setIdCard(vo.getIdCard());
body.setBankName(vo.getBankName());
body.setBankCard(vo.getBankCard());
body.setBankBranch(vo.getBankBranch());
body.setBankRegion(vo.getBankRegion());
body.setBankAddress(vo.getBankAddress());
body.setIdCardAttachments(vo.getIdCardAttachments());
List<FieldMismatch> fields = compareExpertProfile(expert, body);
if (!fields.isEmpty()) {
ImportMismatch im = new ImportMismatch();
im.setRowNo(rowNo);
im.setPhone(phone);
im.setName(vo.getName());
im.setFields(fields);
mismatches.add(im);
}
}
Map<String, Object> result = new HashMap<>();
result.put("total", rows.size());
result.put("mismatches", mismatches);
return result;
}
/**
* 比对参会人表单与专家表档案的公共字段 (两表都有的文本档案字段, 手机号是定位键不比对,
* 身份证附件是 OSS 文件 URL 不比对). 规则: 专家表已有值 (非空) 且与输入不一致 (含输入为空)
* → 记一条 mismatch; 专家表为空不算冲突.
*/
private List<FieldMismatch> compareExpertProfile(BizExpert expert, BizMeetingAttendee form) {
List<FieldMismatch> out = new ArrayList<>();
addMismatch(out, "name", "姓名", expert.getName(), form.getName());
addMismatch(out, "workUnit", "医院", expert.getWorkUnit(), form.getWorkUnit());
addMismatch(out, "department", "科室", expert.getDepartment(), form.getDepartment());
addMismatch(out, "title", "职称", expert.getTitle(), form.getTitle());
addMismatch(out, "idCard", "身份证号", expert.getIdCard(), form.getIdCard());
addMismatch(out, "bankCard", "银行卡号", expert.getBankCard(), form.getBankCard());
addMismatch(out, "bankName", "银行名称", expert.getBankName(), form.getBankName());
addMismatch(out, "bankBranch", "开户行支行", expert.getBankBranch(), form.getBankBranch());
addMismatch(out, "bankRegion", "开户行地址", expert.getBankRegion(), form.getBankRegion());
addMismatch(out, "bankAddress", "银行详细地址", expert.getBankAddress(), form.getBankAddress());
// 身份证附件(idCardAttachments)是 OSS 文件 URL, 重新上传即变, 逐字符比对无意义 → 不参与比对
return out;
}
private void addMismatch(List<FieldMismatch> out, String field, String label, String expertValue, String inputValue) {
String ev = expertValue == null ? "" : expertValue.trim();
if (ev.isEmpty()) return; // 专家表为空 → 无"现有数据"可比, 不算冲突
String iv = inputValue == null ? "" : inputValue.trim();
if (ev.equals(iv)) return;
FieldMismatch m = new FieldMismatch();
m.setField(field);
m.setLabel(label);
m.setExpertValue(expertValue);
m.setInputValue(inputValue == null ? "" : inputValue);
out.add(m);
}
@Override @Override
public int pushEsign(List<Long> attendeeIds) { public int pushEsign(List<Long> attendeeIds) {
if (attendeeIds == null || attendeeIds.isEmpty()) return 0; if (attendeeIds == null || attendeeIds.isEmpty()) return 0;
@@ -625,6 +739,7 @@ public class BizMeetingAttendeeServiceImpl implements IBizMeetingAttendeeService
if (attendeeIds == null || attendeeIds.isEmpty()) return 0; if (attendeeIds == null || attendeeIds.isEmpty()) return 0;
int sent = 0; int sent = 0;
Map<Long, BizMeeting> meetingCache = new HashMap<>(); Map<Long, BizMeeting> meetingCache = new HashMap<>();
Map<Long, byte[]> templateCache = new HashMap<>();
for (Long id : attendeeIds) { for (Long id : attendeeIds) {
if (id == null) continue; if (id == null) continue;
try { try {
@@ -642,8 +757,23 @@ public class BizMeetingAttendeeServiceImpl implements IBizMeetingAttendeeService
String meetingName = m != null ? m.getMeetingName() : null; String meetingName = m != null ? m.getMeetingName() : null;
Date startTime = m != null ? m.getStartTime() : 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 // 推"会议邀请"站内信 (不发短信) + 置 is_invited=1
bizNotifyService.meetingInvitation(a.getUserId(), mid, meetingName, startTime); bizNotifyService.meetingInvitation(a.getUserId(), a.getId(), mid, meetingName, startTime);
mapper.markInvitedById(id); mapper.markInvitedById(id);
sent++; sent++;
} catch (Exception e) { } catch (Exception e) {
@@ -722,107 +852,157 @@ public class BizMeetingAttendeeServiceImpl implements IBizMeetingAttendeeService
} }
@Override @Override
public int uploadAgreements(MultipartFile file, Long meetingId) throws Exception { public String submitAgreementUpload(MultipartFile file, Long meetingId) throws Exception {
if (meetingId == null) throw new ServiceException("meetingId 不能为空"); if (meetingId == null) throw new ServiceException("meetingId 不能为空");
if (file == null || file.isEmpty()) throw new ServiceException("请选择要上传的 zip 文件"); if (file == null || file.isEmpty()) throw new ServiceException("请选择要上传的 zip 文件");
String jobId = UUID.randomUUID().toString().replace("-", "");
byte[] zipBytes = file.getBytes();
// 后台线程无 SecurityContext, 在请求线程提前取好操作人
String username = SecurityUtils.getUsername();
uploadProgressRegistry.start(jobId);
zipUploadExecutor.submit(() -> {
try {
Map<String, Object> result = doUploadAgreements(zipBytes, meetingId, jobId, username);
uploadProgressRegistry.finish(jobId, result);
}
catch (Exception e) {
log.warn("[attendee] 劳务协议后台回填失败 meetingId={} err={}", meetingId, e.getMessage(), e);
uploadProgressRegistry.fail(jobId, e.getMessage() == null ? "上传失败" : e.getMessage());
}
});
return jobId;
}
private Map<String, Object> doUploadAgreements(byte[] zipBytes, Long meetingId, String jobId, String username) throws Exception {
List<BizMeetingAttendee> list = selectByMeetingId(meetingId); List<BizMeetingAttendee> list = selectByMeetingId(meetingId);
if (list.isEmpty()) throw new ServiceException("该会议暂无参会人, 无法回填劳务协议"); if (list.isEmpty()) throw new ServiceException("该会议暂无参会人, 无法回填劳务协议");
byte[] zipBytes = file.getBytes(); LinkedHashMap<String, ZipFolder> folders = parseAttendeeZip(zipBytes);
uploadProgressRegistry.setTotal(jobId, folders.size());
// 目录序号 (下划线前数字, 0-based) → 该目录下文件字节 + 文件名
Map<Integer, List<byte[]>> folderBytes = new HashMap<>();
Map<Integer, List<String>> folderNames = new HashMap<>();
try {
readAttendeeZip(zipBytes, StandardCharsets.UTF_8, folderBytes, folderNames);
} catch (IllegalArgumentException e) {
// 中文 Windows 打包器 (WinRAR/资源管理器"发送到压缩文件夹") 用 GBK 编码条目名且不置 UTF-8 标志,
// 强制 UTF-8 解码会抛 malformed input → 回退 GBK 重新解析
log.warn("[attendee] 协议 zip UTF-8 解析失败, 回退 GBK: {}", e.getMessage());
folderBytes.clear();
folderNames.clear();
readAttendeeZip(zipBytes, Charset.forName("GBK"), folderBytes, folderNames);
}
int updated = 0; int updated = 0;
for (Map.Entry<Integer, List<byte[]>> e : folderBytes.entrySet()) { int processed = 0;
int idx = e.getKey(); List<Map<String, Object>> results = new ArrayList<>();
if (idx < 0 || idx >= list.size()) { for (ZipFolder f : folders.values()) {
log.warn("[attendee] 协议 zip 目录序号 {} 越界 (参会人共 {}), 跳过", idx + 1, list.size()); processed++;
MatchResult m = matchAttendee(list, f);
Map<String, Object> r = new HashMap<>();
r.put("dir", f.dir);
r.put("name", f.name);
if (m.attendee == null) {
r.put("matched", false);
r.put("reason", m.reason);
results.add(r);
log.warn("[attendee] 协议目录 {} 无法定位参会人, 跳过", f.dir);
uploadProgressRegistry.update(jobId, processed);
continue; continue;
} }
BizMeetingAttendee a = list.get(idx);
List<String> names = folderNames.get(idx);
String fileName = (names != null && !names.isEmpty()) ? names.get(0) : "labor-protocol.pdf";
// labor_protocol 是单 URL, 每目录只取第一个文件作为协议 // 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(); BizMeetingAttendee entity = new BizMeetingAttendee();
entity.setId(a.getId()); entity.setId(m.attendee.getId());
entity.setLaborProtocol(url); entity.setLaborProtocol(url);
entity.setUpdateBy(SecurityUtils.getUsername()); entity.setUpdateBy(username);
updateLaborProtocol(entity); updateLaborProtocol(entity);
updated++; updated++;
r.put("matched", true);
r.put("attendeeName", m.attendee.getName());
results.add(r);
uploadProgressRegistry.update(jobId, processed);
} }
log.info("[attendee] 劳务协议回填完成 meetingId={} 共{}个", meetingId, updated); log.info("[attendee] 劳务协议回填完成 meetingId={} 共{}个 (跳过{}个)", meetingId, updated, folders.size() - updated);
return updated; return buildZipResult(updated, list.size(), results);
} }
@Override @Override
public int uploadExpertPhotos(MultipartFile file, Long meetingId) throws Exception { public String submitExpertPhotoUpload(MultipartFile file, Long meetingId) throws Exception {
if (meetingId == null) throw new ServiceException("meetingId 不能为空"); if (meetingId == null) throw new ServiceException("meetingId 不能为空");
if (file == null || file.isEmpty()) throw new ServiceException("请选择要上传的 zip 文件"); if (file == null || file.isEmpty()) throw new ServiceException("请选择要上传的 zip 文件");
String jobId = UUID.randomUUID().toString().replace("-", "");
byte[] zipBytes = file.getBytes();
// 后台线程无 SecurityContext, 在请求线程提前取好操作人
String username = SecurityUtils.getUsername();
uploadProgressRegistry.start(jobId);
zipUploadExecutor.submit(() -> {
try {
Map<String, Object> result = doUploadExpertPhotos(zipBytes, meetingId, jobId, username);
uploadProgressRegistry.finish(jobId, result);
}
catch (Exception e) {
log.warn("[attendee] 专家照片后台回填失败 meetingId={} err={}", meetingId, e.getMessage(), e);
uploadProgressRegistry.fail(jobId, e.getMessage() == null ? "上传失败" : e.getMessage());
}
});
return jobId;
}
private Map<String, Object> doUploadExpertPhotos(byte[] zipBytes, Long meetingId, String jobId, String username) throws Exception {
List<BizMeetingAttendee> list = selectByMeetingId(meetingId); List<BizMeetingAttendee> list = selectByMeetingId(meetingId);
if (list.isEmpty()) throw new ServiceException("该会议暂无参会人, 无法回填专家照片"); if (list.isEmpty()) throw new ServiceException("该会议暂无参会人, 无法回填专家照片");
byte[] zipBytes = file.getBytes(); LinkedHashMap<String, ZipFolder> folders = parseAttendeeZip(zipBytes);
Map<Integer, List<byte[]>> folderBytes = new HashMap<>(); uploadProgressRegistry.setTotal(jobId, folders.size());
Map<Integer, List<String>> folderNames = new HashMap<>();
try {
readAttendeeZip(zipBytes, StandardCharsets.UTF_8, folderBytes, folderNames);
} catch (IllegalArgumentException e) {
log.warn("[attendee] 专家照片 zip UTF-8 解析失败, 回退 GBK: {}", e.getMessage());
folderBytes.clear();
folderNames.clear();
readAttendeeZip(zipBytes, Charset.forName("GBK"), folderBytes, folderNames);
}
int updated = 0; int updated = 0;
for (Map.Entry<Integer, List<byte[]>> e : folderBytes.entrySet()) { int processed = 0;
int idx = e.getKey(); List<Map<String, Object>> results = new ArrayList<>();
if (idx < 0 || idx >= list.size()) { for (ZipFolder f : folders.values()) {
log.warn("[attendee] 专家照片 zip 目录序号 {} 越界 (参会人共 {}), 跳过", idx + 1, list.size()); processed++;
MatchResult m = matchAttendee(list, f);
Map<String, Object> r = new HashMap<>();
r.put("dir", f.dir);
r.put("name", f.name);
if (m.attendee == null) {
r.put("matched", false);
r.put("reason", m.reason);
results.add(r);
log.warn("[attendee] 专家照片目录 {} 无法定位参会人, 跳过", f.dir);
uploadProgressRegistry.update(jobId, processed);
continue; continue;
} }
BizMeetingAttendee a = list.get(idx);
List<byte[]> files = e.getValue();
List<String> names = folderNames.get(idx);
// 专家照片可多张: 目录内所有文件都上传, 逗号拼接覆盖式回填 on_site_photos // 专家照片可多张: 目录内所有文件都上传, 逗号拼接覆盖式回填 on_site_photos
StringBuilder sb = new StringBuilder(); StringBuilder sb = new StringBuilder();
for (int i = 0; i < files.size(); i++) { for (int i = 0; i < f.files.size(); i++) {
String fileName = (names != null && i < names.size()) ? names.get(i) : ("photo-" + (i + 1) + ".jpg"); String fileName = (i < f.fileNames.size()) ? f.fileNames.get(i) : ("photo-" + (i + 1) + ".jpg");
String url = ossUploader.upload(files.get(i), fileName, "ry8080/meeting/" + meetingId + "/expert-photo"); String url = ossUploader.upload(f.files.get(i), fileName, "ry8080/meeting/" + meetingId + "/expert-photo");
if (sb.length() > 0) sb.append(","); if (sb.length() > 0) sb.append(",");
sb.append(url); sb.append(url);
} }
BizMeetingAttendee entity = new BizMeetingAttendee(); BizMeetingAttendee entity = new BizMeetingAttendee();
entity.setId(a.getId()); entity.setId(m.attendee.getId());
entity.setOnSitePhotos(sb.toString()); entity.setOnSitePhotos(sb.toString());
entity.setUpdateBy(SecurityUtils.getUsername()); entity.setUpdateBy(username);
updateProfile(entity); updateProfile(entity);
updated++; updated++;
r.put("matched", true);
r.put("attendeeName", m.attendee.getName());
results.add(r);
uploadProgressRegistry.update(jobId, processed);
} }
log.info("[attendee] 专家照片回填完成 meetingId={} 共{}个", meetingId, updated); log.info("[attendee] 专家照片回填完成 meetingId={} 共{}个 (跳过{}个)", meetingId, updated, folders.size() - updated);
return updated; return buildZipResult(updated, list.size(), results);
} }
/** 按指定字符集解压 zip, 把 {根目录}/{序号}_姓名/ 目录下的文件归集到对应参会人序号 */ /** 解压 zip, 归集每个 {序号}_姓名 目录下的文件; UTF-8 解析失败回退 GBK (中文 Windows 打包器). */
private void readAttendeeZip(byte[] zipBytes, Charset charset, private LinkedHashMap<String, ZipFolder> parseAttendeeZip(byte[] zipBytes) throws IOException {
Map<Integer, List<byte[]>> folderBytes, try {
Map<Integer, List<String>> folderNames) throws IOException { return readAttendeeZip(zipBytes, StandardCharsets.UTF_8);
} catch (IllegalArgumentException e) {
log.warn("[attendee] zip UTF-8 解析失败, 回退 GBK: {}", e.getMessage());
return readAttendeeZip(zipBytes, Charset.forName("GBK"));
}
}
/** 按指定字符集解压 zip, 把 {根目录}/{序号}_姓名/ 目录下的文件归集到对应目录 */
private LinkedHashMap<String, ZipFolder> readAttendeeZip(byte[] zipBytes, Charset charset) throws IOException {
LinkedHashMap<String, ZipFolder> folders = new LinkedHashMap<>();
try (ZipInputStream zis = new ZipInputStream(new ByteArrayInputStream(zipBytes), charset)) { try (ZipInputStream zis = new ZipInputStream(new ByteArrayInputStream(zipBytes), charset)) {
ZipEntry entry; ZipEntry entry;
while ((entry = zis.getNextEntry()) != null) { while ((entry = zis.getNextEntry()) != null) {
@@ -830,31 +1010,103 @@ public class BizMeetingAttendeeServiceImpl implements IBizMeetingAttendeeService
String path = entry.getName(); String path = entry.getName();
// 跳过 macOS 打包产生的元数据 // 跳过 macOS 打包产生的元数据
if (path.contains("__MACOSX") || path.endsWith(".DS_Store")) continue; if (path.contains("__MACOSX") || path.endsWith(".DS_Store")) continue;
Integer idx = parseAgreementIndex(path); String[] seg = parseAgreementDir(path);
if (idx == null) continue; // 不在 {序号}_姓名 目录下, 忽略 if (seg == null) continue; // 文件不在 {序号}_姓名 目录下, 忽略
byte[] data = readAllBytes(zis); int seq;
folderBytes.computeIfAbsent(idx, k -> new ArrayList<>()).add(data); try {
folderNames.computeIfAbsent(idx, k -> new ArrayList<>()).add(lastSegment(path)); 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 */ /** 从 zip 条目路径里定位参会人 → [序号数字, 姓名段]; 无法定位返回 null.
private static Integer parseAgreementIndex(String path) { * 兼容多种形态:
* - 目录: {序号}_姓名/ 作为路径中任意一层目录 (套 1~N 层均可), 且文件可在该目录下的子目录里 → 取最靠近文件的匹配段
* - 单文件: 无目录时, 文件名本身为 {序号}_姓名[.扩展名] (如 1_张三.pdf) → 姓名剥掉最后一个扩展名
* 优先目录段, 找不到再认文件名. */
private static String[] parseAgreementDir(String path) {
if (path == null || path.isEmpty()) return null; if (path == null || path.isEmpty()) return null;
for (String seg : path.split("/")) { String[] segs = path.split("/");
if (seg.isEmpty()) continue; if (segs.length == 0) return null;
int underscore = seg.indexOf('_');
if (underscore <= 0) continue; // 1) 目录段优先: 从倒数第 2 段向根方向扫, 取最深匹配 {序号}_姓名 的目录段
String numPart = seg.substring(0, underscore); for (int i = segs.length - 2; i >= 0; i--) {
if (!numPart.matches("\\d+")) continue; String[] t = attendeeTokenOf(segs[i], false);
try { if (t != null) return t;
return Integer.parseInt(numPart) - 1;
} catch (NumberFormatException ex) {
return null;
}
} }
return null;
// 2) 文件名兜底: 最后一段 (文件名) 匹配 {序号}_姓名[.扩展名]
return attendeeTokenOf(segs[segs.length - 1], true);
}
/** 从单个路径段解析 {序号}_姓名 → [序号数字, 姓名段]; 不匹配返回 null.
* stripExt=true 时把姓名段末尾的最后一个 .扩展名 剥掉 (仅文件名兜底用). */
private static String[] attendeeTokenOf(String s, boolean stripExt) {
if (s == null || s.isEmpty()) return null;
int underscore = s.indexOf('_');
if (underscore <= 0) return null;
String numPart = s.substring(0, underscore);
if (!numPart.matches("\\d+")) return null;
String name = s.substring(underscore + 1);
if (stripExt) {
int dot = name.lastIndexOf('.');
if (dot > 0) name = name.substring(0, dot);
}
return new String[] { numPart, name };
}
/** 严格匹配: 序号 (order by id 后的 1-based 下标) 必须落位, 且该位姓名与目录姓名一致才回填.
* 两者任一不符 → 视为名单变动/重名错位, 跳过并给出原因, 避免把协议错挂到别人身上. */
private MatchResult matchAttendee(List<BizMeetingAttendee> list, ZipFolder f) {
int idx = f.seq - 1;
if (idx < 0 || idx >= list.size()) {
return new MatchResult(null, "序号" + f.seq + "越界(参会人共" + list.size() + "位)");
}
BizMeetingAttendee a = list.get(idx);
String want = safeAttendeeName(f.name);
String actual = safeAttendeeName(a.getName());
if (!want.equals(actual)) {
return new MatchResult(null, "姓名不一致: 目录[" + f.name + "] ≠ 序号" + f.seq + "指向的[" + actual + "]");
}
return new MatchResult(a, null);
}
private Map<String, Object> buildZipResult(int updated, int total, List<Map<String, Object>> results) {
Map<String, Object> result = new HashMap<>();
result.put("updated", updated);
result.put("total", total);
result.put("skipped", results.size() - updated);
result.put("results", results);
return result;
}
/** zip 里解析出的一个 {序号}_姓名 目录 */
private static class ZipFolder {
final String dir; // 目录段原文, 如 "1_张三"
final int seq; // 1-based 序号
final String name; // 姓名段 (下划线后)
final List<byte[]> files = new ArrayList<>();
final List<String> fileNames = new ArrayList<>();
ZipFolder(String dir, int seq, String name) { this.dir = dir; this.seq = seq; this.name = name; }
}
/** 目录 → 参会人匹配结果 */
private static class MatchResult {
final BizMeetingAttendee attendee; // null 表示未匹配
final String reason; // null=匹配成功; 非空=失败原因
MatchResult(BizMeetingAttendee attendee, String reason) { this.attendee = attendee; this.reason = reason; }
} }
private static String lastSegment(String path) { private static String lastSegment(String path) {
@@ -20,42 +20,59 @@ import java.io.InputStream;
import java.nio.charset.Charset; import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.util.Collections; import java.util.Collections;
import java.util.UUID;
import java.util.concurrent.ExecutorService;
import java.util.zip.ZipEntry; import java.util.zip.ZipEntry;
import java.util.zip.ZipInputStream; import java.util.zip.ZipInputStream;
import java.util.zip.ZipOutputStream; import java.util.zip.ZipOutputStream;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.context.annotation.Lazy; import org.springframework.context.annotation.Lazy;
import org.springframework.dao.DuplicateKeyException; import org.springframework.dao.DuplicateKeyException;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile; import org.springframework.web.multipart.MultipartFile;
import org.apache.poi.ss.usermodel.Cell;
import org.apache.poi.ss.usermodel.CellType;
import org.apache.poi.ss.usermodel.DataFormatter;
import org.apache.poi.ss.usermodel.DateUtil;
import org.apache.poi.ss.usermodel.Row;
import org.apache.poi.ss.usermodel.Sheet;
import org.apache.poi.ss.usermodel.Workbook;
import org.apache.poi.ss.usermodel.WorkbookFactory;
import cn.hutool.json.JSONArray;
import cn.hutool.json.JSONObject;
import cn.hutool.json.JSONUtil;
import com.ruoyi.common.exception.ServiceException; import com.ruoyi.common.exception.ServiceException;
import com.ruoyi.common.utils.SecurityUtils; import com.ruoyi.common.utils.SecurityUtils;
import com.ruoyi.business.domain.BizMeeting; import com.ruoyi.business.domain.BizMeeting;
import com.ruoyi.business.domain.BizMeetingAttendee; import com.ruoyi.business.domain.BizMeetingAttendee;
import com.ruoyi.business.domain.BizMeetingMaterial; import com.ruoyi.business.domain.BizMeetingMaterial;
import com.ruoyi.business.mapper.BizMeetingMapper; import com.ruoyi.business.mapper.BizMeetingMapper;
import com.ruoyi.business.mapper.BizMeetingInvoiceMapper;
import com.ruoyi.business.mapper.BizMeetingMaterialMapper; import com.ruoyi.business.mapper.BizMeetingMaterialMapper;
import com.ruoyi.business.oss.OssUploader; import com.ruoyi.business.oss.OssUploader;
import com.ruoyi.business.oss.OssZipService; import com.ruoyi.business.oss.OssZipService;
import com.ruoyi.business.service.IBizMeetingAttendeeService; import com.ruoyi.business.service.IBizMeetingAttendeeService;
import com.ruoyi.business.service.IBizMeetingMaterialService; import com.ruoyi.business.service.IBizMeetingMaterialService;
import com.ruoyi.business.service.IBizMeetingService;
import com.ruoyi.business.upload.UploadProgressRegistry;
@Service @Service
public class BizMeetingMaterialServiceImpl implements IBizMeetingMaterialService { public class BizMeetingMaterialServiceImpl implements IBizMeetingMaterialService {
/** 非发票 subType (与前端 MeetingDetail.NON_OCR_SUBTYPES 对齐): 现场照片/签到表等不 OCR, 避免误识别脏 amount */ /** 非发票 subType (与前端 MeetingDetail.NON_OCR_SUBTYPES 对齐): 现场照片/签到表等不 OCR, 避免误识别脏 amount */
private static final Set<String> NON_OCR_SUBTYPES = new HashSet<>(Arrays.asList( private static final Set<String> NON_OCR_SUBTYPES = new HashSet<>(Arrays.asList(
"L_ENTERPRISE_BENEFIT", "L_SIGN_IN", "L_PANORAMA_FRONT", "L_PANORAMA_BACK", "L_EXPERT_PHOTO")); "L_ENTERPRISE_BENEFIT", "L_ESIGN_IN", "L_SIGN_IN", "L_PANORAMA_FRONT", "L_PANORAMA_BACK", "L_EXPERT_PHOTO"));
/** 扫码拍照白名单 subType (公开端点只允许这三类照片回传) */ /** 扫码拍照白名单 subType (公开端点只允许这三类照片回传) */
private static final Set<String> CAMERA_SUBTYPES = new HashSet<>(Arrays.asList( private static final Set<String> CAMERA_SUBTYPES = new HashSet<>(Arrays.asList(
"L_SIGN_IN", "L_PANORAMA_FRONT", "L_PANORAMA_BACK")); "L_SIGN_IN", "L_PANORAMA_FRONT", "L_PANORAMA_BACK"));
/** 可识别文件扩展名 (与前端 isRecognizable 对齐): 图片/PDF 才触发 OCR */ /** 可识别文件扩展名 (与前端 isRecognizable 对齐): 图片/PDF/zip 才触发 OCR (zip 走解压分析发票) */
private static final Pattern RECOGNIZABLE = Pattern.compile("\\.(jpe?g|png|pdf)$", Pattern.CASE_INSENSITIVE); private static final Pattern RECOGNIZABLE = Pattern.compile("\\.(jpe?g|png|pdf|zip)$", Pattern.CASE_INSENSITIVE);
/** 会务下载范围: 会务材料 (SERVICE) + 会务凭证 (SERVICE_VOUCHER) */ /** 会务下载范围: 会务材料 (SERVICE) + 会务凭证 (SERVICE_VOUCHER) */
private static final Set<String> SERVICE_MATERIAL_TYPES = new HashSet<>(Arrays.asList("SERVICE", "SERVICE_VOUCHER")); private static final Set<String> SERVICE_MATERIAL_TYPES = new HashSet<>(Arrays.asList("SERVICE", "SERVICE_VOUCHER"));
@@ -71,6 +88,7 @@ public class BizMeetingMaterialServiceImpl implements IBizMeetingMaterialService
SERVICE_SUBTYPE_LABEL.put("M_DESIGN", "设计费"); SERVICE_SUBTYPE_LABEL.put("M_DESIGN", "设计费");
SERVICE_SUBTYPE_LABEL.put("M_OTHER", "其他"); SERVICE_SUBTYPE_LABEL.put("M_OTHER", "其他");
SERVICE_SUBTYPE_LABEL.put("M_SETTLEMENT", "总结算单"); SERVICE_SUBTYPE_LABEL.put("M_SETTLEMENT", "总结算单");
SERVICE_SUBTYPE_LABEL.put("M_SETTLEMENT_STAMP", "总结算单(盖章)");
SERVICE_SUBTYPE_LABEL.put("M_INVOICE", "总发票"); SERVICE_SUBTYPE_LABEL.put("M_INVOICE", "总发票");
SERVICE_SUBTYPE_LABEL.put("SV_PAYMENT", "会务付款凭证"); SERVICE_SUBTYPE_LABEL.put("SV_PAYMENT", "会务付款凭证");
} }
@@ -78,7 +96,7 @@ public class BizMeetingMaterialServiceImpl implements IBizMeetingMaterialService
/** 会务材料 (SERVICE) 子类顺序 (与前端 ROW_CONFIG SERVICE 行对齐), 用于"打包上传"下载空目录模板 + 回填匹配 */ /** 会务材料 (SERVICE) 子类顺序 (与前端 ROW_CONFIG SERVICE 行对齐), 用于"打包上传"下载空目录模板 + 回填匹配 */
private static final List<String> SERVICE_MATERIAL_SUBTYPES = Arrays.asList( private static final List<String> SERVICE_MATERIAL_SUBTYPES = Arrays.asList(
"M_MATERIAL", "M_HOTEL", "M_TRAFFIC_BIG", "M_TRAFFIC_SMALL", "M_MATERIAL", "M_HOTEL", "M_TRAFFIC_BIG", "M_TRAFFIC_SMALL",
"M_EXECUTION", "M_DESIGN", "M_OTHER", "M_SETTLEMENT", "M_INVOICE"); "M_EXECUTION", "M_DESIGN", "M_OTHER", "M_SETTLEMENT", "M_SETTLEMENT_STAMP", "M_INVOICE");
/** 会务材料中文名 → subType (仅 M_* 会务材料, 不含 SV_PAYMENT 付款凭证), 用于上传 zip 按目录名匹配 */ /** 会务材料中文名 → subType (仅 M_* 会务材料, 不含 SV_PAYMENT 付款凭证), 用于上传 zip 按目录名匹配 */
private static final Map<String, String> SERVICE_LABEL_TO_SUBTYPE = new HashMap<>(); private static final Map<String, String> SERVICE_LABEL_TO_SUBTYPE = new HashMap<>();
@@ -100,6 +118,7 @@ public class BizMeetingMaterialServiceImpl implements IBizMeetingMaterialService
LABOR_SUBTYPE_LABEL.put("L_DETAIL", "劳务明细表"); LABOR_SUBTYPE_LABEL.put("L_DETAIL", "劳务明细表");
LABOR_SUBTYPE_LABEL.put("L_AGREEMENT", "劳务协议"); LABOR_SUBTYPE_LABEL.put("L_AGREEMENT", "劳务协议");
LABOR_SUBTYPE_LABEL.put("L_ENTERPRISE_BENEFIT", "企业权益"); LABOR_SUBTYPE_LABEL.put("L_ENTERPRISE_BENEFIT", "企业权益");
LABOR_SUBTYPE_LABEL.put("L_ESIGN_IN", "电子签到表");
LABOR_SUBTYPE_LABEL.put("L_SIGN_IN", "签到表"); LABOR_SUBTYPE_LABEL.put("L_SIGN_IN", "签到表");
LABOR_SUBTYPE_LABEL.put("L_PANORAMA_FRONT", "前全景"); LABOR_SUBTYPE_LABEL.put("L_PANORAMA_FRONT", "前全景");
LABOR_SUBTYPE_LABEL.put("L_PANORAMA_BACK", "后全景"); LABOR_SUBTYPE_LABEL.put("L_PANORAMA_BACK", "后全景");
@@ -114,6 +133,8 @@ public class BizMeetingMaterialServiceImpl implements IBizMeetingMaterialService
@Autowired @Autowired
private BizMeetingMapper bizMeetingMapper; private BizMeetingMapper bizMeetingMapper;
@Autowired @Autowired
private BizMeetingInvoiceMapper bizMeetingInvoiceMapper;
@Autowired
private OssZipService ossZipService; private OssZipService ossZipService;
@Autowired @Autowired
private IBizMeetingAttendeeService attendeeService; private IBizMeetingAttendeeService attendeeService;
@@ -122,6 +143,14 @@ public class BizMeetingMaterialServiceImpl implements IBizMeetingMaterialService
@Autowired @Autowired
@Lazy @Lazy
private InvoiceOcrService invoiceOcrService; private InvoiceOcrService invoiceOcrService;
@Autowired
@Lazy
private IBizMeetingService bizMeetingService;
@Autowired
private UploadProgressRegistry uploadProgressRegistry;
@Autowired
@Qualifier("zipUploadExecutor")
private ExecutorService zipUploadExecutor;
private static final Logger log = LoggerFactory.getLogger(BizMeetingMaterialServiceImpl.class); private static final Logger log = LoggerFactory.getLogger(BizMeetingMaterialServiceImpl.class);
@@ -162,25 +191,48 @@ public class BizMeetingMaterialServiceImpl implements IBizMeetingMaterialService
} }
} }
// 被移除的 subType (旧有、新无) 的发票一并删除, 否则发票表残留孤儿发票行 (material_id 指向已删材料)
Set<String> newSubTypes = new HashSet<>();
if (list != null) {
for (BizMeetingMaterial m : list) {
if (m.getSubType() != null) newSubTypes.add(m.getSubType());
}
}
if (oldList != null) {
for (BizMeetingMaterial o : oldList) {
if (o.getSubType() != null && o.getId() != null && !newSubTypes.contains(o.getSubType())) {
bizMeetingInvoiceMapper.deleteByMaterialId(o.getId());
}
}
}
bizMeetingMaterialMapper.deleteByMeetingId(meetingId); bizMeetingMaterialMapper.deleteByMeetingId(meetingId);
if (list == null || list.isEmpty()) { if (list == null || list.isEmpty()) {
return list; return list;
} }
// 不接受前端传的 id, 走 AUTO_INCREMENT; meetingId 兜底由路径提供 // 不接受前端传的 id, 走 AUTO_INCREMENT; meetingId 兜底由路径提供
// 未变材料的旧 id (subType -> 旧 material id), 全删全插后迁移其发票 material_id 到新 id
Map<String, Long> unchangedOldId = new HashMap<>();
for (BizMeetingMaterial m : list) { for (BizMeetingMaterial m : list) {
m.setId(null); m.setId(null);
m.setMeetingId(meetingId); m.setMeetingId(meetingId);
BizMeetingMaterial old = oldBySubType.get(m.getSubType()); BizMeetingMaterial old = oldBySubType.get(m.getSubType());
boolean unchanged = old != null && Objects.equals(old.getOssUrl(), m.getOssUrl()); boolean unchanged = old != null && Objects.equals(old.getOssUrl(), m.getOssUrl());
if (unchanged) { if (unchanged) {
// 未变: 回填旧金额, 已计算; 同时保留脱敏版 URL (签到表高斯模糊版, 前端全删全插不传 extraOssUrl) // 未变: 回填旧金额, 已计算; 同时保留脱敏版 URL (签到表高斯模糊版) 与电子签到表解析结果 (前端全删全插不传)
m.setAmount(old.getAmount()); m.setAmount(old.getAmount());
m.setFeeStatus(1); m.setFeeStatus(1);
m.setExtraOssUrl(old.getExtraOssUrl()); m.setExtraOssUrl(old.getExtraOssUrl());
m.setMaskedJson(old.getMaskedJson());
unchangedOldId.put(m.getSubType(), old.getId());
} else { } else {
// 新增/替换: 金额清零, 会 OCR 的发票标记待计算 // 新增/替换: 金额清零, 会 OCR 的发票标记待计算
m.setAmount(null); m.setAmount(null);
m.setFeeStatus(needsOcr(m) ? 0 : 1); m.setFeeStatus(needsOcr(m) ? 0 : 1);
// 电子签到表 (L_ESIGN_IN): 保存时解析 + 脱敏 (解析失败抛异常回滚整个保存)
if ("L_ESIGN_IN".equals(m.getSubType()) && m.getOssUrl() != null && !m.getOssUrl().trim().isEmpty()) {
m.setMaskedJson(parseAndMaskSigninSheet(m.getOssUrl(), m.getFileName()));
}
} }
} }
try { try {
@@ -199,6 +251,11 @@ public class BizMeetingMaterialServiceImpl implements IBizMeetingMaterialService
for (BizMeetingMaterial m : list) { for (BizMeetingMaterial m : list) {
if (m.getSubType() != null) { if (m.getSubType() != null) {
m.setId(idBySubType.get(m.getSubType())); m.setId(idBySubType.get(m.getSubType()));
// 未变材料: 材料全删全插导致 id 变化, 迁移其发票 material_id 旧 id → 新 id
Long oldId = unchangedOldId.get(m.getSubType());
if (oldId != null && m.getId() != null && !m.getId().equals(oldId)) {
bizMeetingInvoiceMapper.updateInvoiceMaterialId(oldId, m.getId());
}
} }
} }
return list; return list;
@@ -232,6 +289,7 @@ public class BizMeetingMaterialServiceImpl implements IBizMeetingMaterialService
subs.addAll(newUrl.keySet()); subs.addAll(newUrl.keySet());
subs.remove("M_INVOICE"); subs.remove("M_INVOICE");
subs.remove("M_SETTLEMENT"); subs.remove("M_SETTLEMENT");
subs.remove("M_SETTLEMENT_STAMP");
for (String sub : subs) { for (String sub : subs) {
if (!Objects.equals(oldUrl.get(sub), newUrl.get(sub))) return true; if (!Objects.equals(oldUrl.get(sub), newUrl.get(sub))) return true;
} }
@@ -259,18 +317,29 @@ public class BizMeetingMaterialServiceImpl implements IBizMeetingMaterialService
* 扫码拍照回传: ry-h5 手机端拍照直传 OSS 后回传 URL, 按 (meetingId, subType) upsert 单行. * 扫码拍照回传: ry-h5 手机端拍照直传 OSS 后回传 URL, 按 (meetingId, subType) upsert 单行.
* 公开端点 (匿名) — 白名单 subType + 会议存在校验兜底. * 公开端点 (匿名) — 白名单 subType + 会议存在校验兜底.
* 照片类 NON_OCR, 不触发 OCR, 不影响会议费用, 故不触发费用重算. * 照片类 NON_OCR, 不触发 OCR, 不影响会议费用, 故不触发费用重算.
* extraOssUrl: 签到表(L_SIGN_IN)拍照时额外生成的高斯模糊版 URL, sponsor 只看这个; 其他 subType 传空. * 签到表 (L_SIGN_IN) 连拍多张: ossUrl/extraOssUrl 存 JSON 数组字符串; 前后全景单张取第一个.
* extraOssUrl: 签到表拍照时额外生成的高斯模糊版 URL, sponsor 只看这个; 其他 subType 传空.
*/ */
@Override @Override
@Transactional(rollbackFor = Exception.class) @Transactional(rollbackFor = Exception.class)
public void upsertFromCamera(Long meetingId, String subType, String ossUrl, String extraOssUrl) { public void upsertFromCamera(Long meetingId, String subType, List<String> ossUrls, List<String> extraOssUrls) {
if (meetingId == null) throw new ServiceException("缺少 meetingId"); if (meetingId == null) throw new ServiceException("缺少 meetingId");
if (subType == null || !CAMERA_SUBTYPES.contains(subType)) throw new ServiceException("非法的拍照类型"); if (subType == null || !CAMERA_SUBTYPES.contains(subType)) throw new ServiceException("非法的拍照类型");
if (ossUrl == null || ossUrl.isEmpty()) throw new ServiceException("缺少照片 URL"); if (ossUrls == null || ossUrls.isEmpty()) throw new ServiceException("缺少照片 URL");
BizMeeting meeting = bizMeetingMapper.selectByPrimaryKey(meetingId); BizMeeting meeting = bizMeetingMapper.selectByPrimaryKey(meetingId);
if (meeting == null) throw new ServiceException("会议不存在"); if (meeting == null) throw new ServiceException("会议不存在");
boolean multi = "L_SIGN_IN".equals(subType);
String ossUrl = multi ? JSONUtil.toJsonStr(ossUrls) : ossUrls.get(0);
String extraOssUrl;
if (multi) {
List<String> masked = (extraOssUrls == null) ? Collections.emptyList() : extraOssUrls;
extraOssUrl = JSONUtil.toJsonStr(masked);
} else {
extraOssUrl = (extraOssUrls != null && !extraOssUrls.isEmpty()) ? extraOssUrls.get(0) : "";
}
// 找该 subType 现有行 (同会议同 subType 唯一) // 找该 subType 现有行 (同会议同 subType 唯一)
BizMeetingMaterial row = null; BizMeetingMaterial row = null;
List<BizMeetingMaterial> existing = bizMeetingMaterialMapper.selectByMeetingId(meetingId); List<BizMeetingMaterial> existing = bizMeetingMaterialMapper.selectByMeetingId(meetingId);
@@ -482,6 +551,11 @@ public class BizMeetingMaterialServiceImpl implements IBizMeetingMaterialService
{ {
if (m.getOssUrl() == null || m.getOssUrl().isEmpty()) continue; if (m.getOssUrl() == null || m.getOssUrl().isEmpty()) continue;
if (m.getMaterialType() == null || !LABOR_MATERIAL_TYPES.contains(m.getMaterialType())) continue; if (m.getMaterialType() == null || !LABOR_MATERIAL_TYPES.contains(m.getMaterialType())) continue;
// oss_url 是 JSON 数组字符串 (签到表连拍 / 电子签到表 / 企业权益多文件): 逐个 copy (XX-1.ext ...); 老数据单 URL 由 copyJsonUrls 兜底
if (m.getOssUrl().trim().startsWith("[")) {
copied += copyJsonUrls(m.getOssUrl(), prefix + folderName + "/" + safeName(laborFolderName(m.getSubType())) + "/", laborFileLabel(m.getSubType()));
continue;
}
String srcKey = ossZipService.extractKey(m.getOssUrl()); String srcKey = ossZipService.extractKey(m.getOssUrl());
if (srcKey == null || srcKey.isEmpty()) continue; if (srcKey == null || srcKey.isEmpty()) continue;
String folder = laborFolderName(m.getSubType()); String folder = laborFolderName(m.getSubType());
@@ -500,10 +574,106 @@ public class BizMeetingMaterialServiceImpl implements IBizMeetingMaterialService
byte[] excel = attendeeService.buildAttendeeInfoExcel(attendees); byte[] excel = attendeeService.buildAttendeeInfoExcel(attendees);
ossZipService.putObject(prefix + folderName + "/参会人信息/参会人信息.xlsx", excel, XLSX_CONTENT_TYPE); ossZipService.putObject(prefix + folderName + "/参会人信息/参会人信息.xlsx", excel, XLSX_CONTENT_TYPE);
copied++; copied++;
// 补参会人照片/身份证照/劳务协议 (存 attendee 表, 材料表扫不到, 单独 copy 进劳务 zip)
copied += stageAttendeeFiles(attendees, prefix, folderName);
} }
return copied; return copied;
} }
/** 把参会人照片(on_site_photos)/身份证照(id_card_attachments)/劳务协议(labor_protocol) copy 进劳务 zip, 返回写入文件数 */
private int stageAttendeeFiles(List<BizMeetingAttendee> attendees, String prefix, String folderName)
{
int copied = 0;
for (int i = 0; i < attendees.size(); i++)
{
BizMeetingAttendee a = attendees.get(i);
// 序号 = 列表 1 起下标, 与前端表格 index 及"下载目录模板"的 {序号}_{姓名} 命名对齐
String person = (i + 1) + "_" + safeName(a.getName());
copied += copyCsvUrls(a.getOnSitePhotos(), prefix + folderName + "/参会人照片/" + person + "/", "照片");
copied += copyIdCardFiles(a.getIdCardAttachments(), prefix + folderName + "/参会人身份证/" + person + "/");
copied += copySingleUrl(a.getLaborProtocol(), prefix + folderName + "/劳务协议/", person);
}
return copied;
}
/** 逗号分隔 OSS URL → 逐个 copy 到 dstDir, 文件名 = baseName-i{ext}; 返回 copy 数 */
private int copyCsvUrls(String csvUrls, String dstDir, String baseName)
{
if (csvUrls == null || csvUrls.trim().isEmpty()) return 0;
String[] urls = csvUrls.split(",");
int copied = 0;
for (int i = 0; i < urls.length; i++)
{
String srcKey = ossZipService.extractKey(urls[i].trim());
if (srcKey == null || srcKey.isEmpty()) continue;
String fileName = safeName(baseName) + "-" + (i + 1) + extOf(srcKey);
ossZipService.copyObject(srcKey, dstDir + fileName);
copied++;
}
return copied;
}
/** JSON 数组 OSS URL (签到表连拍多张) → 逐个 copy 到 dstDir, 文件名 = baseName-i{ext}; 返回 copy 数 */
private int copyJsonUrls(String jsonUrls, String dstDir, String baseName)
{
if (jsonUrls == null || jsonUrls.trim().isEmpty()) return 0;
List<String> urls;
try {
JSONArray arr = JSONUtil.parseArray(jsonUrls);
urls = new ArrayList<>();
for (int i = 0; i < arr.size(); i++) {
String u = arr.getStr(i);
if (u != null && !u.trim().isEmpty()) urls.add(u.trim());
}
} catch (Exception e) {
// 老数据是单 URL 字符串 (非 JSON), 退回单张 copy
String srcKey = ossZipService.extractKey(jsonUrls.trim());
if (srcKey == null || srcKey.isEmpty()) return 0;
String fileName = safeName(baseName) + extOf(srcKey);
ossZipService.copyObject(srcKey, dstDir + fileName);
return 1;
}
int copied = 0;
for (int i = 0; i < urls.size(); i++)
{
String srcKey = ossZipService.extractKey(urls.get(i));
if (srcKey == null || srcKey.isEmpty()) continue;
String fileName = safeName(baseName) + "-" + (i + 1) + extOf(srcKey);
ossZipService.copyObject(srcKey, dstDir + fileName);
copied++;
}
return copied;
}
/** 身份证照 (正/反两张, 顺序固定 front,back) copy 到 dstDir, 文件名 身份证-正面/身份证-反面{ext}; 返回 copy 数 */
private int copyIdCardFiles(String csvUrls, String dstDir)
{
if (csvUrls == null || csvUrls.trim().isEmpty()) return 0;
String[] urls = csvUrls.split(",");
String[] labels = { "身份证-正面", "身份证-反面" };
int copied = 0;
for (int i = 0; i < urls.length && i < labels.length; i++)
{
String srcKey = ossZipService.extractKey(urls[i].trim());
if (srcKey == null || srcKey.isEmpty()) continue;
String fileName = labels[i] + extOf(srcKey);
ossZipService.copyObject(srcKey, dstDir + fileName);
copied++;
}
return copied;
}
/** 单个 OSS URL → copy 到 dstDir, 文件名 = baseName{ext}; 返回 copy 数 (0/1) */
private int copySingleUrl(String url, String dstDir, String baseName)
{
if (url == null || url.trim().isEmpty()) return 0;
String srcKey = ossZipService.extractKey(url.trim());
if (srcKey == null || srcKey.isEmpty()) return 0;
String fileName = safeName(baseName) + extOf(srcKey);
ossZipService.copyObject(srcKey, dstDir + fileName);
return 1;
}
/** 把会议日程海报 (biz_meeting.schedule_url) staging 到 zip 的"日程海报"目录, 返回写入的文件数 (0 = 无日程海报) */ /** 把会议日程海报 (biz_meeting.schedule_url) staging 到 zip 的"日程海报"目录, 返回写入的文件数 (0 = 无日程海报) */
private int stageSchedulePoster(BizMeeting meeting, String prefix, String folderName) private int stageSchedulePoster(BizMeeting meeting, String prefix, String folderName)
{ {
@@ -637,18 +807,35 @@ public class BizMeetingMaterialServiceImpl implements IBizMeetingMaterialService
} }
/** /**
* 上传"会务材料" zip, 解压后按 会务材料/{材料类型中文名}/ 目录匹配 subType, * 提交"会务材料" zip 上传任务 (异步): 请求线程读字节 + 登记进度后立即返回 jobId,
* 单文件直接上传 OSS, 多文件先打 zip 再上传 OSS, 按 (meetingId, SERVICE, subType) upsert 回填. * 后台线程解压按 会务材料/{材料类型中文名}/ 目录匹配 subType upsert 回填 (单文件直传 OSS /
* 替换文件清空 amount; 单文件可识别 (jpg/png/pdf) 或多文件打 zip → 直接后台触发 OCR 回写金额. * 多文件先打 zip; 内容未变且已计算过则跳过, 否则替换文件清空 amount后台触发 OCR),
* 前端轮询 /business/upload/progress/{jobId} 拿进度与结果 (result = 回填的材料类数).
*/ */
@Override @Override
@Transactional(rollbackFor = Exception.class) public String submitServiceMaterialsUpload(MultipartFile file, Long meetingId) throws Exception {
public int uploadServiceMaterials(MultipartFile file, Long meetingId) throws Exception {
if (meetingId == null) throw new ServiceException("meetingId 不能为空"); if (meetingId == null) throw new ServiceException("meetingId 不能为空");
if (file == null || file.isEmpty()) throw new ServiceException("请选择要上传的 zip 文件"); if (file == null || file.isEmpty()) throw new ServiceException("请选择要上传的 zip 文件");
String jobId = UUID.randomUUID().toString().replace("-", "");
byte[] zipBytes = file.getBytes(); byte[] zipBytes = file.getBytes();
// 后台线程无 SecurityContext, 在请求线程提前取好操作人
Long userId = SecurityUtils.getUserId();
uploadProgressRegistry.start(jobId);
zipUploadExecutor.submit(() -> {
try {
int updated = doUploadServiceMaterials(zipBytes, meetingId, jobId, userId);
uploadProgressRegistry.finish(jobId, updated);
}
catch (Exception e) {
log.warn("[material] 会务材料后台回填失败 meetingId={} err={}", meetingId, e.getMessage(), e);
uploadProgressRegistry.fail(jobId, e.getMessage() == null ? "上传失败" : e.getMessage());
}
});
return jobId;
}
private int doUploadServiceMaterials(byte[] zipBytes, Long meetingId, String jobId, Long userId) throws Exception {
// subType -> 该目录下文件字节 + 文件名 // subType -> 该目录下文件字节 + 文件名
Map<String, List<byte[]>> folderBytes = new HashMap<>(); Map<String, List<byte[]>> folderBytes = new HashMap<>();
Map<String, List<String>> folderNames = new HashMap<>(); Map<String, List<String>> folderNames = new HashMap<>();
@@ -672,12 +859,16 @@ public class BizMeetingMaterialServiceImpl implements IBizMeetingMaterialService
} }
} }
uploadProgressRegistry.setTotal(jobId, folderBytes.size());
String subDir = "ry8080/meeting/" + meetingId + "/service"; String subDir = "ry8080/meeting/" + meetingId + "/service";
int updated = 0; int updated = 0;
int processed = 0;
for (Map.Entry<String, List<byte[]>> e : folderBytes.entrySet()) { for (Map.Entry<String, List<byte[]>> e : folderBytes.entrySet()) {
String subType = e.getKey(); String subType = e.getKey();
List<byte[]> files = e.getValue(); List<byte[]> files = e.getValue();
if (files == null || files.isEmpty()) continue; if (files == null || files.isEmpty()) continue;
processed++;
List<String> names = folderNames.get(subType); List<String> names = folderNames.get(subType);
String label = SERVICE_SUBTYPE_LABEL.getOrDefault(subType, subType); String label = SERVICE_SUBTYPE_LABEL.getOrDefault(subType, subType);
BizMeetingMaterial old = existingBySubType.get(subType); BizMeetingMaterial old = existingBySubType.get(subType);
@@ -698,6 +889,7 @@ public class BizMeetingMaterialServiceImpl implements IBizMeetingMaterialService
if (old != null && old.getFeeStatus() != null && old.getFeeStatus() == 1 if (old != null && old.getFeeStatus() != null && old.getFeeStatus() == 1
&& contentEquals(old.getOssUrl(), toUpload)) { && contentEquals(old.getOssUrl(), toUpload)) {
log.info("[material] 会务材料 {} 内容未变, 跳过重传重 OCR", subType); log.info("[material] 会务材料 {} 内容未变, 跳过重传重 OCR", subType);
uploadProgressRegistry.update(jobId, processed);
continue; continue;
} }
@@ -722,7 +914,7 @@ public class BizMeetingMaterialServiceImpl implements IBizMeetingMaterialService
ins.setFileName(fileName); ins.setFileName(fileName);
ins.setAmount(null); ins.setAmount(null);
ins.setFeeStatus(feeStatus); ins.setFeeStatus(feeStatus);
ins.setCreatorId(SecurityUtils.getUserId()); ins.setCreatorId(userId);
ins.setCreateTime(new Date()); ins.setCreateTime(new Date());
bizMeetingMaterialMapper.insertBatch(Collections.singletonList(ins)); bizMeetingMaterialMapper.insertBatch(Collections.singletonList(ins));
// foreach 批量插入 useGeneratedKeys 不可靠, 按 subType 查回真实 id // foreach 批量插入 useGeneratedKeys 不可靠, 按 subType 查回真实 id
@@ -732,9 +924,13 @@ public class BizMeetingMaterialServiceImpl implements IBizMeetingMaterialService
} }
// 与前端"保存"流程一致, 后台触发 OCR (识别为发票回写 amount → 会议费用汇总) // 与前端"保存"流程一致, 后台触发 OCR (识别为发票回写 amount → 会议费用汇总)
if (ocrNeeded) { if (ocrNeeded) {
invoiceOcrService.submitRecognition(materialId, meetingId, ossUrl, isZip, oldMaterialId); invoiceOcrService.submitRecognition(materialId, meetingId, ossUrl, oldMaterialId, userId);
} }
updated++; updated++;
uploadProgressRegistry.update(jobId, processed);
}
if (updated > 0) {
bizMeetingService.recomputeMeetingFee(meetingId);
} }
log.info("[material] 会务材料回填完成 meetingId={} 共{}类", meetingId, updated); log.info("[material] 会务材料回填完成 meetingId={} 共{}类", meetingId, updated);
return updated; return updated;
@@ -824,4 +1020,494 @@ public class BizMeetingMaterialServiceImpl implements IBizMeetingMaterialService
if (m.getSubType() != null && NON_OCR_SUBTYPES.contains(m.getSubType())) return false; if (m.getSubType() != null && NON_OCR_SUBTYPES.contains(m.getSubType())) return false;
return RECOGNIZABLE.matcher(m.getOssUrl()).find(); return RECOGNIZABLE.matcher(m.getOssUrl()).find();
} }
// ===================================================================
// 电子签到表 (L_ESIGN_IN) 解析 + 脱敏 (保存时调用, 结果存 masked_json)
// ===================================================================
/** 电子签到表敏感表头 → 脱敏类型 */
private static final Map<String, String> SIGNIN_SENSITIVE_HEADERS = new HashMap<>();
static {
SIGNIN_SENSITIVE_HEADERS.put("姓名", "NAME");
SIGNIN_SENSITIVE_HEADERS.put("名字", "NAME");
SIGNIN_SENSITIVE_HEADERS.put("医生", "NAME");
SIGNIN_SENSITIVE_HEADERS.put("name", "NAME");
SIGNIN_SENSITIVE_HEADERS.put("手机", "PHONE");
SIGNIN_SENSITIVE_HEADERS.put("手机号", "PHONE");
SIGNIN_SENSITIVE_HEADERS.put("电话", "PHONE");
SIGNIN_SENSITIVE_HEADERS.put("联系方式", "PHONE");
SIGNIN_SENSITIVE_HEADERS.put("phone", "PHONE");
SIGNIN_SENSITIVE_HEADERS.put("mobile", "PHONE");
SIGNIN_SENSITIVE_HEADERS.put("身份证", "ID_CARD");
SIGNIN_SENSITIVE_HEADERS.put("身份证号", "ID_CARD");
SIGNIN_SENSITIVE_HEADERS.put("银行卡", "BANK_CARD");
SIGNIN_SENSITIVE_HEADERS.put("银行卡号", "BANK_CARD");
SIGNIN_SENSITIVE_HEADERS.put("邮箱", "EMAIL");
SIGNIN_SENSITIVE_HEADERS.put("邮件", "EMAIL");
SIGNIN_SENSITIVE_HEADERS.put("email", "EMAIL");
SIGNIN_SENSITIVE_HEADERS.put("mail", "EMAIL");
}
/**
* 下载电子签到表文件并解析脱敏, 序列化为 JSON 返回.
* <p>
* 单文件: ossUrl 为普通 URL → {@code {"headers":[], "rows":[]}} (与历史数据一致).
* 多文件: ossUrl 为 JSON 数组字符串 (多个 Excel 分别直传 OSS) → {@code {"sheets":[{"name","headers","rows"}, ...]}}.
* 解析/下载失败抛 ServiceException (保存回滚).
*/
private String parseAndMaskSigninSheet(String ossUrl, String fileName) {
// 多文件 (前端 multiple 上传, ossUrl 存 JSON 数组字符串)
if (ossUrl != null && ossUrl.trim().startsWith("[")) {
List<String> urls = parseUrlArray(ossUrl);
if (urls.isEmpty()) throw new ServiceException("电子签到表文件列表为空");
if (urls.size() == 1) {
byte[] bytes = downloadBytes(urls.get(0));
return JSONUtil.toJsonStr(parseSingleSigninSheet(bytes, detectExt(null, urls.get(0))));
}
List<Map<String, Object>> sheets = new ArrayList<>();
for (String u : urls) {
byte[] bytes = downloadBytes(u);
Map<String, Object> parsed = parseSingleSigninSheet(bytes, detectExt(null, u));
parsed.put("name", nameFromUrl(u));
sheets.add(parsed);
}
return JSONUtil.toJsonStr(Collections.singletonMap("sheets", sheets));
}
byte[] bytes = downloadBytes(ossUrl);
return JSONUtil.toJsonStr(parseSingleSigninSheet(bytes, detectExt(fileName, ossUrl)));
}
/**
* 解析单个签到表字节 (首个 sheet 首行作表头), 按敏感表头脱敏, 返回 {headers, rows}.
* 解析失败抛 ServiceException (整体失败回滚).
*/
private Map<String, Object> parseSingleSigninSheet(byte[] bytes, String ext) {
List<List<String>> matrix;
try {
if ("csv".equals(ext)) {
matrix = readCsvMatrix(bytes);
} else {
matrix = readExcelMatrix(new ByteArrayInputStream(bytes));
}
} catch (Exception e) {
log.warn("[material] 电子签到表解析失败 ext={} err={}", ext, e.getMessage());
throw new ServiceException("电子签到表解析失败, 请确认上传的是有效的 xls/xlsx/csv 文件");
}
if (matrix.isEmpty()) {
throw new ServiceException("电子签到表为空, 无法解析");
}
List<String> headers = matrix.get(0);
// 敏感列 index → 脱敏类型
Map<Integer, String> maskByCol = new HashMap<>();
for (int c = 0; c < headers.size(); c++) {
String type = detectMaskType(headers.get(c));
if (type != null) maskByCol.put(c, type);
}
// 逐行脱敏 (过滤全空行)
List<List<String>> rows = new ArrayList<>();
for (int i = 1; i < matrix.size(); i++) {
List<String> row = matrix.get(i);
boolean allEmpty = true;
for (String cell : row) {
if (cell != null && !cell.trim().isEmpty()) { allEmpty = false; break; }
}
if (allEmpty) continue;
List<String> masked = new ArrayList<>(row);
for (Map.Entry<Integer, String> e : maskByCol.entrySet()) {
int c = e.getKey();
if (c < masked.size() && masked.get(c) != null && !masked.get(c).trim().isEmpty()) {
masked.set(c, maskCell(e.getValue(), masked.get(c)));
}
}
rows.add(masked);
}
Map<String, Object> result = new HashMap<>();
result.put("headers", headers);
result.put("rows", rows);
return result;
}
/** JSON 数组字符串 (前端 multiple 上传) → OSS URL 列表 */
private List<String> parseUrlArray(String jsonUrls) {
List<String> urls = new ArrayList<>();
try {
JSONArray arr = JSONUtil.parseArray(jsonUrls);
for (int i = 0; i < arr.size(); i++) {
String u = arr.getStr(i);
if (u != null && !u.trim().isEmpty()) urls.add(u.trim());
}
} catch (Exception e) {
log.warn("[material] 电子签到表 URL 列表解析失败: {}", jsonUrls);
throw new ServiceException("电子签到表文件列表解析失败");
}
return urls;
}
/** 从 OSS URL 还原原文件名 (前端 key 格式 原名_13位时间戳_6位随机.扩展名 → 原名.扩展名; 否则取末段) */
private String nameFromUrl(String url) {
if (url == null) return "";
String path = url.contains("?") ? url.substring(0, url.indexOf('?')) : url;
int i = path.lastIndexOf('/');
String last = i >= 0 ? path.substring(i + 1) : path;
try { last = java.net.URLDecoder.decode(last, "UTF-8"); } catch (Exception e) { /* 已解码, 原样 */ }
java.util.regex.Matcher m = Pattern.compile("^(.+)_\\d{13}_[a-z0-9]{6}(\\.[^.]+)?$").matcher(last);
if (m.find()) return m.group(1) + (m.group(2) == null ? "" : m.group(2));
return last;
}
/** 旧 masked_json → sheets 列表 (单文件 {headers,rows} 视为 1 个 sheet; 多文件取 sheets 数组) */
private List<Map<String, Object>> sheetsFromMaskedJson(String maskedJson) {
List<Map<String, Object>> sheets = new ArrayList<>();
if (maskedJson == null || maskedJson.trim().isEmpty()) return sheets;
try {
JSONObject obj = JSONUtil.parseObj(maskedJson);
JSONArray arr = obj.getJSONArray("sheets");
if (arr != null && !arr.isEmpty()) {
for (int i = 0; i < arr.size(); i++) {
JSONObject s = arr.getJSONObject(i);
if (s != null) sheets.add(s);
}
} else if (obj.containsKey("headers")) {
sheets.add(obj);
}
} catch (Exception e) {
log.warn("[material] 电子签到表旧 masked_json 解析失败, 忽略旧脱敏数据: {}", e.getMessage());
}
return sheets;
}
// ===================================================================
// 电子签到表 (L_ESIGN_IN) 多文件 + zip 上传 (同步, 追加式保存)
// ===================================================================
/** 单次上传允许的最多签到表文件数 */
private static final int ESIGN_MAX_FILES = 50;
/** 单个签到表文件最大字节 (20MB) */
private static final long ESIGN_MAX_FILE_SIZE = 20L * 1024 * 1024;
/** 签到表文件扩展名白名单 (xls/xlsx/csv) */
private static boolean isExcelName(String name) {
if (name == null) return false;
String ext = detectExt(name, null);
return "xls".equals(ext) || "xlsx".equals(ext) || "csv".equals(ext);
}
private static boolean isZipName(String name) {
return name != null && "zip".equals(detectExt(name, null));
}
/**
* 电子签到表 (L_ESIGN_IN) 多文件/zip 上传 (同步, 追加式保存).
* <p>
* file 为单个 Excel (xls/xlsx/csv) 或 zip (后台解压并分析其中所有 Excel).
* 逐个解析脱敏 + 直传 OSS; 单行 L_ESIGN_IN 存多文件:
* oss_url 存单 URL (1 个) / JSON 数组 (>1 个), masked_json 存 {headers,rows} / {sheets:[...]}.
* 已有 L_ESIGN_IN 文件保留, 新文件追加到末尾 (合并旧 ossUrl/maskedJson 后删旧插新).
* 任一文件解析失败/超限 → 抛异常整体失败 (事务回滚, 不落任何数据).
*
* @return { count: 本次新增文件数, total: 合并后总文件数 }
*/
@Override
@Transactional(rollbackFor = Exception.class)
public Map<String, Object> uploadEsignIn(MultipartFile file, Long meetingId) throws Exception {
if (meetingId == null) throw new ServiceException("meetingId 不能为空");
if (file == null || file.isEmpty()) throw new ServiceException("请选择要上传的电子签到表文件");
BizMeeting meeting = bizMeetingMapper.selectByPrimaryKey(meetingId);
if (meeting == null) throw new ServiceException("会议不存在");
String originalName = file.getOriginalFilename();
byte[] bytes = file.getBytes();
// 收集 (文件名, 字节) 列表: zip 解压所有 Excel / 单文件直接入列
List<FileBytes> files = new ArrayList<>();
if (isZipName(originalName)) {
files = unzipExcelFiles(bytes);
if (files.isEmpty()) throw new ServiceException("未在压缩包中找到 Excel (xls/xlsx/csv) 文件");
} else {
if (!isExcelName(originalName)) throw new ServiceException("仅支持 xls / xlsx / csv 或 zip 文件");
files.add(new FileBytes(originalName, bytes));
}
if (files.size() > ESIGN_MAX_FILES) throw new ServiceException("一次最多上传 " + ESIGN_MAX_FILES + " 个签到表文件");
// 逐个解析 (整体失败: 任一解析失败/超限抛异常, 此时尚未上传任何文件)
List<Map<String, Object>> sheets = new ArrayList<>();
for (FileBytes fb : files) {
if (fb.bytes.length > ESIGN_MAX_FILE_SIZE) throw new ServiceException("文件「" + fb.name + "」超过 20MB 限制");
Map<String, Object> parsed = parseSingleSigninSheet(fb.bytes, detectExt(fb.name, null));
parsed.put("name", fb.name);
sheets.add(parsed);
}
// 全部解析通过后逐个直传 OSS
String subDir = "ry8080/meeting/" + meetingId + "/labor";
List<String> urls = new ArrayList<>();
for (FileBytes fb : files) {
urls.add(ossUploader.uploadWithOriginalName(fb.bytes, fb.name, subDir));
}
// 追加模式: 读取已有 L_ESIGN_IN 行的 ossUrl + maskedJson, 与新文件合并后删旧插新
BizMeetingMaterial oldEsign = null;
List<BizMeetingMaterial> existing = bizMeetingMaterialMapper.selectByMeetingId(meetingId);
if (existing != null) {
for (BizMeetingMaterial m : existing) {
if ("L_ESIGN_IN".equals(m.getSubType())) { oldEsign = m; break; }
}
}
List<String> mergedUrls = new ArrayList<>();
List<Map<String, Object>> mergedSheets = new ArrayList<>();
if (oldEsign != null && oldEsign.getOssUrl() != null && !oldEsign.getOssUrl().trim().isEmpty()) {
String oldOss = oldEsign.getOssUrl().trim();
if (oldOss.startsWith("[")) {
mergedUrls.addAll(parseUrlArray(oldOss));
} else {
mergedUrls.add(oldOss);
}
mergedSheets.addAll(sheetsFromMaskedJson(oldEsign.getMaskedJson()));
}
mergedUrls.addAll(urls);
mergedSheets.addAll(sheets);
if (mergedUrls.size() > ESIGN_MAX_FILES) {
throw new ServiceException("电子签到表最多 " + ESIGN_MAX_FILES + " 个文件, 当前已 " + mergedUrls.size() + "");
}
String mergedOssUrl = mergedUrls.size() == 1 ? mergedUrls.get(0) : JSONUtil.toJsonStr(mergedUrls);
String mergedMaskedJson = mergedSheets.size() == 1
? JSONUtil.toJsonStr(mergedSheets.get(0))
: JSONUtil.toJsonStr(Collections.singletonMap("sheets", mergedSheets));
// 删旧插新 (updateByPrimaryKey 不更新 masked_json, 直接删插最稳)
if (oldEsign != null) bizMeetingMaterialMapper.deleteByPrimaryKey(oldEsign.getId());
BizMeetingMaterial ins = new BizMeetingMaterial();
ins.setMeetingId(meetingId);
ins.setMaterialType("LABOR");
ins.setSubType("L_ESIGN_IN");
ins.setOssUrl(mergedOssUrl);
ins.setFileName(originalName);
ins.setMaskedJson(mergedMaskedJson);
ins.setAmount(null);
ins.setFeeStatus(1); // 非发票, 不参与费用汇总
ins.setCreatorId(SecurityUtils.getUserId());
ins.setCreateTime(new Date());
bizMeetingMaterialMapper.insert(ins);
Map<String, Object> result = new HashMap<>();
result.put("count", files.size()); // 本次新增文件数
result.put("total", mergedUrls.size()); // 合并后总文件数
return result;
}
/** zip 解压所有 Excel 条目, 返回 (文件名, 字节) 列表.
* 文件名用 ISO-8859-1 无损读原始字节, 再按 UTF-8/GBK 解码 (GBK 中文名直接按 UTF-8 读会出乱码). */
private List<FileBytes> unzipExcelFiles(byte[] zipBytes) {
List<FileBytes> result = new ArrayList<>();
try (ZipInputStream zis = new ZipInputStream(new ByteArrayInputStream(zipBytes), StandardCharsets.ISO_8859_1)) {
ZipEntry entry;
while ((entry = zis.getNextEntry()) != null) {
if (entry.isDirectory()) continue;
String path = decodeZipName(entry.getName());
if (path.contains("__MACOSX") || path.endsWith(".DS_Store")) continue;
String name = lastSegment(path);
if (!isExcelName(name)) continue; // 只收 Excel, 其他文件忽略
byte[] data = readAllBytes(zis);
if (data.length == 0) continue;
result.add(new FileBytes(name, data));
}
} catch (IOException e) {
throw new ServiceException("电子签到表 zip 解压失败: " + e.getMessage());
}
return result;
}
/** ISO-8859-1 无损读到的条目名 → 真实文件名: UTF-8 优先, 含替换符 (非法 UTF-8 字节) 则回退 GBK */
private String decodeZipName(String raw) {
byte[] bytes = raw.getBytes(StandardCharsets.ISO_8859_1);
String utf8 = new String(bytes, StandardCharsets.UTF_8);
if (utf8.indexOf('') >= 0) {
return new String(bytes, Charset.forName("GBK"));
}
return utf8;
}
/** zip/上传时的文件名 + 字节对 */
private static final class FileBytes {
final String name;
final byte[] bytes;
FileBytes(String name, byte[] bytes) { this.name = name; this.bytes = bytes; }
}
/** 表头 → 脱敏类型 (contains 匹配, 不区分大小写) */
private String detectMaskType(String header) {
if (header == null) return null;
String h = header.trim().toLowerCase();
for (Map.Entry<String, String> e : SIGNIN_SENSITIVE_HEADERS.entrySet()) {
if (h.contains(e.getKey())) return e.getValue();
}
return null;
}
private String maskCell(String type, String value) {
switch (type) {
case "NAME": return maskName(value);
case "PHONE": return maskPhone(value);
case "ID_CARD": return maskIdCard(value);
case "BANK_CARD": return maskBankCard(value);
case "EMAIL": return maskEmail(value);
default: return value;
}
}
/** 姓名脱敏: 保留首尾, 中间打码 (张三 → 张*, 李小明 → 李*明) */
private String maskName(String v) {
if (v == null) return null;
String s = v.trim();
if (s.isEmpty()) return v;
if (s.length() == 1) return "*";
if (s.length() == 2) return s.charAt(0) + "*";
return s.charAt(0) + "*".repeat(s.length() - 2) + s.charAt(s.length() - 1);
}
/** 手机号脱敏: 前3 + **** + 后4 */
private String maskPhone(String v) {
if (v == null) return v;
String s = v.trim();
if (s.length() != 11) return v;
return s.substring(0, 3) + "****" + s.substring(7);
}
/** 银行卡号脱敏: 前4 + ******** + 后4 */
private String maskBankCard(String v) {
if (v == null) return v;
String s = v.trim();
if (s.length() < 8) return v;
return s.substring(0, 4) + "********" + s.substring(s.length() - 4);
}
/** 身份证号脱敏: 前6 + ******** + 后4 */
private String maskIdCard(String v) {
if (v == null) return v;
String s = v.trim();
if (s.length() != 18) return v;
return s.substring(0, 6) + "********" + s.substring(14);
}
/** 邮箱脱敏: 保留本地部分前 1~2 字符 + "***" + @ + 完整域名
* (zhangsan@x.com → zh***@x.com; 1~2 字符本地部分全保留; 无 @ 原样返回) */
private String maskEmail(String v) {
if (v == null) return v;
String s = v.trim();
int at = s.indexOf('@');
if (at <= 0) return v; // 无 @ 或 @ 在首位 → 不算邮箱, 原样返回
String local = s.substring(0, at);
String domain = s.substring(at); // 含 @
if (local.length() <= 2) return local + "***" + domain;
return local.substring(0, 2) + "***" + domain;
}
/** Excel (xls/xlsx) → 首 sheet 的二维矩阵 (第 0 行=表头) */
private List<List<String>> readExcelMatrix(InputStream is) throws Exception {
try (Workbook wb = WorkbookFactory.create(is)) {
Sheet sheet = wb.getSheetAt(0);
List<List<String>> matrix = new ArrayList<>();
int lastRow = sheet.getLastRowNum();
for (int r = 0; r <= lastRow; r++) {
Row row = sheet.getRow(r);
if (row == null) continue;
List<String> cells = new ArrayList<>();
int lastCell = row.getLastCellNum();
for (int c = 0; c < lastCell; c++) {
cells.add(cellString(row.getCell(c)));
}
matrix.add(cells);
}
return matrix;
}
}
/** 单元格 → 字符串: 数字格用 BigDecimal 还原整串避免科学计数法, 日期格走 DataFormatter */
private String cellString(Cell cell) {
if (cell == null) return "";
CellType type = cell.getCellType();
if (type == CellType.STRING) return cell.getStringCellValue();
if (type == CellType.NUMERIC) {
if (DateUtil.isCellDateFormatted(cell)) {
return new DataFormatter().formatCellValue(cell);
}
double d = cell.getNumericCellValue();
if (d == Math.rint(d) && !Double.isInfinite(d) && !Double.isNaN(d)) {
return BigDecimal.valueOf(d).toPlainString();
}
return new DataFormatter().formatCellValue(cell);
}
if (type == CellType.BOOLEAN) return String.valueOf(cell.getBooleanCellValue());
if (type == CellType.FORMULA) return cell.getCellFormula();
return "";
}
/** CSV → 二维矩阵 (UTF-8 优先, 乱码回退 GBK; 去掉 BOM) */
private List<List<String>> readCsvMatrix(byte[] bytes) {
String text = new String(bytes, StandardCharsets.UTF_8);
if (text.indexOf('') >= 0) {
text = new String(bytes, Charset.forName("GBK"));
}
if (text.startsWith("")) text = text.substring(1);
List<List<String>> matrix = new ArrayList<>();
for (String line : text.split("\\r?\\n")) {
if (line.trim().isEmpty()) continue;
matrix.add(parseCsvLine(line));
}
return matrix;
}
/** 单行 CSV 解析 (逗号分隔, 支持双引号包裹与转义) */
private List<String> parseCsvLine(String line) {
List<String> cells = new ArrayList<>();
StringBuilder cur = new StringBuilder();
boolean inQuotes = false;
for (int i = 0; i < line.length(); i++) {
char c = line.charAt(i);
if (inQuotes) {
if (c == '"') {
if (i + 1 < line.length() && line.charAt(i + 1) == '"') {
cur.append('"'); i++;
} else {
inQuotes = false;
}
} else {
cur.append(c);
}
} else {
if (c == '"') inQuotes = true;
else if (c == ',') { cells.add(cur.toString().trim()); cur.setLength(0); }
else cur.append(c);
}
}
cells.add(cur.toString().trim());
return cells;
}
/** 从 OSS URL 下载文件字节 (单文件, 有超时) */
private byte[] downloadBytes(String url) {
try {
java.net.URLConnection conn = URI.create(url).toURL().openConnection();
conn.setConnectTimeout(10000);
conn.setReadTimeout(30000);
try (InputStream in = conn.getInputStream()) {
return readAllBytes(in);
}
} catch (Exception e) {
log.warn("[material] 电子签到表下载失败 url={} err={}", url, e.getMessage());
throw new ServiceException("电子签到表下载失败, 请稍后重试");
}
}
/** 文件扩展名 (优先 fileName, 兜底 ossUrl; 小写) */
private static String detectExt(String fileName, String ossUrl) {
String src = (fileName != null && !fileName.trim().isEmpty()) ? fileName : ossUrl;
if (src == null) return "";
String path = src.contains("?") ? src.substring(0, src.indexOf('?')) : src;
int i = path.lastIndexOf('.');
if (i < 0) return "";
return path.substring(i + 1).toLowerCase();
}
} }
@@ -214,7 +214,7 @@ public class BizMeetingServiceImpl implements IBizMeetingService
BigDecimal sum = BigDecimal.ZERO; BigDecimal sum = BigDecimal.ZERO;
for (BizMeetingMaterial m : mats) { for (BizMeetingMaterial m : mats) {
if (m.getSubType() == null || !m.getSubType().startsWith("M_")) continue; if (m.getSubType() == null || !m.getSubType().startsWith("M_")) continue;
if ("M_INVOICE".equals(m.getSubType()) || "M_SETTLEMENT".equals(m.getSubType())) continue; if ("M_INVOICE".equals(m.getSubType()) || "M_SETTLEMENT".equals(m.getSubType()) || "M_SETTLEMENT_STAMP".equals(m.getSubType())) continue;
if (m.getAmount() != null) sum = sum.add(m.getAmount()); if (m.getAmount() != null) sum = sum.add(m.getAmount());
} }
return sum; return sum;
@@ -232,4 +232,9 @@ public class BizMeetingServiceImpl implements IBizMeetingService
BigDecimal totalFee = laborFee.add(meetingFee); BigDecimal totalFee = laborFee.add(meetingFee);
bizMeetingMapper.updateLaborFee(meetingId, laborFee, totalFee); bizMeetingMapper.updateLaborFee(meetingId, laborFee, totalFee);
} }
@Override
public void recomputeSubmitDeadlinesByProject(Long projectId, Integer days) {
if (projectId == null) return;
bizMeetingMapper.recomputeSubmitDeadlinesByProject(projectId, days);
}
} }
@@ -65,9 +65,14 @@ public class BizOrgServiceImpl implements IBizOrgService {
} }
@Override @Override
@Transactional
public int deleteByPrimaryKeys(Long[] orgIds) { public int deleteByPrimaryKeys(Long[] orgIds) {
int rows = 0; int rows = 0;
for (Long id : orgIds) { rows += bizOrgMapper.deleteByPrimaryKeys(new Long[]{id}); } for (Long id : orgIds) {
// 先级联软删机构下所有账号 (主 + 子), 再软删机构 (删除机构=软删除, 账号也随之软删)
bizOrgMapper.softDeleteUsersByOrgId(id);
rows += bizOrgMapper.deleteByPrimaryKeys(new Long[]{id});
}
return rows; return rows;
} }
@@ -87,8 +92,8 @@ public class BizOrgServiceImpl implements IBizOrgService {
} }
@Override @Override
public Map<String, Object> selectMySponsorCompany(Long userId) { public Map<String, Object> selectMyCompany(Long userId) {
return bizOrgMapper.selectMySponsorCompany(userId); return bizOrgMapper.selectMyCompany(userId);
} }
@Override @Override
@@ -106,9 +111,14 @@ public class BizOrgServiceImpl implements IBizOrgService {
@Override @Override
@Transactional @Transactional
public int toggleStatus(Long orgId, String newBizOrgStatus) { public int toggleStatus(Long orgId, String newBizOrgStatus) {
// 1. 查 org 取主账号 user_id // 1. 查 org 取主账号 user_id + org_type
BizOrg org = bizOrgMapper.selectByPrimaryKey(orgId); BizOrg org = bizOrgMapper.selectByPrimaryKey(orgId);
if (org == null) throw new ServiceException("组织不存在"); if (org == null) throw new ServiceException("组织不存在");
// 2026-09-08: 执行方状态由供应商系统管理, 本系统不再支持手动启停
// 走 SupplierAccountSyncService 把 supplier 端 disabled/deleted 翻译成 sys_user.del_flag + biz_org.del_flag
if ("executor".equals(org.getOrgType())) {
throw new ServiceException("执行方状态由供应商系统管理,本系统不支持手动启停");
}
if (org.getUserId() == null) throw new ServiceException("该组织未关联主账号"); if (org.getUserId() == null) throw new ServiceException("该组织未关联主账号");
// 2. UPDATE biz_org.status // 2. UPDATE biz_org.status
@@ -28,6 +28,10 @@ public class BizPersonServiceImpl implements IBizPersonService
public BizPerson getById(String personId) public BizPerson getById(String personId)
{ return bizPersonMapper.selectByPrimaryKey(personId); } { return bizPersonMapper.selectByPrimaryKey(personId); }
@Override
public BizPerson getByUserId(Long userId)
{ return bizPersonMapper.selectByUserId(userId); }
@Override @Override
public List<BizPerson> selectList(BizPerson entity) public List<BizPerson> selectList(BizPerson entity)
{ return bizPersonMapper.selectList(entity); } { return bizPersonMapper.selectList(entity); }
@@ -14,8 +14,8 @@ public class BizProjectExecutorAssignServiceImpl implements IBizProjectExecutorA
@Override @Override
public int insertAssign(BizProjectExecutorAssign entity) { public int insertAssign(BizProjectExecutorAssign entity) {
// 执行方分配策略: 先按 project_id 删, 再插 // 执行方分配策略: 先按 (project_id + executor_org_id) 删本执行方旧分配, 再插 (避免覆盖别家执行方)
mapper.deleteByProjectId(entity.getProjectId()); mapper.deleteByProjectIdAndOrg(entity.getProjectId(), entity.getExecutorOrgId());
return mapper.insertAssign(entity); return mapper.insertAssign(entity);
} }
@@ -29,8 +29,8 @@ public class BizProjectExecutorAssignServiceImpl implements IBizProjectExecutorA
public int assignStaffForProject(BizProjectExecutorAssign body, java.util.List<Long> staffUserIds) { public int assignStaffForProject(BizProjectExecutorAssign body, java.util.List<Long> staffUserIds) {
if (body == null || body.getProjectId() == null) return 0; if (body == null || body.getProjectId() == null) return 0;
if (staffUserIds == null || staffUserIds.isEmpty()) return 0; if (staffUserIds == null || staffUserIds.isEmpty()) return 0;
// 一次性清旧, 不在循环里清 // 一次性清旧 (只清本执行方 executor_org_id 的, 不在循环里清)
mapper.deleteByProjectId(body.getProjectId()); mapper.deleteByProjectIdAndOrg(body.getProjectId(), body.getExecutorOrgId());
int inserted = 0; int inserted = 0;
for (Long sid : staffUserIds) { for (Long sid : staffUserIds) {
BizProjectExecutorAssign item = new BizProjectExecutorAssign(); BizProjectExecutorAssign item = new BizProjectExecutorAssign();
@@ -46,12 +46,12 @@ public class BizProjectExecutorAssignServiceImpl implements IBizProjectExecutorA
} }
@Override @Override
public List<BizProjectExecutorAssign> listByProjectId(String projectId) { public List<BizProjectExecutorAssign> listByProjectId(Long projectId, Long executorOrgId) {
return mapper.selectByProjectId(projectId); return mapper.selectByProjectIdAndOrg(projectId, executorOrgId);
} }
@Override @Override
public int deleteByProjectId(String projectId) { public int deleteByProjectId(Long projectId) {
return mapper.deleteByProjectId(projectId); return mapper.deleteByProjectId(projectId);
} }
} }
@@ -31,6 +31,10 @@ public class BizProjectPlanServiceImpl implements IBizProjectPlanService
// 补 create_by / create_time (历史一直没写, 导致"提交时间"为空; 列表按 create_time 倒序) // 补 create_by / create_time (历史一直没写, 导致"提交时间"为空; 列表按 create_time 倒序)
if (entity.getCreateBy() == null) entity.setCreateBy(SecurityUtils.getUsername()); if (entity.getCreateBy() == null) entity.setCreateBy(SecurityUtils.getUsername());
if (entity.getCreateTime() == null) entity.setCreateTime(new Date()); 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); return bizProjectPlanMapper.insert(entity);
} }
@@ -1,6 +1,8 @@
package com.ruoyi.business.service.impl; package com.ruoyi.business.service.impl;
import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.Map;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
@@ -15,6 +17,7 @@ import com.ruoyi.business.mapper.BizMeetingMapper;
import com.ruoyi.business.service.IBizProjectService; import com.ruoyi.business.service.IBizProjectService;
import com.ruoyi.business.service.IBizMeetingService; import com.ruoyi.business.service.IBizMeetingService;
import com.ruoyi.common.utils.SecurityUtils; import com.ruoyi.common.utils.SecurityUtils;
import com.ruoyi.common.utils.id.SnowflakeId;
@Service @Service
public class BizProjectServiceImpl implements IBizProjectService public class BizProjectServiceImpl implements IBizProjectService
@@ -43,8 +46,8 @@ public class BizProjectServiceImpl implements IBizProjectService
public List<BizProject> selectList(BizProject entity) public List<BizProject> selectList(BizProject entity)
{ return bizProjectMapper.selectList(entity); } { return bizProjectMapper.selectList(entity); }
@Override @Override
public List<BizProject> selectPublicAnnouncements() public List<BizProject> selectPublicAnnouncements(String keyword)
{ return bizProjectMapper.selectPublicAnnouncements(); } { return bizProjectMapper.selectPublicAnnouncements(keyword); }
@Override @Override
public List<BizProject> selectSponsorList(BizProject entity) public List<BizProject> selectSponsorList(BizProject entity)
{ return bizProjectMapper.selectSponsorList(entity); } { return bizProjectMapper.selectSponsorList(entity); }
@@ -55,10 +58,10 @@ public class BizProjectServiceImpl implements IBizProjectService
public List<BizProject> selectExecutorStaffList(BizProject entity) public List<BizProject> selectExecutorStaffList(BizProject entity)
{ return bizProjectMapper.selectExecutorStaffList(entity); } { return bizProjectMapper.selectExecutorStaffList(entity); }
@Override @Override
// : biz_project.project_id 用 DB AUTO_INCREMENT, 不需要 SnowflakeId 注入; // 雪花 ID 主键: biz_project.project_id 由应用生成 53-bit 雪花 ID (不再走 DB AUTO_INCREMENT).
// 项目 ID 用 Long 后, SnowflakeId.injectIfEmpty 反射 setProjectId(String) 会 NoSuchMethodException 被吞掉 (SnowflakeId.java:30-31), 行为安全.
// create_user_id 走当前登录用户 (前台 API 无 @DataScope, 不会被过滤; 后台 @PreAuthorize 受角色限制) // create_user_id 走当前登录用户 (前台 API 无 @DataScope, 不会被过滤; 后台 @PreAuthorize 受角色限制)
public int insert(BizProject entity) { public int insert(BizProject entity) {
SnowflakeId.injectIfEmpty(entity, "projectId");
if (entity.getCreateUserId() == null) { if (entity.getCreateUserId() == null) {
entity.setCreateUserId(SecurityUtils.getUserId()); entity.setCreateUserId(SecurityUtils.getUserId());
} }
@@ -113,8 +116,8 @@ public class BizProjectServiceImpl implements IBizProjectService
bizProjectAssignMapper.softDeleteByProjectId(projectId); bizProjectAssignMapper.softDeleteByProjectId(projectId);
// 5) sponsor assign (String) // 5) sponsor assign (String)
bizProjectSponsorAssignMapper.softDeleteByProjectId(String.valueOf(projectId)); bizProjectSponsorAssignMapper.softDeleteByProjectId(String.valueOf(projectId));
// 5.5) executor assign (String) // 5.5) executor assign
bizProjectExecutorAssignMapper.softDeleteByProjectId(String.valueOf(projectId)); bizProjectExecutorAssignMapper.softDeleteByProjectId(projectId);
// 6) rating // 6) rating
bizProjectRatingMapper.softDeleteByProjectId(projectId); bizProjectRatingMapper.softDeleteByProjectId(projectId);
// 7) 会议链: 查项目下所有 meeting → 调 BizMeetingService.softDeleteCascadeBatch // 7) 会议链: 查项目下所有 meeting → 调 BizMeetingService.softDeleteCascadeBatch
@@ -153,4 +156,9 @@ public class BizProjectServiceImpl implements IBizProjectService
public int closeExpiredOpenStatus() { public int closeExpiredOpenStatus() {
return bizProjectMapper.closeExpiredOpenStatus(); return bizProjectMapper.closeExpiredOpenStatus();
} }
@Override
public List<Map<String, Object>> checkMeetingSettled(List<Long> projectIds) {
if (projectIds == null || projectIds.isEmpty()) return new ArrayList<>();
return bizProjectMapper.checkMeetingSettled(projectIds);
}
} }

Some files were not shown because too many files have changed in this diff Show More