diff --git a/doc/plans/2026-02-08-purchase-transaction-import-design.md b/doc/plans/2026-02-08-purchase-transaction-import-design.md new file mode 100644 index 0000000..7ef3e8f --- /dev/null +++ b/doc/plans/2026-02-08-purchase-transaction-import-design.md @@ -0,0 +1,839 @@ +# 采购交易管理导入功能优化设计文档 + +## 文档信息 +- **创建日期**: 2026-02-08 +- **模块**: 采购交易管理 +- **设计目标**: 优化导入功能,采用后台异步处理+通知提示,避免弹窗阻塞用户操作 +- **参考方案**: 员工信息维护导入功能 + +--- + +## 目录 +1. [需求概述](#需求概述) +2. [整体架构](#整体架构) +3. [前端组件结构](#前端组件结构) +4. [UI组件修改](#ui组件修改) +5. [核心方法实现](#核心方法实现) +6. [完整修改清单](#完整修改清单) +7. [测试要点](#测试要点) + +--- + +## 需求概述 + +### 当前问题 +采购交易管理的导入功能采用同步处理方式,上传文件后需要等待导入完成,使用弹窗显示结果,阻塞用户操作。 + +### 优化目标 +1. ✅ 采用后台异步处理,上传后立即关闭对话框 +2. ✅ 使用右上角通知提示,不使用弹窗 +3. ✅ 自动轮询导入状态,完成后通知用户 +4. ✅ 支持查看导入失败记录 +5. ✅ 状态持久化,刷新页面后仍可查看上次导入结果 + +### 设计原则 +- **完全复用**员工信息维护的导入逻辑 +- 保持一致的交互体验 +- 最小化代码修改,复用已有组件 + +--- + +## 整体架构 + +### 用户交互流程 + +``` +用户点击"导入"按钮 + ↓ +打开导入对话框 + ↓ +选择Excel文件,点击"确定" + ↓ +上传文件到后端 + ↓ +立即关闭导入对话框 + ↓ +右上角显示通知:"导入任务已提交,正在后台处理中,处理完成后将通知您" + ↓ +系统后台每2秒轮询一次导入状态 + ↓ +导入完成后,右上角显示结果通知: + - 全部成功: "导入完成!全部成功!共导入N条数据" + - 部分失败: "导入完成!成功N条,失败M条" + ↓ +如果有失败记录: + - 在页面操作栏显示"查看导入失败记录"按钮 + - 带tooltip显示上次导入信息 +``` + +### 数据存储策略 + +使用localStorage存储导入任务状态,实现状态持久化: + +**存储Key**: `purchase_transaction_import_last_task` + +**存储内容**: +```javascript +{ + taskId: "task-20250206-123456789", + status: "SUCCESS", // PROCESSING/SUCCESS/FAILED + saveTime: 1707225600000, + hasFailures: true, + totalCount: 1000, + successCount: 980, + failureCount: 20 +} +``` + +**数据保留时间**: 7天(过期自动清除) + +### 轮询机制 + +- **轮询间隔**: 2秒 +- **最大轮询次数**: 150次(5分钟) +- **超时处理**: 显示"导入任务处理超时,请联系管理员" +- **状态检查**: 当status !== 'PROCESSING'时停止轮询 + +--- + +## 前端组件结构 + +### 新增data属性 + +```javascript +data() { + return { + // ... 现有属性 + + // 导入轮询定时器 + importPollingTimer: null, + + // 是否显示查看失败记录按钮 + showFailureButton: false, + + // 当前导入任务ID + currentTaskId: null, + + // 失败记录对话框 + failureDialogVisible: false, + failureList: [], + failureLoading: false, + failureTotal: 0, + failureQueryParams: { + pageNum: 1, + pageSize: 10 + } + } +} +``` + +### 新增computed属性 + +```javascript +computed: { + /** + * 上次导入信息摘要 + */ + lastImportInfo() { + const savedTask = this.getImportTaskFromStorage(); + if (savedTask && savedTask.totalCount) { + return `导入时间: ${this.parseTime(savedTask.saveTime)} | 总数: ${savedTask.totalCount}条 | 成功: ${savedTask.successCount}条 | 失败: ${savedTask.failureCount}条`; + } + return ''; + } +} +``` + +### 生命周期钩子修改 + +```javascript +created() { + this.getList(); + this.restoreImportState(); // 新增:恢复导入状态 +}, + +beforeDestroy() { + // 清理定时器 + if (this.importPollingTimer) { + clearInterval(this.importPollingTimer); + this.importPollingTimer = null; + } +} +``` + +### 需要新增/修改的方法 + +| 方法名 | 类型 | 说明 | +|--------|------|------| +| saveImportTaskToStorage | 新增 | 保存导入状态到localStorage | +| getImportTaskFromStorage | 新增 | 读取导入状态 | +| clearImportTaskFromStorage | 新增 | 清除导入状态 | +| restoreImportState | 新增 | 页面加载时恢复导入状态 | +| getLastImportTooltip | 新增 | 获取上次导入提示信息 | +| handleFileSuccess | 修改 | 上传成功后不弹窗,开始轮询 | +| startImportStatusPolling | 新增 | 开始轮询导入状态 | +| handleImportComplete | 新增 | 处理导入完成 | +| viewImportFailures | 新增 | 查看导入失败记录 | +| getFailureList | 新增 | 查询失败记录列表 | +| clearImportHistory | 新增 | 清除导入历史记录 | + +--- + +## UI组件修改 + +### 1. 修改导入对话框 + +**移除loading相关属性:** + +```vue + + + + + +``` + +**原因**: 导入改为后台异步处理,不需要在对话框中显示loading。 + +### 2. 操作栏添加"查看导入失败记录"按钮 + +**位置**: 导入按钮和导出按钮之后 + +```vue + + + 查看导入失败记录 + + +``` + +**条件显示**: `v-if="showFailureButton"` - 仅当有失败记录时显示 + +### 3. 新增导入失败记录对话框 + +**位置**: 导入结果对话框之后 + +```vue + + + + + + + + + + + + + + +``` + +**显示字段**: +- 采购事项ID (purchaseId) +- 项目名称 (projectName) +- 标的物名称 (subjectName) +- 失败原因 (errorMessage) + +--- + +## 核心方法实现 + +### 1. handleFileSuccess - 上传成功处理 + +**修改说明**: 移除弹窗提示,改为通知+轮询 + +```javascript +handleFileSuccess(response, file, fileList) { + this.upload.isUploading = false; + this.upload.open = false; + + if (response.code === 200) { + // 验证响应数据完整性 + if (!response.data || !response.data.taskId) { + this.$modal.msgError('导入任务创建失败:缺少任务ID'); + this.upload.isUploading = false; + this.upload.open = true; + return; + } + + const taskId = response.data.taskId; + + // 清除旧的轮询定时器 + if (this.importPollingTimer) { + clearInterval(this.importPollingTimer); + this.importPollingTimer = null; + } + + this.clearImportTaskFromStorage(); + + // 保存新任务的初始状态 + this.saveImportTaskToStorage({ + taskId: taskId, + status: 'PROCESSING', + timestamp: Date.now(), + hasFailures: false + }); + + // 重置状态 + this.showFailureButton = false; + this.currentTaskId = taskId; + + // 显示后台处理提示(不是弹窗,是通知) + this.$notify({ + title: '导入任务已提交', + message: '正在后台处理中,处理完成后将通知您', + type: 'info', + duration: 3000 + }); + + // 开始轮询检查状态 + this.startImportStatusPolling(taskId); + } else { + this.$modal.msgError(response.msg); + } +} +``` + +### 2. startImportStatusPolling - 轮询导入状态 + +```javascript +startImportStatusPolling(taskId) { + let pollCount = 0; + const maxPolls = 150; // 最多轮询150次(5分钟) + + this.importPollingTimer = setInterval(async () => { + try { + pollCount++; + + // 超时检查 + if (pollCount > maxPolls) { + clearInterval(this.importPollingTimer); + this.$modal.msgWarning('导入任务处理超时,请联系管理员'); + return; + } + + const response = await getImportStatus(taskId); + + if (response.data && response.data.status !== 'PROCESSING') { + clearInterval(this.importPollingTimer); + this.handleImportComplete(response.data); + } + } catch (error) { + clearInterval(this.importPollingTimer); + this.$modal.msgError('查询导入状态失败: ' + error.message); + } + }, 2000); // 每2秒轮询一次 +} +``` + +### 3. handleImportComplete - 处理导入完成 + +```javascript +handleImportComplete(statusResult) { + // 更新localStorage中的任务状态 + this.saveImportTaskToStorage({ + taskId: statusResult.taskId, + status: statusResult.status, + hasFailures: statusResult.failureCount > 0, + totalCount: statusResult.totalCount, + successCount: statusResult.successCount, + failureCount: statusResult.failureCount + }); + + if (statusResult.status === 'SUCCESS') { + // 全部成功 + this.$notify({ + title: '导入完成', + message: `全部成功!共导入${statusResult.totalCount}条数据`, + type: 'success', + duration: 5000 + }); + this.showFailureButton = false; // 成功时清除失败按钮显示 + this.getList(); // 刷新列表 + } else if (statusResult.failureCount > 0) { + // 部分失败 + this.$notify({ + title: '导入完成', + message: `成功${statusResult.successCount}条,失败${statusResult.failureCount}条`, + type: 'warning', + duration: 5000 + }); + + // 显示查看失败记录按钮 + this.showFailureButton = true; + this.currentTaskId = statusResult.taskId; + + // 刷新列表 + this.getList(); + } +} +``` + +### 4. localStorage状态管理 + +```javascript +/** + * 保存导入任务到localStorage + */ +saveImportTaskToStorage(taskData) { + try { + const data = { + ...taskData, + saveTime: Date.now() + }; + localStorage.setItem('purchase_transaction_import_last_task', JSON.stringify(data)); + } catch (error) { + console.error('保存导入任务状态失败:', error); + } +}, + +/** + * 从localStorage读取导入任务 + */ +getImportTaskFromStorage() { + try { + const data = localStorage.getItem('purchase_transaction_import_last_task'); + if (!data) return null; + + const task = JSON.parse(data); + + // 数据格式校验 + if (!task || !task.taskId) { + this.clearImportTaskFromStorage(); + return null; + } + + // 时间戳校验 + if (task.saveTime && typeof task.saveTime !== 'number') { + this.clearImportTaskFromStorage(); + return null; + } + + // 过期检查(7天) + const sevenDays = 7 * 24 * 60 * 60 * 1000; + if (Date.now() - task.saveTime > sevenDays) { + this.clearImportTaskFromStorage(); + return null; + } + + return task; + } catch (error) { + console.error('读取导入任务状态失败:', error); + this.clearImportTaskFromStorage(); + return null; + } +}, + +/** + * 清除localStorage中的导入任务 + */ +clearImportTaskFromStorage() { + try { + localStorage.removeItem('purchase_transaction_import_last_task'); + } catch (error) { + console.error('清除导入任务状态失败:', error); + } +} +``` + +### 5. restoreImportState - 恢复导入状态 + +```javascript +/** + * 恢复导入状态 + * 在created()钩子中调用 + */ +restoreImportState() { + const savedTask = this.getImportTaskFromStorage(); + + if (!savedTask) { + this.showFailureButton = false; + this.currentTaskId = null; + return; + } + + // 如果有失败记录,恢复按钮显示 + if (savedTask.hasFailures && savedTask.taskId) { + this.currentTaskId = savedTask.taskId; + this.showFailureButton = true; + } +} +``` + +### 6. getLastImportTooltip - 获取导入提示 + +```javascript +/** + * 获取上次导入的提示信息 + */ +getLastImportTooltip() { + const savedTask = this.getImportTaskFromStorage(); + if (savedTask && savedTask.saveTime) { + const date = new Date(savedTask.saveTime); + const timeStr = this.parseTime(date, '{y}-{m}-{d} {h}:{i}'); + return `上次导入: ${timeStr}`; + } + return ''; +} +``` + +### 7. viewImportFailures - 查看失败记录 + +```javascript +/** + * 查看导入失败记录 + */ +viewImportFailures() { + this.failureDialogVisible = true; + this.getFailureList(); +} +``` + +### 8. getFailureList - 查询失败记录列表 + +```javascript +/** + * 查询失败记录列表 + */ +getFailureList() { + this.failureLoading = true; + getImportFailures( + this.currentTaskId, + this.failureQueryParams.pageNum, + this.failureQueryParams.pageSize + ).then(response => { + this.failureList = response.rows; + this.failureTotal = response.total; + this.failureLoading = false; + }).catch(error => { + this.failureLoading = false; + + // 处理不同类型的错误 + if (error.response) { + const status = error.response.status; + + if (status === 404) { + // 记录不存在或已过期 + this.$modal.msgWarning('导入记录已过期,无法查看失败记录'); + this.clearImportTaskFromStorage(); + this.showFailureButton = false; + this.currentTaskId = null; + this.failureDialogVisible = false; + } else if (status === 500) { + this.$modal.msgError('服务器错误,请稍后重试'); + } else { + this.$modal.msgError(`查询失败: ${error.response.data.msg || '未知错误'}`); + } + } else if (error.request) { + // 请求发送了但没有收到响应 + this.$modal.msgError('网络连接失败,请检查网络'); + } else { + this.$modal.msgError('查询失败记录失败: ' + error.message); + } + }); +} +``` + +### 9. clearImportHistory - 清除历史记录 + +```javascript +/** + * 清除导入历史记录 + * 用户手动触发 + */ +clearImportHistory() { + this.$confirm('确认清除上次导入记录?', '提示', { + confirmButtonText: '确定', + cancelButtonText: '取消', + type: 'warning' + }).then(() => { + this.clearImportTaskFromStorage(); + this.showFailureButton = false; + this.currentTaskId = null; + this.failureDialogVisible = false; + this.$message.success('已清除'); + }).catch(() => {}); +} +``` + +--- + +## 完整修改清单 + +### 需要修改的文件 + +**文件路径**: `ruoyi-ui/src/views/ccdiPurchaseTransaction/index.vue` + +### 具体修改项 + +#### 1. data()中新增属性 + +```javascript +// 在data()返回对象中添加: +importPollingTimer: null, +showFailureButton: false, +currentTaskId: null, +failureDialogVisible: false, +failureList: [], +failureLoading: false, +failureTotal: 0, +failureQueryParams: { + pageNum: 1, + pageSize: 10 +} +``` + +#### 2. computed中新增属性 + +```javascript +// 在computed中添加: +lastImportInfo() { + const savedTask = this.getImportTaskFromStorage(); + if (savedTask && savedTask.totalCount) { + return `导入时间: ${this.parseTime(savedTask.saveTime)} | 总数: ${savedTask.totalCount}条 | 成功: ${savedTask.successCount}条 | 失败: ${savedTask.failureCount}条`; + } + return ''; +} +``` + +#### 3. created钩子 + +```javascript +// 在created()中添加: +this.restoreImportState(); +``` + +#### 4. beforeDestroy钩子 + +```javascript +// 在beforeDestroy()中添加: +if (this.importPollingTimer) { + clearInterval(this.importPollingTimer); + this.importPollingTimer = null; +} +``` + +#### 5. methods中新增方法 + +需要新增10个方法(见上文"核心方法实现"部分) + +#### 6. 模板修改 + +- 导入对话框: 移除v-loading和element-loading-*属性 +- 操作栏: 添加"查看导入失败记录"按钮 +- 新增导入失败记录对话框 + +--- + +## 测试要点 + +### 1. 正常导入流程测试 + +**测试步骤**: +1. 点击"导入"按钮 +2. 选择有效的Excel文件 +3. 点击"确定"上传 + +**预期结果**: +- ✅ 导入对话框立即关闭 +- ✅ 右上角显示通知:"导入任务已提交,正在后台处理中,处理完成后将通知您" +- ✅ 后台开始轮询状态(每2秒一次) +- ✅ 导入完成后,右上角显示结果通知 +- ✅ 列表自动刷新,显示新导入的数据 + +### 2. 全部成功场景测试 + +**测试步骤**: +1. 上传包含100条有效数据的Excel文件 + +**预期结果**: +- ✅ 显示成功通知:"导入完成!全部成功!共导入100条数据" +- ✅ 不显示"查看导入失败记录"按钮 +- ✅ 列表中显示100条新数据 + +### 3. 部分失败场景测试 + +**测试步骤**: +1. 上传包含部分错误数据的Excel文件 + +**预期结果**: +- ✅ 显示警告通知:"导入完成!成功80条,失败20条" +- ✅ 显示"查看导入失败记录"按钮 +- ✅ 按钮tooltip显示上次导入信息 +- ✅ 列表中显示80条成功导入的数据 + +### 4. 失败记录查看测试 + +**测试步骤**: +1. 导入有失败的数据 +2. 点击"查看导入失败记录"按钮 + +**预期结果**: +- ✅ 打开失败记录对话框 +- ✅ 顶部显示导入信息提示(总数、成功、失败) +- ✅ 表格显示失败记录,包含: + - 采购事项ID + - 项目名称 + - 标的物名称 + - 失败原因 +- ✅ 支持分页查询 + +### 5. 状态持久化测试 + +**测试步骤**: +1. 导入有失败的数据 +2. 刷新页面 + +**预期结果**: +- ✅ "查看导入失败记录"按钮仍然显示 +- ✅ tooltip显示正确的导入时间 +- ✅ 点击按钮可以正常查看失败记录 + +### 6. 清除历史记录测试 + +**测试步骤**: +1. 打开失败记录对话框 +2. 点击"清除历史记录"按钮 +3. 确认清除 + +**预期结果**: +- ✅ localStorage中的导入状态被清除 +- ✅ "查看导入失败记录"按钮消失 +- ✅ 失败记录对话框关闭 + +### 7. 边界情况测试 + +**测试场景**: + +**a. 轮询超时** +- 测试方法: 模拟导入任务超过5分钟未完成 +- 预期结果: 显示"导入任务处理超时,请联系管理员" + +**b. 记录过期** +- 测试方法: 修改localStorage中的saveTime为8天前 +- 预期结果: 自动清除过期记录,不显示"查看导入失败记录"按钮 + +**c. 网络错误** +- 测试方法: 断网后查询失败记录 +- 预期结果: 显示"网络连接失败,请检查网络" + +**d. 服务器错误(404)** +- 测试方法: 查询不存在的taskId的失败记录 +- 预期结果: 显示"导入记录已过期,无法查看失败记录",自动清除状态 + +**e. 服务器错误(500)** +- 测试方法: 后端返回500错误 +- 预期结果: 显示"服务器错误,请稍后重试" + +### 8. 用户体验测试 + +**测试要点**: +- ✅ 导入过程中用户可以继续操作页面(不被阻塞) +- ✅ 通知消息清晰易懂 +- ✅ 失败记录对话框字段对齐,支持长文本省略 +- ✅ tooltip提示信息准确 +- ✅ 分页功能正常 + +--- + +## 附录 + +### A. 与员工信息维护的差异对比 + +| 对比项 | 员工信息维护 | 采购交易管理 | +|--------|-------------|-------------| +| localStorage Key | `employee_import_last_task` | `purchase_transaction_import_last_task` | +| API路径 | `/ccdi/employee/importData` | `/ccdi/purchaseTransaction/importData` | +| 失败记录字段 | name, employeeId, idCard, phone, errorMessage | purchaseId, projectName, subjectName, errorMessage | +| 轮询超时时间 | 5分钟(150次×2秒) | 5分钟(150次×2秒) | + +### B. 后端API依赖 + +本设计依赖以下后端API(已实现): + +1. **导入数据**: + - 路径: `POST /ccdi/purchaseTransaction/importData` + - 参数: `updateSupport` (是否更新已存在数据) + - 响应: `{code: 200, data: {taskId: "task-xxx"}}` + +2. **查询导入状态**: + - 路径: `GET /ccdi/purchaseTransaction/importStatus/{taskId}` + - 响应: `{code: 200, data: {taskId, status, totalCount, successCount, failureCount}}` + +3. **查询导入失败记录**: + - 路径: `GET /ccdi/purchaseTransaction/importFailures/{taskId}` + - 参数: `pageNum`, `pageSize` + - 响应: `{code: 200, rows: [...], total: N}` + +### C. 技术栈 + +- **前端框架**: Vue 2.6.12 +- **UI组件库**: Element UI 2.15.14 +- **HTTP客户端**: Axios 0.28.1 +- **状态管理**: localStorage (浏览器原生API) + +### D. 参考文档 + +- 员工信息维护导入功能: `ruoyi-ui/src/views/ccdiEmployee/index.vue` +- 采购交易API文档: `doc/api/ccdi_purchase_transaction_api.md` + +--- + +## 版本历史 + +| 版本 | 日期 | 说明 | 作者 | +|------|------|------|------| +| 1.0.0 | 2026-02-08 | 初始设计文档 | Claude | + +--- + +## 结语 + +本设计完全复用了员工信息维护的导入逻辑,实现了采购交易管理的后台异步导入功能。通过采用通知提示替代弹窗,避免了阻塞用户操作,提供了更好的用户体验。所有设计均已详细说明,可直接进入实施阶段。 diff --git a/doc/test-data/purchase_transaction/purchase_1770454949058.xlsx b/doc/test-data/purchase_transaction/purchase_1770454949058.xlsx new file mode 100644 index 0000000..9806522 Binary files /dev/null and b/doc/test-data/purchase_transaction/purchase_1770454949058.xlsx differ diff --git a/doc/test-data/purchase_transaction/purchase_test_data_2000.xlsx b/doc/test-data/purchase_transaction/purchase_test_data_2000.xlsx new file mode 100644 index 0000000..249ec3e Binary files /dev/null and b/doc/test-data/purchase_transaction/purchase_test_data_2000.xlsx differ diff --git a/ruoyi-ccdi/src/main/java/com/ruoyi/ccdi/domain/vo/IntermediaryEntityImportFailureVO.java b/ruoyi-ccdi/src/main/java/com/ruoyi/ccdi/domain/vo/IntermediaryEntityImportFailureVO.java new file mode 100644 index 0000000..9aa865c --- /dev/null +++ b/ruoyi-ccdi/src/main/java/com/ruoyi/ccdi/domain/vo/IntermediaryEntityImportFailureVO.java @@ -0,0 +1,43 @@ +package com.ruoyi.ccdi.domain.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; +import java.util.Date; + +/** + * 实体中介导入失败记录VO + * + * @author ruoyi + * @date 2026-02-06 + */ +@Data +@Schema(description = "实体中介导入失败记录") +public class IntermediaryEntityImportFailureVO implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + @Schema(description = "机构名称") + private String enterpriseName; + + @Schema(description = "统一社会信用代码") + private String socialCreditCode; + + @Schema(description = "主体类型") + private String enterpriseType; + + @Schema(description = "企业性质") + private String enterpriseNature; + + @Schema(description = "法定代表人") + private String legalRepresentative; + + @Schema(description = "成立日期") + private Date establishDate; + + @Schema(description = "错误信息") + private String errorMessage; +} diff --git a/ruoyi-ccdi/src/main/java/com/ruoyi/ccdi/domain/vo/IntermediaryPersonImportFailureVO.java b/ruoyi-ccdi/src/main/java/com/ruoyi/ccdi/domain/vo/IntermediaryPersonImportFailureVO.java new file mode 100644 index 0000000..df1c71a --- /dev/null +++ b/ruoyi-ccdi/src/main/java/com/ruoyi/ccdi/domain/vo/IntermediaryPersonImportFailureVO.java @@ -0,0 +1,42 @@ +package com.ruoyi.ccdi.domain.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; + +/** + * 个人中介导入失败记录VO + * + * @author ruoyi + * @date 2026-02-06 + */ +@Data +@Schema(description = "个人中介导入失败记录") +public class IntermediaryPersonImportFailureVO implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + @Schema(description = "姓名") + private String name; + + @Schema(description = "证件号码") + private String personId; + + @Schema(description = "人员类型") + private String personType; + + @Schema(description = "性别") + private String gender; + + @Schema(description = "手机号码") + private String mobile; + + @Schema(description = "所在公司") + private String company; + + @Schema(description = "错误信息") + private String errorMessage; +} diff --git a/ruoyi-ccdi/src/main/java/com/ruoyi/ccdi/service/ICcdiIntermediaryEntityImportService.java b/ruoyi-ccdi/src/main/java/com/ruoyi/ccdi/service/ICcdiIntermediaryEntityImportService.java new file mode 100644 index 0000000..74f5665 --- /dev/null +++ b/ruoyi-ccdi/src/main/java/com/ruoyi/ccdi/service/ICcdiIntermediaryEntityImportService.java @@ -0,0 +1,45 @@ +package com.ruoyi.ccdi.service; + +import com.ruoyi.ccdi.domain.excel.CcdiIntermediaryEntityExcel; +import com.ruoyi.ccdi.domain.vo.ImportStatusVO; +import com.ruoyi.ccdi.domain.vo.IntermediaryEntityImportFailureVO; + +import java.util.List; + +/** + * 实体中介异步导入Service接口 + * + * @author ruoyi + * @date 2026-02-06 + */ +public interface ICcdiIntermediaryEntityImportService { + + /** + * 异步导入实体中介数据 + * + * @param excelList Excel数据列表 + * @param isUpdateSupport 是否更新已存在的数据 + * @param taskId 任务ID + * @param userName 当前用户名(用于审计字段) + */ + void importEntityAsync(List excelList, + Boolean isUpdateSupport, + String taskId, + String userName); + + /** + * 查询导入状态 + * + * @param taskId 任务ID + * @return 导入状态信息 + */ + ImportStatusVO getImportStatus(String taskId); + + /** + * 获取导入失败记录 + * + * @param taskId 任务ID + * @return 失败记录列表 + */ + List getImportFailures(String taskId); +} diff --git a/ruoyi-ccdi/src/main/java/com/ruoyi/ccdi/service/ICcdiIntermediaryPersonImportService.java b/ruoyi-ccdi/src/main/java/com/ruoyi/ccdi/service/ICcdiIntermediaryPersonImportService.java new file mode 100644 index 0000000..37fab76 --- /dev/null +++ b/ruoyi-ccdi/src/main/java/com/ruoyi/ccdi/service/ICcdiIntermediaryPersonImportService.java @@ -0,0 +1,45 @@ +package com.ruoyi.ccdi.service; + +import com.ruoyi.ccdi.domain.excel.CcdiIntermediaryPersonExcel; +import com.ruoyi.ccdi.domain.vo.ImportStatusVO; +import com.ruoyi.ccdi.domain.vo.IntermediaryPersonImportFailureVO; + +import java.util.List; + +/** + * 个人中介异步导入Service接口 + * + * @author ruoyi + * @date 2026-02-06 + */ +public interface ICcdiIntermediaryPersonImportService { + + /** + * 异步导入个人中介数据 + * + * @param excelList Excel数据列表 + * @param isUpdateSupport 是否更新已存在的数据 + * @param taskId 任务ID + * @param userName 当前用户名(用于审计字段) + */ + void importPersonAsync(List excelList, + Boolean isUpdateSupport, + String taskId, + String userName); + + /** + * 查询导入状态 + * + * @param taskId 任务ID + * @return 导入状态信息 + */ + ImportStatusVO getImportStatus(String taskId); + + /** + * 获取导入失败记录 + * + * @param taskId 任务ID + * @return 失败记录列表 + */ + List getImportFailures(String taskId); +} diff --git a/ruoyi-ccdi/src/main/java/com/ruoyi/ccdi/service/impl/CcdiIntermediaryEntityImportServiceImpl.java b/ruoyi-ccdi/src/main/java/com/ruoyi/ccdi/service/impl/CcdiIntermediaryEntityImportServiceImpl.java new file mode 100644 index 0000000..cdefe7a --- /dev/null +++ b/ruoyi-ccdi/src/main/java/com/ruoyi/ccdi/service/impl/CcdiIntermediaryEntityImportServiceImpl.java @@ -0,0 +1,253 @@ +package com.ruoyi.ccdi.service.impl; + +import com.alibaba.fastjson2.JSON; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.ruoyi.ccdi.domain.CcdiEnterpriseBaseInfo; +import com.ruoyi.ccdi.domain.excel.CcdiIntermediaryEntityExcel; +import com.ruoyi.ccdi.domain.vo.ImportResult; +import com.ruoyi.ccdi.domain.vo.ImportStatusVO; +import com.ruoyi.ccdi.domain.vo.IntermediaryEntityImportFailureVO; +import com.ruoyi.ccdi.mapper.CcdiEnterpriseBaseInfoMapper; +import com.ruoyi.ccdi.service.ICcdiIntermediaryEntityImportService; +import com.ruoyi.common.utils.StringUtils; +import jakarta.annotation.Resource; +import org.springframework.beans.BeanUtils; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.scheduling.annotation.Async; +import org.springframework.scheduling.annotation.EnableAsync; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.*; +import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; + +/** + * 实体中介异步导入Service实现 + * + * @author ruoyi + * @date 2026-02-06 + */ +@Service +@EnableAsync +public class CcdiIntermediaryEntityImportServiceImpl implements ICcdiIntermediaryEntityImportService { + + @Resource + private CcdiEnterpriseBaseInfoMapper entityMapper; + + @Resource + private RedisTemplate redisTemplate; + + @Override + @Async + @Transactional(rollbackFor = Exception.class) + public void importEntityAsync(List excelList, + Boolean isUpdateSupport, + String taskId, + String userName) { + List newRecords = new ArrayList<>(); + List updateRecords = new ArrayList<>(); + List failures = new ArrayList<>(); + + // 1. 批量查询已存在的统一社会信用代码 + Set existingCreditCodes = getExistingCreditCodes(excelList); + + // 2. 分类数据 + for (int i = 0; i < excelList.size(); i++) { + CcdiIntermediaryEntityExcel excel = excelList.get(i); + + try { + // 验证数据 + validateEntityData(excel, isUpdateSupport, existingCreditCodes); + + CcdiEnterpriseBaseInfo entity = new CcdiEnterpriseBaseInfo(); + BeanUtils.copyProperties(excel, entity); + + // 设置数据来源和审计字段 + entity.setDataSource("IMPORT"); + entity.setEntSource("INTERMEDIARY"); + entity.setCreatedBy(userName); + + if (existingCreditCodes.contains(excel.getSocialCreditCode())) { + if (isUpdateSupport) { + // 更新模式:设置更新人 + entity.setUpdatedBy(userName); + updateRecords.add(entity); + } else { + throw new RuntimeException("该统一社会信用代码已存在"); + } + } else { + newRecords.add(entity); + } + + } catch (Exception e) { + IntermediaryEntityImportFailureVO failure = new IntermediaryEntityImportFailureVO(); + BeanUtils.copyProperties(excel, failure); + failure.setErrorMessage(e.getMessage()); + failures.add(failure); + } + } + + // 3. 批量插入新数据 + if (!newRecords.isEmpty()) { + saveBatch(newRecords, 500); + } + + // 4. 批量更新已有数据(先删除再插入) + if (!updateRecords.isEmpty() && isUpdateSupport) { + // 先批量删除已存在的记录 + List creditCodes = updateRecords.stream() + .map(CcdiEnterpriseBaseInfo::getSocialCreditCode) + .collect(Collectors.toList()); + + LambdaQueryWrapper deleteWrapper = new LambdaQueryWrapper<>(); + deleteWrapper.in(CcdiEnterpriseBaseInfo::getSocialCreditCode, creditCodes); + entityMapper.delete(deleteWrapper); + + // 批量插入更新后的数据 + entityMapper.insertBatch(updateRecords); + } + + // 5. 保存失败记录到Redis + if (!failures.isEmpty()) { + String failuresKey = "import:intermediary-entity:" + taskId + ":failures"; + redisTemplate.opsForValue().set(failuresKey, failures, 7, TimeUnit.DAYS); + } + + // 6. 更新最终状态 + ImportResult result = new ImportResult(); + result.setTotalCount(excelList.size()); + result.setSuccessCount(newRecords.size() + updateRecords.size()); + result.setFailureCount(failures.size()); + + String finalStatus = result.getFailureCount() == 0 ? "SUCCESS" : "PARTIAL_SUCCESS"; + updateImportStatus(taskId, finalStatus, result); + } + + @Override + public ImportStatusVO getImportStatus(String taskId) { + String key = "import:intermediary-entity:" + taskId; + Boolean hasKey = redisTemplate.hasKey(key); + + if (Boolean.FALSE.equals(hasKey)) { + throw new RuntimeException("任务不存在或已过期"); + } + + Map statusMap = redisTemplate.opsForHash().entries(key); + + ImportStatusVO statusVO = new ImportStatusVO(); + statusVO.setTaskId((String) statusMap.get("taskId")); + statusVO.setStatus((String) statusMap.get("status")); + statusVO.setTotalCount((Integer) statusMap.get("totalCount")); + statusVO.setSuccessCount((Integer) statusMap.get("successCount")); + statusVO.setFailureCount((Integer) statusMap.get("failureCount")); + statusVO.setProgress((Integer) statusMap.get("progress")); + statusVO.setStartTime((Long) statusMap.get("startTime")); + statusVO.setEndTime((Long) statusMap.get("endTime")); + statusVO.setMessage((String) statusMap.get("message")); + + return statusVO; + } + + @Override + public List getImportFailures(String taskId) { + String key = "import:intermediary-entity:" + taskId + ":failures"; + Object failuresObj = redisTemplate.opsForValue().get(key); + + if (failuresObj == null) { + return Collections.emptyList(); + } + + return JSON.parseArray(JSON.toJSONString(failuresObj), IntermediaryEntityImportFailureVO.class); + } + + /** + * 批量查询已存在的统一社会信用代码 + */ + private Set getExistingCreditCodes(List excelList) { + List creditCodes = excelList.stream() + .map(CcdiIntermediaryEntityExcel::getSocialCreditCode) + .filter(StringUtils::isNotEmpty) + .collect(Collectors.toList()); + + if (creditCodes.isEmpty()) { + return Collections.emptySet(); + } + + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.in(CcdiEnterpriseBaseInfo::getSocialCreditCode, creditCodes); + List existingEntities = entityMapper.selectList(wrapper); + + return existingEntities.stream() + .map(CcdiEnterpriseBaseInfo::getSocialCreditCode) + .collect(Collectors.toSet()); + } + + /** + * 批量保存 + */ + private void saveBatch(List list, int batchSize) { + // 使用真正的批量插入,分批次执行以提高性能 + for (int i = 0; i < list.size(); i += batchSize) { + int end = Math.min(i + batchSize, list.size()); + List subList = list.subList(i, end); + entityMapper.insertBatch(subList); + } + } + + /** + * 更新导入状态 + */ + private void updateImportStatus(String taskId, String status, ImportResult result) { + String key = "import:intermediary-entity:" + taskId; + Map statusData = new HashMap<>(); + statusData.put("status", status); + statusData.put("successCount", result.getSuccessCount()); + statusData.put("failureCount", result.getFailureCount()); + statusData.put("progress", 100); + statusData.put("endTime", System.currentTimeMillis()); + + if ("SUCCESS".equals(status)) { + statusData.put("message", "全部成功!共导入" + result.getTotalCount() + "条数据"); + } else { + statusData.put("message", "成功" + result.getSuccessCount() + "条,失败" + result.getFailureCount() + "条"); + } + + redisTemplate.opsForHash().putAll(key, statusData); + } + + /** + * 验证实体中介数据 + * + * @param excel Excel数据 + * @param isUpdateSupport 是否支持更新 + * @param existingCreditCodes 已存在的统一社会信用代码集合 + */ + private void validateEntityData(CcdiIntermediaryEntityExcel excel, + Boolean isUpdateSupport, + Set existingCreditCodes) { + // 验证必填字段:机构名称 + if (StringUtils.isEmpty(excel.getEnterpriseName())) { + throw new RuntimeException("机构名称不能为空"); + } + + // 验证必填字段:统一社会信用代码 + if (StringUtils.isEmpty(excel.getSocialCreditCode())) { + throw new RuntimeException("统一社会信用代码不能为空"); + } + + // 如果统一社会信用代码已存在但未启用更新支持,抛出异常 + if (existingCreditCodes.contains(excel.getSocialCreditCode()) && !isUpdateSupport) { + throw new RuntimeException("该统一社会信用代码已存在"); + } + + // 如果统一社会信用代码不存在,检查唯一性 + if (!existingCreditCodes.contains(excel.getSocialCreditCode())) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(CcdiEnterpriseBaseInfo::getSocialCreditCode, excel.getSocialCreditCode()); + if (entityMapper.selectCount(wrapper) > 0) { + throw new RuntimeException("该统一社会信用代码已存在"); + } + } + } +} diff --git a/ruoyi-ccdi/src/main/java/com/ruoyi/ccdi/service/impl/CcdiIntermediaryPersonImportServiceImpl.java b/ruoyi-ccdi/src/main/java/com/ruoyi/ccdi/service/impl/CcdiIntermediaryPersonImportServiceImpl.java new file mode 100644 index 0000000..d0069db --- /dev/null +++ b/ruoyi-ccdi/src/main/java/com/ruoyi/ccdi/service/impl/CcdiIntermediaryPersonImportServiceImpl.java @@ -0,0 +1,259 @@ +package com.ruoyi.ccdi.service.impl; + +import com.alibaba.fastjson2.JSON; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.ruoyi.ccdi.domain.CcdiBizIntermediary; +import com.ruoyi.ccdi.domain.excel.CcdiIntermediaryPersonExcel; +import com.ruoyi.ccdi.domain.vo.ImportResult; +import com.ruoyi.ccdi.domain.vo.ImportStatusVO; +import com.ruoyi.ccdi.domain.vo.IntermediaryPersonImportFailureVO; +import com.ruoyi.ccdi.mapper.CcdiBizIntermediaryMapper; +import com.ruoyi.ccdi.service.ICcdiIntermediaryPersonImportService; +import com.ruoyi.common.utils.IdCardUtil; +import com.ruoyi.common.utils.StringUtils; +import jakarta.annotation.Resource; +import org.springframework.beans.BeanUtils; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.scheduling.annotation.Async; +import org.springframework.scheduling.annotation.EnableAsync; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.*; +import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; + +/** + * 个人中介异步导入Service实现 + * + * @author ruoyi + * @date 2026-02-06 + */ +@Service +@EnableAsync +public class CcdiIntermediaryPersonImportServiceImpl implements ICcdiIntermediaryPersonImportService { + + @Resource + private CcdiBizIntermediaryMapper intermediaryMapper; + + @Resource + private RedisTemplate redisTemplate; + + @Override + @Async + @Transactional(rollbackFor = Exception.class) + public void importPersonAsync(List excelList, + Boolean isUpdateSupport, + String taskId, + String userName) { + List newRecords = new ArrayList<>(); + List updateRecords = new ArrayList<>(); + List failures = new ArrayList<>(); + + // 1. 批量查询已存在的证件号 + Set existingPersonIds = getExistingPersonIds(excelList); + + // 2. 分类数据 + for (int i = 0; i < excelList.size(); i++) { + CcdiIntermediaryPersonExcel excel = excelList.get(i); + + try { + // 验证数据 + validatePersonData(excel, isUpdateSupport, existingPersonIds); + + CcdiBizIntermediary intermediary = new CcdiBizIntermediary(); + BeanUtils.copyProperties(excel, intermediary); + + // 设置数据来源和审计字段 + intermediary.setDataSource("IMPORT"); + intermediary.setCreatedBy(userName); + + if (existingPersonIds.contains(excel.getPersonId())) { + if (isUpdateSupport) { + // 更新模式:设置更新人 + intermediary.setUpdatedBy(userName); + updateRecords.add(intermediary); + } else { + throw new RuntimeException("该证件号码已存在"); + } + } else { + newRecords.add(intermediary); + } + + } catch (Exception e) { + IntermediaryPersonImportFailureVO failure = new IntermediaryPersonImportFailureVO(); + BeanUtils.copyProperties(excel, failure); + failure.setErrorMessage(e.getMessage()); + failures.add(failure); + } + } + + // 3. 批量插入新数据 + if (!newRecords.isEmpty()) { + saveBatch(newRecords, 500); + } + + // 4. 批量更新已有数据(先删除再插入) + if (!updateRecords.isEmpty() && isUpdateSupport) { + // 先批量删除已存在的记录 + List personIds = updateRecords.stream() + .map(CcdiBizIntermediary::getPersonId) + .collect(Collectors.toList()); + + LambdaQueryWrapper deleteWrapper = new LambdaQueryWrapper<>(); + deleteWrapper.in(CcdiBizIntermediary::getPersonId, personIds); + intermediaryMapper.delete(deleteWrapper); + + // 批量插入更新后的数据 + intermediaryMapper.insertBatch(updateRecords); + } + + // 5. 保存失败记录到Redis + if (!failures.isEmpty()) { + String failuresKey = "import:intermediary:" + taskId + ":failures"; + redisTemplate.opsForValue().set(failuresKey, failures, 7, TimeUnit.DAYS); + } + + // 6. 更新最终状态 + ImportResult result = new ImportResult(); + result.setTotalCount(excelList.size()); + result.setSuccessCount(newRecords.size() + updateRecords.size()); + result.setFailureCount(failures.size()); + + String finalStatus = result.getFailureCount() == 0 ? "SUCCESS" : "PARTIAL_SUCCESS"; + updateImportStatus(taskId, finalStatus, result); + } + + @Override + public ImportStatusVO getImportStatus(String taskId) { + String key = "import:intermediary:" + taskId; + Boolean hasKey = redisTemplate.hasKey(key); + + if (Boolean.FALSE.equals(hasKey)) { + throw new RuntimeException("任务不存在或已过期"); + } + + Map statusMap = redisTemplate.opsForHash().entries(key); + + ImportStatusVO statusVO = new ImportStatusVO(); + statusVO.setTaskId((String) statusMap.get("taskId")); + statusVO.setStatus((String) statusMap.get("status")); + statusVO.setTotalCount((Integer) statusMap.get("totalCount")); + statusVO.setSuccessCount((Integer) statusMap.get("successCount")); + statusVO.setFailureCount((Integer) statusMap.get("failureCount")); + statusVO.setProgress((Integer) statusMap.get("progress")); + statusVO.setStartTime((Long) statusMap.get("startTime")); + statusVO.setEndTime((Long) statusMap.get("endTime")); + statusVO.setMessage((String) statusMap.get("message")); + + return statusVO; + } + + @Override + public List getImportFailures(String taskId) { + String key = "import:intermediary:" + taskId + ":failures"; + Object failuresObj = redisTemplate.opsForValue().get(key); + + if (failuresObj == null) { + return Collections.emptyList(); + } + + return JSON.parseArray(JSON.toJSONString(failuresObj), IntermediaryPersonImportFailureVO.class); + } + + /** + * 批量查询已存在的证件号 + */ + private Set getExistingPersonIds(List excelList) { + List personIds = excelList.stream() + .map(CcdiIntermediaryPersonExcel::getPersonId) + .filter(StringUtils::isNotEmpty) + .collect(Collectors.toList()); + + if (personIds.isEmpty()) { + return Collections.emptySet(); + } + + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.in(CcdiBizIntermediary::getPersonId, personIds); + List existingIntermediaries = intermediaryMapper.selectList(wrapper); + + return existingIntermediaries.stream() + .map(CcdiBizIntermediary::getPersonId) + .collect(Collectors.toSet()); + } + + /** + * 批量保存 + */ + private void saveBatch(List list, int batchSize) { + // 使用真正的批量插入,分批次执行以提高性能 + for (int i = 0; i < list.size(); i += batchSize) { + int end = Math.min(i + batchSize, list.size()); + List subList = list.subList(i, end); + intermediaryMapper.insertBatch(subList); + } + } + + /** + * 更新导入状态 + */ + private void updateImportStatus(String taskId, String status, ImportResult result) { + String key = "import:intermediary:" + taskId; + Map statusData = new HashMap<>(); + statusData.put("status", status); + statusData.put("successCount", result.getSuccessCount()); + statusData.put("failureCount", result.getFailureCount()); + statusData.put("progress", 100); + statusData.put("endTime", System.currentTimeMillis()); + + if ("SUCCESS".equals(status)) { + statusData.put("message", "全部成功!共导入" + result.getTotalCount() + "条数据"); + } else { + statusData.put("message", "成功" + result.getSuccessCount() + "条,失败" + result.getFailureCount() + "条"); + } + + redisTemplate.opsForHash().putAll(key, statusData); + } + + /** + * 验证个人中介数据 + * + * @param excel Excel数据 + * @param isUpdateSupport 是否支持更新 + * @param existingPersonIds 已存在的证件号集合 + */ + private void validatePersonData(CcdiIntermediaryPersonExcel excel, + Boolean isUpdateSupport, + Set existingPersonIds) { + // 验证必填字段:姓名 + if (StringUtils.isEmpty(excel.getName())) { + throw new RuntimeException("姓名不能为空"); + } + + // 验证必填字段:证件号码 + if (StringUtils.isEmpty(excel.getPersonId())) { + throw new RuntimeException("证件号码不能为空"); + } + + // 验证证件号码格式 + String idCardError = IdCardUtil.getErrorMessage(excel.getPersonId()); + if (idCardError != null) { + throw new RuntimeException("证件号码" + idCardError); + } + + // 如果证件号已存在但未启用更新支持,抛出异常 + if (existingPersonIds.contains(excel.getPersonId()) && !isUpdateSupport) { + throw new RuntimeException("该证件号码已存在"); + } + + // 如果证件号不存在,检查唯一性 + if (!existingPersonIds.contains(excel.getPersonId())) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(CcdiBizIntermediary::getPersonId, excel.getPersonId()); + if (intermediaryMapper.selectCount(wrapper) > 0) { + throw new RuntimeException("该证件号码已存在"); + } + } + } +}