Compare commits
10
Commits
e47f76478b
...
0fb98311ce
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0fb98311ce | ||
|
|
91bf0fa3b9 | ||
|
|
9ea4e18161 | ||
|
|
9dc378974f | ||
|
|
5a0a892574 | ||
|
|
56615f9806 | ||
|
|
beb9799a06 | ||
|
|
a14d95288c | ||
|
|
f4d8bc1463 | ||
|
|
1876601ba7 |
@@ -89,3 +89,12 @@ ry-vue2/ # 旧 vue 控制台
|
||||
# ===== OCR 模型 (大二进制, 不进版本库, 部署时放 jar 同目录 models/) =====
|
||||
ry-api/models/
|
||||
ry-ocr-java/src/main/resources/models/
|
||||
|
||||
# ===== OA 单点登录临时笔记 (含 OA 管理员密码, 不入库) =====
|
||||
oalogin/
|
||||
|
||||
# ===== 临时测试产物 / 设计源文件 (不入库) =====
|
||||
*.rar
|
||||
11.pdf
|
||||
11_extracted.txt
|
||||
proto/合规系统0904/
|
||||
|
||||
@@ -212,7 +212,7 @@ COMMENT='֧�ֵ�λ��';
|
||||
| Key | 列 | 类型 | 用途覆盖 |
|
||||
|---|---|---|---|
|
||||
| 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_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 命中 |
|
||||
|
||||
@@ -339,7 +339,7 @@ CREATE TABLE `biz_person` (
|
||||
| uk_org_user_type | `user_id`,`org_type` (UNIQUE) | ✅ 一用户一类型一公司 |
|
||||
| idx_org_type_status | `org_type`,`status` | ✅ |
|
||||
| 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 里所有查询路径, 无需新增.
|
||||
|
||||
|
||||
@@ -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> >
|
||||
<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>
|
||||
@@ -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> >
|
||||
<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>
|
||||
@@ -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> > 发送邀请
|
||||
</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>
|
||||
@@ -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-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>
|
||||
</ul>
|
||||
</aside>
|
||||
|
||||
@@ -16,6 +16,16 @@ import com.ruoyi.common.config.RuoYiConfig;
|
||||
import com.ruoyi.common.config.RuoYiConfig.OssProperties;
|
||||
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 签名服务
|
||||
*
|
||||
@@ -167,4 +177,180 @@ public class OssController
|
||||
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";
|
||||
}
|
||||
}
|
||||
+81
-1
@@ -25,7 +25,10 @@ import com.ruoyi.system.service.ISysConfigService;
|
||||
import com.ruoyi.system.service.ISysMenuService;
|
||||
import com.ruoyi.system.service.ISysUserService;
|
||||
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.IBizPersonService;
|
||||
|
||||
/**
|
||||
* 登录验证
|
||||
@@ -56,6 +59,12 @@ public class SysLoginController
|
||||
@Autowired
|
||||
private IBizExpertService expertService;
|
||||
|
||||
@Autowired
|
||||
private IBizPersonService personService;
|
||||
|
||||
@Autowired
|
||||
private ComplianceLoginService complianceLoginService;
|
||||
|
||||
/**
|
||||
* 登录方法
|
||||
*
|
||||
@@ -70,6 +79,12 @@ public class SysLoginController
|
||||
{
|
||||
return AjaxResult.error("您的信息正在审核中");
|
||||
}
|
||||
// 合规人员/项目负责人门禁: 校验其仍存在于 ecology 视图, 角色变更则软删重建
|
||||
String complianceGate = complianceGate(loginBody.getUsername());
|
||||
if (complianceGate != null)
|
||||
{
|
||||
return AjaxResult.error(complianceGate);
|
||||
}
|
||||
AjaxResult ajax = AjaxResult.success();
|
||||
// 生成令牌
|
||||
String token = loginService.login(loginBody.getUsername(), loginBody.getPassword(), loginBody.getCode(),
|
||||
@@ -101,6 +116,28 @@ public class SysLoginController
|
||||
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();
|
||||
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("permissions", permissions);
|
||||
ajax.put("pwdChrtype", getSysAccountChrtype());
|
||||
ajax.put("isDefaultModifyPwd", initPasswordIsModify(user.getPwdUpdateDate()));
|
||||
ajax.put("isPasswordExpired", passwordIsExpiration(user.getPwdUpdateDate()));
|
||||
ajax.put("isPasswordEmpty", isPasswordEmpty(user.getUserId()));
|
||||
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 路由信息
|
||||
*/
|
||||
@GetMapping("getRouters")
|
||||
@@ -156,6 +221,21 @@ public class SysLoginController
|
||||
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)
|
||||
{
|
||||
|
||||
+4
-2
@@ -103,11 +103,13 @@ public class SysProfileController extends BaseController
|
||||
Long userId = loginUser.getUserId();
|
||||
SysUser user = userService.selectUserById(userId);
|
||||
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("修改密码失败,旧密码错误");
|
||||
}
|
||||
if (SecurityUtils.matchesPassword(newPassword, password))
|
||||
if (!passwordEmpty && SecurityUtils.matchesPassword(newPassword, password))
|
||||
{
|
||||
return error("新密码不能与旧密码相同");
|
||||
}
|
||||
|
||||
@@ -4,10 +4,7 @@ spring:
|
||||
type: com.alibaba.druid.pool.DruidDataSource
|
||||
driverClassName: com.mysql.cj.jdbc.Driver
|
||||
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_
|
||||
# master 数据源 url/username/password 按环境拆分到 application-test.yml / application-prod.yml
|
||||
slave:
|
||||
enabled: false
|
||||
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/uploadPath,Linux配置 /home/ruoyi/uploadPath)
|
||||
profile: D:/ruoyi/uploadPath
|
||||
# 医生劳务协议签署 — token 签名密钥 (HMAC-SHA1)
|
||||
sign-token-secret: hwt-sign-secret-2026
|
||||
# 获取ip地址开关
|
||||
addressEnabled: false
|
||||
# 验证码类型 math 数字计算 char 字符验证
|
||||
captchaType: math
|
||||
# OSS 配置 (前端直传签名, secretKey 仅后端使用)
|
||||
oss:
|
||||
enabled: true
|
||||
endpoint: https://hwtossbamlorgcn.oss-cn-beijing.aliyuncs.com
|
||||
bucket: hwtossbamlorgcn
|
||||
accessKeyId: LTAI5tQKWWu96ihmg2VChQsY
|
||||
accessKeySecret: gA0fTFa8arUYaYA5Us2ClPKVSM1Ypv
|
||||
dirPrefix: ry8080/
|
||||
expireSeconds: 3600
|
||||
# 阿里云函数计算 (FC) 打 zip 端点 (与 hwt-serve 共用同一 bucket, 参考 OssApi.ossDownload)
|
||||
zip-func-url: https://zip-oss-func-zip-oss-swqpembfsl.cn-beijing.fcapp.run
|
||||
# 阿里云短信配置 (hwt-code ali.sms 模式)
|
||||
# dev/prod 区分走代码: phone 以 "10" 开头视为 dev 测试 (固定码 1234), 其它走 aliyun 真发
|
||||
sms:
|
||||
accessKeyId: LTAI5tAgeAUviVdPSsxYCkPY
|
||||
accessKeySecret: 556LX6mXnJIPZgf0vFXcl4mKCzzgKq
|
||||
signName: 北京整合医学学会
|
||||
template: SMS_291440833
|
||||
esignTemplate: SMS_512040098
|
||||
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:
|
||||
@@ -103,14 +6,18 @@ spring:
|
||||
# 国际化资源文件路径
|
||||
basename: i18n/messages
|
||||
profiles:
|
||||
active: druid
|
||||
# 默认 prod 环境; 本地测试用 --spring.profiles.active=test (group 自动展开为 druid,test)
|
||||
active: prod
|
||||
group:
|
||||
test: druid,test
|
||||
prod: druid,prod
|
||||
# 文件上传
|
||||
servlet:
|
||||
multipart:
|
||||
# 单个文件大小
|
||||
max-file-size: 10MB
|
||||
max-file-size: 200MB
|
||||
# 设置总上传的文件大小
|
||||
max-request-size: 20MB
|
||||
max-request-size: 200MB
|
||||
jackson:
|
||||
time-zone: GMT+8
|
||||
date-format: yyyy-MM-dd HH:mm:ss
|
||||
@@ -142,6 +49,21 @@ spring:
|
||||
max-active: 8
|
||||
# #连接池最大阻塞等待时间(使用负值表示没有限制)
|
||||
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:
|
||||
@@ -195,4 +117,104 @@ xss:
|
||||
# 排除链接(多个用逗号分隔)
|
||||
excludes: /system/notice
|
||||
# 匹配链接
|
||||
urlPatterns: /system/*,/monitor/*,/tool/*
|
||||
urlPatterns: /system/*,/monitor/*,/tool/*
|
||||
# 项目相关配置
|
||||
ruoyi:
|
||||
# 名称
|
||||
name: RuoYi
|
||||
# 版本
|
||||
version: 3.9.2
|
||||
# 版权年份
|
||||
copyrightYear: 2026
|
||||
# 文件路径 示例( Windows配置D:/ruoyi/uploadPath,Linux配置 /home/ruoyi/uploadPath)
|
||||
profile: D:/ruoyi/uploadPath
|
||||
# 医生劳务协议签署 — token 签名密钥 (HMAC-SHA1)
|
||||
sign-token-secret: hwt-sign-secret-2026
|
||||
# 获取ip地址开关
|
||||
addressEnabled: false
|
||||
# 验证码类型 math 数字计算 char 字符验证
|
||||
captchaType: math
|
||||
# OSS 配置 (前端直传签名, secretKey 仅后端使用)
|
||||
oss:
|
||||
enabled: true
|
||||
endpoint: https://hwtossbamlorgcn.oss-cn-beijing.aliyuncs.com
|
||||
bucket: hwtossbamlorgcn
|
||||
accessKeyId: LTAI5tQKWWu96ihmg2VChQsY
|
||||
accessKeySecret: gA0fTFa8arUYaYA5Us2ClPKVSM1Ypv
|
||||
dirPrefix: ry8080/
|
||||
expireSeconds: 3600
|
||||
# 阿里云函数计算 (FC) 打 zip 端点 (与 hwt-serve 共用同一 bucket, 参考 OssApi.ossDownload)
|
||||
zip-func-url: https://zip-oss-func-zip-oss-swqpembfsl.cn-beijing.fcapp.run
|
||||
# 阿里云短信配置 (hwt-code ali.sms 模式)
|
||||
# mock-enabled 按环境拆分到 application-test/prod.yml: test=true 固定码 1234 不真发, prod=false aliyun 真发
|
||||
sms:
|
||||
accessKeyId: LTAI5tAgeAUviVdPSsxYCkPY
|
||||
accessKeySecret: 556LX6mXnJIPZgf0vFXcl4mKCzzgKq
|
||||
signName: 北京整合医学学会
|
||||
template: SMS_291440833
|
||||
esignTemplate: SMS_512040098
|
||||
# 发送邀请短信模板 (新增独立模板, 不复用 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
|
||||
|
||||
@@ -34,6 +34,11 @@
|
||||
<artifactId>aliyun-java-sdk-dysmsapi</artifactId>
|
||||
<version>2.2.1</version>
|
||||
</dependency>
|
||||
<!-- 邮件发送 (发送邀请 email 渠道, 参考 xxl-job EmailJobAlarm) — 需 mvn install -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-mail</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.aliyun</groupId>
|
||||
<artifactId>aliyun-java-sdk-core</artifactId>
|
||||
|
||||
+42
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
* zip 批量上传执行器 (劳务协议 / 专家照片 / 会务材料): 解压 zip + 逐文件上传 OSS (网络 IO 密集).
|
||||
* <p>
|
||||
* 上传接口改为"提交即返回 jobId" (处理移后台), 前端轮询进度; 4 线程并发处理不同用户的上传任务.
|
||||
*/
|
||||
@Bean(name = "zipUploadExecutor", destroyMethod = "shutdown")
|
||||
public ExecutorService zipUploadExecutor()
|
||||
{
|
||||
return Executors.newFixedThreadPool(4);
|
||||
}
|
||||
}
|
||||
+16
-11
@@ -19,7 +19,9 @@ import com.ruoyi.business.dto.SmsValidForm;
|
||||
import com.ruoyi.business.mapper.BizPersonMapper;
|
||||
import com.ruoyi.business.service.IBizExpertService;
|
||||
import com.ruoyi.business.service.IBizOrgService;
|
||||
import com.ruoyi.business.service.ComplianceLoginService;
|
||||
import com.ruoyi.business.service.SysSmsService;
|
||||
import com.ruoyi.common.exception.ServiceException;
|
||||
import com.ruoyi.common.utils.id.SnowflakeId;
|
||||
import com.ruoyi.common.constant.Constants;
|
||||
import com.ruoyi.common.core.controller.BaseController;
|
||||
@@ -69,6 +71,9 @@ public class BizAuthController extends BaseController {
|
||||
@Autowired
|
||||
private IBizExpertService expertService;
|
||||
|
||||
@Autowired
|
||||
private ComplianceLoginService complianceLoginService;
|
||||
|
||||
@Autowired
|
||||
private BizPersonMapper bizPersonMapper;
|
||||
|
||||
@@ -101,8 +106,8 @@ public class BizAuthController extends BaseController {
|
||||
if (phone == null || !phone.matches("^1\\d{10}$")) {
|
||||
return error("请输入正确的手机号");
|
||||
}
|
||||
// 登录反义: 手机号未注册则不发
|
||||
if (!userService.isPhoneRegistered(phone)) {
|
||||
// 登录反义: 手机号未注册则不发; 但合规人员/项目负责人可从 ecology 视图自动建号, 放行发码
|
||||
if (!userService.isPhoneRegistered(phone) && !complianceLoginService.existsInEcology(phone)) {
|
||||
return error("该手机号未注册, 请先注册账号");
|
||||
}
|
||||
String uuid = smsService.sendCode(phone);
|
||||
@@ -138,15 +143,15 @@ public class BizAuthController extends BaseController {
|
||||
return error("验证码错误或已过期: " + e.getMessage());
|
||||
}
|
||||
|
||||
// 3. 查用户 (checkPhoneUnique 只 select user_id+phonenumber, 缺 role_type/user_name/status,
|
||||
// 需再按 userId 查完整 user, 否则 LoginUser 里 roleType=null 导致前端拿不到角色而跳回登录页)
|
||||
SysUser probe = userMapper.checkPhoneUnique(phone);
|
||||
if (probe == null || probe.getUserId() == null) {
|
||||
return error("该手机号未注册");
|
||||
// 3. 查用户 + 合规人员/项目负责人身份校验 (可能从 ecology 视图自动建号)
|
||||
SysUser user;
|
||||
try {
|
||||
user = complianceLoginService.reconcileForLogin(phone);
|
||||
} catch (ServiceException e) {
|
||||
return error(e.getMessage());
|
||||
}
|
||||
SysUser user = userMapper.selectUserById(probe.getUserId());
|
||||
if (user == null) {
|
||||
return error("该手机号未注册");
|
||||
return error("用户不存在");
|
||||
}
|
||||
|
||||
// 4. 校验用户状态
|
||||
@@ -276,7 +281,7 @@ public class BizAuthController extends BaseController {
|
||||
self.setUpdateBy(username);
|
||||
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);
|
||||
bizPersonMapper.insert(self);
|
||||
|
||||
return success("注册成功, 请等待审核").put("userId", userId).put("orgId", orgId);
|
||||
return success("注册成功").put("userId", userId).put("orgId", orgId);
|
||||
}
|
||||
}
|
||||
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
package com.ruoyi.business.controller;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import com.ruoyi.business.service.EcologyProjectService;
|
||||
import com.ruoyi.common.core.controller.BaseController;
|
||||
import com.ruoyi.common.core.domain.AjaxResult;
|
||||
import com.ruoyi.common.exception.ServiceException;
|
||||
|
||||
/**
|
||||
* ecology 项目信息拉取 (admin/projects/new 按项目编号自动回填).
|
||||
* <p>
|
||||
* 读接口开放给已登录用户 (admin/manager 新建项目用), 与 /business/org/*Options 下拉一致, 不加 @PreAuthorize.
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/business/ecology")
|
||||
public class BizEcologyController extends BaseController
|
||||
{
|
||||
@Autowired
|
||||
private EcologyProjectService ecologyProjectService;
|
||||
|
||||
/**
|
||||
* 项目编号模糊搜索 (下拉选项): GET /business/ecology/project/search?xmbh=xxx
|
||||
* 返回 [{projectNo, projectName}].
|
||||
*/
|
||||
@GetMapping("/project/search")
|
||||
public AjaxResult searchProjects(@RequestParam(value = "xmbh", required = false) String xmbh)
|
||||
{
|
||||
List<Map<String, Object>> list = ecologyProjectService.searchProjectNos(xmbh);
|
||||
return success(list);
|
||||
}
|
||||
|
||||
/**
|
||||
* 按项目编号拉取完整信息 + 负责人静默建档: GET /business/ecology/project/byNo?projectNo=xxx
|
||||
* 返回 {projectNo, projectName, projectForm, startTime, endTime, totalAmount, manageFee,
|
||||
* leaderResolved, leaderName, leaderPhone, leaderUserId}.
|
||||
*/
|
||||
@GetMapping("/project/byNo")
|
||||
public AjaxResult fetchProject(@RequestParam(value = "projectNo", required = false) String projectNo)
|
||||
{
|
||||
try
|
||||
{
|
||||
return success(ecologyProjectService.fetchProjectByNo(projectNo));
|
||||
}
|
||||
catch (ServiceException e)
|
||||
{
|
||||
return error(e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
+22
-4
@@ -14,9 +14,11 @@ import com.ruoyi.common.enums.BusinessType;
|
||||
import com.ruoyi.common.utils.SecurityUtils;
|
||||
import com.ruoyi.common.utils.poi.ExcelUtil;
|
||||
import com.ruoyi.system.mapper.SysUserMapper;
|
||||
import com.ruoyi.business.domain.BizExpert;
|
||||
import com.ruoyi.business.domain.BizExecutionIntent;
|
||||
import com.ruoyi.business.domain.BizProject;
|
||||
import com.ruoyi.business.domain.vo.BizSignupExpertExportVo;
|
||||
import com.ruoyi.business.service.IBizExpertService;
|
||||
import com.ruoyi.business.service.IBizExecutionIntentService;
|
||||
import com.ruoyi.business.service.IBizProjectService;
|
||||
|
||||
@@ -33,6 +35,8 @@ public class BizExecutionIntentController extends BaseController
|
||||
private IBizExecutionIntentService bizExecutionIntentService;
|
||||
@Autowired
|
||||
private IBizProjectService bizProjectService;
|
||||
@Autowired
|
||||
private IBizExpertService bizExpertService;
|
||||
|
||||
/**
|
||||
* 公开门户点击"立即报名" — 写入意向 (user_id = 当前登录用户)
|
||||
@@ -60,6 +64,8 @@ public class BizExecutionIntentController extends BaseController
|
||||
q.setProjectNo(proj.getProjectNo());
|
||||
List<BizExecutionIntent> exists = bizExecutionIntentService.selectList(q);
|
||||
if (exists != null && !exists.isEmpty()) return error("您已报名过该项目,无需重复报名");
|
||||
// 真报名: 先物理删掉幽灵占位记录 (del_flag='1')
|
||||
bizExecutionIntentService.hardDeletePlaceholder(SecurityUtils.getUserId(), proj.getProjectNo());
|
||||
bizExecutionIntent.setProjectNo(proj.getProjectNo());
|
||||
bizExecutionIntent.setProjectName(proj.getProjectName());
|
||||
Long uid = SecurityUtils.getUserId();
|
||||
@@ -136,16 +142,28 @@ public class BizExecutionIntentController extends BaseController
|
||||
if (bizExecutionIntent.getProjectNo() == null || bizExecutionIntent.getProjectNo().isEmpty()) {
|
||||
throw new IllegalArgumentException("projectNo 不能为空");
|
||||
}
|
||||
List<BizExecutionIntent> list = bizExecutionIntentService.selectList(bizExecutionIntent);
|
||||
List<BizExecutionIntent> list = bizExecutionIntentService.selectListIncludingDeleted(bizExecutionIntent);
|
||||
List<BizSignupExpertExportVo> exportList = new ArrayList<>(list.size());
|
||||
for (BizExecutionIntent e : list) {
|
||||
BizSignupExpertExportVo v = new BizSignupExpertExportVo();
|
||||
v.setProjectNo(e.getProjectNo());
|
||||
v.setProjectName(e.getProjectName());
|
||||
v.setName(e.getName());
|
||||
v.setDepartment(e.getDepartment());
|
||||
v.setWorkUnit(e.getWorkUnit());
|
||||
v.setPosition(e.getPosition());
|
||||
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);
|
||||
|
||||
+119
@@ -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);
|
||||
}
|
||||
}
|
||||
+90
@@ -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));
|
||||
}
|
||||
}
|
||||
+155
-13
@@ -1,14 +1,18 @@
|
||||
package com.ruoyi.business.controller;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
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.exception.ServiceException;
|
||||
import com.ruoyi.common.utils.SecurityUtils;
|
||||
import com.ruoyi.common.utils.poi.ExcelUtil;
|
||||
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.vo.BizMeetingAttendeeImportVo;
|
||||
import com.ruoyi.business.notify.BizNotifyService;
|
||||
import com.ruoyi.business.service.IBizExecutionIntentService;
|
||||
import com.ruoyi.business.service.IBizMeetingAttendeeService;
|
||||
import com.ruoyi.business.service.IBizMeetingService;
|
||||
|
||||
@@ -44,17 +49,21 @@ public class BizMeetingAttendeeController extends BaseController {
|
||||
@Autowired
|
||||
private IBizMeetingService bizMeetingService;
|
||||
@Autowired
|
||||
private IBizExecutionIntentService bizExecutionIntentService;
|
||||
@Autowired
|
||||
private BizNotifyService bizNotifyService;
|
||||
|
||||
/**
|
||||
* 当前登录用户的"待签署协议"列表 (已推送电子签 is_esigned=1 且 任一未签)
|
||||
* 用于 /doctor/home 工作台
|
||||
* 用于 /doctor/home 工作台, 支持分页 (前端按 5 条/页)
|
||||
* pageNum / pageSize 从 request param 读 (RuoYi 标准做法, TableSupport 自动解析)
|
||||
*/
|
||||
@GetMapping("/unsigned")
|
||||
public AjaxResult listUnsigned() {
|
||||
public TableDataInfo listUnsigned() {
|
||||
Long userId = SecurityUtils.getUserId();
|
||||
startPage(); // PageHelper 仅拦截同一线程的下一条 select, 所以放 service.list() 之前
|
||||
List<BizMeetingAttendee> rows = attendeeService.selectUnsignedByUserId(userId);
|
||||
return success(rows);
|
||||
return getDataTable(rows);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -68,13 +77,55 @@ public class BizMeetingAttendeeController extends BaseController {
|
||||
return success(rows);
|
||||
}
|
||||
|
||||
/**
|
||||
* 会议邀请函详情 (参会人/医生点开站内信里的邀请链接后查看).
|
||||
* <p>安全: 仅参会人本人可见 (attendee.userId == 当前登录用户), 防止横向越权.
|
||||
* <p>返回 {attendeeName, title, laborForm, projectName, meetingName, meetingTime, scheduleUrl, invitationUrl}.
|
||||
*/
|
||||
@GetMapping("/invitation/{attendeeId}")
|
||||
public AjaxResult invitationDetail(@PathVariable("attendeeId") Long attendeeId) {
|
||||
if (attendeeId == null) return error("缺少参会记录");
|
||||
BizMeetingAttendee a = attendeeService.selectById(attendeeId);
|
||||
if (a == null) return error("参会记录不存在");
|
||||
Long uid = SecurityUtils.getUserId();
|
||||
if (a.getUserId() == null || !a.getUserId().equals(uid)) {
|
||||
return error("无权限查看该邀请函");
|
||||
}
|
||||
BizMeeting m = a.getMeetingId() == null ? null : bizMeetingService.getById(a.getMeetingId());
|
||||
// 点开邀请函 = 立即报名: 确保该专家已"报名"该项目 (幂等, 已报名则跳过)
|
||||
if (m != null && m.getProjectNo() != null && !m.getProjectNo().trim().isEmpty()) {
|
||||
bizExecutionIntentService.signupByProject(m.getProjectNo(), m.getProjectName());
|
||||
}
|
||||
Map<String, Object> data = new HashMap<>();
|
||||
data.put("attendeeName", a.getName());
|
||||
data.put("title", a.getTitle());
|
||||
data.put("laborForm", a.getLaborForm());
|
||||
data.put("projectName", m != null ? m.getProjectName() : null);
|
||||
data.put("meetingName", m != null ? m.getMeetingName() : null);
|
||||
data.put("meetingTime", m != null ? m.getStartTime() : null);
|
||||
data.put("scheduleUrl", m != null ? m.getScheduleUrl() : null);
|
||||
// 优先参会人个性化邀请函 (盖章+姓名); 未生成回退会议级模板
|
||||
data.put("invitationUrl", a.getInvitationUrl() != null && !a.getInvitationUrl().trim().isEmpty()
|
||||
? a.getInvitationUrl()
|
||||
: (m != null ? m.getInvitationUrl() : null));
|
||||
return success(data);
|
||||
}
|
||||
|
||||
/**
|
||||
* 管理端: 某会议的全部参会人 (MeetingDetail 参会人 CRUD 用).
|
||||
* 返回全字段, 前端按需展示.
|
||||
* <p>sponsor (监督方) 只读且敏感信息脱敏: 姓名/手机号/银行卡号/账户名称/身份证号在后端打码,
|
||||
* 明文不下发 (前端遮罩只是显示层, 不是安全边界).
|
||||
*/
|
||||
@GetMapping("/list/{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)
|
||||
@PostMapping
|
||||
public AjaxResult add(@RequestBody BizMeetingAttendee body) {
|
||||
// 参会人名单写操作拦截: executor 劳务提交后禁止 (新增)
|
||||
checkAttendeeEditable(body.getMeetingId());
|
||||
Long attendeeId = attendeeService.insertByPhoneWithProfile(body);
|
||||
// 人员变化 → 立即重算劳务费 (不碰会务费/不置统计中)
|
||||
bizMeetingService.recomputeLaborFee(body.getMeetingId());
|
||||
@@ -111,6 +164,8 @@ public class BizMeetingAttendeeController extends BaseController {
|
||||
return error("id 不能为空");
|
||||
}
|
||||
BizMeetingAttendee before = attendeeService.selectById(body.getId());
|
||||
// 参会人名单写操作拦截: executor 劳务提交后禁止 (编辑)
|
||||
checkAttendeeEditable(before != null ? before.getMeetingId() : null);
|
||||
body.setUpdateBy(SecurityUtils.getUsername());
|
||||
int rows = attendeeService.updateProfile(body);
|
||||
// 人员变化 → 立即重算劳务费 (不碰会务费/不置统计中)
|
||||
@@ -120,6 +175,15 @@ public class BizMeetingAttendeeController extends BaseController {
|
||||
return toAjax(rows);
|
||||
}
|
||||
|
||||
/**
|
||||
* 新增/编辑参会人保存前的预检: 比对表单与专家表 (biz_expert) 档案, 返回不一致字段列表.
|
||||
* <p>前端据此弹窗 + 标红 + 二次确认, 确认后才真正 POST/PUT. 无差异返回空数组.
|
||||
*/
|
||||
@PostMapping("/diff")
|
||||
public AjaxResult diff(@RequestBody BizMeetingAttendee body) {
|
||||
return success(attendeeService.diffAgainstExpert(body));
|
||||
}
|
||||
|
||||
/**
|
||||
* 管理端: 按 id 删除参会人 (单条).
|
||||
* 注意: 仅删 biz_meeting_attendee 行, 不删 sys_user (参会人可能是真实用户, 不能因参会关系删号).
|
||||
@@ -128,6 +192,8 @@ public class BizMeetingAttendeeController extends BaseController {
|
||||
@DeleteMapping("/{id}")
|
||||
public AjaxResult remove(@PathVariable("id") Long id) {
|
||||
BizMeetingAttendee before = attendeeService.selectById(id);
|
||||
// 参会人名单写操作拦截: executor 劳务提交后禁止 (删除)
|
||||
checkAttendeeEditable(before != null ? before.getMeetingId() : null);
|
||||
int rows = attendeeService.deleteByPrimaryKey(id);
|
||||
// 人员变化 → 立即重算劳务费 (不碰会务费/不置统计中)
|
||||
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 传, 决定导入到哪个会议)
|
||||
*/
|
||||
@Log(title = "参会人批量导入", businessType = BusinessType.IMPORT)
|
||||
@PostMapping("/importData")
|
||||
public AjaxResult importData(@RequestParam("file") MultipartFile file,
|
||||
@RequestParam("meetingId") Long meetingId) throws Exception {
|
||||
@RequestParam("meetingId") Long meetingId,
|
||||
@RequestParam(value = "force", required = false, defaultValue = "false") boolean force) throws Exception {
|
||||
// 参会人名单写操作拦截: executor 劳务提交后禁止 (导入)
|
||||
checkAttendeeEditable(meetingId);
|
||||
if (!force) {
|
||||
// dry-run: 解析 + 比对专家表, 不落库
|
||||
return success(attendeeService.previewImport(file, meetingId));
|
||||
}
|
||||
// 解析 + 入库 (邀请参会已改为手动, 不再自动推"会议邀请", 由前端"邀请参会"按钮触发)
|
||||
ImportResult result = attendeeService.importFromExcel(file, meetingId, SecurityUtils.getUsername());
|
||||
// 人员变化 → 立即重算劳务费 (有成功导入才需重算)
|
||||
@@ -270,8 +345,10 @@ public class BizMeetingAttendeeController extends BaseController {
|
||||
@PostMapping("/uploadAgreements")
|
||||
public AjaxResult uploadAgreements(@RequestParam("file") MultipartFile file,
|
||||
@RequestParam("meetingId") Long meetingId) throws Exception {
|
||||
int updated = attendeeService.uploadAgreements(file, meetingId);
|
||||
return success(updated);
|
||||
String jobId = attendeeService.submitAgreementUpload(file, meetingId);
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
result.put("jobId", jobId);
|
||||
return success(result);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -295,8 +372,73 @@ public class BizMeetingAttendeeController extends BaseController {
|
||||
@PostMapping("/uploadExpertPhotos")
|
||||
public AjaxResult uploadExpertPhotos(@RequestParam("file") MultipartFile file,
|
||||
@RequestParam("meetingId") Long meetingId) throws Exception {
|
||||
int updated = attendeeService.uploadExpertPhotos(file, meetingId);
|
||||
return success(updated);
|
||||
String jobId = attendeeService.submitExpertPhotoUpload(file, meetingId);
|
||||
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);
|
||||
}
|
||||
|
||||
}
|
||||
+94
-11
@@ -1,5 +1,6 @@
|
||||
package com.ruoyi.business.controller;
|
||||
|
||||
import java.text.SimpleDateFormat;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Calendar;
|
||||
import java.util.Date;
|
||||
@@ -73,11 +74,18 @@ public class BizMeetingController extends BaseController {
|
||||
private PosterService posterService;
|
||||
|
||||
@GetMapping("/list")
|
||||
public TableDataInfo list(BizMeeting bizMeeting) {
|
||||
public TableDataInfo list(BizMeeting bizMeeting,
|
||||
@RequestParam(value = "signedStatus", required = false) String signedStatus) {
|
||||
Long uid = SecurityUtils.getUserId();
|
||||
String roleType = SecurityUtils.getLoginUser().getUser().getRoleType();
|
||||
// 医生/专家角色: 后端兜底只查"我参加的会议" (走 biz_meeting_attendee 中间表)
|
||||
if ("doctor".equals(roleType) || "expert".equals(roleType)) {
|
||||
// doctor 列表页"签署状态"下拉: unsigned=待签署 / signed=已签署 / 其他=不过滤
|
||||
// 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);
|
||||
}
|
||||
// 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)) {
|
||||
bizMeeting.getParams().put("managerCreateUserId", uid);
|
||||
}
|
||||
// 项目负责人(leader) 数据权限: 只看"本人负责的项目"下的会议 (project_id ∈ lead_user_id = 自己)
|
||||
else if ("leader".equals(roleType)) {
|
||||
bizMeeting.getParams().put("leaderUserId", uid);
|
||||
}
|
||||
startPage();
|
||||
List<BizMeeting> list = bizMeetingService.selectList(bizMeeting);
|
||||
return getDataTable(list);
|
||||
@@ -187,8 +199,12 @@ public class BizMeetingController extends BaseController {
|
||||
bizMeeting.setTotalPeriods(proj.getTotalSessions());
|
||||
}
|
||||
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);
|
||||
Long[] attendeeUserIds = bizMeeting.getAttendeeUserIds();
|
||||
if (attendeeUserIds != null && attendeeUserIds.length > 0) {
|
||||
@@ -198,6 +214,39 @@ public class BizMeetingController extends BaseController {
|
||||
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)
|
||||
@PutMapping
|
||||
public AjaxResult edit(@RequestBody BizMeeting bizMeeting) {
|
||||
@@ -234,6 +283,26 @@ public class BizMeetingController extends BaseController {
|
||||
BizProject proj = bizProjectService.getById(bizMeeting.getProjectId());
|
||||
if (proj != null) {
|
||||
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);
|
||||
@@ -285,7 +354,7 @@ public class BizMeetingController extends BaseController {
|
||||
* <li>校验 1: 当前用户是该会议执行方 (项目级归属, 强校验)</li>
|
||||
* <li>校验 2: 已执行 (is_executed=1) 且未冻结</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>
|
||||
* body: { "types": ["LABOR","SERVICE"] }
|
||||
* 通过后该轨 → SUBMITTED (compliance_approved=0), 每轨记一条 audit_log.
|
||||
@@ -310,8 +379,9 @@ public class BizMeetingController extends BaseController {
|
||||
}
|
||||
|
||||
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_"));
|
||||
// 劳务材料 = 参会人员列表 (不是上传的文件), 提交门槛 = 至少 1 名参会人
|
||||
boolean hasLabor = !attendeeService.selectByMeetingId(meetingId).isEmpty();
|
||||
|
||||
for (String type : types) {
|
||||
String label = "LABOR".equals(type) ? "劳务材料" : "会务材料";
|
||||
@@ -319,8 +389,11 @@ public class BizMeetingController extends BaseController {
|
||||
if (!"NOT_SUBMITTED".equals(stage) && !"REJECTED".equals(stage)) {
|
||||
throw new ServiceException(label + "当前阶段 (" + stage + ") 不允许提交");
|
||||
}
|
||||
boolean has = "LABOR".equals(type) ? hasLabor : hasService;
|
||||
if (!has) throw new ServiceException(label + "至少上传一条");
|
||||
if ("LABOR".equals(type)) {
|
||||
if (!hasLabor) throw new ServiceException("请先添加参会人员 (劳务材料)");
|
||||
} else if (!hasService) {
|
||||
throw new ServiceException("会务材料至少上传一条");
|
||||
}
|
||||
}
|
||||
|
||||
for (String type : types) {
|
||||
@@ -572,8 +645,14 @@ public class BizMeetingController extends BaseController {
|
||||
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 hasSv = existingMaterials != null && existingMaterials.stream().anyMatch(v -> "SV_PAYMENT".equals(v.getSubType()) && v.getOssUrl() != null && !v.getOssUrl().isEmpty());
|
||||
if (!hasLv || !hasSv) {
|
||||
throw new ServiceException("请先上传付款凭证");
|
||||
if (!hasLv && !hasSv) {
|
||||
throw new ServiceException("请先上传劳务凭证与会务凭证");
|
||||
}
|
||||
if (!hasLv) {
|
||||
throw new ServiceException("请先上传劳务凭证");
|
||||
}
|
||||
if (!hasSv) {
|
||||
throw new ServiceException("请先上传会务凭证");
|
||||
}
|
||||
|
||||
m.setIsSettled(1);
|
||||
@@ -685,8 +764,12 @@ public class BizMeetingController extends BaseController {
|
||||
log.setAuditResult(result);
|
||||
log.setOpinion(opinion);
|
||||
log.setMaterialType(materialType);
|
||||
log.setCreateTime(new Date());
|
||||
log.setAuditTime(new Date());
|
||||
Date now = 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.setSponsorStage(stageDeriver.deriveDisplay("sponsor", m));
|
||||
log.setManagerStage(stageDeriver.deriveDisplay("manager", m));
|
||||
|
||||
+21
-9
@@ -4,14 +4,16 @@ import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import com.ruoyi.common.core.controller.BaseController;
|
||||
import com.ruoyi.common.core.domain.AjaxResult;
|
||||
import com.ruoyi.business.service.IBizMeetingInvoiceService;
|
||||
import com.ruoyi.business.service.impl.InvoiceOcrService;
|
||||
import com.ruoyi.common.utils.SecurityUtils;
|
||||
|
||||
/**
|
||||
* 会议发票识别 Controller (v3)
|
||||
* <p>
|
||||
* v3 变更:
|
||||
* - 端点改为"提交即返回 SUBMITTED", OCR 在后台 ExecutorService 跑
|
||||
* - 新增 isZip 字段: true → zip 路径(解压 → 重传 OSS → 写多行 invoice)
|
||||
* - zip 由后端按 ossUrl 后缀判断 (解压 → 重传 OSS → 写多行 invoice), 前端无需传 isZip
|
||||
* - 新增 oldMaterialId 字段: 替换场景, 后端先清旧 invoice + material.amount=0
|
||||
* - 不再走旧的同步 recognizeAndSave, 已被 submitRecognition 取代
|
||||
*/
|
||||
@@ -23,14 +25,16 @@ public class BizMeetingInvoiceController extends BaseController
|
||||
@Autowired
|
||||
private InvoiceOcrService invoiceOcrService;
|
||||
|
||||
@Autowired
|
||||
private IBizMeetingInvoiceService invoiceService;
|
||||
|
||||
/**
|
||||
* 提交发票识别 (后台执行, 立即返回)
|
||||
* <p>
|
||||
* body: {
|
||||
* "materialId": 123,
|
||||
* "meetingId": 456,
|
||||
* "ossUrl": "https://...",
|
||||
* "isZip": false, // 新增
|
||||
* "ossUrl": "https://...", // .zip 结尾走 zip 解压, 否则按单文件
|
||||
* "oldMaterialId": null // 新增, 替换时携带原 materialId
|
||||
* }
|
||||
*/
|
||||
@@ -41,19 +45,29 @@ public class BizMeetingInvoiceController extends BaseController
|
||||
body.getMaterialId(),
|
||||
body.getMeetingId(),
|
||||
body.getOssUrl(),
|
||||
body.isZip(),
|
||||
body.getOldMaterialId());
|
||||
body.getOldMaterialId(),
|
||||
SecurityUtils.getUserId());
|
||||
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 */
|
||||
public static class RecognizeBody
|
||||
{
|
||||
private Long materialId;
|
||||
private Long meetingId;
|
||||
private String ossUrl;
|
||||
/** v3 新增: true=zip 路径(解压遍历), false=单文件路径 */
|
||||
private boolean isZip;
|
||||
/** v3 新增: 替换场景携带的旧 materialId, 后端先 DELETE invoice WHERE material_id=old + material.amount=0 */
|
||||
private Long oldMaterialId;
|
||||
|
||||
@@ -63,8 +77,6 @@ public class BizMeetingInvoiceController extends BaseController
|
||||
public void setMeetingId(Long meetingId) { this.meetingId = meetingId; }
|
||||
public String getOssUrl() { return 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 void setOldMaterialId(Long oldMaterialId) { this.oldMaterialId = oldMaterialId; }
|
||||
}
|
||||
|
||||
+24
-9
@@ -1,7 +1,9 @@
|
||||
package com.ruoyi.business.controller;
|
||||
|
||||
import java.io.InputStream;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
@@ -92,12 +94,12 @@ public class BizMeetingMaterialController extends BaseController {
|
||||
/**
|
||||
* 扫码拍照回传 (公开端点, ry-h5 手机端拍照直传 OSS 后回传 URL).
|
||||
* <p>
|
||||
* body: { meetingId, subType, ossUrl }. 后端白名单 subType + 会议存在校验.
|
||||
* 照片类 NON_OCR, 不影响会议费用.
|
||||
* body: { meetingId, subType, ossUrls[], extraOssUrls[] }. 后端白名单 subType + 会议存在校验.
|
||||
* 签到表连拍多张传多个 URL, 前后全景传单个. 照片类 NON_OCR, 不影响会议费用.
|
||||
*/
|
||||
@PostMapping("/cameraUpload")
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -197,12 +199,25 @@ public class BizMeetingMaterialController extends BaseController {
|
||||
@PostMapping("/uploadServiceMaterials")
|
||||
public AjaxResult uploadServiceMaterials(@RequestParam("file") MultipartFile file,
|
||||
@RequestParam("meetingId") Long meetingId) throws Exception {
|
||||
int updated = bizMeetingMaterialService.uploadServiceMaterials(file, meetingId);
|
||||
// 仅当真有材料被替换 (内容变化, updated>0) 才立即重算会务费; 全量未变不置"统计中"
|
||||
if (updated > 0) {
|
||||
bizMeetingService.recomputeMeetingFee(meetingId);
|
||||
}
|
||||
return success(updated);
|
||||
String jobId = bizMeetingMaterialService.submitServiceMaterialsUpload(file, meetingId);
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
result.put("jobId", jobId);
|
||||
return success(result);
|
||||
}
|
||||
|
||||
/**
|
||||
* 电子签到表 (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 */
|
||||
|
||||
+16
-5
@@ -31,26 +31,37 @@ public class BizMeetingStageStatsController extends BaseController
|
||||
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).
|
||||
* 其他角色的统计端点单独实现, 此端点非 sponsor 调用返回空 map (不泄露全量数据).
|
||||
* 其他角色的统计端点单独实现, 此端点非 sponsor/leader 调用返回空 map (不泄露全量数据).
|
||||
*/
|
||||
@GetMapping("/stageStats")
|
||||
public AjaxResult stageStats() {
|
||||
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);
|
||||
}
|
||||
BizMeeting q = new BizMeeting();
|
||||
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);
|
||||
if (current != null && "SUB".equals(current.getAccountType())) {
|
||||
q.getParams().put("monitorUserId", uid);
|
||||
} else {
|
||||
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)) {
|
||||
// resultType=map 的 key 大小写随 JDBC 驱动, 做大小写不敏感匹配, 避免"统计恒 0"
|
||||
String stage = null;
|
||||
long cnt = 0L;
|
||||
for (Map.Entry<String, Object> e : r.entrySet()) {
|
||||
@@ -66,6 +77,6 @@ public class BizMeetingStageStatsController extends BaseController
|
||||
result.put(stage, cnt);
|
||||
}
|
||||
}
|
||||
return success(result);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
+5
-4
@@ -69,14 +69,15 @@ public class BizOrgController extends BaseController {
|
||||
}
|
||||
|
||||
/**
|
||||
* 当前登录 sponsor 的所属公司 (供 /sponsor/account 页面回显)
|
||||
* 返回 { orgId, orgName, isOwner: 1|0 }
|
||||
* 主账号 isOwner=1 可改名, 子账号 isOwner=0 只读
|
||||
* 当前登录账号的所属公司 (sponsor + executor 共用, 供 /sponsor/account + /executor/account 页面回显)
|
||||
* 根据 sys_user.role_type 自动选 sponsor/executor
|
||||
* 返回 { orgId, orgName, orgType, isOwner: 1|0 }
|
||||
* 注: 账号信息页只读展示, 主账号也不可改单位名称
|
||||
*/
|
||||
@GetMapping("/myCompany")
|
||||
public AjaxResult myCompany() {
|
||||
Long uid = SecurityUtils.getUserId();
|
||||
Map<String, Object> data = bizOrgService.selectMySponsorCompany(uid);
|
||||
Map<String, Object> data = bizOrgService.selectMyCompany(uid);
|
||||
return success(data);
|
||||
}
|
||||
|
||||
|
||||
+9
-5
@@ -48,7 +48,7 @@ public class BizPersonController extends BaseController
|
||||
@GetMapping("/list")
|
||||
public TableDataInfo list(BizPerson bizPerson)
|
||||
{
|
||||
// 当前登录用户是 MAIN 业务账号 → 自动注入 subUserIds, 隔离到本主账号的子账号对应人员
|
||||
// 当前登录用户是 MAIN 业务账号 → 自动注入 subUserIds, 隔离到「本主账号自己 + 其子账号」对应人员
|
||||
Long currentUserId = getUserId();
|
||||
SysUser currentUser = currentUserId == null ? null : sysUserMapper.selectUserById(currentUserId);
|
||||
if (currentUser != null
|
||||
@@ -56,10 +56,14 @@ public class BizPersonController extends BaseController
|
||||
&& currentUser.getRoleType() != null
|
||||
&& BUSINESS_MAIN_ROLES.contains(currentUser.getRoleType())) {
|
||||
List<Long> subUserIds = sysUserMapper.selectUserIdsByParent(currentUserId);
|
||||
// 即使没子账号也强制注入, 避免漏出 sys_user.parent_user_id=NULL 的孤立 person
|
||||
bizPerson.getParams().put("subUserIds", subUserIds == null || subUserIds.isEmpty()
|
||||
? java.util.Collections.singletonList(-1L)
|
||||
: subUserIds);
|
||||
// 隔离范围 = 主账号自己 + 子账号. 主账号自己也要能看到自己的 biz_person
|
||||
// (公益支持/项目执行意向回显 工作单位/部门/职务 用), 同时仍不漏出 parent_user_id=NULL 的孤立 person
|
||||
List<Long> visibleUserIds = new ArrayList<>();
|
||||
visibleUserIds.add(currentUserId);
|
||||
if (subUserIds != null) {
|
||||
visibleUserIds.addAll(subUserIds);
|
||||
}
|
||||
bizPerson.getParams().put("subUserIds", visibleUserIds);
|
||||
}
|
||||
startPage();
|
||||
List<BizPerson> list = bizPersonService.selectList(bizPerson);
|
||||
|
||||
+219
-6
@@ -9,6 +9,7 @@ import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import com.ruoyi.common.annotation.Log;
|
||||
import com.ruoyi.common.core.controller.BaseController;
|
||||
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.exception.ServiceException;
|
||||
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.BizOrg;
|
||||
import com.ruoyi.business.domain.BizProject;
|
||||
import com.ruoyi.business.domain.BizProjectAssign;
|
||||
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.IBizProjectService;
|
||||
import com.ruoyi.business.service.IBizExecutionIntentService;
|
||||
import com.ruoyi.business.service.IBizMeetingService;
|
||||
import com.ruoyi.business.domain.BizProjectSponsorAssign;
|
||||
import com.ruoyi.business.domain.BizProjectExecutorAssign;
|
||||
import com.ruoyi.business.notify.BizNotifyService;
|
||||
@@ -66,6 +74,10 @@ public class BizProjectController extends BaseController
|
||||
private SysUserMapper sysUserMapper;
|
||||
@Autowired
|
||||
private IBizOrgService bizOrgService;
|
||||
@Autowired
|
||||
private IBizExpertService bizExpertService;
|
||||
@Autowired
|
||||
private IBizMeetingService bizMeetingService;
|
||||
|
||||
/**
|
||||
* 我报名的项目 (当前用户在 biz_execution_intent 里有意向的项目)
|
||||
@@ -97,10 +109,12 @@ public class BizProjectController extends BaseController
|
||||
@GetMapping("/list")
|
||||
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();
|
||||
if ("manager".equals(roleType)) {
|
||||
bizProject.getParams().put("createUserId", SecurityUtils.getUserId());
|
||||
} else if ("leader".equals(roleType)) {
|
||||
bizProject.getParams().put("leaderUserId", SecurityUtils.getUserId());
|
||||
}
|
||||
startPage();
|
||||
List<BizProject> list = bizProjectService.selectList(bizProject);
|
||||
@@ -183,6 +197,7 @@ public class BizProjectController extends BaseController
|
||||
List<BizProject> list = bizProjectService.selectExecutorList(bizProject);
|
||||
return getDataTable(list);
|
||||
}
|
||||
|
||||
@GetMapping("/{projectId}")
|
||||
public AjaxResult getInfo(@PathVariable("projectId") Long projectId)
|
||||
{
|
||||
@@ -218,7 +233,31 @@ public class BizProjectController extends BaseController
|
||||
@PutMapping
|
||||
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)
|
||||
@DeleteMapping("/{ids}")
|
||||
@@ -346,7 +385,7 @@ public class BizProjectController extends BaseController
|
||||
if (projectId == null) throw new ServiceException("项目不存在");
|
||||
BizProject p = bizProjectService.getById(projectId);
|
||||
if (p == null) throw new ServiceException("项目不存在");
|
||||
if ("1".equals(p.getIsFinished())) throw new ServiceException("已结题项目不能分配");
|
||||
if ("Y".equals(p.getIsFinished())) throw new ServiceException("已结题项目不能分配");
|
||||
return p;
|
||||
}
|
||||
|
||||
@@ -519,7 +558,7 @@ public class BizProjectController extends BaseController
|
||||
return error("projectId 必填");
|
||||
}
|
||||
// 已结题项目不能分配
|
||||
requireAssignableProject(parseProjectId(body.getProjectId()));
|
||||
requireAssignableProject(body.getProjectId());
|
||||
java.util.List<Long> sids = body.getStaffUserIds();
|
||||
if (sids == null || sids.isEmpty()) {
|
||||
// 向后兼容: 单值 staffUserId
|
||||
@@ -539,11 +578,13 @@ public class BizProjectController extends BaseController
|
||||
/**
|
||||
* 查询项目已分配的执行人列表 (供前端 dialog 重开时回显)
|
||||
* GET /business/project/{projectId}/executorAssigns
|
||||
* 按当前登录执行方的 executor_org_id 隔离, 只回显本执行方自己的执行人 (避免串读别家执行人显示成裸 user_id)
|
||||
*/
|
||||
@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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 结题前置检查: 查若干项目里还有未结算会议(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
|
||||
@@ -616,4 +680,153 @@ public class BizProjectController extends BaseController
|
||||
ajax.put("errors", errors);
|
||||
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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+122
-9
@@ -3,24 +3,33 @@ package com.ruoyi.business.controller;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.net.URLEncoder;
|
||||
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 org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.core.env.Environment;
|
||||
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.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.BizProject;
|
||||
import com.ruoyi.business.domain.BizSupportLetter;
|
||||
import com.ruoyi.business.domain.BizInvitation;
|
||||
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.IBizProjectService;
|
||||
import com.ruoyi.business.service.IBizSupportLetterService;
|
||||
import com.ruoyi.business.service.IBizInvitationService;
|
||||
import com.ruoyi.business.service.IBizInviteService;
|
||||
import com.ruoyi.business.service.IBizProjectPlanService;
|
||||
import com.ruoyi.business.service.IBizSpecialPlanService;
|
||||
import com.ruoyi.business.service.WxShareService;
|
||||
import com.ruoyi.business.domain.BizSpecialPlan;
|
||||
|
||||
/**
|
||||
@@ -33,9 +42,15 @@ public class BizPublicController extends BaseController {
|
||||
@Autowired private IBizProjectService projectService;
|
||||
@Autowired private IBizSupportLetterService supportLetterService;
|
||||
@Autowired private IBizInvitationService invitationService;
|
||||
@Autowired private IBizInviteService inviteService;
|
||||
@Autowired private IBizProjectPlanService projectPlanService;
|
||||
@Autowired private IBizArticleService articleService;
|
||||
@Autowired private IBizSpecialPlanService specialPlanService;
|
||||
@Autowired private WxShareService wxShareService;
|
||||
|
||||
@Autowired private Environment environment;
|
||||
private static final Logger log = LoggerFactory.getLogger(BizPublicController.class);
|
||||
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||
|
||||
/**
|
||||
* 按类型获取已发布的协议/政策 (供注册页底部链接打开)
|
||||
@@ -71,7 +86,7 @@ public class BizPublicController extends BaseController {
|
||||
public AjaxResult index() {
|
||||
Map<String, Object> map = new HashMap<>();
|
||||
BizProject publishedQuery = new BizProject();
|
||||
publishedQuery.setIsPublished("1");
|
||||
publishedQuery.setIsPublished("Y");
|
||||
List<BizProject> announcements = projectService.selectList(publishedQuery);
|
||||
List<BizProjectPlan> plans = projectPlanService.selectList(new BizProjectPlan());
|
||||
map.put("announcements", announcements);
|
||||
@@ -81,10 +96,11 @@ public class BizPublicController extends BaseController {
|
||||
}
|
||||
|
||||
@GetMapping("/announcements")
|
||||
public AjaxResult announcements() {
|
||||
// 公示列表按发布时间倒序 (后端排序), 只返回已发布项目
|
||||
List<BizProject> list = projectService.selectPublicAnnouncements();
|
||||
return success(list);
|
||||
public TableDataInfo announcements(@RequestParam(required = false) String keyword)
|
||||
{
|
||||
// 公示列表: 只查公示页展示列 (不跑关联子查询), PageHelper 分页, keyword 按项目名模糊
|
||||
startPage();
|
||||
return getDataTable(projectService.selectPublicAnnouncements(keyword));
|
||||
}
|
||||
|
||||
@GetMapping("/project/{projectId}")
|
||||
@@ -92,6 +108,69 @@ public class BizPublicController extends BaseController {
|
||||
return success(projectService.getById(projectId));
|
||||
}
|
||||
|
||||
/**
|
||||
* 招标公告查询: 按项目编号查外部招标平台的 noticeId, 返回详情跳转 URL.
|
||||
* <p>数据源: GET {bidding-api.notice-list-url}?pageNum=1&pageSize=200&keyword={projectNo}&projectType=10¬iceType=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¬iceType=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}")
|
||||
public AjaxResult supportLetterDetail(@PathVariable String annId) {
|
||||
return success(supportLetterService.getById(annId));
|
||||
@@ -106,4 +185,38 @@ public class BizPublicController extends BaseController {
|
||||
public AjaxResult apply(String annId) {
|
||||
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("确认成功");
|
||||
}
|
||||
}
|
||||
|
||||
+48
@@ -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 = "正文")
|
||||
private String content;
|
||||
|
||||
/** 状态 0启用 1停用 */
|
||||
@Excel(name = "状态", readConverterExp = "0=启用,1=停用")
|
||||
/** 状态 Y启用 N停用 */
|
||||
@Excel(name = "状态", readConverterExp = "Y=启用,N=停用")
|
||||
private String status;
|
||||
|
||||
public Long getId() { return id; }
|
||||
|
||||
@@ -17,8 +17,8 @@ public class BizDepartment extends BaseEntity
|
||||
@Excel(name = "排序")
|
||||
private Integer sort;
|
||||
|
||||
/** 0=启用 1=停用 */
|
||||
@Excel(name = "状态", readConverterExp = "0=启用,1=停用")
|
||||
/** Y=启用 N=停用 */
|
||||
@Excel(name = "状态", readConverterExp = "Y=启用,N=停用")
|
||||
private String status;
|
||||
|
||||
public Long getDeptId() { return deptId; }
|
||||
|
||||
@@ -51,6 +51,8 @@ public class BizExecutionIntent extends BaseEntity {
|
||||
@Excel(name = "update_time")
|
||||
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
|
||||
private Date updateTime;
|
||||
/** 删除标记 (0正常 1已删除, 幽灵占位记录用) */
|
||||
private String delFlag;
|
||||
public String getIntentId() { return intentId; }
|
||||
public void setIntentId(String intentId) { this.intentId = intentId; }
|
||||
public Long getUserId() { return userId; }
|
||||
@@ -79,4 +81,6 @@ public class BizExecutionIntent extends BaseEntity {
|
||||
public void setUpdateBy(String updateBy) { this.updateBy = updateBy; }
|
||||
public Date getUpdateTime() { return updateTime; }
|
||||
public void setUpdateTime(Date updateTime) { this.updateTime = updateTime; }
|
||||
public String getDelFlag() { return delFlag; }
|
||||
public void setDelFlag(String delFlag) { this.delFlag = delFlag; }
|
||||
}
|
||||
@@ -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")
|
||||
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 内合规审/支持方审) */
|
||||
private Integer laborComplianceApproved;
|
||||
/** 会务材料合规是否已通过 0否1是 (区分 SUBMITTED 内合规审/支持方审) */
|
||||
@@ -114,7 +120,7 @@ public class BizMeeting extends BaseEntity {
|
||||
private String scheduleUrl;
|
||||
/** 生成的海报URL (生成海报按钮产出) */
|
||||
private String posterUrl;
|
||||
/** 签署劳务 0未签 1已签 */
|
||||
/** 签署劳务 N未签 Y已签 */
|
||||
private String laborSigned;
|
||||
/** 参会人 user_id (非持久化字段, 仅用于 mapper WHERE 过滤; controller 注入, 配合 biz_meeting_attendee 中间表) */
|
||||
private transient Long userId;
|
||||
@@ -219,6 +225,10 @@ public class BizMeeting extends BaseEntity {
|
||||
public void setFreezeTime(Date freezeTime) { this.freezeTime = freezeTime; }
|
||||
public Date getMaterialAuditTime() { return 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 void setLaborComplianceApproved(Integer laborComplianceApproved) { this.laborComplianceApproved = laborComplianceApproved; }
|
||||
public Integer getServiceComplianceApproved() { return serviceComplianceApproved; }
|
||||
|
||||
@@ -54,6 +54,8 @@ public class BizMeetingAttendee extends BaseEntity {
|
||||
private String laborProtocol;
|
||||
/** 脱敏版协议 URL (身份证/银行卡用 * 替换) */
|
||||
private String laborProtocolMasked;
|
||||
/** 盖章后个性化邀请函 URL (OSS, 发送邀请函时生成; null=未生成, 回退会议级模板) */
|
||||
private String invitationUrl;
|
||||
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
|
||||
private Date createTime;
|
||||
/* ====== 非持久化字段, 用于 selectUnsignedByUserId 联表查询 ====== */
|
||||
@@ -121,6 +123,8 @@ public class BizMeetingAttendee extends BaseEntity {
|
||||
public void setLaborProtocol(String laborProtocol) { this.laborProtocol = laborProtocol; }
|
||||
public String getLaborProtocolMasked() { return laborProtocolMasked; }
|
||||
public void setLaborProtocolMasked(String laborProtocolMasked) { this.laborProtocolMasked = laborProtocolMasked; }
|
||||
public String getInvitationUrl() { return invitationUrl; }
|
||||
public void setInvitationUrl(String invitationUrl) { this.invitationUrl = invitationUrl; }
|
||||
public Date getCreateTime() { return createTime; }
|
||||
public void setCreateTime(Date createTime) { this.createTime = createTime; }
|
||||
public String getMeetingName() { return meetingName; }
|
||||
|
||||
@@ -58,6 +58,18 @@ public class BizMeetingInvoice {
|
||||
/** 原始文件名 (zip 解压时记录, 单文件路径可空) */
|
||||
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 void setId(Long id) { this.id = id; }
|
||||
|
||||
@@ -93,4 +105,16 @@ public class BizMeetingInvoice {
|
||||
|
||||
public String getSourceFilename() { return 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; }
|
||||
}
|
||||
+23
-1
@@ -2,7 +2,9 @@ package com.ruoyi.business.domain;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
import com.fasterxml.jackson.annotation.JsonFormat;
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
|
||||
/**
|
||||
* 会议材料对象 biz_meeting_material (单表)
|
||||
@@ -10,7 +12,7 @@ import com.fasterxml.jackson.annotation.JsonFormat;
|
||||
* 包含 4 大类 17 子类:
|
||||
* <ul>
|
||||
* <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>
|
||||
* <p>
|
||||
* 注意: 不继承 BaseEntity — 不要 create_by / update_by / update_time 字段.
|
||||
@@ -41,6 +43,15 @@ public class BizMeetingMaterial {
|
||||
/** 脱敏版 OSS URL (签到表拍照时额外生成的高斯模糊版, sponsor 只看这个以隐藏手机号/身份证号) */
|
||||
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) */
|
||||
private BigDecimal amount;
|
||||
|
||||
@@ -78,6 +89,17 @@ public class BizMeetingMaterial {
|
||||
public String getExtraOssUrl() { return 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 void setAmount(BigDecimal amount) { this.amount = amount; }
|
||||
|
||||
|
||||
@@ -50,6 +50,10 @@ public class BizOrg extends BaseEntity {
|
||||
/** update_time */
|
||||
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
|
||||
private Date updateTime;
|
||||
/** del_flag 删除标志 0正常 1删除 */
|
||||
private String delFlag;
|
||||
/** is_synced 同步来源标记 0注册 1供应商同步 */
|
||||
private Integer isSynced;
|
||||
|
||||
public Long getOrgId() { return 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 Date getUpdateTime() { return updateTime; }
|
||||
public void setUpdateTime(Date updateTime) { this.updateTime = updateTime; }
|
||||
public String getDelFlag() { return delFlag; }
|
||||
public void setDelFlag(String delFlag) { this.delFlag = delFlag; }
|
||||
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 */
|
||||
public class BizProject extends BaseEntity {
|
||||
private static final long serialVersionUID = 1L;
|
||||
/** projectId (与 DB bigint 对齐, AUTO_INCREMENT) */
|
||||
/** projectId (雪花 ID, 与 DB bigint 对齐) */
|
||||
private Long projectId;
|
||||
/** project_no */
|
||||
@Excel(name = "project_no")
|
||||
@@ -56,6 +56,8 @@ public class BizProject extends BaseEntity {
|
||||
private String sponsorAdminUserName;
|
||||
/** 支持单位名称 (列表展示列, JOIN biz_org 取 org_name, org_type='sponsor') */
|
||||
private String sponsorOrgName;
|
||||
/** 项目ID筛选 (导出时勾选项目透传, IN 查询) */
|
||||
private List<Long> projectIds;
|
||||
/** 支持单位筛选 (biz_org.org_id 列表, IN 查询, 多选 select 透传) */
|
||||
private List<Long> sponsorOrgIds;
|
||||
/** 服务机构名称列表, 多个用英文逗号连接 (GROUP_CONCAT 派生, 不入库, 仅展示) */
|
||||
@@ -124,7 +126,7 @@ public class BizProject extends BaseEntity {
|
||||
private String publishUrl;
|
||||
/** 日程文件URL */
|
||||
private String scheduleUrl;
|
||||
/** 是否已发布公示 0否 1是 */
|
||||
/** 是否已发布公示 N否 Y是 */
|
||||
private String isPublished;
|
||||
/** 发布时间 */
|
||||
@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 String getSponsorOrgName() { return 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 void setSponsorOrgIds(List<Long> sponsorOrgIds) { this.sponsorOrgIds = sponsorOrgIds; }
|
||||
public String getExecOrgNames() { return execOrgNames; }
|
||||
|
||||
+4
-4
@@ -8,7 +8,7 @@ import com.ruoyi.common.core.domain.BaseEntity;
|
||||
public class BizProjectExecutorAssign extends BaseEntity {
|
||||
private static final long serialVersionUID = 1L;
|
||||
private Long id;
|
||||
private String projectId;
|
||||
private Long projectId;
|
||||
/** 分配方企业 org_id (biz_org.org_id, 分配人审计; 原 executor_user_id) */
|
||||
private Long executorOrgId;
|
||||
/** 执行人 user_id (被分配) */
|
||||
@@ -26,15 +26,15 @@ public class BizProjectExecutorAssign extends BaseEntity {
|
||||
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
|
||||
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;
|
||||
public Integer getIsDeleted() { return isDeleted; }
|
||||
public void setIsDeleted(Integer isDeleted) { this.isDeleted = isDeleted; }
|
||||
|
||||
public Long getId() { return id; }
|
||||
public void setId(Long id) { this.id = id; }
|
||||
public String getProjectId() { return projectId; }
|
||||
public void setProjectId(String projectId) { this.projectId = projectId; }
|
||||
public Long getProjectId() { return projectId; }
|
||||
public void setProjectId(Long projectId) { this.projectId = projectId; }
|
||||
public Long getExecutorOrgId() { return executorOrgId; }
|
||||
public void setExecutorOrgId(Long executorOrgId) { this.executorOrgId = executorOrgId; }
|
||||
public Long getStaffUserId() { return staffUserId; }
|
||||
|
||||
+61
@@ -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; }
|
||||
}
|
||||
+66
@@ -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; }
|
||||
}
|
||||
+2
-2
@@ -93,8 +93,8 @@ public class BizMeetingAttendeeImportVo {
|
||||
@Excel(name = "银行详细地址", sort = 18)
|
||||
private String bankAddress;
|
||||
|
||||
/** 身份证附件(正反面 URL CSV) */
|
||||
@Excel(name = "身份证附件", sort = 19)
|
||||
/** 身份证附件(正反面 URL CSV)— 仅导出,不进入导入模板(附件文件无法通过 Excel 导入) */
|
||||
@Excel(name = "身份证附件", sort = 19, type = Excel.Type.EXPORT)
|
||||
private String idCardAttachments;
|
||||
|
||||
public String getPhone() { return phone; }
|
||||
|
||||
+105
@@ -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; }
|
||||
}
|
||||
+59
@@ -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; }
|
||||
}
|
||||
+21
-8
@@ -7,32 +7,45 @@ import com.ruoyi.common.annotation.Excel;
|
||||
/**
|
||||
* 报名专家导出 VO (中文列头)
|
||||
*
|
||||
* <p>数据源: biz_execution_intent (公开门户"立即报名"写入的执行意向表, 即已报名专家).
|
||||
* <p>数据源: biz_execution_intent (公开门户"立即报名"写入的执行意向表, 即已报名专家)
|
||||
* + biz_expert (医生档案, 报名专家 userId → biz_expert.userId 反查补全科室/医院/职称).
|
||||
* 仅用于 Excel 导出, 不参与业务逻辑.
|
||||
*
|
||||
* <p>字段: 项目编号 项目名称 专家姓名 科室 医院 职称 报名时间 手机号
|
||||
*
|
||||
* @author guoju
|
||||
*/
|
||||
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;
|
||||
|
||||
@Excel(name = "科室", sort = 2)
|
||||
@Excel(name = "科室", sort = 4)
|
||||
private String department;
|
||||
|
||||
@Excel(name = "医院", sort = 3)
|
||||
@Excel(name = "医院", sort = 5)
|
||||
private String workUnit;
|
||||
|
||||
@Excel(name = "职称", sort = 4)
|
||||
@Excel(name = "职称", sort = 6)
|
||||
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")
|
||||
private Date createTime;
|
||||
|
||||
@Excel(name = "手机号", sort = 6)
|
||||
@Excel(name = "手机号", sort = 8)
|
||||
private String phone;
|
||||
|
||||
public String getProjectNo() { return projectNo; }
|
||||
public void setProjectNo(String projectNo) { this.projectNo = projectNo; }
|
||||
public String getProjectName() { return projectName; }
|
||||
public void setProjectName(String projectName) { this.projectName = projectName; }
|
||||
public String getName() { return name; }
|
||||
public void setName(String name) { this.name = name; }
|
||||
public String getDepartment() { return department; }
|
||||
@@ -45,4 +58,4 @@ public class BizSignupExpertExportVo {
|
||||
public void setCreateTime(Date createTime) { this.createTime = createTime; }
|
||||
public String getPhone() { return phone; }
|
||||
public void setPhone(String phone) { this.phone = phone; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
package com.ruoyi.business.domain.vo;
|
||||
|
||||
/**
|
||||
* 参会人表单字段与专家表 (biz_expert) 档案的不一致项.
|
||||
*
|
||||
* <p>用于「新增/编辑参会人」和「批量导入」保存前的二次确认:
|
||||
* 后端按 手机号→sys_user→biz_expert 解析出专家档案, 与用户输入比对,
|
||||
* 专家表已有值且与输入不一致时生成一条 mismatch.
|
||||
*/
|
||||
public class FieldMismatch {
|
||||
|
||||
/** 字段 key (name/workUnit/...) */
|
||||
private String field;
|
||||
|
||||
/** 字段中文名 (姓名/医院/...) */
|
||||
private String label;
|
||||
|
||||
/** 专家表当前值 */
|
||||
private String expertValue;
|
||||
|
||||
/** 用户输入值 */
|
||||
private String inputValue;
|
||||
|
||||
public String getField() { return field; }
|
||||
public void setField(String field) { this.field = field; }
|
||||
public String getLabel() { return label; }
|
||||
public void setLabel(String label) { this.label = label; }
|
||||
public String getExpertValue() { return expertValue; }
|
||||
public void setExpertValue(String expertValue) { this.expertValue = expertValue; }
|
||||
public String getInputValue() { return inputValue; }
|
||||
public void setInputValue(String inputValue) { this.inputValue = inputValue; }
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package com.ruoyi.business.domain.vo;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 批量导入 dry-run 中某一行与专家表 (biz_expert) 档案的不一致 (导入二次确认用).
|
||||
*/
|
||||
public class ImportMismatch {
|
||||
|
||||
/** Excel 行号 (1=表头) */
|
||||
private int rowNo;
|
||||
|
||||
private String phone;
|
||||
|
||||
private String name;
|
||||
|
||||
/** 该行不一致的字段列表 */
|
||||
private List<FieldMismatch> fields;
|
||||
|
||||
public int getRowNo() { return rowNo; }
|
||||
public void setRowNo(int rowNo) { this.rowNo = rowNo; }
|
||||
public String getPhone() { return phone; }
|
||||
public void setPhone(String phone) { this.phone = phone; }
|
||||
public String getName() { return name; }
|
||||
public void setName(String name) { this.name = name; }
|
||||
public List<FieldMismatch> getFields() { return fields; }
|
||||
public void setFields(List<FieldMismatch> fields) { this.fields = fields; }
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+7
@@ -1,5 +1,6 @@
|
||||
package com.ruoyi.business.mapper;
|
||||
import java.util.List;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import com.ruoyi.business.domain.BizExecutionIntent;
|
||||
|
||||
/**
|
||||
@@ -9,8 +10,14 @@ public interface BizExecutionIntentMapper
|
||||
{
|
||||
BizExecutionIntent selectByPrimaryKey(String intentId);
|
||||
List<BizExecutionIntent> selectList(BizExecutionIntent entity);
|
||||
/** 查询全部记录 (含 del_flag='1' 幽灵占位), 仅"已报名专家导出"用 */
|
||||
List<BizExecutionIntent> selectListIncludingDeleted(BizExecutionIntent entity);
|
||||
int insert(BizExecutionIntent entity);
|
||||
int updateByPrimaryKey(BizExecutionIntent entity);
|
||||
int deleteByPrimaryKey(String intentId);
|
||||
int deleteByPrimaryKeys(String[] intentIds);
|
||||
/** 物理删除幽灵占位记录 (del_flag='1'), 真报名时调用 */
|
||||
int hardDeletePlaceholder(@Param("userId") Long userId, @Param("projectNo") String projectNo);
|
||||
/** 该用户+项目是否已存在意向记录 (真/假都算), 建占位前去重 */
|
||||
int countByUserAndProject(@Param("userId") Long userId, @Param("projectNo") String projectNo);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
+21
@@ -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);
|
||||
}
|
||||
+7
@@ -20,6 +20,8 @@ public interface BizMeetingAttendeeMapper {
|
||||
/** 提交签字: 一次性存 handsign + labor_protocol + signed_at + signed_ip */
|
||||
int updateSign(BizMeetingAttendee entity);
|
||||
int updateLaborProtocol(BizMeetingAttendee entity);
|
||||
/** 发送邀请函后回写盖章个性化邀请函 URL (单条) */
|
||||
int updateInvitationUrl(@Param("id") Long id, @Param("invitationUrl") String invitationUrl);
|
||||
/** 推送电子签后置 is_esigned=1 (单条) */
|
||||
int markEsignedById(@Param("id") Long id);
|
||||
/** 邀请参会后置 is_invited=1 (单条) */
|
||||
@@ -33,6 +35,11 @@ public interface BizMeetingAttendeeMapper {
|
||||
List<BizMeetingAttendee> selectByMeetingId(Long meetingId);
|
||||
List<BizMeetingAttendee> selectByUserId(Long userId);
|
||||
BizMeetingAttendee selectById(Long id);
|
||||
/**
|
||||
* 按 (meetingId, userId) 查参会人行 id (导入覆盖更新用: 手机号已在该会议时拿旧行 id).
|
||||
* 无匹配返回 null.
|
||||
*/
|
||||
Long selectIdByMeetingIdAndUserId(@Param("meetingId") Long meetingId, @Param("userId") Long userId);
|
||||
List<BizMeetingAttendee> selectUnsignedByUserId(Long userId);
|
||||
/** 当前用户的"待参加"会议 (已邀请参会 is_invited=1), 联表取会议名/时间 */
|
||||
List<BizMeetingAttendee> selectInvitedByUserId(Long userId);
|
||||
|
||||
+22
@@ -51,6 +51,22 @@ public interface BizMeetingInvoiceMapper {
|
||||
int updateAmountByMaterial(@org.apache.ibatis.annotations.Param("materialId") Long materialId,
|
||||
@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: 按主键更新 (状态 + 金额 + 错误信息)
|
||||
*/
|
||||
@@ -58,4 +74,10 @@ public interface BizMeetingInvoiceMapper {
|
||||
@org.apache.ibatis.annotations.Param("recognizeStatus") String recognizeStatus,
|
||||
@org.apache.ibatis.annotations.Param("amount") java.math.BigDecimal amount,
|
||||
@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 每分钟触发.
|
||||
*/
|
||||
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 列表.
|
||||
*/
|
||||
@@ -71,4 +77,11 @@ public interface BizMeetingMapper
|
||||
int updateLaborFee(@Param("meetingId") Long meetingId,
|
||||
@Param("laborFee") BigDecimal laborFee,
|
||||
@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 updateByPrimaryKey(BizOrg entity);
|
||||
int deleteByPrimaryKeys(Long[] orgIds);
|
||||
/** 级联软删机构下所有账号 (主 + 子), 删除机构时调用 */
|
||||
int softDeleteUsersByOrgId(Long orgId);
|
||||
/** 支持方下拉选项 (JOIN sys_user.user_name), 用于 manager 项目分配弹窗, 返回 orgId/orgName/userName */
|
||||
List<Map<String, Object>> selectSponsorOrgOptions(BizOrg entity);
|
||||
/** 执行方下拉选项 (JOIN sys_user MAIN 账号 user_name), 用于 manager 项目分配弹窗
|
||||
@@ -22,15 +24,20 @@ public interface BizOrgMapper {
|
||||
* 与 selectSponsorOrgOptions 区别: 不 JOIN sys_user (无主账号的 org 也可选), 返回 orgId + mainUserId
|
||||
* 返回 Map: orgId / orgName / mainUserId (= biz_org.user_id, 可为 null 表示该企业暂无主账号) */
|
||||
List<Map<String, Object>> selectSponsorRegisterOptions(BizOrg entity);
|
||||
/** 当前登录 sponsor 的所属公司
|
||||
* 主账号 (sys_user.parent_user_id IS NULL): biz_org.user_id = #{userId}
|
||||
* 子账号 (有 biz_person 记录): biz_org.org_id = biz_person.org_id
|
||||
* 返回 Map: orgId / orgName / isOwner (1=主账号, 0=子账号)
|
||||
* 用于 /sponsor/account 页面回显 + 主账号修改 org_name */
|
||||
Map<String, Object> selectMySponsorCompany(Long userId);
|
||||
/** 当前登录账号的所属公司 (sponsor + executor 共用)
|
||||
* 根据 sys_user.role_type 自动选 sponsor/executor
|
||||
* 主账号 (biz_org.user_id = #{userId}): 走 own
|
||||
* 子账号 (有 biz_person 记录): biz_org.org_id = biz_person.org_id
|
||||
* 返回 Map: orgId / orgName / orgType / isOwner (1=主账号, 0=子账号)
|
||||
* 用于 /sponsor/account + /executor/account 页面只读回显单位名称 (主账号也不可改) */
|
||||
Map<String, Object> selectMyCompany(Long userId);
|
||||
/** user_id → org_id 单一可信源反查: COALESCE(biz_org.user_id, biz_person.user_id)
|
||||
* MAIN 主账号走 biz_org.user_id; SUB 子账号走 biz_person.org_id; 都没有返回 null */
|
||||
Long selectOrgIdByUserId(Long userId);
|
||||
/** 单位名称查重: 同 orgType 下 org_name 精确匹配的条数 (新增单位前判重) */
|
||||
int countByOrgName(BizOrg entity);
|
||||
/** 供应商同步 org 去重: 按税号精确定位 executor org (取 org_id 最小) */
|
||||
BizOrg selectByTaxNo(BizOrg entity);
|
||||
/** 供应商同步 org 去重兜底: 税号缺失时按企业名精确定位 executor org (取 org_id 最小) */
|
||||
BizOrg selectByNameAndType(BizOrg entity);
|
||||
}
|
||||
|
||||
+9
-4
@@ -1,13 +1,18 @@
|
||||
package com.ruoyi.business.mapper;
|
||||
|
||||
import java.util.List;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import com.ruoyi.business.domain.BizProjectExecutorAssign;
|
||||
|
||||
public interface BizProjectExecutorAssignMapper {
|
||||
int insertAssign(BizProjectExecutorAssign entity);
|
||||
List<BizProjectExecutorAssign> selectByProjectId(String projectId);
|
||||
List<BizProjectExecutorAssign> selectByProjectId(Long projectId);
|
||||
/** 按 project_id 全删 (执行方分配: 先删后插策略) */
|
||||
int deleteByProjectId(String projectId);
|
||||
/** 软删除: 项目级联删除时按 project_id (String) 置 is_deleted=1 */
|
||||
int softDeleteByProjectId(String projectId);
|
||||
int deleteByProjectId(Long projectId);
|
||||
/** 软删除: 项目级联删除时按 project_id 置 is_deleted=1 */
|
||||
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;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import com.ruoyi.business.domain.BizProject;
|
||||
|
||||
/**
|
||||
@@ -9,8 +10,9 @@ public interface BizProjectMapper
|
||||
{
|
||||
BizProject selectByPrimaryKey(Long projectId);
|
||||
List<BizProject> selectList(BizProject entity);
|
||||
/** 公开门户公示列表: is_published='1' 且未删除, 按 publish_time 倒序 (最新在前, 空值排最后) */
|
||||
List<BizProject> selectPublicAnnouncements();
|
||||
/** 公开门户公示列表: is_published='Y' 且未删除, 按 publish_time 倒序 (最新在前, 空值排最后).
|
||||
* 仅查公示页展示的少量标量列, keyword 按项目名模糊过滤, 分页由 controller startPage 注入. */
|
||||
List<BizProject> selectPublicAnnouncements(@org.apache.ibatis.annotations.Param("keyword") String keyword);
|
||||
/** sponsor 端专属: projectIds + LEFT JOIN 当前 login 用户评分, 用于评分回显 */
|
||||
List<BizProject> selectSponsorList(BizProject entity);
|
||||
/** executor 端专属: JOIN biz_project_assign 过滤, 只返回分给当前 user 的项目 */
|
||||
@@ -35,4 +37,6 @@ public interface BizProjectMapper
|
||||
int clearAnnouncement(@org.apache.ibatis.annotations.Param("projectId") Long projectId);
|
||||
/** 开通到期回收: 到期(open_deadline <= 今天)的 open_status='Y' 置回 'N' */
|
||||
int closeExpiredOpenStatus();
|
||||
/** 结题前置检查: 返回还有未结算会议(is_settled<>1)的项目 [{projectId,projectNo,unsettledCount}], 仅含 unsettledCount>0 */
|
||||
List<Map<String, Object>> checkMeetingSettled(@org.apache.ibatis.annotations.Param("projectIds") List<Long> projectIds);
|
||||
}
|
||||
+40
-5
@@ -59,6 +59,8 @@ public class BizNotifyService
|
||||
public static final String BIZ_PROJECT = "project";
|
||||
public static final String BIZ_PROJECT_PLAN = "projectPlan";
|
||||
public static final String BIZ_MEETING = "meeting";
|
||||
/** 会议邀请函 (参会人点击通知直接跳转邀请函详情页, bizId=attendeeId) */
|
||||
public static final String BIZ_MEETING_INVITATION = "meetingInvitation";
|
||||
public static final String BIZ_AGREEMENT = "agreement";
|
||||
public static final String BIZ_PUBLICITY = "publicity";
|
||||
public static final String BIZ_ORG = "org";
|
||||
@@ -166,11 +168,12 @@ public class BizNotifyService
|
||||
* 业务去重由调用方保证 (差集), 本方法不再判断.
|
||||
*
|
||||
* @param userId 被邀请人 sys_user.user_id
|
||||
* @param attendeeId biz_meeting_attendee.id (存 bizId, 前端点击通知跳转到邀请函页)
|
||||
* @param meetingId biz_meeting.meeting_id
|
||||
* @param meetingName 会议名 (可空, 兜底 "会议 #ID")
|
||||
* @param startTime 会议开始时间 (可空, 不展示具体时间)
|
||||
*/
|
||||
public void meetingInvitation(Long userId, Long meetingId, String meetingName, Date startTime)
|
||||
public void meetingInvitation(Long userId, Long attendeeId, Long meetingId, String meetingName, Date startTime)
|
||||
{
|
||||
if (userId == null) {
|
||||
log.warn("[notify] meetingInvitation: userId 为空, 跳过 (meetingId={})", meetingId);
|
||||
@@ -181,18 +184,18 @@ public class BizNotifyService
|
||||
if (startTime != null) {
|
||||
content.append(", 时间: ").append(new java.text.SimpleDateFormat("yyyy-MM-dd HH:mm").format(startTime));
|
||||
}
|
||||
content.append("。请登录系统查看详情。");
|
||||
content.append("。请点击查看邀请函。");
|
||||
|
||||
BizMessage msg = new BizMessage();
|
||||
msg.setReceiverUserId(userId);
|
||||
msg.setMsgType(TYPE_NOTIFY); // 会议邀请 = 普通通知 (医生只需知道被邀请, 不是必须立即操作)
|
||||
msg.setTitle("会议邀请: " + name);
|
||||
msg.setContent(content.toString());
|
||||
msg.setBizType(BIZ_MEETING);
|
||||
msg.setBizId(meetingId);
|
||||
msg.setBizType(BIZ_MEETING_INVITATION); // 独立类型: 前端点击直接跳邀请函页, 不弹 dialog
|
||||
msg.setBizId(attendeeId);
|
||||
msg.setCreateBy("system");
|
||||
bizMessageService.insert(msg);
|
||||
log.info("[notify] meetingInvitation 已发 uid={} meetingId={}", userId, meetingId);
|
||||
log.info("[notify] meetingInvitation 已发 uid={} meetingId={} attendeeId={}", userId, meetingId, attendeeId);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -375,4 +378,36 @@ public class BizNotifyService
|
||||
bizMessageService.insert(msg);
|
||||
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.FileOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.Charset;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.zip.ZipEntry;
|
||||
@@ -39,31 +41,17 @@ public class ZipExtractor
|
||||
if (size <= 0) throw new IOException("OSS zip 下载失败: " + zipUrl);
|
||||
log.info("zip 下载: url={} size={}B tmp={}", zipUrl, size, zipFile.getAbsolutePath());
|
||||
|
||||
List<File> out = new ArrayList<>();
|
||||
try (ZipInputStream zin = new ZipInputStream(new FileInputStream(zipFile)))
|
||||
// 优先 UTF-8 解包; 若 entry 名非 UTF-8 (常见 GBK, Windows 打包) 会抛
|
||||
// IllegalArgumentException (malformed input) → 回退 GBK 重试
|
||||
try
|
||||
{
|
||||
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);
|
||||
}
|
||||
return doExtract(zipFile, tmpRoot, StandardCharsets.UTF_8);
|
||||
}
|
||||
catch (IllegalArgumentException e)
|
||||
{
|
||||
log.warn("zip 文件名非 UTF-8, 回退 GBK 重试: {}", e.getMessage());
|
||||
return doExtract(zipFile, tmpRoot, Charset.forName("GBK"));
|
||||
}
|
||||
log.info("zip 解压完成: 文件数={}", out.size());
|
||||
return out;
|
||||
}
|
||||
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)
|
||||
{
|
||||
|
||||
@@ -9,6 +9,7 @@ import java.io.ByteArrayInputStream;
|
||||
import java.text.SimpleDateFormat;
|
||||
import java.util.Date;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.ThreadLocalRandom;
|
||||
|
||||
/**
|
||||
* 服务端 OSS 上传器 (copy 自 hwt-serve/ruoyi-common-base/.../third/AliOssService,精简)
|
||||
@@ -70,6 +71,57 @@ public class OssUploader
|
||||
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)
|
||||
{
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
+22
@@ -87,4 +87,26 @@ public class MeetingStageScheduler
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -10,7 +10,7 @@ import lombok.extern.slf4j.Slf4j;
|
||||
* 项目"开通"到期回收调度器 (每天 00:05 一次).
|
||||
* <p>
|
||||
* 开通状态流转: 默认 N → 经理"开通"置 Y + open_deadline → 到期(open_deadline <= 今天)回收为 N.
|
||||
* 结题(is_finished=1)且 open_status=N 的项目, sponsor 不可见
|
||||
* 结题(is_finished=Y)且 open_status=N 的项目, sponsor 不可见
|
||||
* (见 BizProjectMapper.selectSponsorList / BizMeetingMapper.selectList 的过滤).
|
||||
* <p>
|
||||
* 需要启动类 {@code @EnableScheduling} 才会生效 (RuoYiApplication 已有).
|
||||
|
||||
+66
-9
@@ -4,6 +4,10 @@ import java.net.URLEncoder;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
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.core.env.Environment;
|
||||
@@ -21,12 +25,17 @@ import com.ruoyi.common.utils.http.HttpUtils;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
/**
|
||||
* 供应商账号数据拉取调度器: 每分钟拉取最近 N 分钟更新的账号, 解密后同步到执行方侧.
|
||||
* 供应商账号数据拉取调度器.
|
||||
* <p>
|
||||
* 数据源: {@code GET /supplier-api/bidding/supplier/openapi/accounts}
|
||||
* 入参 lastUpdatedTime(最后更新时间) / pageNum / pageSize, 按更新时间倒序返回.
|
||||
* 返回 data 字段为 AES-256-GCM 加密串, 用 {@link SupplierAccountApiCodec} 解密.
|
||||
* <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}).
|
||||
*/
|
||||
@Slf4j
|
||||
@@ -36,10 +45,11 @@ public class SupplierAccountPullScheduler
|
||||
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 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 =
|
||||
"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");
|
||||
|
||||
@@ -51,8 +61,46 @@ public class SupplierAccountPullScheduler
|
||||
@Autowired
|
||||
private SupplierAccountSyncService supplierAccountSyncService;
|
||||
|
||||
@Scheduled(fixedRate = 60_000, initialDelay = 30_000)
|
||||
public void pullAccounts()
|
||||
/** 启动全量拉取专用线程池 (daemon 单线程, 不阻塞启动, 也不阻塞 JVM 退出) */
|
||||
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
|
||||
{
|
||||
@@ -64,8 +112,7 @@ public class SupplierAccountPullScheduler
|
||||
}
|
||||
String baseUrl = env.getProperty(BASE_URL, DEFAULT_BASE_URL);
|
||||
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(pullMinutes).format(TIME_FMT);
|
||||
String lastUpdatedTime = LocalDateTime.now().minusMinutes(minutes).format(TIME_FMT);
|
||||
|
||||
int pageNum = 1;
|
||||
int fetched = 0;
|
||||
@@ -109,11 +156,21 @@ public class SupplierAccountPullScheduler
|
||||
}
|
||||
pageNum++;
|
||||
}
|
||||
log.info("[SupplierAccountPull] 本轮完成, 共 {} 条", fetched);
|
||||
log.info("[SupplierAccountPull] 本轮完成, 窗口={}分钟, 共 {} 条", minutes, fetched);
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
+259
@@ -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;
|
||||
}
|
||||
}
|
||||
+230
@@ -0,0 +1,230 @@
|
||||
package com.ruoyi.business.service;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.math.RoundingMode;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.beans.factory.annotation.Qualifier;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
import com.ruoyi.common.exception.ServiceException;
|
||||
|
||||
/**
|
||||
* 从泛微 ecology 只读视图拉取项目信息 (admin/projects/new 按项目编号自动回填).
|
||||
* <p>
|
||||
* 数据源: {@code v_compliance_project_list} (项目列表) + {@code v_compliance_project_manager} (负责人姓名/手机号).
|
||||
* <p>
|
||||
* 字段映射:
|
||||
* xmbh→项目编号, xmmc→项目名称, xmqssj/xmjssj→开始/结束时间(补全 00:00:00 / 23:59:59),
|
||||
* xmlx_name→项目形式(线上加线下→线上+线下), yqsry→总金额, glfsfbl→管理费=总金额×比例%,
|
||||
* xmfzr→项目负责人(通过 v_compliance_project_manager 解析姓名+手机号, 再查/建 sys_user 静默建档).
|
||||
*/
|
||||
@Service
|
||||
public class EcologyProjectService
|
||||
{
|
||||
private static final Logger log = LoggerFactory.getLogger(EcologyProjectService.class);
|
||||
|
||||
private static final String PROJECT_BY_NO_SQL =
|
||||
"SELECT id, xmbh, xmmc, xmfzr, yqsry, xmqssj, xmjssj, glfsfbl, xmlx_name " +
|
||||
"FROM v_compliance_project_list WHERE xmbh = ? ORDER BY id LIMIT 1";
|
||||
|
||||
private static final String PROJECT_SEARCH_SQL =
|
||||
"SELECT xmbh, xmmc FROM v_compliance_project_list WHERE xmbh LIKE ? " +
|
||||
"GROUP BY xmbh, xmmc ORDER BY xmbh LIMIT 50";
|
||||
|
||||
private static final String LEADER_BY_ID_SQL =
|
||||
"SELECT lastname, mobile FROM v_compliance_project_manager WHERE id = ? LIMIT 1";
|
||||
|
||||
@Autowired
|
||||
@Qualifier("ecologyJdbcTemplate")
|
||||
private JdbcTemplate ecologyJdbcTemplate;
|
||||
|
||||
@Autowired
|
||||
private ComplianceLoginService complianceLoginService;
|
||||
|
||||
/**
|
||||
* 项目编号模糊搜索 (下拉选项用): 返回 [{projectNo, projectName}], 空关键词返回空列表.
|
||||
*/
|
||||
public List<Map<String, Object>> searchProjectNos(String kw)
|
||||
{
|
||||
if (kw == null || kw.trim().isEmpty())
|
||||
{
|
||||
return new ArrayList<>();
|
||||
}
|
||||
String like = "%" + escapeLike(kw.trim()) + "%";
|
||||
try
|
||||
{
|
||||
List<Map<String, Object>> rows = ecologyJdbcTemplate.queryForList(PROJECT_SEARCH_SQL, like);
|
||||
List<Map<String, Object>> result = new ArrayList<>();
|
||||
for (Map<String, Object> row : rows)
|
||||
{
|
||||
Map<String, Object> m = new LinkedHashMap<>();
|
||||
m.put("projectNo", str(row, "xmbh"));
|
||||
m.put("projectName", str(row, "xmmc"));
|
||||
result.add(m);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
log.warn("查询 ecology 项目编号失败: {}", e.getMessage());
|
||||
return new ArrayList<>();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 按项目编号拉取完整项目信息 + 负责人静默查号/建号.
|
||||
* 返回字段: projectNo/projectName/projectForm/startTime/endTime/totalAmount/manageFee/
|
||||
* leaderResolved/leaderName/leaderPhone/leaderUserId.
|
||||
* 项目不存在或 ecology 不可达抛 {@link ServiceException}.
|
||||
*/
|
||||
public Map<String, Object> fetchProjectByNo(String projectNo)
|
||||
{
|
||||
if (projectNo == null || projectNo.trim().isEmpty())
|
||||
{
|
||||
throw new ServiceException("项目编号不能为空");
|
||||
}
|
||||
Map<String, Object> row;
|
||||
try
|
||||
{
|
||||
List<Map<String, Object>> rows = ecologyJdbcTemplate.queryForList(PROJECT_BY_NO_SQL, projectNo.trim());
|
||||
if (rows == null || rows.isEmpty())
|
||||
{
|
||||
throw new ServiceException("ecology 中未找到该项目编号");
|
||||
}
|
||||
row = rows.get(0);
|
||||
}
|
||||
catch (ServiceException e)
|
||||
{
|
||||
throw e;
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
log.warn("查询 ecology 项目信息失败: {}", e.getMessage());
|
||||
throw new ServiceException("ecology 数据源不可用");
|
||||
}
|
||||
|
||||
BigDecimal yqsry = decimal(row, "yqsry");
|
||||
BigDecimal ratio = decimal(row, "glfsfbl");
|
||||
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
result.put("projectNo", str(row, "xmbh"));
|
||||
result.put("projectName", str(row, "xmmc"));
|
||||
result.put("projectForm", mapProjectForm(str(row, "xmlx_name")));
|
||||
result.put("startTime", toStartTime(str(row, "xmqssj")));
|
||||
result.put("endTime", toEndTime(str(row, "xmjssj")));
|
||||
result.put("totalAmount", yqsry);
|
||||
result.put("manageFee", calcManageFee(yqsry, ratio));
|
||||
resolveLeader(row, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 负责人解析: xmfzr(int, hrmresource.id) → v_compliance_project_manager 查姓名/手机号 →
|
||||
* 静默查/建 sys_user(role_type=leader). 解析不到(负责人不在视图/无手机号)则 leaderResolved=false.
|
||||
*/
|
||||
private void resolveLeader(Map<String, Object> row, Map<String, Object> result)
|
||||
{
|
||||
Object xmfzr = row.get("xmfzr");
|
||||
result.put("leaderResolved", false);
|
||||
result.put("leaderName", null);
|
||||
result.put("leaderPhone", null);
|
||||
result.put("leaderUserId", null);
|
||||
if (xmfzr == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
try
|
||||
{
|
||||
List<Map<String, Object>> lr = ecologyJdbcTemplate.queryForList(
|
||||
LEADER_BY_ID_SQL, ((Number) xmfzr).longValue());
|
||||
if (lr == null || lr.isEmpty())
|
||||
{
|
||||
return;
|
||||
}
|
||||
String name = str(lr.get(0), "lastname");
|
||||
String phone = str(lr.get(0), "mobile");
|
||||
if (phone == null || phone.isEmpty())
|
||||
{
|
||||
return;
|
||||
}
|
||||
Long userId = complianceLoginService.ensureLeaderAccount(name, phone);
|
||||
result.put("leaderResolved", true);
|
||||
result.put("leaderName", name);
|
||||
result.put("leaderPhone", phone);
|
||||
result.put("leaderUserId", userId);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
log.warn("解析 ecology 项目负责人失败 (xmfzr={}): {}", xmfzr, e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private String str(Map<String, Object> row, String key)
|
||||
{
|
||||
Object v = row.get(key);
|
||||
if (v == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
String s = v.toString().trim();
|
||||
return s.isEmpty() ? null : s;
|
||||
}
|
||||
|
||||
private BigDecimal decimal(Map<String, Object> row, String key)
|
||||
{
|
||||
Object v = row.get(key);
|
||||
if (v == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
try
|
||||
{
|
||||
return new BigDecimal(v.toString());
|
||||
}
|
||||
catch (NumberFormatException e)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** 项目形式: 线上加线下 → 线上+线下 (表单口径), 其余原样返回. */
|
||||
private String mapProjectForm(String xmlxName)
|
||||
{
|
||||
if (xmlxName == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
return "线上加线下".equals(xmlxName) ? "线上+线下" : xmlxName;
|
||||
}
|
||||
|
||||
private String toStartTime(String day)
|
||||
{
|
||||
return day == null ? null : day + " 00:00:00";
|
||||
}
|
||||
|
||||
private String toEndTime(String day)
|
||||
{
|
||||
return day == null ? null : day + " 23:59:59";
|
||||
}
|
||||
|
||||
/** 管理费 = 总金额 × 比例 / 100, 保留 2 位小数. */
|
||||
private BigDecimal calcManageFee(BigDecimal yqsry, BigDecimal ratio)
|
||||
{
|
||||
if (yqsry == null || ratio == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
return yqsry.multiply(ratio).divide(BigDecimal.valueOf(100), 2, RoundingMode.HALF_UP);
|
||||
}
|
||||
|
||||
private String escapeLike(String s)
|
||||
{
|
||||
return s.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_");
|
||||
}
|
||||
}
|
||||
+14
@@ -10,8 +10,22 @@ public interface IBizExecutionIntentService
|
||||
{
|
||||
BizExecutionIntent getById(String intentId);
|
||||
List<BizExecutionIntent> selectList(BizExecutionIntent entity);
|
||||
/** 查询全部记录 (含 del_flag='1' 幽灵占位), 仅"已报名专家导出"用 */
|
||||
List<BizExecutionIntent> selectListIncludingDeleted(BizExecutionIntent entity);
|
||||
int insert(BizExecutionIntent entity);
|
||||
int updateByPrimaryKey(BizExecutionIntent entity);
|
||||
int deleteByPrimaryKey(String intentId);
|
||||
int deleteByPrimaryKeys(String[] intentId);
|
||||
|
||||
/**
|
||||
* 当前登录用户对某项目「报名」 (幂等): 按 userId + projectNo 查重, 已报名返回 null, 新报名返回新建记录.
|
||||
* name/phone 从 sys_user 兜底 (与"立即报名"口径一致), onboardStatus=未入库.
|
||||
*/
|
||||
BizExecutionIntent signupByProject(String projectNo, String projectName);
|
||||
|
||||
/** 物理删除幽灵占位记录 (del_flag='1'), 真报名时调用 */
|
||||
void hardDeletePlaceholder(Long userId, String projectNo);
|
||||
|
||||
/** 该用户+项目是否已存在意向记录 (真/假都算), 建占位前去重 */
|
||||
boolean existsAny(Long userId, String projectNo);
|
||||
}
|
||||
|
||||
+41
@@ -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);
|
||||
}
|
||||
+42
-10
@@ -1,9 +1,12 @@
|
||||
package com.ruoyi.business.service;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
import com.ruoyi.business.domain.BizMeetingAttendee;
|
||||
import com.ruoyi.business.domain.dto.ImportResult;
|
||||
import com.ruoyi.business.domain.vo.FieldMismatch;
|
||||
|
||||
public interface IBizMeetingAttendeeService {
|
||||
int insert(BizMeetingAttendee entity);
|
||||
@@ -38,6 +41,14 @@ public interface IBizMeetingAttendeeService {
|
||||
* @throws ServiceException phone 为空 / 用户已在会议中
|
||||
*/
|
||||
Long insertByPhoneWithProfile(BizMeetingAttendee body);
|
||||
|
||||
/**
|
||||
* 导入参会人单行 upsert: 手机号已在该会议 → 覆盖更新已有行 (走 updateProfile 修改逻辑);
|
||||
* 否则新增 (复用 insertByPhoneWithProfile 的查/建号 + 写档案 + 建报名占位).
|
||||
* 与 {@link #insertByPhoneWithProfile} 的区别: 手机号重复不抛异常, 而是更新.
|
||||
*/
|
||||
Long upsertByPhoneWithProfile(BizMeetingAttendee body);
|
||||
|
||||
List<BizMeetingAttendee> selectByMeetingId(Long meetingId);
|
||||
List<BizMeetingAttendee> selectByUserId(Long userId);
|
||||
BizMeetingAttendee selectById(Long id);
|
||||
@@ -63,6 +74,27 @@ public interface IBizMeetingAttendeeService {
|
||||
*/
|
||||
ImportResult importFromExcel(MultipartFile file, Long meetingId, String operName) throws Exception;
|
||||
|
||||
/**
|
||||
* 新增/编辑参会人前, 比对表单与专家表 (biz_expert) 档案, 返回不一致字段列表.
|
||||
*
|
||||
* <p>解析口径与 {@link #insertByPhoneWithProfile} 一致: 编辑按 body.id 反查 userId,
|
||||
* 新增按 body.phone 查 sys_user 得 userId (不建号), 再 userId→biz_expert. 查不到专家返回空列表.
|
||||
*
|
||||
* @param body 参会人表单 (含 id 或 phone 定位)
|
||||
* @return 不一致字段 (专家表已有值且与输入不同); 无差异/无专家 → 空列表
|
||||
*/
|
||||
List<FieldMismatch> diffAgainstExpert(BizMeetingAttendee body);
|
||||
|
||||
/**
|
||||
* 批量导入 dry-run: 解析 Excel + 逐行比对专家表, 不落库.
|
||||
*
|
||||
* @param file Excel 文件
|
||||
* @param meetingId 会议 id
|
||||
* @return { total: 数据行数, mismatches: [ImportMismatch] }
|
||||
* @throws Exception 文件为空 / 解析异常
|
||||
*/
|
||||
Map<String, Object> previewImport(MultipartFile file, Long meetingId) throws Exception;
|
||||
|
||||
/**
|
||||
* 推送电子签 (批量/单条通用): 对给定 attendee.id 列表逐个
|
||||
* 发短信 (电子签模板) + 推站内信 + 置 is_esigned=1.
|
||||
@@ -106,15 +138,15 @@ public interface IBizMeetingAttendeeService {
|
||||
byte[] buildAgreementTemplateZip(Long meetingId);
|
||||
|
||||
/**
|
||||
* 上传"劳务协议" zip, 解压后按 劳务协议/{序号}_{姓名}/ 目录匹配参会人,
|
||||
* 取目录内第一个文件上传 OSS 并回填 labor_protocol (覆盖式).
|
||||
* 提交"劳务协议" zip 上传任务 (异步): 请求线程读字节 + 登记进度后立即返回 jobId,
|
||||
* 后台线程解压匹配参会人并回填 labor_protocol, 前端轮询 /business/upload/progress/{jobId} 拿进度与结果.
|
||||
*
|
||||
* @param file 用户重新压缩的 zip
|
||||
* @param meetingId 会议 id (决定 OSS 子目录 + 参会人列表)
|
||||
* @return 成功回填的参会人数
|
||||
* @throws Exception 文件为空 / 无参会人 / zip 解析异常
|
||||
* @return jobId (进度/结果轮询键)
|
||||
* @throws Exception 文件为空 / 读字节失败
|
||||
*/
|
||||
int uploadAgreements(MultipartFile file, Long meetingId) throws Exception;
|
||||
String submitAgreementUpload(MultipartFile file, Long meetingId) throws Exception;
|
||||
|
||||
/**
|
||||
* 生成"专家照片"空目录模板 zip (专家照片 "下载目录模板" 按钮).
|
||||
@@ -126,13 +158,13 @@ public interface IBizMeetingAttendeeService {
|
||||
byte[] buildExpertPhotoTemplateZip(Long meetingId);
|
||||
|
||||
/**
|
||||
* 上传"专家照片" zip, 解压后按 专家照片/{序号}_{姓名}/ 目录匹配参会人,
|
||||
* 目录内所有文件上传 OSS 后逗号拼接, 覆盖式回填 on_site_photos.
|
||||
* 提交"专家照片" zip 上传任务 (异步): 请求线程读字节 + 登记进度后立即返回 jobId,
|
||||
* 后台线程解压匹配参会人并回填 on_site_photos, 前端轮询 /business/upload/progress/{jobId} 拿进度与结果.
|
||||
*
|
||||
* @param file 用户重新压缩的 zip
|
||||
* @param meetingId 会议 id
|
||||
* @return 成功回填的参会人数
|
||||
* @throws Exception 文件为空 / 无参会人 / zip 解析异常
|
||||
* @return jobId (进度/结果轮询键)
|
||||
* @throws Exception 文件为空 / 读字节失败
|
||||
*/
|
||||
int uploadExpertPhotos(MultipartFile file, Long meetingId) throws Exception;
|
||||
String submitExpertPhotoUpload(MultipartFile file, Long meetingId) throws Exception;
|
||||
}
|
||||
+23
-7
@@ -1,6 +1,8 @@
|
||||
package com.ruoyi.business.service;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
import com.ruoyi.business.domain.BizMeetingMaterial;
|
||||
|
||||
@@ -52,10 +54,11 @@ public interface IBizMeetingMaterialService {
|
||||
/**
|
||||
* 扫码拍照回传: ry-h5 手机端拍照直传 OSS 后, 回传 URL 到此存库.
|
||||
* 按 (meetingId, subType) upsert 单行 (存在改 ossUrl, 不存在 insert).
|
||||
* extraOssUrl: 签到表拍照时额外生成的高斯模糊版 URL (sponsor 只看这个), 其他 subType 传空.
|
||||
* 签到表 (L_SIGN_IN) 连拍多张: ossUrls/extraOssUrls 为多张, 存库时序列化为 JSON 数组字符串;
|
||||
* 前后全景单张: 取第一个. extraOssUrls 是高斯模糊版 (sponsor 只看这个), 其他 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, 合规人员上传).
|
||||
@@ -114,13 +117,26 @@ public interface IBizMeetingMaterialService {
|
||||
byte[] buildServiceTemplateZip(Long meetingId);
|
||||
|
||||
/**
|
||||
* 上传"会务材料" zip, 解压后按 会务材料/{材料类型中文名}/ 目录匹配 subType,
|
||||
* 单文件直接上传 OSS, 多文件先打 zip 再上传 OSS, 按 (meetingId, SERVICE, subType) upsert 回填.
|
||||
* 提交"会务材料" zip 上传任务 (异步): 请求线程读字节 + 登记进度后立即返回 jobId,
|
||||
* 后台线程解压按 会务材料/{材料类型中文名}/ 目录匹配 subType 并回填, 前端轮询
|
||||
* /business/upload/progress/{jobId} 拿进度与结果 (result = 回填的材料类数).
|
||||
*
|
||||
* @param file 用户重新压缩的 zip
|
||||
* @param meetingId 会议 id (决定 OSS 子目录)
|
||||
* @return 成功回填的材料类数
|
||||
* @throws Exception 文件为空 / zip 解析异常 / 未找到有效目录
|
||||
* @return jobId (进度/结果轮询键)
|
||||
* @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 调度器).
|
||||
*/
|
||||
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);
|
||||
/** 支持方注册下拉选项 (匿名公开, 含无主账号 org): 返回 orgId / orgName / mainUserId */
|
||||
List<Map<String, Object>> selectSponsorRegisterOptions(BizOrg entity);
|
||||
/** 当前登录 sponsor 的所属公司 (主账号可改, 子账号只读) */
|
||||
Map<String, Object> selectMySponsorCompany(Long userId);
|
||||
/** 当前登录账号的所属公司 (sponsor + executor 共用, 只读展示)
|
||||
* 返回 Map: orgId / orgName / orgType / isOwner (1=主账号, 0=子账号) */
|
||||
Map<String, Object> selectMyCompany(Long userId);
|
||||
|
||||
/** user_id → org_id 单一可信源反查 (MAIN 走 biz_org, SUB 走 biz_person), 找不到返回 null */
|
||||
Long selectOrgIdByUserId(Long userId);
|
||||
|
||||
@@ -14,6 +14,8 @@ import com.ruoyi.common.core.domain.entity.SysUser;
|
||||
public interface IBizPersonService
|
||||
{
|
||||
BizPerson getById(String personId);
|
||||
/** 按 user_id 反查人员档案 (登录态拿姓名/手机号, navbar 显示姓名用) */
|
||||
BizPerson getByUserId(Long userId);
|
||||
List<BizPerson> selectList(BizPerson entity);
|
||||
/** sponsor 专属: 走 BizPersonMapper.selectSponsorList, 用 sys_user.parent_user_id 做归属过滤 */
|
||||
List<BizPerson> selectSponsorList(BizPerson entity);
|
||||
|
||||
+2
-2
@@ -8,6 +8,6 @@ public interface IBizProjectExecutorAssignService {
|
||||
int insertAssign(BizProjectExecutorAssign entity);
|
||||
/** 执行方多条分配 (一个项目 ↔ N 执行人: 先按 project_id 删, 再逐个 insert, 不会循环 delete) */
|
||||
int assignStaffForProject(BizProjectExecutorAssign body, List<Long> staffUserIds);
|
||||
List<BizProjectExecutorAssign> listByProjectId(String projectId);
|
||||
int deleteByProjectId(String projectId);
|
||||
List<BizProjectExecutorAssign> listByProjectId(Long projectId, Long executorOrgId);
|
||||
int deleteByProjectId(Long projectId);
|
||||
}
|
||||
|
||||
+5
-2
@@ -1,6 +1,7 @@
|
||||
package com.ruoyi.business.service;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import com.ruoyi.business.domain.BizProject;
|
||||
|
||||
/**
|
||||
@@ -10,8 +11,8 @@ public interface IBizProjectService
|
||||
{
|
||||
BizProject getById(Long projectId);
|
||||
List<BizProject> selectList(BizProject entity);
|
||||
/** 公开门户公示列表: 已发布, 按发布时间倒序 */
|
||||
List<BizProject> selectPublicAnnouncements();
|
||||
/** 公开门户公示列表: 已发布, 按发布时间倒序, keyword 按项目名模糊过滤 (分页由 controller startPage 注入) */
|
||||
List<BizProject> selectPublicAnnouncements(String keyword);
|
||||
/** sponsor 端专属: LEFT JOIN 当前 login 用户评分回显 */
|
||||
List<BizProject> selectSponsorList(BizProject entity);
|
||||
/** executor 端专属: JOIN biz_project_assign 过滤, 只返回分给当前 user 的项目 */
|
||||
@@ -47,4 +48,6 @@ public interface IBizProjectService
|
||||
|
||||
/** 开通到期回收: 到期(open_deadline <= 今天)的 open_status='Y' 置回 'N' */
|
||||
int closeExpiredOpenStatus();
|
||||
/** 结题前置检查: 返回还有未结算会议的项目 [{projectId,projectNo,unsettledCount}], 仅含 unsettledCount>0 */
|
||||
List<Map<String, Object>> checkMeetingSettled(List<Long> projectIds);
|
||||
}
|
||||
|
||||
+292
@@ -0,0 +1,292 @@
|
||||
package com.ruoyi.business.service;
|
||||
|
||||
import cn.hutool.http.HttpUtil;
|
||||
import com.itextpdf.io.font.PdfEncodings;
|
||||
import com.itextpdf.io.image.ImageData;
|
||||
import com.itextpdf.io.image.ImageDataFactory;
|
||||
import com.itextpdf.kernel.colors.DeviceRgb;
|
||||
import com.itextpdf.kernel.font.PdfFont;
|
||||
import com.itextpdf.kernel.font.PdfFontFactory;
|
||||
import com.itextpdf.kernel.geom.LineSegment;
|
||||
import com.itextpdf.kernel.geom.Rectangle;
|
||||
import com.itextpdf.kernel.geom.Vector;
|
||||
import com.itextpdf.kernel.pdf.PdfDocument;
|
||||
import com.itextpdf.kernel.pdf.PdfPage;
|
||||
import com.itextpdf.kernel.pdf.PdfReader;
|
||||
import com.itextpdf.kernel.pdf.PdfWriter;
|
||||
import com.itextpdf.kernel.pdf.canvas.PdfCanvas;
|
||||
import com.itextpdf.kernel.pdf.canvas.parser.EventType;
|
||||
import com.itextpdf.kernel.pdf.canvas.parser.PdfTextExtractor;
|
||||
import com.itextpdf.kernel.pdf.canvas.parser.data.IEventData;
|
||||
import com.itextpdf.kernel.pdf.canvas.parser.data.TextRenderInfo;
|
||||
import com.itextpdf.kernel.pdf.canvas.parser.listener.ITextExtractionStrategy;
|
||||
import com.ruoyi.business.oss.OssUploader;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* 会议邀请函个性化 PDF 生成服务.
|
||||
*
|
||||
* <p>流程 (「发送邀请函」触发): 下载会议邀请函模板 PDF → 把模板里的 {@code xxx}/{@code XXX}
|
||||
* 替换为医生姓名 → 找到<strong>最后一个</strong>「北京整合医学学会」在其位置盖公章 → 上传 OSS.
|
||||
*
|
||||
* <p>文本定位用 iText7 的 {@link PdfTextExtractor} + 自定义 {@link ITextExtractionStrategy},
|
||||
* 逐字形拿到 baseline / ascent / descent / 字号, 白底盖住占位符后在原 baseline 处写姓名.
|
||||
* 模板若为扫描图片 PDF (无文本层), 抽取不到目标文本 → 仅记 warn、原样返回 (不盖名章).
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
public class InvitationPdfService {
|
||||
|
||||
@Autowired
|
||||
private OssUploader ossUploader;
|
||||
|
||||
/** 公章图片相对路径 (运行目录, 与 simsun.ttc / 劳务协议盖章同图) */
|
||||
private static final String SEAL_IMAGE_PATH = "zhenghe.png";
|
||||
/** 公章尺寸 (宽高, px), 复用 PdfService 劳务协议章口径 */
|
||||
private static final float SEAL_WIDTH = 120f;
|
||||
private static final float SEAL_HEIGHT = 120f;
|
||||
/** 需盖章的落款机构名 (取全文最后一个) */
|
||||
private static final String ORG_TEXT = "北京整合医学学会";
|
||||
/** 姓名占位符 (忽略大小写, 兼容 xxx / XXX) */
|
||||
private static final String PLACEHOLDER = "xxx";
|
||||
|
||||
/** 按 URL 下载邀请函模板 PDF 字节 (批量发送时对同一会议只调用一次) */
|
||||
public byte[] downloadTemplate(String url) {
|
||||
log.info("[invitation] 下载邀请函模板: {}", url);
|
||||
try {
|
||||
return HttpUtil.downloadBytes(url);
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException("下载邀请函模板失败: " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
/** 生成个性化 PDF 并上传 OSS, 返回完整 URL */
|
||||
public String generateAndUpload(byte[] template, String name) {
|
||||
if (template == null || template.length == 0) {
|
||||
throw new IllegalArgumentException("邀请函模板为空");
|
||||
}
|
||||
if (name == null || name.trim().isEmpty()) {
|
||||
name = "专家";
|
||||
}
|
||||
byte[] out = generate(template, name);
|
||||
String filename = System.currentTimeMillis() + "_" + (int) (Math.random() * 1000) + ".pdf";
|
||||
return ossUploader.upload(out, filename, "meeting/invitation");
|
||||
}
|
||||
|
||||
/** 模板 PDF → 个性化 PDF (替换姓名 + 盖章) */
|
||||
public byte[] generate(byte[] template, String name) {
|
||||
try {
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
PdfDocument doc = new PdfDocument(
|
||||
new PdfReader(new ByteArrayInputStream(template)),
|
||||
new PdfWriter(baos));
|
||||
try {
|
||||
render(doc, name);
|
||||
} finally {
|
||||
doc.close();
|
||||
}
|
||||
return baos.toByteArray();
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException("生成邀请函 PDF 失败: " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
/** 第一遍抽取文本定位 → 第二遍叠加姓名 + 公章 */
|
||||
private void render(PdfDocument doc, String name) throws IOException {
|
||||
List<NameTarget> nameTargets = new ArrayList<>();
|
||||
SealTarget seal = null;
|
||||
int pageCount = doc.getNumberOfPages();
|
||||
|
||||
// 第一遍: 逐页抽取文本, 记录 xxx 位置 + 最后一个「北京整合医学学会」位置
|
||||
for (int p = 1; p <= pageCount; p++) {
|
||||
PdfPage page = doc.getPage(p);
|
||||
TextPositionStrategy strategy = new TextPositionStrategy();
|
||||
PdfTextExtractor.getTextFromPage(page, strategy);
|
||||
|
||||
// 展平为「字符 → 字形」列表, 供子串定位 (要求 1 字符 = 1 字形, CJK/拉丁模板恒成立)
|
||||
List<TextRenderInfo> charInfos = new ArrayList<>();
|
||||
StringBuilder sb = new StringBuilder();
|
||||
for (TextRenderInfo info : strategy.infos()) {
|
||||
List<TextRenderInfo> chars = info.getCharacterRenderInfos();
|
||||
if (chars != null && !chars.isEmpty()) {
|
||||
for (TextRenderInfo c : chars) {
|
||||
String ch = c.getText();
|
||||
if (ch != null && !ch.isEmpty()) {
|
||||
charInfos.add(c);
|
||||
sb.append(ch);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
String t = info.getText();
|
||||
if (t != null && !t.isEmpty()) {
|
||||
charInfos.add(info);
|
||||
sb.append(t);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
String full = sb.toString();
|
||||
if (full.isEmpty()) continue;
|
||||
if (charInfos.size() != full.length()) {
|
||||
log.warn("[invitation] 第{}页字形/字符数不一致 ({}!={}), 跳过定位", p, charInfos.size(), full.length());
|
||||
continue;
|
||||
}
|
||||
|
||||
String lower = full.toLowerCase(Locale.ROOT);
|
||||
int i = 0;
|
||||
while ((i = lower.indexOf(PLACEHOLDER, i)) >= 0) {
|
||||
nameTargets.add(buildNameTarget(charInfos, i, PLACEHOLDER.length(), p));
|
||||
i += PLACEHOLDER.length();
|
||||
}
|
||||
int j = 0;
|
||||
while ((j = full.indexOf(ORG_TEXT, j)) >= 0) {
|
||||
seal = buildSealTarget(charInfos, j, ORG_TEXT.length(), p);
|
||||
j += ORG_TEXT.length();
|
||||
}
|
||||
}
|
||||
|
||||
// 第二遍: 叠加
|
||||
if (nameTargets.isEmpty()) {
|
||||
log.warn("[invitation] 未找到占位符 xxx, 跳过姓名替换");
|
||||
} else {
|
||||
PdfFont cjkFont = PdfFontFactory.createFont("simsun.ttc,0", PdfEncodings.IDENTITY_H, false);
|
||||
for (NameTarget t : nameTargets) {
|
||||
drawName(doc, cjkFont, t, name);
|
||||
}
|
||||
}
|
||||
if (seal == null) {
|
||||
log.warn("[invitation] 未找到 '{}', 跳过盖章", ORG_TEXT);
|
||||
} else {
|
||||
drawSeal(doc, seal);
|
||||
}
|
||||
}
|
||||
|
||||
private NameTarget buildNameTarget(List<TextRenderInfo> charInfos, int start, int len, int page) {
|
||||
TextRenderInfo first = charInfos.get(start);
|
||||
LineSegment firstBase = first.getBaseline();
|
||||
float x = firstBase.getStartPoint().get(Vector.I1);
|
||||
float baselineY = firstBase.getStartPoint().get(Vector.I2);
|
||||
float fontSize = first.getFontSize();
|
||||
float width = 0f;
|
||||
for (int k = start; k < start + len && k < charInfos.size(); k++) {
|
||||
width += charInfos.get(k).getBaseline().getLength();
|
||||
}
|
||||
return new NameTarget(page, x, baselineY, fontSize, width);
|
||||
}
|
||||
|
||||
private SealTarget buildSealTarget(List<TextRenderInfo> charInfos, int start, int len, int page) {
|
||||
TextRenderInfo first = charInfos.get(start);
|
||||
TextRenderInfo last = charInfos.get(start + len - 1);
|
||||
float x = first.getBaseline().getStartPoint().get(Vector.I1);
|
||||
float endX = last.getBaseline().getEndPoint().get(Vector.I1);
|
||||
float ascent = first.getAscentLine().getStartPoint().get(Vector.I2);
|
||||
float descent = first.getDescentLine().getStartPoint().get(Vector.I2);
|
||||
float cx = (x + endX) / 2f;
|
||||
float cy = (ascent + descent) / 2f;
|
||||
return new SealTarget(page, cx, cy);
|
||||
}
|
||||
|
||||
/** 白底盖住 xxx, 在原 baseline 处写医生姓名 (左对齐) */
|
||||
private void drawName(PdfDocument doc, PdfFont font, NameTarget t, String name) {
|
||||
PdfPage page = doc.getPage(t.page);
|
||||
PdfCanvas canvas = new PdfCanvas(page.newContentStreamAfter(), page.getResources(), doc);
|
||||
float pad = t.fontSize * 0.1f;
|
||||
float y = t.baselineY - t.fontSize * 0.28f;
|
||||
float h = t.fontSize * 1.2f;
|
||||
canvas.setFillColor(DeviceRgb.WHITE);
|
||||
canvas.rectangle(t.x - pad, y, t.width + 2 * pad, h);
|
||||
canvas.fill();
|
||||
|
||||
canvas.setFillColor(DeviceRgb.BLACK);
|
||||
canvas.beginText();
|
||||
canvas.setFontAndSize(font, t.fontSize);
|
||||
canvas.setTextMatrix(t.x, t.baselineY);
|
||||
canvas.showText(name);
|
||||
canvas.endText();
|
||||
}
|
||||
|
||||
/** 公章居中叠加到最后一个「北京整合医学学会」文本中心 */
|
||||
private void drawSeal(PdfDocument doc, SealTarget s) {
|
||||
PdfPage page = doc.getPage(s.page);
|
||||
PdfCanvas canvas = new PdfCanvas(page.newContentStreamAfter(), page.getResources(), doc);
|
||||
try {
|
||||
ImageData imageData = ImageDataFactory.create(SEAL_IMAGE_PATH);
|
||||
float x = s.cx - SEAL_WIDTH / 2f;
|
||||
// 公章中心相对落款文本中心再往下偏 1/2 个公章半径 (半径 = SEAL_HEIGHT/2, 即下移 SEAL_HEIGHT/4)
|
||||
// PDF 坐标系 Y 轴向上为正, "往下" 即减小 Y
|
||||
float y = s.cy - SEAL_HEIGHT / 2f - SEAL_HEIGHT / 4f;
|
||||
canvas.addImage(imageData, new Rectangle(x, y, SEAL_WIDTH, SEAL_HEIGHT), false);
|
||||
} catch (Exception e) {
|
||||
log.warn("[invitation] 公章图片加载失败, 跳过盖章: {}", SEAL_IMAGE_PATH, e);
|
||||
}
|
||||
}
|
||||
|
||||
/** 收集所有 RENDER_TEXT 事件的 TextRenderInfo (字形级定位) */
|
||||
private static class TextPositionStrategy implements ITextExtractionStrategy {
|
||||
private final List<TextRenderInfo> infos = new ArrayList<>();
|
||||
|
||||
@Override
|
||||
public void eventOccurred(IEventData data, EventType type) {
|
||||
if (type == EventType.RENDER_TEXT && data instanceof TextRenderInfo) {
|
||||
TextRenderInfo info = (TextRenderInfo) data;
|
||||
// 必须保留 graphics state, 否则抽取结束后 getBaseline()/getFontSize()/
|
||||
// getCharacterRenderInfos() 等会抛 IllegalStateException (iText7 默认丢弃)
|
||||
info.preserveGraphicsState();
|
||||
infos.add(info);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public Set<EventType> getSupportedEvents() {
|
||||
return Collections.singleton(EventType.RENDER_TEXT);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getResultantText() {
|
||||
return "";
|
||||
}
|
||||
|
||||
List<TextRenderInfo> infos() {
|
||||
return infos;
|
||||
}
|
||||
}
|
||||
|
||||
private static class NameTarget {
|
||||
final int page;
|
||||
final float x;
|
||||
final float baselineY;
|
||||
final float fontSize;
|
||||
final float width;
|
||||
|
||||
NameTarget(int page, float x, float baselineY, float fontSize, float width) {
|
||||
this.page = page;
|
||||
this.x = x;
|
||||
this.baselineY = baselineY;
|
||||
this.fontSize = fontSize;
|
||||
this.width = width;
|
||||
}
|
||||
}
|
||||
|
||||
private static class SealTarget {
|
||||
final int page;
|
||||
final float cx;
|
||||
final float cy;
|
||||
|
||||
SealTarget(int page, float cx, float cy) {
|
||||
this.page = page;
|
||||
this.cx = cx;
|
||||
this.cy = cy;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package com.ruoyi.business.service;
|
||||
import com.itextpdf.html2pdf.ConverterProperties;
|
||||
import com.itextpdf.html2pdf.HtmlConverter;
|
||||
import com.itextpdf.io.font.PdfEncodings;
|
||||
import com.itextpdf.kernel.events.PdfDocumentEvent;
|
||||
import com.itextpdf.kernel.font.PdfFont;
|
||||
import com.itextpdf.kernel.font.PdfFontFactory;
|
||||
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.layout.font.FontProvider;
|
||||
import com.ruoyi.business.oss.OssUploader;
|
||||
import com.ruoyi.business.pdf.SealStamper;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
@@ -28,6 +30,18 @@ public class PdfService {
|
||||
@Autowired
|
||||
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 字节流
|
||||
*/
|
||||
@@ -48,6 +62,10 @@ public class PdfService {
|
||||
fontProvider.addFont(cjkFont.getFontProgram(), PdfEncodings.IDENTITY_H);
|
||||
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);
|
||||
pdfDocument.close();
|
||||
return baos.toByteArray();
|
||||
|
||||
@@ -88,8 +88,42 @@ public class StageDeriver
|
||||
if (t(m.getIsFinished())) return "已完结";
|
||||
if (t(m.getIsSettled())) return "已结算";
|
||||
|
||||
int chosen = chooseState(role, laborState(m), serviceState(m));
|
||||
return render(role, chosen, executionPhase(m));
|
||||
int phase = 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)
|
||||
{
|
||||
case 0: // R 退回
|
||||
return "executor".equals(role) ? "已退回" : "待整改";
|
||||
return "待整改";
|
||||
case 1: // N 未提交 (时间驱动三态: 未执行 → 执行中 → 已执行, 所有角色统一)
|
||||
if (phase == 0) return "未执行";
|
||||
if (phase == 1) return "执行中";
|
||||
return "已执行";
|
||||
case 2: // C0 合规审中
|
||||
if ("sponsor".equals(role)) return "已执行未传材料"; // 只读
|
||||
return "待审核"; // executor / manager / admin
|
||||
return "待审核"; // executor / manager / sponsor / admin
|
||||
case 3: // C1 支持方审中
|
||||
if ("manager".equals(role)) return "审核通过";
|
||||
return "待审核"; // executor / sponsor / admin
|
||||
if ("sponsor".equals(role)) return "待监察";
|
||||
return "待审核"; // executor / admin
|
||||
default: // 4 = A 已通过
|
||||
return "待结算";
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import com.ruoyi.common.core.redis.RedisCache;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.UUID;
|
||||
@@ -38,12 +39,13 @@ public class SysSmsService {
|
||||
@Autowired
|
||||
private AliyunSmsSender aliyunSmsSender;
|
||||
|
||||
/**
|
||||
* dev 模式判断: 手机号以 "10" 开头视为开发测试 (固定码 1234), 其它走 aliyun 真发
|
||||
* 该逻辑不依赖 application.yml 配置, 确保开发/生产两种手机号能并存
|
||||
*/
|
||||
private boolean isDev(String phone) {
|
||||
return phone.startsWith("10");
|
||||
/** 测试环境短信开关: true=固定码 1234 且不真发 (test), false=aliyun 真发 (prod) */
|
||||
@Value("${ruoyi.sms.mock-enabled:false}")
|
||||
private boolean mockEnabled;
|
||||
|
||||
/** 生产测试账号: 手机号以 "10" 开头视为测试号, 固定码 1234 且不真发 (生产环境测试用) */
|
||||
private boolean isTestAccount(String phone) {
|
||||
return phone != null && phone.startsWith("10");
|
||||
}
|
||||
|
||||
/** 发送短信验证码, 返回 uuid (前端提交验证时回传) */
|
||||
@@ -56,12 +58,12 @@ public class SysSmsService {
|
||||
}
|
||||
|
||||
String code;
|
||||
if (isDev(phone)) {
|
||||
// dev 模式 (10 开头): 固定码 1234 (供内部测试)
|
||||
if (mockEnabled || isTestAccount(phone)) {
|
||||
// 测试环境 / 生产测试账号(手机号 10 开头): 固定码 1234 (不真发)
|
||||
code = "1234";
|
||||
log.info("[SMS][DEV][phone-prefix=10] 向 {} 发送验证码: {}", phone, code);
|
||||
log.info("[SMS][MOCK] 向 {} 发送验证码(测试固定码): {}", phone, code);
|
||||
} else {
|
||||
// prod 模式: aliyun 真发
|
||||
// 生产环境: aliyun 真发
|
||||
code = generateCode(4);
|
||||
log.info("[SMS][PROD] 向 {} 发送验证码: {}", phone, code);
|
||||
boolean ok = aliyunSmsSender.sendCode(phone, code);
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
package com.ruoyi.business.service;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.core.env.Environment;
|
||||
import org.springframework.stereotype.Service;
|
||||
import com.alibaba.fastjson2.JSONObject;
|
||||
import com.ruoyi.common.core.redis.RedisCache;
|
||||
import com.ruoyi.common.utils.http.HttpUtils;
|
||||
|
||||
/**
|
||||
* 公众号 JS-SDK 分享签名服务
|
||||
* <p>链路: appId + appSecret → access_token → jsapi_ticket → signature(sha1)
|
||||
* <p>参考 hwt-serve WxService.getAccessToken/getTicket, 但签名改在后端完整计算,
|
||||
* 不把 jsapi_ticket 暴露给前端.
|
||||
*/
|
||||
@Service
|
||||
public class WxShareService
|
||||
{
|
||||
private static final Logger log = LoggerFactory.getLogger(WxShareService.class);
|
||||
|
||||
/** access_token / jsapi_ticket 缓存有效期 (官方 7200s, 提前 200s 刷新) */
|
||||
private static final int CACHE_TTL_SECONDS = 7000;
|
||||
|
||||
@Autowired
|
||||
private Environment environment;
|
||||
|
||||
@Autowired
|
||||
private RedisCache redisCache;
|
||||
|
||||
private String appId()
|
||||
{
|
||||
return environment.getProperty("wx.app-id", "wx2414cddf4bd56092");
|
||||
}
|
||||
|
||||
private String appSecret()
|
||||
{
|
||||
return environment.getProperty("wx.app-secret");
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取公众号 access_token (Redis 缓存, 避免频繁调用被限流 2000 次/日)
|
||||
*/
|
||||
private String getAccessToken()
|
||||
{
|
||||
String appId = appId();
|
||||
String key = "wx:access_token:" + appId;
|
||||
String token = redisCache.getCacheObject(key);
|
||||
if (token != null && !token.isEmpty())
|
||||
{
|
||||
return token;
|
||||
}
|
||||
String url = "https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid="
|
||||
+ appId + "&secret=" + appSecret();
|
||||
String resp = HttpUtils.sendGet(url);
|
||||
JSONObject obj = (resp == null || resp.isEmpty()) ? null : JSONObject.parseObject(resp);
|
||||
if (obj == null || obj.getString("access_token") == null)
|
||||
{
|
||||
log.error("获取微信 access_token 失败, resp={}", resp);
|
||||
throw new RuntimeException("获取微信 access_token 失败: "
|
||||
+ (obj == null ? resp : obj.getString("errmsg")));
|
||||
}
|
||||
token = obj.getString("access_token");
|
||||
redisCache.setCacheObject(key, token, CACHE_TTL_SECONDS, TimeUnit.SECONDS);
|
||||
return token;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 jsapi_ticket (Redis 缓存)
|
||||
*/
|
||||
private String getJsapiTicket(String accessToken)
|
||||
{
|
||||
String appId = appId();
|
||||
String key = "wx:jsapi_ticket:" + appId;
|
||||
String ticket = redisCache.getCacheObject(key);
|
||||
if (ticket != null && !ticket.isEmpty())
|
||||
{
|
||||
return ticket;
|
||||
}
|
||||
String url = "https://api.weixin.qq.com/cgi-bin/ticket/getticket?access_token="
|
||||
+ accessToken + "&type=jsapi";
|
||||
String resp = HttpUtils.sendGet(url);
|
||||
JSONObject obj = (resp == null || resp.isEmpty()) ? null : JSONObject.parseObject(resp);
|
||||
if (obj == null || !"0".equals(obj.getString("errcode")) || obj.getString("ticket") == null)
|
||||
{
|
||||
log.error("获取微信 jsapi_ticket 失败, resp={}", resp);
|
||||
throw new RuntimeException("获取微信 jsapi_ticket 失败: "
|
||||
+ (obj == null ? resp : obj.getString("errmsg")));
|
||||
}
|
||||
ticket = obj.getString("ticket");
|
||||
redisCache.setCacheObject(key, ticket, CACHE_TTL_SECONDS, TimeUnit.SECONDS);
|
||||
return ticket;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成前端 wx.config 所需参数
|
||||
*
|
||||
* @param url 当前页面地址 (必须不含 # 及之后 fragment, 微信签名校验会忽略 fragment)
|
||||
* @return {appId, timestamp, nonceStr, signature}
|
||||
*/
|
||||
public Map<String, Object> buildShareConfig(String url)
|
||||
{
|
||||
String ticket = getJsapiTicket(getAccessToken());
|
||||
long timestamp = System.currentTimeMillis() / 1000;
|
||||
String nonceStr = UUID.randomUUID().toString().replace("-", "").substring(0, 16);
|
||||
// 官方签名算法: key 按字典序 (jsapi_ticket < noncestr < timestamp < url) 拼接后 sha1
|
||||
String string1 = "jsapi_ticket=" + ticket + "&noncestr=" + nonceStr
|
||||
+ "×tamp=" + timestamp + "&url=" + url;
|
||||
String signature = sha1(string1);
|
||||
|
||||
Map<String, Object> ret = new HashMap<>();
|
||||
ret.put("appId", appId());
|
||||
ret.put("timestamp", timestamp);
|
||||
ret.put("nonceStr", nonceStr);
|
||||
ret.put("signature", signature);
|
||||
return ret;
|
||||
}
|
||||
|
||||
/**
|
||||
* SHA-1 十六进制小写摘要
|
||||
*/
|
||||
private static String sha1(String str)
|
||||
{
|
||||
try
|
||||
{
|
||||
MessageDigest digest = MessageDigest.getInstance("SHA-1");
|
||||
byte[] bytes = digest.digest(str.getBytes(StandardCharsets.UTF_8));
|
||||
StringBuilder sb = new StringBuilder();
|
||||
for (byte b : bytes)
|
||||
{
|
||||
String hex = Integer.toHexString(b & 0xFF);
|
||||
if (hex.length() < 2)
|
||||
{
|
||||
sb.append('0');
|
||||
}
|
||||
sb.append(hex);
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
catch (NoSuchAlgorithmException e)
|
||||
{
|
||||
throw new RuntimeException("SHA-1 算法不可用", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
-1
@@ -26,7 +26,7 @@ import com.ruoyi.system.service.ISysUserService;
|
||||
@Service
|
||||
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
|
||||
private ISysUserService sysUserService;
|
||||
@@ -79,6 +79,7 @@ public class BizAdminUserServiceImpl implements IBizAdminUserService {
|
||||
switch (role) {
|
||||
case "admin":
|
||||
case "manager":
|
||||
case "leader": // 项目负责人: 纯 sys_user (姓名落 sys_user.nick_name, 供 lead_user_name 反查兜底)
|
||||
return insertPlain(b, role, encPwd);
|
||||
case "doctor":
|
||||
return insertDoctor(b, encPwd);
|
||||
|
||||
+51
@@ -6,12 +6,17 @@ import org.springframework.stereotype.Service;
|
||||
import com.ruoyi.business.domain.BizExecutionIntent;
|
||||
import com.ruoyi.business.mapper.BizExecutionIntentMapper;
|
||||
import com.ruoyi.business.service.IBizExecutionIntentService;
|
||||
import com.ruoyi.common.core.domain.entity.SysUser;
|
||||
import com.ruoyi.common.utils.SecurityUtils;
|
||||
import com.ruoyi.system.mapper.SysUserMapper;
|
||||
|
||||
@Service
|
||||
public class BizExecutionIntentServiceImpl implements IBizExecutionIntentService
|
||||
{
|
||||
@Autowired
|
||||
private BizExecutionIntentMapper bizExecutionIntentMapper;
|
||||
@Autowired
|
||||
private SysUserMapper sysUserMapper;
|
||||
|
||||
@Override
|
||||
public BizExecutionIntent getById(String intentId)
|
||||
@@ -20,6 +25,9 @@ public class BizExecutionIntentServiceImpl implements IBizExecutionIntentService
|
||||
public List<BizExecutionIntent> selectList(BizExecutionIntent entity)
|
||||
{ return bizExecutionIntentMapper.selectList(entity); }
|
||||
@Override
|
||||
public List<BizExecutionIntent> selectListIncludingDeleted(BizExecutionIntent entity)
|
||||
{ return bizExecutionIntentMapper.selectListIncludingDeleted(entity); }
|
||||
@Override
|
||||
public int insert(BizExecutionIntent entity) { com.ruoyi.common.utils.id.SnowflakeId.injectIfEmpty(entity, "intentId"); return bizExecutionIntentMapper.insert(entity); }
|
||||
@Override
|
||||
public int updateByPrimaryKey(BizExecutionIntent entity)
|
||||
@@ -30,4 +38,47 @@ public class BizExecutionIntentServiceImpl implements IBizExecutionIntentService
|
||||
@Override
|
||||
public int deleteByPrimaryKeys(String[] intentId)
|
||||
{ return bizExecutionIntentMapper.deleteByPrimaryKeys(intentId); }
|
||||
|
||||
@Override
|
||||
public void hardDeletePlaceholder(Long userId, String projectNo)
|
||||
{
|
||||
if (userId == null || projectNo == null || projectNo.trim().isEmpty()) return;
|
||||
bizExecutionIntentMapper.hardDeletePlaceholder(userId, projectNo);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean existsAny(Long userId, String projectNo)
|
||||
{
|
||||
if (userId == null || projectNo == null || projectNo.trim().isEmpty()) return false;
|
||||
return bizExecutionIntentMapper.countByUserAndProject(userId, projectNo) > 0;
|
||||
}
|
||||
|
||||
@Override
|
||||
public BizExecutionIntent signupByProject(String projectNo, String projectName)
|
||||
{
|
||||
if (projectNo == null || projectNo.trim().isEmpty()) return null;
|
||||
Long uid = SecurityUtils.getUserId();
|
||||
BizExecutionIntent q = new BizExecutionIntent();
|
||||
q.setUserId(uid);
|
||||
q.setProjectNo(projectNo);
|
||||
List<BizExecutionIntent> exists = bizExecutionIntentMapper.selectList(q);
|
||||
if (exists != null && !exists.isEmpty()) return null;
|
||||
|
||||
// 真报名: 先物理删掉幽灵占位记录 (del_flag='1'), 避免残留
|
||||
hardDeletePlaceholder(uid, projectNo);
|
||||
|
||||
BizExecutionIntent e = new BizExecutionIntent();
|
||||
e.setUserId(uid);
|
||||
e.setProjectNo(projectNo);
|
||||
e.setProjectName(projectName);
|
||||
SysUser u = sysUserMapper.selectUserById(uid);
|
||||
if (u != null) {
|
||||
e.setName(u.getNickName());
|
||||
e.setPhone(u.getPhonenumber());
|
||||
}
|
||||
e.setOnboardStatus("未入库");
|
||||
e.setCreateBy(SecurityUtils.getUsername());
|
||||
insert(e);
|
||||
return e;
|
||||
}
|
||||
}
|
||||
|
||||
+193
@@ -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;
|
||||
}
|
||||
}
|
||||
+186
@@ -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);
|
||||
}
|
||||
}
|
||||
+479
-227
@@ -8,12 +8,20 @@ import java.math.BigDecimal;
|
||||
import java.math.RoundingMode;
|
||||
import java.nio.charset.Charset;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.ZoneId;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.Date;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.ThreadLocalRandom;
|
||||
import java.util.zip.ZipEntry;
|
||||
import java.util.zip.ZipInputStream;
|
||||
import java.util.zip.ZipOutputStream;
|
||||
@@ -21,25 +29,32 @@ import org.apache.poi.xssf.streaming.SXSSFWorkbook;
|
||||
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.stereotype.Service;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.ruoyi.business.domain.BizExecutionIntent;
|
||||
import com.ruoyi.business.domain.BizExpert;
|
||||
import com.ruoyi.business.domain.BizMeeting;
|
||||
import com.ruoyi.business.domain.BizMeetingAttendee;
|
||||
import com.ruoyi.business.domain.BizProject;
|
||||
import com.ruoyi.business.domain.dto.ImportResult;
|
||||
import com.ruoyi.business.domain.vo.BizMeetingAttendeeImportVo;
|
||||
import com.ruoyi.business.domain.vo.FieldMismatch;
|
||||
import com.ruoyi.business.domain.vo.ImportMismatch;
|
||||
import com.ruoyi.business.mapper.BizExpertMapper;
|
||||
import com.ruoyi.business.mapper.BizMeetingAttendeeMapper;
|
||||
import com.ruoyi.business.mapper.BizMeetingMapper;
|
||||
import com.ruoyi.business.mapper.BizProjectMapper;
|
||||
import com.ruoyi.business.notify.BizNotifyService;
|
||||
import com.ruoyi.business.oss.OssUploader;
|
||||
import com.ruoyi.business.service.IBizExecutionIntentService;
|
||||
import com.ruoyi.business.service.IBizMeetingAttendeeService;
|
||||
import com.ruoyi.business.service.IBizMeetingService;
|
||||
import com.ruoyi.business.service.InvitationPdfService;
|
||||
import com.ruoyi.business.sms.AliyunSmsSender;
|
||||
import com.ruoyi.business.upload.UploadProgressRegistry;
|
||||
import com.ruoyi.common.core.domain.entity.SysUser;
|
||||
import com.ruoyi.common.exception.ServiceException;
|
||||
import com.ruoyi.common.utils.SecurityUtils;
|
||||
@@ -65,6 +80,8 @@ public class BizMeetingAttendeeServiceImpl implements IBizMeetingAttendeeService
|
||||
private ISysUserService sysUserService;
|
||||
@Autowired
|
||||
private BizExpertMapper bizExpertMapper;
|
||||
@Autowired
|
||||
private IBizExecutionIntentService bizExecutionIntentService;
|
||||
|
||||
/** Jackson (Spring Boot 自带), 解析 biz_project.role_labor JSON 数组 [{role, customName, amount}] */
|
||||
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||
@@ -77,6 +94,13 @@ public class BizMeetingAttendeeServiceImpl implements IBizMeetingAttendeeService
|
||||
private IBizMeetingService bizMeetingService;
|
||||
@Autowired
|
||||
private OssUploader ossUploader;
|
||||
@Autowired
|
||||
private InvitationPdfService invitationPdfService;
|
||||
@Autowired
|
||||
private UploadProgressRegistry uploadProgressRegistry;
|
||||
@Autowired
|
||||
@Qualifier("zipUploadExecutor")
|
||||
private ExecutorService zipUploadExecutor;
|
||||
|
||||
@Override
|
||||
public int insert(BizMeetingAttendee entity) {
|
||||
@@ -114,120 +138,145 @@ public class BizMeetingAttendeeServiceImpl implements IBizMeetingAttendeeService
|
||||
*/
|
||||
@Override
|
||||
public Long insertByPhoneWithProfile(BizMeetingAttendee body) {
|
||||
String phone = body.getPhone();
|
||||
if (phone == null || phone.trim().isEmpty()) {
|
||||
throw new ServiceException("手机号不能为空");
|
||||
}
|
||||
phone = phone.trim();
|
||||
String phone = requirePhone(body);
|
||||
Long userId = resolveUserIdByPhone(phone, body.getName());
|
||||
|
||||
// 校验实发金额不超所选角色劳务金额合计 (与手动新增弹窗 MeetingDetail.validateFee 一致;
|
||||
// 无角色/无金额/项目无 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) 是否已存在 → 友好提示
|
||||
// 检查 (meetingId, userId) 是否已存在 → 友好提示
|
||||
// 仅 SELECT, 不 DELETE (deleteByMeetingIdAndUserId 是破坏性的, 不能误用做探测)
|
||||
java.util.Set<Long> existing = new java.util.HashSet<>(mapper.selectUserIdsByMeetingId(body.getMeetingId()));
|
||||
if (existing.contains(userId)) {
|
||||
throw new ServiceException("该手机号参会人已在会议中, 无需重复添加");
|
||||
}
|
||||
|
||||
// 3.5 确保专家档案存在 (不存在则新建, 存在则补齐空字段), 让参会人在专家库可见
|
||||
ensureExpertProfile(body, userId);
|
||||
return insertNewAttendee(body, userId, phone);
|
||||
}
|
||||
|
||||
// 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.setCreateBy(SecurityUtils.getUsername());
|
||||
body.setId(IdGenerator.generateId());
|
||||
mapper.insertWithProfile(body);
|
||||
Long newId = body.getId();
|
||||
log.info("[attendee] 新增参会人 meetingId={} userId={} attendeeId={}", body.getMeetingId(), userId, newId);
|
||||
|
||||
// 同步一条"报名项目"幽灵占位记录 (del_flag='1'): 新增/导入都走这里, 一处覆盖两入口
|
||||
createFakeSignup(body.getMeetingId(), userId, phone, body);
|
||||
return newId;
|
||||
}
|
||||
|
||||
/**
|
||||
* 确保参会人对应的 biz_expert 记录存在且字段尽量完整:
|
||||
* - 不存在 → 新建 (status='Y' 正常, auditStatus='2' 通过, 与 importExpert 约定一致)
|
||||
* - 存在但部分字段为空 → 只用参会人档案里的非空值补齐空字段, 不覆盖已有值
|
||||
* 新增/导入参会人时, 同步给该医生建一条"报名项目"幽灵占位记录 (del_flag='1'):
|
||||
* - 纯占位, 报名列表/导出/报名查重/参会人 has_intent 均按 del_flag='0' 过滤, 不会露出;
|
||||
* - 报名时间随机: 日期 ∈ [项目开始日, 今天], 时间 ∈ [07:00, 21:00);
|
||||
* - 真报名(报名/邀请函)时会先物理删除这条占位, 再写真实报名.
|
||||
* 失败只记日志, 不阻断参会人新增/导入.
|
||||
*/
|
||||
private void ensureExpertProfile(BizMeetingAttendee body, Long userId) {
|
||||
if (userId == null) return;
|
||||
BizExpert existed = bizExpertMapper.selectByUserId(userId);
|
||||
if (existed == null) {
|
||||
BizExpert e = new BizExpert();
|
||||
e.setExpertId(IdGenerator.generateId());
|
||||
private void createFakeSignup(Long meetingId, Long userId, String phone, BizMeetingAttendee body) {
|
||||
try {
|
||||
if (meetingId == null || userId == null) return;
|
||||
BizMeeting meeting = meetingMapper.selectByPrimaryKey(meetingId);
|
||||
if (meeting == null || meeting.getProjectId() == null) return;
|
||||
BizProject project = projectMapper.selectByPrimaryKey(meeting.getProjectId());
|
||||
if (project == null || project.getProjectNo() == null || project.getProjectNo().trim().isEmpty()) return;
|
||||
String projectNo = project.getProjectNo();
|
||||
// 去重: 该 user+project 已有意向(真/假都算)则不再建占位
|
||||
if (bizExecutionIntentService.existsAny(userId, projectNo)) return;
|
||||
|
||||
String name = (body.getName() != null && !body.getName().trim().isEmpty()) ? body.getName().trim() : phone;
|
||||
BizExecutionIntent e = new BizExecutionIntent();
|
||||
e.setUserId(userId);
|
||||
e.setName(isBlank(body.getName()) ? body.getPhone() : body.getName());
|
||||
e.setPhone(body.getPhone());
|
||||
e.setWorkUnit(body.getWorkUnit());
|
||||
e.setDepartment(body.getDepartment());
|
||||
e.setTitle(body.getTitle());
|
||||
e.setIdCard(body.getIdCard());
|
||||
e.setBankCard(body.getBankCard());
|
||||
e.setBankName(body.getBankName());
|
||||
e.setBankBranch(body.getBankBranch());
|
||||
e.setBankRegion(body.getBankRegion());
|
||||
e.setBankAddress(body.getBankAddress());
|
||||
e.setIdCardAttachments(body.getIdCardAttachments());
|
||||
e.setStatus("Y");
|
||||
e.setAuditStatus("2");
|
||||
e.setAuditBy(SecurityUtils.getUsername());
|
||||
e.setAuditTime(new Date());
|
||||
e.setProjectNo(projectNo);
|
||||
e.setProjectName(project.getProjectName());
|
||||
e.setName(name);
|
||||
e.setPhone(phone);
|
||||
e.setOnboardStatus("未入库");
|
||||
e.setDelFlag("1");
|
||||
e.setCreateTime(randomSignupTime(project.getStartTime()));
|
||||
e.setCreateBy(SecurityUtils.getUsername());
|
||||
e.setCreateTime(new Date());
|
||||
bizExpertMapper.insert(e);
|
||||
return;
|
||||
}
|
||||
// 补充: 只填 biz_expert 里为空的字段, 已有值不覆盖
|
||||
BizExpert u = new BizExpert();
|
||||
u.setUserId(userId);
|
||||
boolean changed = false;
|
||||
if (isBlank(existed.getName()) && !isBlank(body.getName())) { u.setName(body.getName()); changed = true; }
|
||||
if (isBlank(existed.getPhone()) && !isBlank(body.getPhone())) { u.setPhone(body.getPhone()); changed = true; }
|
||||
if (isBlank(existed.getWorkUnit()) && !isBlank(body.getWorkUnit())) { u.setWorkUnit(body.getWorkUnit()); changed = true; }
|
||||
if (isBlank(existed.getDepartment()) && !isBlank(body.getDepartment())) { u.setDepartment(body.getDepartment()); changed = true; }
|
||||
if (isBlank(existed.getTitle()) && !isBlank(body.getTitle())) { u.setTitle(body.getTitle()); changed = true; }
|
||||
if (isBlank(existed.getIdCard()) && !isBlank(body.getIdCard())) { u.setIdCard(body.getIdCard()); changed = true; }
|
||||
if (isBlank(existed.getBankCard()) && !isBlank(body.getBankCard())) { u.setBankCard(body.getBankCard()); changed = true; }
|
||||
if (isBlank(existed.getBankName()) && !isBlank(body.getBankName())) { u.setBankName(body.getBankName()); changed = true; }
|
||||
if (isBlank(existed.getBankBranch()) && !isBlank(body.getBankBranch())) { u.setBankBranch(body.getBankBranch()); changed = true; }
|
||||
if (isBlank(existed.getBankRegion()) && !isBlank(body.getBankRegion())) { u.setBankRegion(body.getBankRegion()); changed = true; }
|
||||
if (isBlank(existed.getBankAddress()) && !isBlank(body.getBankAddress())) { u.setBankAddress(body.getBankAddress()); changed = true; }
|
||||
if (isBlank(existed.getIdCardAttachments()) && !isBlank(body.getIdCardAttachments())) { u.setIdCardAttachments(body.getIdCardAttachments()); changed = true; }
|
||||
if (changed) {
|
||||
bizExpertMapper.updateByUserId(u);
|
||||
bizExecutionIntentService.insert(e);
|
||||
log.info("[attendee] 建报名占位记录 userId={} projectNo={}", userId, projectNo);
|
||||
} catch (Exception ex) {
|
||||
log.warn("[attendee] 建报名占位记录失败 meetingId={} userId={}: {}", meetingId, userId, ex.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean isBlank(String s) { return s == null || s.trim().isEmpty(); }
|
||||
/** 报名时间随机: 日期 ∈ [项目开始日, 今天], 时间 ∈ [07:00, 21:00) (分钟粒度) */
|
||||
private Date randomSignupTime(Date projectStart) {
|
||||
LocalDate today = LocalDate.now();
|
||||
LocalDate from = projectStart == null ? today
|
||||
: projectStart.toInstant().atZone(ZoneId.systemDefault()).toLocalDate();
|
||||
if (from.isAfter(today)) from = today;
|
||||
long days = ChronoUnit.DAYS.between(from, today) + 1;
|
||||
LocalDate day = from.plusDays(ThreadLocalRandom.current().nextLong(days));
|
||||
int minute = ThreadLocalRandom.current().nextInt(7 * 60, 21 * 60); // 420..1259
|
||||
LocalDateTime dt = day.atTime(minute / 60, minute % 60);
|
||||
return Date.from(dt.atZone(ZoneId.systemDefault()).toInstant());
|
||||
}
|
||||
|
||||
@Override
|
||||
public int updateProfile(BizMeetingAttendee entity) {
|
||||
@@ -236,8 +285,6 @@ public class BizMeetingAttendeeServiceImpl implements IBizMeetingAttendeeService
|
||||
BizMeetingAttendee old = mapper.selectById(entity.getId());
|
||||
if (old != null) meetingId = old.getMeetingId();
|
||||
}
|
||||
// 校验实发金额不超所选角色劳务金额合计 (与手动新增弹窗 MeetingDetail.validateFee 一致)
|
||||
validateFeeAgainstRole(loadRoleLabor(meetingId), entity.getLaborForm(), entity.getFee());
|
||||
return mapper.updateProfile(entity);
|
||||
}
|
||||
|
||||
@@ -377,24 +424,6 @@ public class BizMeetingAttendeeServiceImpl implements IBizMeetingAttendeeService
|
||||
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}] 里按单个角色名匹配劳务金额.
|
||||
* 匹配规则与前端 ProjectRoleSelect 一致: role === '其他' 时用 customName 作 label, 否则用 role.
|
||||
@@ -434,46 +463,13 @@ public class BizMeetingAttendeeServiceImpl implements IBizMeetingAttendeeService
|
||||
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).
|
||||
*
|
||||
* <p>复用 {@link #insertByPhoneWithProfile} 做单行处理, 让"按手机号查/建 sys_user + 写 attendee"
|
||||
* 逻辑和单条新增一致. 单行失败只计入 ngList, 不中断其它行.
|
||||
* <p>复用 {@link #upsertByPhoneWithProfile} 做单行处理: 手机号已在该会议 → 覆盖更新已有行,
|
||||
* 否则按手机号查/建 sys_user + 写 attendee. 单行失败只计入 ngList, 不中断其它行.
|
||||
*
|
||||
* <p>注意: 本方法只插数据, 不发 #5 通知. controller 在 import 前快照 userIds, import 后 diff,
|
||||
* <p>注意: 本方法只落库(新增或覆盖更新), 不发 #5 通知. controller 在 import 前快照 userIds, import 后 diff,
|
||||
* 只对"新加入"的 userId 调 notify, 避免给已存在的参会人重发邀请.
|
||||
*/
|
||||
@Override
|
||||
@@ -515,6 +511,19 @@ public class BizMeetingAttendeeServiceImpl implements IBizMeetingAttendeeService
|
||||
result.fail(rowNo, "手机号格式不正确: " + phone);
|
||||
continue;
|
||||
}
|
||||
// 身份证号位数校验 (18位, 末位可为 X/x); 空值跳过 (导入不强制必填, 与前端 dialog 的必填规则分开)
|
||||
String idCard = vo.getIdCard() == null ? null : vo.getIdCard().trim();
|
||||
if (idCard != null && !idCard.isEmpty() && !idCard.matches("^\\d{17}[\\dXx]$")) {
|
||||
result.fail(rowNo, "身份证号应为18位(末位可为X): " + idCard);
|
||||
continue;
|
||||
}
|
||||
// 角色(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)
|
||||
BizMeetingAttendee body = new BizMeetingAttendee();
|
||||
@@ -524,7 +533,7 @@ public class BizMeetingAttendeeServiceImpl implements IBizMeetingAttendeeService
|
||||
body.setWorkUnit(vo.getWorkUnit());
|
||||
body.setDepartment(vo.getDepartment());
|
||||
body.setTitle(vo.getTitle());
|
||||
body.setIdCard(vo.getIdCard());
|
||||
body.setIdCard(idCard);
|
||||
body.setBankName(vo.getBankName());
|
||||
body.setBankCard(vo.getBankCard());
|
||||
body.setBankBranch(vo.getBankBranch());
|
||||
@@ -532,7 +541,7 @@ public class BizMeetingAttendeeServiceImpl implements IBizMeetingAttendeeService
|
||||
body.setBankRegion(vo.getBankRegion());
|
||||
body.setBankAddress(vo.getBankAddress());
|
||||
body.setIdCardAttachments(vo.getIdCardAttachments());
|
||||
body.setLaborForm(vo.getLaborForm());
|
||||
body.setLaborForm(laborForm);
|
||||
// 金额联动补算 (照搬 hwt importLaborData): 已有值优先, 空白才按链补算, 避免覆盖人工填写
|
||||
BigDecimal fee = vo.getFee();
|
||||
BigDecimal tax = vo.getTax();
|
||||
@@ -567,7 +576,7 @@ public class BizMeetingAttendeeServiceImpl implements IBizMeetingAttendeeService
|
||||
body.setFee(fee);
|
||||
body.setSummary(vo.getSummary());
|
||||
|
||||
insertByPhoneWithProfile(body); // 失败抛 ServiceException, 被 catch
|
||||
upsertByPhoneWithProfile(body); // 重复手机号 → 覆盖更新; 其它失败抛 ServiceException, 被 catch
|
||||
result.ok();
|
||||
} catch (Exception e) {
|
||||
String msg = e.getMessage();
|
||||
@@ -579,6 +588,111 @@ public class BizMeetingAttendeeServiceImpl implements IBizMeetingAttendeeService
|
||||
return result;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<FieldMismatch> diffAgainstExpert(BizMeetingAttendee body) {
|
||||
Long userId = null;
|
||||
if (body.getId() != null) {
|
||||
BizMeetingAttendee existing = mapper.selectById(body.getId());
|
||||
if (existing != null) userId = existing.getUserId();
|
||||
} else if (body.getPhone() != null && !body.getPhone().trim().isEmpty()) {
|
||||
String phone = body.getPhone().trim();
|
||||
List<SysUser> hits = sysUserMapper.selectByPhoneList(Collections.singletonList(phone));
|
||||
if (hits != null && !hits.isEmpty()) userId = hits.get(0).getUserId();
|
||||
}
|
||||
if (userId == null) return Collections.emptyList();
|
||||
BizExpert expert = bizExpertMapper.selectByUserId(userId);
|
||||
if (expert == null) return Collections.emptyList();
|
||||
return compareExpertProfile(expert, body);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Map<String, Object> previewImport(MultipartFile file, Long meetingId) throws Exception {
|
||||
if (meetingId == null) throw new ServiceException("meetingId 不能为空");
|
||||
|
||||
ExcelUtil<BizMeetingAttendeeImportVo> util = new ExcelUtil<>(BizMeetingAttendeeImportVo.class);
|
||||
List<BizMeetingAttendeeImportVo> rows = util.importExcel(file.getInputStream());
|
||||
if (rows == null || rows.isEmpty()) {
|
||||
throw new ServiceException("导入数据不能为空");
|
||||
}
|
||||
|
||||
List<ImportMismatch> mismatches = new ArrayList<>();
|
||||
for (int i = 0; i < rows.size(); i++) {
|
||||
BizMeetingAttendeeImportVo vo = rows.get(i);
|
||||
int rowNo = i + 2; // Excel 行号: 1=表头, 2=第一行数据
|
||||
String phone = vo.getPhone() == null ? null : vo.getPhone().trim();
|
||||
// 手机号非法/缺失的行走真导入时会被拦截, dry-run 不参与比对
|
||||
if (phone == null || phone.isEmpty() || !phone.matches("^1\\d{10}$")) continue;
|
||||
List<SysUser> hits = sysUserMapper.selectByPhoneList(Collections.singletonList(phone));
|
||||
Long userId = (hits != null && !hits.isEmpty()) ? hits.get(0).getUserId() : null;
|
||||
if (userId == null) continue;
|
||||
BizExpert expert = bizExpertMapper.selectByUserId(userId);
|
||||
if (expert == null) continue;
|
||||
|
||||
BizMeetingAttendee body = new BizMeetingAttendee();
|
||||
body.setPhone(phone);
|
||||
body.setName(vo.getName());
|
||||
body.setWorkUnit(vo.getWorkUnit());
|
||||
body.setDepartment(vo.getDepartment());
|
||||
body.setTitle(vo.getTitle());
|
||||
body.setIdCard(vo.getIdCard());
|
||||
body.setBankName(vo.getBankName());
|
||||
body.setBankCard(vo.getBankCard());
|
||||
body.setBankBranch(vo.getBankBranch());
|
||||
body.setBankRegion(vo.getBankRegion());
|
||||
body.setBankAddress(vo.getBankAddress());
|
||||
body.setIdCardAttachments(vo.getIdCardAttachments());
|
||||
|
||||
List<FieldMismatch> fields = compareExpertProfile(expert, body);
|
||||
if (!fields.isEmpty()) {
|
||||
ImportMismatch im = new ImportMismatch();
|
||||
im.setRowNo(rowNo);
|
||||
im.setPhone(phone);
|
||||
im.setName(vo.getName());
|
||||
im.setFields(fields);
|
||||
mismatches.add(im);
|
||||
}
|
||||
}
|
||||
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
result.put("total", rows.size());
|
||||
result.put("mismatches", mismatches);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 比对参会人表单与专家表档案的公共字段 (两表都有的文本档案字段, 手机号是定位键不比对,
|
||||
* 身份证附件是 OSS 文件 URL 不比对). 规则: 专家表已有值 (非空) 且与输入不一致 (含输入为空)
|
||||
* → 记一条 mismatch; 专家表为空不算冲突.
|
||||
*/
|
||||
private List<FieldMismatch> compareExpertProfile(BizExpert expert, BizMeetingAttendee form) {
|
||||
List<FieldMismatch> out = new ArrayList<>();
|
||||
addMismatch(out, "name", "姓名", expert.getName(), form.getName());
|
||||
addMismatch(out, "workUnit", "医院", expert.getWorkUnit(), form.getWorkUnit());
|
||||
addMismatch(out, "department", "科室", expert.getDepartment(), form.getDepartment());
|
||||
addMismatch(out, "title", "职称", expert.getTitle(), form.getTitle());
|
||||
addMismatch(out, "idCard", "身份证号", expert.getIdCard(), form.getIdCard());
|
||||
addMismatch(out, "bankCard", "银行卡号", expert.getBankCard(), form.getBankCard());
|
||||
addMismatch(out, "bankName", "银行名称", expert.getBankName(), form.getBankName());
|
||||
addMismatch(out, "bankBranch", "开户行支行", expert.getBankBranch(), form.getBankBranch());
|
||||
addMismatch(out, "bankRegion", "开户行地址", expert.getBankRegion(), form.getBankRegion());
|
||||
addMismatch(out, "bankAddress", "银行详细地址", expert.getBankAddress(), form.getBankAddress());
|
||||
// 身份证附件(idCardAttachments)是 OSS 文件 URL, 重新上传即变, 逐字符比对无意义 → 不参与比对
|
||||
return out;
|
||||
}
|
||||
|
||||
private void addMismatch(List<FieldMismatch> out, String field, String label, String expertValue, String inputValue) {
|
||||
String ev = expertValue == null ? "" : expertValue.trim();
|
||||
if (ev.isEmpty()) return; // 专家表为空 → 无"现有数据"可比, 不算冲突
|
||||
String iv = inputValue == null ? "" : inputValue.trim();
|
||||
if (ev.equals(iv)) return;
|
||||
FieldMismatch m = new FieldMismatch();
|
||||
m.setField(field);
|
||||
m.setLabel(label);
|
||||
m.setExpertValue(expertValue);
|
||||
m.setInputValue(inputValue == null ? "" : inputValue);
|
||||
out.add(m);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int pushEsign(List<Long> attendeeIds) {
|
||||
if (attendeeIds == null || attendeeIds.isEmpty()) return 0;
|
||||
@@ -625,6 +739,7 @@ public class BizMeetingAttendeeServiceImpl implements IBizMeetingAttendeeService
|
||||
if (attendeeIds == null || attendeeIds.isEmpty()) return 0;
|
||||
int sent = 0;
|
||||
Map<Long, BizMeeting> meetingCache = new HashMap<>();
|
||||
Map<Long, byte[]> templateCache = new HashMap<>();
|
||||
for (Long id : attendeeIds) {
|
||||
if (id == null) continue;
|
||||
try {
|
||||
@@ -642,8 +757,23 @@ public class BizMeetingAttendeeServiceImpl implements IBizMeetingAttendeeService
|
||||
String meetingName = m != null ? m.getMeetingName() : null;
|
||||
Date startTime = m != null ? m.getStartTime() : null;
|
||||
|
||||
// 生成个性化邀请函 (替换姓名 + 盖章 + 上传): 模板按会议只下载一次; 失败不阻断站内信
|
||||
try {
|
||||
if (m != null && m.getInvitationUrl() != null && !m.getInvitationUrl().trim().isEmpty()) {
|
||||
byte[] template = templateCache.get(mid);
|
||||
if (template == null) {
|
||||
template = invitationPdfService.downloadTemplate(m.getInvitationUrl().trim());
|
||||
templateCache.put(mid, template);
|
||||
}
|
||||
String url = invitationPdfService.generateAndUpload(template, a.getName());
|
||||
mapper.updateInvitationUrl(a.getId(), url);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("[attendee] 生成个性化邀请函失败 attendeeId={}: {}", id, e.getMessage());
|
||||
}
|
||||
|
||||
// 推"会议邀请"站内信 (不发短信) + 置 is_invited=1
|
||||
bizNotifyService.meetingInvitation(a.getUserId(), mid, meetingName, startTime);
|
||||
bizNotifyService.meetingInvitation(a.getUserId(), a.getId(), mid, meetingName, startTime);
|
||||
mapper.markInvitedById(id);
|
||||
sent++;
|
||||
} catch (Exception e) {
|
||||
@@ -722,107 +852,157 @@ public class BizMeetingAttendeeServiceImpl implements IBizMeetingAttendeeService
|
||||
}
|
||||
|
||||
@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 (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);
|
||||
if (list.isEmpty()) throw new ServiceException("该会议暂无参会人, 无法回填劳务协议");
|
||||
|
||||
byte[] zipBytes = file.getBytes();
|
||||
|
||||
// 目录序号 (下划线前数字, 0-based) → 该目录下文件字节 + 文件名
|
||||
Map<Integer, List<byte[]>> folderBytes = new HashMap<>();
|
||||
Map<Integer, List<String>> folderNames = new HashMap<>();
|
||||
try {
|
||||
readAttendeeZip(zipBytes, StandardCharsets.UTF_8, folderBytes, folderNames);
|
||||
} catch (IllegalArgumentException e) {
|
||||
// 中文 Windows 打包器 (WinRAR/资源管理器"发送到压缩文件夹") 用 GBK 编码条目名且不置 UTF-8 标志,
|
||||
// 强制 UTF-8 解码会抛 malformed input → 回退 GBK 重新解析
|
||||
log.warn("[attendee] 协议 zip UTF-8 解析失败, 回退 GBK: {}", e.getMessage());
|
||||
folderBytes.clear();
|
||||
folderNames.clear();
|
||||
readAttendeeZip(zipBytes, Charset.forName("GBK"), folderBytes, folderNames);
|
||||
}
|
||||
LinkedHashMap<String, ZipFolder> folders = parseAttendeeZip(zipBytes);
|
||||
uploadProgressRegistry.setTotal(jobId, folders.size());
|
||||
|
||||
int updated = 0;
|
||||
for (Map.Entry<Integer, List<byte[]>> e : folderBytes.entrySet()) {
|
||||
int idx = e.getKey();
|
||||
if (idx < 0 || idx >= list.size()) {
|
||||
log.warn("[attendee] 协议 zip 目录序号 {} 越界 (参会人共 {}), 跳过", idx + 1, list.size());
|
||||
int processed = 0;
|
||||
List<Map<String, Object>> results = new ArrayList<>();
|
||||
for (ZipFolder f : folders.values()) {
|
||||
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;
|
||||
}
|
||||
BizMeetingAttendee a = list.get(idx);
|
||||
List<String> names = folderNames.get(idx);
|
||||
String fileName = (names != null && !names.isEmpty()) ? names.get(0) : "labor-protocol.pdf";
|
||||
// labor_protocol 是单 URL, 每目录只取第一个文件作为协议
|
||||
String url = ossUploader.upload(e.getValue().get(0), fileName, "ry8080/meeting/" + meetingId + "/labor-protocol");
|
||||
String fileName = (!f.fileNames.isEmpty()) ? f.fileNames.get(0) : "labor-protocol.pdf";
|
||||
String url = ossUploader.upload(f.files.get(0), fileName, "ry8080/meeting/" + meetingId + "/labor-protocol");
|
||||
|
||||
BizMeetingAttendee entity = new BizMeetingAttendee();
|
||||
entity.setId(a.getId());
|
||||
entity.setId(m.attendee.getId());
|
||||
entity.setLaborProtocol(url);
|
||||
entity.setUpdateBy(SecurityUtils.getUsername());
|
||||
entity.setUpdateBy(username);
|
||||
updateLaborProtocol(entity);
|
||||
updated++;
|
||||
|
||||
r.put("matched", true);
|
||||
r.put("attendeeName", m.attendee.getName());
|
||||
results.add(r);
|
||||
uploadProgressRegistry.update(jobId, processed);
|
||||
}
|
||||
log.info("[attendee] 劳务协议回填完成 meetingId={} 共{}个", meetingId, updated);
|
||||
return updated;
|
||||
log.info("[attendee] 劳务协议回填完成 meetingId={} 共{}个 (跳过{}个)", meetingId, updated, folders.size() - updated);
|
||||
return buildZipResult(updated, list.size(), results);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int uploadExpertPhotos(MultipartFile file, Long meetingId) throws Exception {
|
||||
public String submitExpertPhotoUpload(MultipartFile file, Long meetingId) throws Exception {
|
||||
if (meetingId == null) throw new ServiceException("meetingId 不能为空");
|
||||
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);
|
||||
if (list.isEmpty()) throw new ServiceException("该会议暂无参会人, 无法回填专家照片");
|
||||
|
||||
byte[] zipBytes = file.getBytes();
|
||||
Map<Integer, List<byte[]>> folderBytes = new HashMap<>();
|
||||
Map<Integer, List<String>> folderNames = new HashMap<>();
|
||||
try {
|
||||
readAttendeeZip(zipBytes, StandardCharsets.UTF_8, folderBytes, folderNames);
|
||||
} catch (IllegalArgumentException e) {
|
||||
log.warn("[attendee] 专家照片 zip UTF-8 解析失败, 回退 GBK: {}", e.getMessage());
|
||||
folderBytes.clear();
|
||||
folderNames.clear();
|
||||
readAttendeeZip(zipBytes, Charset.forName("GBK"), folderBytes, folderNames);
|
||||
}
|
||||
LinkedHashMap<String, ZipFolder> folders = parseAttendeeZip(zipBytes);
|
||||
uploadProgressRegistry.setTotal(jobId, folders.size());
|
||||
|
||||
int updated = 0;
|
||||
for (Map.Entry<Integer, List<byte[]>> e : folderBytes.entrySet()) {
|
||||
int idx = e.getKey();
|
||||
if (idx < 0 || idx >= list.size()) {
|
||||
log.warn("[attendee] 专家照片 zip 目录序号 {} 越界 (参会人共 {}), 跳过", idx + 1, list.size());
|
||||
int processed = 0;
|
||||
List<Map<String, Object>> results = new ArrayList<>();
|
||||
for (ZipFolder f : folders.values()) {
|
||||
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;
|
||||
}
|
||||
BizMeetingAttendee a = list.get(idx);
|
||||
List<byte[]> files = e.getValue();
|
||||
List<String> names = folderNames.get(idx);
|
||||
// 专家照片可多张: 目录内所有文件都上传, 逗号拼接覆盖式回填 on_site_photos
|
||||
StringBuilder sb = new StringBuilder();
|
||||
for (int i = 0; i < files.size(); i++) {
|
||||
String fileName = (names != null && i < names.size()) ? names.get(i) : ("photo-" + (i + 1) + ".jpg");
|
||||
String url = ossUploader.upload(files.get(i), fileName, "ry8080/meeting/" + meetingId + "/expert-photo");
|
||||
for (int i = 0; i < f.files.size(); i++) {
|
||||
String fileName = (i < f.fileNames.size()) ? f.fileNames.get(i) : ("photo-" + (i + 1) + ".jpg");
|
||||
String url = ossUploader.upload(f.files.get(i), fileName, "ry8080/meeting/" + meetingId + "/expert-photo");
|
||||
if (sb.length() > 0) sb.append(",");
|
||||
sb.append(url);
|
||||
}
|
||||
|
||||
BizMeetingAttendee entity = new BizMeetingAttendee();
|
||||
entity.setId(a.getId());
|
||||
entity.setId(m.attendee.getId());
|
||||
entity.setOnSitePhotos(sb.toString());
|
||||
entity.setUpdateBy(SecurityUtils.getUsername());
|
||||
entity.setUpdateBy(username);
|
||||
updateProfile(entity);
|
||||
updated++;
|
||||
|
||||
r.put("matched", true);
|
||||
r.put("attendeeName", m.attendee.getName());
|
||||
results.add(r);
|
||||
uploadProgressRegistry.update(jobId, processed);
|
||||
}
|
||||
log.info("[attendee] 专家照片回填完成 meetingId={} 共{}个", meetingId, updated);
|
||||
return updated;
|
||||
log.info("[attendee] 专家照片回填完成 meetingId={} 共{}个 (跳过{}个)", meetingId, updated, folders.size() - updated);
|
||||
return buildZipResult(updated, list.size(), results);
|
||||
}
|
||||
|
||||
/** 按指定字符集解压 zip, 把 {根目录}/{序号}_姓名/ 目录下的文件归集到对应参会人序号 */
|
||||
private void readAttendeeZip(byte[] zipBytes, Charset charset,
|
||||
Map<Integer, List<byte[]>> folderBytes,
|
||||
Map<Integer, List<String>> folderNames) throws IOException {
|
||||
/** 解压 zip, 归集每个 {序号}_姓名 目录下的文件; UTF-8 解析失败回退 GBK (中文 Windows 打包器). */
|
||||
private LinkedHashMap<String, ZipFolder> parseAttendeeZip(byte[] zipBytes) throws IOException {
|
||||
try {
|
||||
return readAttendeeZip(zipBytes, StandardCharsets.UTF_8);
|
||||
} catch (IllegalArgumentException e) {
|
||||
log.warn("[attendee] zip UTF-8 解析失败, 回退 GBK: {}", e.getMessage());
|
||||
return readAttendeeZip(zipBytes, Charset.forName("GBK"));
|
||||
}
|
||||
}
|
||||
|
||||
/** 按指定字符集解压 zip, 把 {根目录}/{序号}_姓名/ 目录下的文件归集到对应目录 */
|
||||
private LinkedHashMap<String, ZipFolder> readAttendeeZip(byte[] zipBytes, Charset charset) throws IOException {
|
||||
LinkedHashMap<String, ZipFolder> folders = new LinkedHashMap<>();
|
||||
try (ZipInputStream zis = new ZipInputStream(new ByteArrayInputStream(zipBytes), charset)) {
|
||||
ZipEntry entry;
|
||||
while ((entry = zis.getNextEntry()) != null) {
|
||||
@@ -830,31 +1010,103 @@ public class BizMeetingAttendeeServiceImpl implements IBizMeetingAttendeeService
|
||||
String path = entry.getName();
|
||||
// 跳过 macOS 打包产生的元数据
|
||||
if (path.contains("__MACOSX") || path.endsWith(".DS_Store")) continue;
|
||||
Integer idx = parseAgreementIndex(path);
|
||||
if (idx == null) continue; // 不落在 {序号}_姓名 目录下, 忽略
|
||||
byte[] data = readAllBytes(zis);
|
||||
folderBytes.computeIfAbsent(idx, k -> new ArrayList<>()).add(data);
|
||||
folderNames.computeIfAbsent(idx, k -> new ArrayList<>()).add(lastSegment(path));
|
||||
String[] seg = parseAgreementDir(path);
|
||||
if (seg == null) continue; // 文件不在 {序号}_姓名 目录下, 忽略
|
||||
int seq;
|
||||
try {
|
||||
seq = Integer.parseInt(seg[0]);
|
||||
} catch (NumberFormatException ex) {
|
||||
continue; // 数字前缀溢出, 视为非法目录
|
||||
}
|
||||
String dir = seg[0] + "_" + seg[1];
|
||||
ZipFolder f = folders.get(dir);
|
||||
if (f == null) {
|
||||
f = new ZipFolder(dir, seq, seg[1]);
|
||||
folders.put(dir, f);
|
||||
}
|
||||
f.files.add(readAllBytes(zis));
|
||||
f.fileNames.add(lastSegment(path));
|
||||
}
|
||||
}
|
||||
return folders;
|
||||
}
|
||||
|
||||
/** 从 zip 条目路径里解析出 {序号}_姓名 目录的 0-based 序号; 不匹配返回 null */
|
||||
private static Integer parseAgreementIndex(String path) {
|
||||
/** 从 zip 条目路径里定位参会人 → [序号数字, 姓名段]; 无法定位返回 null.
|
||||
* 兼容多种形态:
|
||||
* - 目录: {序号}_姓名/ 作为路径中任意一层目录 (套 1~N 层均可), 且文件可在该目录下的子目录里 → 取最靠近文件的匹配段
|
||||
* - 单文件: 无目录时, 文件名本身为 {序号}_姓名[.扩展名] (如 1_张三.pdf) → 姓名剥掉最后一个扩展名
|
||||
* 优先目录段, 找不到再认文件名. */
|
||||
private static String[] parseAgreementDir(String path) {
|
||||
if (path == null || path.isEmpty()) return null;
|
||||
for (String seg : path.split("/")) {
|
||||
if (seg.isEmpty()) continue;
|
||||
int underscore = seg.indexOf('_');
|
||||
if (underscore <= 0) continue;
|
||||
String numPart = seg.substring(0, underscore);
|
||||
if (!numPart.matches("\\d+")) continue;
|
||||
try {
|
||||
return Integer.parseInt(numPart) - 1;
|
||||
} catch (NumberFormatException ex) {
|
||||
return null;
|
||||
}
|
||||
String[] segs = path.split("/");
|
||||
if (segs.length == 0) return null;
|
||||
|
||||
// 1) 目录段优先: 从倒数第 2 段向根方向扫, 取最深匹配 {序号}_姓名 的目录段
|
||||
for (int i = segs.length - 2; i >= 0; i--) {
|
||||
String[] t = attendeeTokenOf(segs[i], false);
|
||||
if (t != null) return t;
|
||||
}
|
||||
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) {
|
||||
|
||||
+701
-15
@@ -20,42 +20,59 @@ import java.io.InputStream;
|
||||
import java.nio.charset.Charset;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Collections;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.zip.ZipEntry;
|
||||
import java.util.zip.ZipInputStream;
|
||||
import java.util.zip.ZipOutputStream;
|
||||
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.context.annotation.Lazy;
|
||||
import org.springframework.dao.DuplicateKeyException;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
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.utils.SecurityUtils;
|
||||
import com.ruoyi.business.domain.BizMeeting;
|
||||
import com.ruoyi.business.domain.BizMeetingAttendee;
|
||||
import com.ruoyi.business.domain.BizMeetingMaterial;
|
||||
import com.ruoyi.business.mapper.BizMeetingMapper;
|
||||
import com.ruoyi.business.mapper.BizMeetingInvoiceMapper;
|
||||
import com.ruoyi.business.mapper.BizMeetingMaterialMapper;
|
||||
import com.ruoyi.business.oss.OssUploader;
|
||||
import com.ruoyi.business.oss.OssZipService;
|
||||
import com.ruoyi.business.service.IBizMeetingAttendeeService;
|
||||
import com.ruoyi.business.service.IBizMeetingMaterialService;
|
||||
import com.ruoyi.business.service.IBizMeetingService;
|
||||
import com.ruoyi.business.upload.UploadProgressRegistry;
|
||||
|
||||
@Service
|
||||
public class BizMeetingMaterialServiceImpl implements IBizMeetingMaterialService {
|
||||
|
||||
/** 非发票 subType (与前端 MeetingDetail.NON_OCR_SUBTYPES 对齐): 现场照片/签到表等不 OCR, 避免误识别脏 amount */
|
||||
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 (公开端点只允许这三类照片回传) */
|
||||
private static final Set<String> CAMERA_SUBTYPES = new HashSet<>(Arrays.asList(
|
||||
"L_SIGN_IN", "L_PANORAMA_FRONT", "L_PANORAMA_BACK"));
|
||||
|
||||
/** 可识别文件扩展名 (与前端 isRecognizable 对齐): 图片/PDF 才触发 OCR */
|
||||
private static final Pattern RECOGNIZABLE = Pattern.compile("\\.(jpe?g|png|pdf)$", Pattern.CASE_INSENSITIVE);
|
||||
/** 可识别文件扩展名 (与前端 isRecognizable 对齐): 图片/PDF/zip 才触发 OCR (zip 走解压分析发票) */
|
||||
private static final Pattern RECOGNIZABLE = Pattern.compile("\\.(jpe?g|png|pdf|zip)$", Pattern.CASE_INSENSITIVE);
|
||||
|
||||
/** 会务下载范围: 会务材料 (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_OTHER", "其他");
|
||||
SERVICE_SUBTYPE_LABEL.put("M_SETTLEMENT", "总结算单");
|
||||
SERVICE_SUBTYPE_LABEL.put("M_SETTLEMENT_STAMP", "总结算单(盖章)");
|
||||
SERVICE_SUBTYPE_LABEL.put("M_INVOICE", "总发票");
|
||||
SERVICE_SUBTYPE_LABEL.put("SV_PAYMENT", "会务付款凭证");
|
||||
}
|
||||
@@ -78,7 +96,7 @@ public class BizMeetingMaterialServiceImpl implements IBizMeetingMaterialService
|
||||
/** 会务材料 (SERVICE) 子类顺序 (与前端 ROW_CONFIG SERVICE 行对齐), 用于"打包上传"下载空目录模板 + 回填匹配 */
|
||||
private static final List<String> SERVICE_MATERIAL_SUBTYPES = Arrays.asList(
|
||||
"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 按目录名匹配 */
|
||||
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_AGREEMENT", "劳务协议");
|
||||
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_PANORAMA_FRONT", "前全景");
|
||||
LABOR_SUBTYPE_LABEL.put("L_PANORAMA_BACK", "后全景");
|
||||
@@ -114,6 +133,8 @@ public class BizMeetingMaterialServiceImpl implements IBizMeetingMaterialService
|
||||
@Autowired
|
||||
private BizMeetingMapper bizMeetingMapper;
|
||||
@Autowired
|
||||
private BizMeetingInvoiceMapper bizMeetingInvoiceMapper;
|
||||
@Autowired
|
||||
private OssZipService ossZipService;
|
||||
@Autowired
|
||||
private IBizMeetingAttendeeService attendeeService;
|
||||
@@ -122,6 +143,14 @@ public class BizMeetingMaterialServiceImpl implements IBizMeetingMaterialService
|
||||
@Autowired
|
||||
@Lazy
|
||||
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);
|
||||
|
||||
@@ -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);
|
||||
if (list == null || list.isEmpty()) {
|
||||
return list;
|
||||
}
|
||||
// 不接受前端传的 id, 走 AUTO_INCREMENT; meetingId 兜底由路径提供
|
||||
// 未变材料的旧 id (subType -> 旧 material id), 全删全插后迁移其发票 material_id 到新 id
|
||||
Map<String, Long> unchangedOldId = new HashMap<>();
|
||||
for (BizMeetingMaterial m : list) {
|
||||
m.setId(null);
|
||||
m.setMeetingId(meetingId);
|
||||
BizMeetingMaterial old = oldBySubType.get(m.getSubType());
|
||||
boolean unchanged = old != null && Objects.equals(old.getOssUrl(), m.getOssUrl());
|
||||
if (unchanged) {
|
||||
// 未变: 回填旧金额, 已计算; 同时保留脱敏版 URL (签到表高斯模糊版, 前端全删全插不传 extraOssUrl)
|
||||
// 未变: 回填旧金额, 已计算; 同时保留脱敏版 URL (签到表高斯模糊版) 与电子签到表解析结果 (前端全删全插不传)
|
||||
m.setAmount(old.getAmount());
|
||||
m.setFeeStatus(1);
|
||||
m.setExtraOssUrl(old.getExtraOssUrl());
|
||||
m.setMaskedJson(old.getMaskedJson());
|
||||
unchangedOldId.put(m.getSubType(), old.getId());
|
||||
} else {
|
||||
// 新增/替换: 金额清零, 会 OCR 的发票标记待计算
|
||||
m.setAmount(null);
|
||||
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 {
|
||||
@@ -199,6 +251,11 @@ public class BizMeetingMaterialServiceImpl implements IBizMeetingMaterialService
|
||||
for (BizMeetingMaterial m : list) {
|
||||
if (m.getSubType() != null) {
|
||||
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;
|
||||
@@ -232,6 +289,7 @@ public class BizMeetingMaterialServiceImpl implements IBizMeetingMaterialService
|
||||
subs.addAll(newUrl.keySet());
|
||||
subs.remove("M_INVOICE");
|
||||
subs.remove("M_SETTLEMENT");
|
||||
subs.remove("M_SETTLEMENT_STAMP");
|
||||
for (String sub : subs) {
|
||||
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 单行.
|
||||
* 公开端点 (匿名) — 白名单 subType + 会议存在校验兜底.
|
||||
* 照片类 NON_OCR, 不触发 OCR, 不影响会议费用, 故不触发费用重算.
|
||||
* extraOssUrl: 签到表(L_SIGN_IN)拍照时额外生成的高斯模糊版 URL, sponsor 只看这个; 其他 subType 传空.
|
||||
* 签到表 (L_SIGN_IN) 连拍多张: ossUrl/extraOssUrl 存 JSON 数组字符串; 前后全景单张取第一个.
|
||||
* extraOssUrl: 签到表拍照时额外生成的高斯模糊版 URL, sponsor 只看这个; 其他 subType 传空.
|
||||
*/
|
||||
@Override
|
||||
@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 (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);
|
||||
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 唯一)
|
||||
BizMeetingMaterial row = null;
|
||||
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.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());
|
||||
if (srcKey == null || srcKey.isEmpty()) continue;
|
||||
String folder = laborFolderName(m.getSubType());
|
||||
@@ -500,10 +574,106 @@ public class BizMeetingMaterialServiceImpl implements IBizMeetingMaterialService
|
||||
byte[] excel = attendeeService.buildAttendeeInfoExcel(attendees);
|
||||
ossZipService.putObject(prefix + folderName + "/参会人信息/参会人信息.xlsx", excel, XLSX_CONTENT_TYPE);
|
||||
copied++;
|
||||
// 补参会人照片/身份证照/劳务协议 (存 attendee 表, 材料表扫不到, 单独 copy 进劳务 zip)
|
||||
copied += stageAttendeeFiles(attendees, prefix, folderName);
|
||||
}
|
||||
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 = 无日程海报) */
|
||||
private int stageSchedulePoster(BizMeeting meeting, String prefix, String folderName)
|
||||
{
|
||||
@@ -637,18 +807,35 @@ public class BizMeetingMaterialServiceImpl implements IBizMeetingMaterialService
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传"会务材料" zip, 解压后按 会务材料/{材料类型中文名}/ 目录匹配 subType,
|
||||
* 单文件直接上传 OSS, 多文件先打 zip 再上传 OSS, 按 (meetingId, SERVICE, subType) upsert 回填.
|
||||
* 替换文件会清空 amount; 单文件可识别 (jpg/png/pdf) 或多文件打 zip → 直接后台触发 OCR 回写金额.
|
||||
* 提交"会务材料" zip 上传任务 (异步): 请求线程读字节 + 登记进度后立即返回 jobId,
|
||||
* 后台线程解压按 会务材料/{材料类型中文名}/ 目录匹配 subType 并 upsert 回填 (单文件直传 OSS /
|
||||
* 多文件先打 zip; 内容未变且已计算过则跳过, 否则替换文件清空 amount 并后台触发 OCR),
|
||||
* 前端轮询 /business/upload/progress/{jobId} 拿进度与结果 (result = 回填的材料类数).
|
||||
*/
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public int uploadServiceMaterials(MultipartFile file, Long meetingId) throws Exception {
|
||||
public String submitServiceMaterialsUpload(MultipartFile file, Long meetingId) throws Exception {
|
||||
if (meetingId == null) throw new ServiceException("meetingId 不能为空");
|
||||
if (file == null || file.isEmpty()) throw new ServiceException("请选择要上传的 zip 文件");
|
||||
|
||||
String jobId = UUID.randomUUID().toString().replace("-", "");
|
||||
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 -> 该目录下文件字节 + 文件名
|
||||
Map<String, List<byte[]>> folderBytes = 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";
|
||||
int updated = 0;
|
||||
int processed = 0;
|
||||
for (Map.Entry<String, List<byte[]>> e : folderBytes.entrySet()) {
|
||||
String subType = e.getKey();
|
||||
List<byte[]> files = e.getValue();
|
||||
if (files == null || files.isEmpty()) continue;
|
||||
processed++;
|
||||
List<String> names = folderNames.get(subType);
|
||||
String label = SERVICE_SUBTYPE_LABEL.getOrDefault(subType, subType);
|
||||
BizMeetingMaterial old = existingBySubType.get(subType);
|
||||
@@ -698,6 +889,7 @@ public class BizMeetingMaterialServiceImpl implements IBizMeetingMaterialService
|
||||
if (old != null && old.getFeeStatus() != null && old.getFeeStatus() == 1
|
||||
&& contentEquals(old.getOssUrl(), toUpload)) {
|
||||
log.info("[material] 会务材料 {} 内容未变, 跳过重传重 OCR", subType);
|
||||
uploadProgressRegistry.update(jobId, processed);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -722,7 +914,7 @@ public class BizMeetingMaterialServiceImpl implements IBizMeetingMaterialService
|
||||
ins.setFileName(fileName);
|
||||
ins.setAmount(null);
|
||||
ins.setFeeStatus(feeStatus);
|
||||
ins.setCreatorId(SecurityUtils.getUserId());
|
||||
ins.setCreatorId(userId);
|
||||
ins.setCreateTime(new Date());
|
||||
bizMeetingMaterialMapper.insertBatch(Collections.singletonList(ins));
|
||||
// foreach 批量插入 useGeneratedKeys 不可靠, 按 subType 查回真实 id
|
||||
@@ -732,9 +924,13 @@ public class BizMeetingMaterialServiceImpl implements IBizMeetingMaterialService
|
||||
}
|
||||
// 与前端"保存"流程一致, 后台触发 OCR (识别为发票回写 amount → 会议费用汇总)
|
||||
if (ocrNeeded) {
|
||||
invoiceOcrService.submitRecognition(materialId, meetingId, ossUrl, isZip, oldMaterialId);
|
||||
invoiceOcrService.submitRecognition(materialId, meetingId, ossUrl, oldMaterialId, userId);
|
||||
}
|
||||
updated++;
|
||||
uploadProgressRegistry.update(jobId, processed);
|
||||
}
|
||||
if (updated > 0) {
|
||||
bizMeetingService.recomputeMeetingFee(meetingId);
|
||||
}
|
||||
log.info("[material] 会务材料回填完成 meetingId={} 共{}类", meetingId, updated);
|
||||
return updated;
|
||||
@@ -824,4 +1020,494 @@ public class BizMeetingMaterialServiceImpl implements IBizMeetingMaterialService
|
||||
if (m.getSubType() != null && NON_OCR_SUBTYPES.contains(m.getSubType())) return false;
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
+6
-1
@@ -214,7 +214,7 @@ public class BizMeetingServiceImpl implements IBizMeetingService
|
||||
BigDecimal sum = BigDecimal.ZERO;
|
||||
for (BizMeetingMaterial m : mats) {
|
||||
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());
|
||||
}
|
||||
return sum;
|
||||
@@ -232,4 +232,9 @@ public class BizMeetingServiceImpl implements IBizMeetingService
|
||||
BigDecimal totalFee = laborFee.add(meetingFee);
|
||||
bizMeetingMapper.updateLaborFee(meetingId, laborFee, totalFee);
|
||||
}
|
||||
@Override
|
||||
public void recomputeSubmitDeadlinesByProject(Long projectId, Integer days) {
|
||||
if (projectId == null) return;
|
||||
bizMeetingMapper.recomputeSubmitDeadlinesByProject(projectId, days);
|
||||
}
|
||||
}
|
||||
|
||||
+14
-4
@@ -65,9 +65,14 @@ public class BizOrgServiceImpl implements IBizOrgService {
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public int deleteByPrimaryKeys(Long[] orgIds) {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -87,8 +92,8 @@ public class BizOrgServiceImpl implements IBizOrgService {
|
||||
}
|
||||
|
||||
@Override
|
||||
public Map<String, Object> selectMySponsorCompany(Long userId) {
|
||||
return bizOrgMapper.selectMySponsorCompany(userId);
|
||||
public Map<String, Object> selectMyCompany(Long userId) {
|
||||
return bizOrgMapper.selectMyCompany(userId);
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -106,9 +111,14 @@ public class BizOrgServiceImpl implements IBizOrgService {
|
||||
@Override
|
||||
@Transactional
|
||||
public int toggleStatus(Long orgId, String newBizOrgStatus) {
|
||||
// 1. 查 org 取主账号 user_id
|
||||
// 1. 查 org 取主账号 user_id + org_type
|
||||
BizOrg org = bizOrgMapper.selectByPrimaryKey(orgId);
|
||||
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("该组织未关联主账号");
|
||||
|
||||
// 2. UPDATE biz_org.status
|
||||
|
||||
+4
@@ -28,6 +28,10 @@ public class BizPersonServiceImpl implements IBizPersonService
|
||||
public BizPerson getById(String personId)
|
||||
{ return bizPersonMapper.selectByPrimaryKey(personId); }
|
||||
|
||||
@Override
|
||||
public BizPerson getByUserId(Long userId)
|
||||
{ return bizPersonMapper.selectByUserId(userId); }
|
||||
|
||||
@Override
|
||||
public List<BizPerson> selectList(BizPerson entity)
|
||||
{ return bizPersonMapper.selectList(entity); }
|
||||
|
||||
+7
-7
@@ -14,8 +14,8 @@ public class BizProjectExecutorAssignServiceImpl implements IBizProjectExecutorA
|
||||
|
||||
@Override
|
||||
public int insertAssign(BizProjectExecutorAssign entity) {
|
||||
// 执行方分配策略: 先按 project_id 删, 再插
|
||||
mapper.deleteByProjectId(entity.getProjectId());
|
||||
// 执行方分配策略: 先按 (project_id + executor_org_id) 删本执行方旧分配, 再插 (避免覆盖别家执行方)
|
||||
mapper.deleteByProjectIdAndOrg(entity.getProjectId(), entity.getExecutorOrgId());
|
||||
return mapper.insertAssign(entity);
|
||||
}
|
||||
|
||||
@@ -29,8 +29,8 @@ public class BizProjectExecutorAssignServiceImpl implements IBizProjectExecutorA
|
||||
public int assignStaffForProject(BizProjectExecutorAssign body, java.util.List<Long> staffUserIds) {
|
||||
if (body == null || body.getProjectId() == null) return 0;
|
||||
if (staffUserIds == null || staffUserIds.isEmpty()) return 0;
|
||||
// 一次性清旧, 不在循环里清
|
||||
mapper.deleteByProjectId(body.getProjectId());
|
||||
// 一次性清旧 (只清本执行方 executor_org_id 的, 不在循环里清)
|
||||
mapper.deleteByProjectIdAndOrg(body.getProjectId(), body.getExecutorOrgId());
|
||||
int inserted = 0;
|
||||
for (Long sid : staffUserIds) {
|
||||
BizProjectExecutorAssign item = new BizProjectExecutorAssign();
|
||||
@@ -46,12 +46,12 @@ public class BizProjectExecutorAssignServiceImpl implements IBizProjectExecutorA
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<BizProjectExecutorAssign> listByProjectId(String projectId) {
|
||||
return mapper.selectByProjectId(projectId);
|
||||
public List<BizProjectExecutorAssign> listByProjectId(Long projectId, Long executorOrgId) {
|
||||
return mapper.selectByProjectIdAndOrg(projectId, executorOrgId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int deleteByProjectId(String projectId) {
|
||||
public int deleteByProjectId(Long projectId) {
|
||||
return mapper.deleteByProjectId(projectId);
|
||||
}
|
||||
}
|
||||
|
||||
+4
@@ -31,6 +31,10 @@ public class BizProjectPlanServiceImpl implements IBizProjectPlanService
|
||||
// 补 create_by / create_time (历史一直没写, 导致"提交时间"为空; 列表按 create_time 倒序)
|
||||
if (entity.getCreateBy() == null) entity.setCreateBy(SecurityUtils.getUsername());
|
||||
if (entity.getCreateTime() == null) entity.setCreateTime(new Date());
|
||||
// "保存并提交" 直接以 status='1' 入库: 与 update 提交口径一致, 写入提交时间 (0→1 不发通知)
|
||||
if ("1".equals(entity.getStatus()) && entity.getSubmitTime() == null) {
|
||||
entity.setSubmitTime(new Date());
|
||||
}
|
||||
return bizProjectPlanMapper.insert(entity);
|
||||
}
|
||||
|
||||
|
||||
+14
-6
@@ -1,6 +1,8 @@
|
||||
package com.ruoyi.business.service.impl;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Service;
|
||||
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.IBizMeetingService;
|
||||
import com.ruoyi.common.utils.SecurityUtils;
|
||||
import com.ruoyi.common.utils.id.SnowflakeId;
|
||||
|
||||
@Service
|
||||
public class BizProjectServiceImpl implements IBizProjectService
|
||||
@@ -43,8 +46,8 @@ public class BizProjectServiceImpl implements IBizProjectService
|
||||
public List<BizProject> selectList(BizProject entity)
|
||||
{ return bizProjectMapper.selectList(entity); }
|
||||
@Override
|
||||
public List<BizProject> selectPublicAnnouncements()
|
||||
{ return bizProjectMapper.selectPublicAnnouncements(); }
|
||||
public List<BizProject> selectPublicAnnouncements(String keyword)
|
||||
{ return bizProjectMapper.selectPublicAnnouncements(keyword); }
|
||||
@Override
|
||||
public List<BizProject> selectSponsorList(BizProject entity)
|
||||
{ return bizProjectMapper.selectSponsorList(entity); }
|
||||
@@ -55,10 +58,10 @@ public class BizProjectServiceImpl implements IBizProjectService
|
||||
public List<BizProject> selectExecutorStaffList(BizProject entity)
|
||||
{ return bizProjectMapper.selectExecutorStaffList(entity); }
|
||||
@Override
|
||||
// 注: biz_project.project_id 用 DB AUTO_INCREMENT, 不需要 SnowflakeId 注入;
|
||||
// 项目 ID 用 Long 后, SnowflakeId.injectIfEmpty 反射 setProjectId(String) 会 NoSuchMethodException 被吞掉 (SnowflakeId.java:30-31), 行为安全.
|
||||
// 雪花 ID 主键: biz_project.project_id 由应用生成 53-bit 雪花 ID (不再走 DB AUTO_INCREMENT).
|
||||
// create_user_id 走当前登录用户 (前台 API 无 @DataScope, 不会被过滤; 后台 @PreAuthorize 受角色限制)
|
||||
public int insert(BizProject entity) {
|
||||
SnowflakeId.injectIfEmpty(entity, "projectId");
|
||||
if (entity.getCreateUserId() == null) {
|
||||
entity.setCreateUserId(SecurityUtils.getUserId());
|
||||
}
|
||||
@@ -113,8 +116,8 @@ public class BizProjectServiceImpl implements IBizProjectService
|
||||
bizProjectAssignMapper.softDeleteByProjectId(projectId);
|
||||
// 5) sponsor assign (String)
|
||||
bizProjectSponsorAssignMapper.softDeleteByProjectId(String.valueOf(projectId));
|
||||
// 5.5) executor assign (String)
|
||||
bizProjectExecutorAssignMapper.softDeleteByProjectId(String.valueOf(projectId));
|
||||
// 5.5) executor assign
|
||||
bizProjectExecutorAssignMapper.softDeleteByProjectId(projectId);
|
||||
// 6) rating
|
||||
bizProjectRatingMapper.softDeleteByProjectId(projectId);
|
||||
// 7) 会议链: 查项目下所有 meeting → 调 BizMeetingService.softDeleteCascadeBatch
|
||||
@@ -153,4 +156,9 @@ public class BizProjectServiceImpl implements IBizProjectService
|
||||
public int 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
Reference in New Issue
Block a user