diff --git a/.DS_Store b/.DS_Store index 0adf18c3..b6a1e2df 100644 Binary files a/.DS_Store and b/.DS_Store differ diff --git a/.gitignore b/.gitignore index 49669283..cc928fe6 100644 --- a/.gitignore +++ b/.gitignore @@ -74,9 +74,24 @@ db_config.conf # Local deployment bundles .deploy/ +/ccdi_????????.zip output/ logs/ -.DS_Store \ No newline at end of file +.DS_Store + +ruoyi-ui/vue.config.js + +*/src/test/ + +.pytest_cache/ + +tests/ + +tongweb_62318.properties + +.superpowers/ + +tmp/ diff --git a/.mcp.json b/.mcp.json deleted file mode 100644 index a6867f6a..00000000 --- a/.mcp.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "mcpServers": { - "mysql": { - "command": "node", - "args": [ - "C:/Users/wkc/.codex/mcp-tools/mysql-server/node_modules/@fhuang/mcp-mysql-server/build/index.js" - ], - "env": { - "MYSQL_DATABASE": "ccdi", - "MYSQL_HOST": "116.62.17.81", - "MYSQL_PASSWORD": "Kfcx@1234", - "MYSQL_PORT": "3306", - "MYSQL_USER": "root" - } - } - } -} diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 00000000..f46d5e39 --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +14.21.3 diff --git a/.opencode b/.opencode deleted file mode 100644 index 44a6dca1..00000000 --- a/.opencode +++ /dev/null @@ -1,24 +0,0 @@ -{ - "$schema": "https://opencode.ai/config.json", - "plugin": [ - "oh-my-opencode@latest" - ], - "agent": { - "Sisyphus-Junior": { - "mode": "subagent", - "model": "glm/glm-5" - }, - "oracle": { - "mode": "subagent", - "model": "gmn/gpt-5.3-codex" - }, - "Metis (Plan Consultant)": { - "mode": "subagent", - "model": "gmn/gpt-5.3-codex" - }, - "Momus (Plan Critic)": { - "mode": "subagent", - "model": "gmn/gpt-5.3-codex" - } - } -} diff --git a/AGENTS.md b/AGENTS.md index b09dfae2..43db9a59 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,5 +1,44 @@ # AGENTS.md - AI Coding Assistant Guide +## 全局执行规则 + +### Git + +- Git 提交时使用中文添加描述 +- 无视 `.DS_Store` + +### AGENT + +- `using-superpowers` 只有在用户明确声明调用时才允许启用;不要因为“会话开始”“任务较复杂”或“可能适用”而自动调用 +- 当用户没有明确声明 `using-superpowers` 时,按普通流程直接处理需求 +- 默认不开启 subagent +- 如用户明确要求启用 subagent,所有 subagent 必须使用 `gpt-5.5`;默认推理强度为高,审查类 subagent 的推理强度为超高,测试类 subagent 的推理强度为中 + +### 文档 + +- 写完的设计文档必须要由审查类子代理进行内容审查,确保方案与实施方法符合实际需求,并检查是否还有需要明确的功能点 +- 当功能设计涉及到前端和后端都有改动时,输出两份执行文档,一份为后端的实施计划,一份为前端的实施计划。如果不是前后端架构的项目不需要输出两份执行文档 +- 当功能修改只涉及到前端或只涉及到后端,只需要输出对应的实施计划 +- 每一次改动都需要留下实施文档,记录修改的内容 +- 每次写设计文档的时候,都要检查一下保存路径是否正确 + +### 测试 + +- 测试结束后,自动结束测试时开启的前后端进程 +- 前端 Node 需要使用 nvm 进行控制版本 +- 在完成页面功能开发后,必须使用 `browser-use` 技能打开浏览器进行实际页面测试,禁止打开 prototype 原型页面进行测试 +- 所有生成的测试文件不需要上传到 Git + +### 方案规范 + +当需要给出方案时必须符合以下规范: + +- 不允许给出兼容性或补丁性的方案,不允许过度设计,保持最短路径实现且不能违反第一条要求 +- 不允许自行给出用户提供的需求以外的方案,例如一些兜底和降级方案,这可能导致业务逻辑偏移问题 +- 必须确保方案的逻辑正确,必须经过全链路的逻辑验证 + +--- + ## 项目概述 本仓库是纪检初核系统主仓库,基于若依 `v3.9.1`,当前技术栈以 `Java 21 + Spring Boot 3 + Vue 2` 为主,并包含独立的流水分析 Mock 服务、Docker 部署文件、SQL 脚本、实施文档与测试文档。 @@ -15,19 +54,58 @@ --- -## 协作约定 +## 高优先级规则 - 使用简体中文进行思考和对话 -- Git 提交说明使用中文 -- Git 提交前必须检查暂存区,仅允许包含本次任务相关文件 -- 若暂存区存在无关文件,必须先移出暂存或与用户确认,禁止顺带提交 -- 根据设计文档产出实施计划时,默认输出两份文档: - - 后端实施计划放 `docs/plans/backend/` - - 前端实施计划放 `docs/plans/frontend/` -- 前端开发直接在当前分支进行,不需要额外创建 git worktree +- Git 提交说明必须使用中文 +- 忽略 `.DS_Store` 文件,不将其视为本次任务需要处理或提交的有效变更 +- 仅当用户明确声明调用 `using-superpowers` 时才允许启用;未明确声明时按普通流程直接处理需求 +- Git 提交前必须检查暂存区,仅允许包含本次任务相关文件;若存在无关文件,必须先移出暂存或与用户确认 +- 每一次改动都需要留下实施文档,记录修改内容、影响范围与验证情况 +- 功能设计同时涉及前端和后端改动时,必须分别输出后端与前端两份实施计划;若仅涉及单侧,则只输出对应实施计划 +- 新增或修改设计文档、实施计划、实施记录前,必须先确认保存路径是否正确 +- 前端相关安装、构建、调试、测试命令执行前,必须先通过 `nvm` 切换并确认 Node 版本 - 测试结束后,自动关闭测试过程中启动的前后端进程 +- 重启后端时,必须优先使用 `bin/restart_java_backend.sh` + +--- + +## 协作约定 + +### 基础协作 + +- 前端开发直接在当前分支进行,不需要额外创建 git worktree +- 给出方案时,必须保持最短路径实现,不允许提供兼容性、补丁性或过度设计的方案 +- 不允许自行扩展出用户需求之外的兜底、降级或变体方案,避免业务逻辑偏移 +- 输出方案前必须完成全链路逻辑校验,确保方案逻辑正确、链路闭环 + +### Git 与变更管理 + +- Git 提交前必须检查暂存区,仅保留本次任务相关文件 +- 若暂存区存在无关文件,必须先移出暂存或与用户确认,禁止顺带提交 +- `.DS_Store` 默认忽略,不纳入任务变更范围 + +### 文档产出 + +- 若需求来自设计文档,默认同时沉淀后端与前端两份实施计划 +- 功能设计同时涉及前端和后端改动时,实施计划分别放在 `docs/plans/backend/` 与 `docs/plans/frontend/` +- 功能修改只涉及前端或只涉及后端时,只输出对应的实施计划 +- 非前后端架构项目不强制拆分两份实施计划 +- 每一次改动都需要留下实施文档,实施记录优先放在 `docs/reports/implementation/` +- 每次新增或修改设计文档、实施计划、实施记录前,都要先确认保存路径是否正确 + +### 测试与运行 + +- 测试结束后,自动关闭测试过程中启动的前后端进程 +- 重启后端时,必须优先使用 `bin/restart_java_backend.sh`,不要直接手工执行 `java -jar` 替代正式重启流程 +- 前端相关安装、构建、调试、测试命令执行前,必须先通过 `nvm` 切换并确认 Node 版本 + +### 数据库与编码 + - 遇到 MCP 数据库操作时,使用项目配置文件中的数据库连接信息 -- 执行包含中文内容的 MySQL SQL 脚本或数据库导入时,禁止直接手写 `mysql -e` 或普通重定向执行;必须优先使用 `bin/mysql_utf8_exec.sh `,确保会话字符集为 `utf8mb4`,避免导入或写入乱码 +- 执行包含中文内容的 MySQL SQL 脚本或数据库导入时,禁止直接手写 `mysql -e` 或普通重定向执行;必须优先使用 `bin/mysql_utf8_exec.sh `,确保会话字符集为 `utf8mb4` +- 所有业务表、系统表新增或修改时,必须显式使用 `utf8mb4` 字符集与 `utf8mb4_general_ci` 排序规则 +- 禁止引入 `utf8mb4_0900_ai_ci`、`utf8mb4_unicode_ci` 或其他混用排序规则 - 银行流水打标相关规则与参数编码需要统一使用全大写;新增或修改 `rule_code`、`indicator_code`、`param_code` 时,禁止混用大小写风格 --- @@ -41,7 +119,7 @@ mvn clean compile # 启动主应用(Jar) -cd ruoyi-admin/target && java -jar ruoyi-admin.jar +sh bin/restart_java_backend.sh # 打包全部模块 mvn clean package @@ -61,6 +139,9 @@ mvn clean package -DskipTests ```bash cd ruoyi-ui +# 使用 nvm 切换到项目所需 Node 版本 +nvm use + # 安装依赖 npm install --registry=https://registry.npmmirror.com @@ -164,7 +245,10 @@ return AjaxResult.success(result); - 非业务字段如 `create_by`、`create_time` 由后端自动维护 - 前端表单不要暴露通用审计字段 - 新增菜单、字典、初始化数据时,同步补充 SQL 脚本 -- 执行数据库脚本或导入数据库前,需确认客户端会话字符集为 `utf8mb4`;涉及中文插入、更新、导入时默认使用 `bin/mysql_utf8_exec.sh` +- 执行数据库脚本或导入数据库前,需确认客户端会话字符集为 `utf8mb4` +- 涉及中文插入、更新、导入时默认使用 `bin/mysql_utf8_exec.sh` +- 所有系统表和业务表的表级、字符字段级排序规则统一为 `utf8mb4_general_ci` +- 新增建表 SQL、字段追加 SQL、表结构修复 SQL 必须显式声明字符集与排序规则,避免因默认排序规则漂移导致联表或条件查询报错 ### 前端规范 @@ -180,6 +264,18 @@ return AjaxResult.success(result); - 返回结果仅展示失败数据 - 大数据量导入优先采用 EasyExcel + 异步处理 +### 导入页面测试规范 + +- 导入功能测试必须进入真实业务页面执行,先在页面内下载当前导入模板,再基于该模板生成测试文件,禁止手工凭记忆新建表头或脱离页面直接构造上传文件 +- 双 Sheet 模板的导入测试必须覆盖两个 Sheet 的联动关系;除“缺少 Sheet / 空 Sheet”专项场景外,默认两个 Sheet 都要准备测试数据 +- 导入测试文件优先放在 `output/spreadsheet/` 或 `output/browser-use/`,不提交到 git +- 需要按场景拆分测试文件,避免多个互斥校验互相覆盖;至少覆盖空模板、主信息必填、主信息格式与金额、主从关系异常、供应商校验、缺少/空 Sheet、成功导入、成功与失败混合、失败记录查看、导入后清理回滚 +- 主从关系异常测试至少覆盖:已存在主键、供应商有数据但主信息缺失、主信息重复、供应商 Sheet 中采购事项 ID 为空 +- 供应商校验测试至少覆盖:重复供应商、多条中标、供应商名称为空、名称超长、联系人超长、银行账户超长、联系电话非法、统一信用代码非法、是否中标枚举非法 +- 页面上传后必须核对页面提示、导入状态、失败记录弹窗和列表总数变化;异步导入场景还要核对任务状态从 `PROCESSING` 到最终状态的变化 +- 对“成功导入 + 异常数据混合”的样本,必须额外核对成功数据是否真正入库、异常数据是否被拦截,以及是否存在被静默忽略的行 +- 导入测试结束后,必须删除本轮成功写入的测试数据,清理页面本地导入任务缓存,并关闭测试过程中启动的前后端进程 + --- ## 当前仓库结构 @@ -225,15 +321,10 @@ ccdi/ ### 主要业务代码分布 - `ccdi-info-collection/src/main/java/com/ruoyi/info/collection/` - - 含 `controller`、`domain`、`mapper`、`service`、`annotation`、`validation` 等目录 - `ccdi-project/src/main/java/com/ruoyi/ccdi/project/` - - 含 `config`、`controller`、`domain`、`mapper`、`service` - `ccdi-lsfx/src/main/java/com/ruoyi/lsfx/` - - 含 `client`、`config`、`constants`、`controller`、`domain/request`、`domain/response` - `ruoyi-ui/src/views/` - - 当前包含 `ccdi`、`ccdiBaseStaff`、`ccdiProject`、`ccdiPurchaseTransaction`、`ccdiIntermediary`、亲属关系、员工调动、招聘等业务页面 - `ruoyi-ui/src/api/ccdi/` - - 放置纪检初核业务 API 封装 ### 添加新后端模块时 @@ -294,6 +385,9 @@ ccdi/ - 只有历史资料或外部原始材料才放入 `assets/` - 如果移动了文档,需同步修正文档内引用路径 - 若需求来自设计文档,默认同时沉淀后端与前端两份实施计划 +- 功能设计同时涉及前端和后端改动时,必须分别输出后端与前端两份实施计划;若仅涉及前端或仅涉及后端,则只输出对应实施计划;非前后端架构项目不强制拆分双文档 +- 每一次改动都需要留下实施文档,记录本次修改内容、影响范围与验证情况,实施记录优先放在 `docs/reports/implementation/` +- 每次新增或修改设计文档、实施计划、实施记录前,都要先确认保存路径是否正确 --- @@ -304,3 +398,4 @@ ccdi/ - `docker/backend`、`docker/frontend`、`docker/mock` 分别对应三类运行时镜像 - `sql/migration/` 用于增量迁移脚本,新增修复脚本优先按日期或功能命名 - 启动前后端或 Mock 服务做验证后,结束测试时要主动停止进程,避免残留占用端口 +- 前端相关安装、构建、调试、测试命令执行前,必须先通过 `nvm` 切换并确认 Node 版本 diff --git a/assets/异常账户.xlsx b/assets/异常账户.xlsx new file mode 100644 index 00000000..5c6d9bc3 Binary files /dev/null and b/assets/异常账户.xlsx differ diff --git a/bin/restart_java_backend.sh b/bin/restart_java_backend.sh index eebe6aeb..f92dcc79 100755 --- a/bin/restart_java_backend.sh +++ b/bin/restart_java_backend.sh @@ -83,7 +83,7 @@ collect_pids() { fi fi - marker_pids=$(pgrep -f "$APP_MARKER" 2>/dev/null || true) + marker_pids=$(pgrep -f -- "$APP_MARKER" 2>/dev/null || true) if [ -n "${marker_pids:-}" ]; then for pid in $marker_pids; do if is_managed_backend_pid "$pid"; then @@ -92,6 +92,15 @@ collect_pids() { done fi + port_pids=$(lsof -tiTCP:"$SERVER_PORT" -sTCP:LISTEN 2>/dev/null || true) + if [ -n "${port_pids:-}" ]; then + for pid in $port_pids; do + if is_managed_backend_pid "$pid"; then + all_pids="$all_pids $pid" + fi + done + fi + unique_pids="" for pid in $all_pids; do case " $unique_pids " in @@ -206,7 +215,7 @@ follow_logs() { start_action() { running_pids=$(collect_pids) if [ -n "${running_pids:-}" ]; then - log_error "检测到已有后端进程在运行: $running_pids,请先执行 stop 或 restart" + log_error "检测到已有后端进程在运行: ${running_pids},请先执行 stop 或 restart" exit 1 fi diff --git a/build_release_ccdi.sh b/build_release_ccdi.sh new file mode 100755 index 00000000..b5f2d6c1 --- /dev/null +++ b/build_release_ccdi.sh @@ -0,0 +1,92 @@ +#!/bin/sh + +set -eu + +ROOT_DIR=$(CDPATH= cd -- "$(dirname "$0")" && pwd) +DATE_STAMP=$(date "+%Y%m%d") +RELEASE_ZIP="$ROOT_DIR/ccdi_${DATE_STAMP}.zip" +STAGE_DIR="$ROOT_DIR/.deploy/ccdi-release-package" +WORK_DIR="$STAGE_DIR/files" +BACKEND_JAR_SOURCE="$ROOT_DIR/ruoyi-admin/target/ruoyi-admin.jar" +FRONTEND_DIR="$ROOT_DIR/ruoyi-ui" +FRONTEND_DIST_DIR="$FRONTEND_DIR/dist" +FRONTEND_DIST_ZIP="$WORK_DIR/dist.zip" + +log_info() { + printf '[%s] %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$1" +} + +log_error() { + printf '[%s] %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$1" >&2 +} + +require_command() { + if ! command -v "$1" >/dev/null 2>&1; then + log_error "缺少命令: $1" + exit 1 + fi +} + +reset_stage_dir() { + rm -rf "$STAGE_DIR" + mkdir -p "$WORK_DIR" +} + +build_backend() { + log_info "开始构建后端生产 jar" + ( + cd "$ROOT_DIR" + mvn -pl ruoyi-admin -am clean package -DskipTests + ) + + if [ ! -f "$BACKEND_JAR_SOURCE" ]; then + log_error "未生成后端 jar: $BACKEND_JAR_SOURCE" + exit 1 + fi +} + +build_frontend() { + log_info "开始构建前端生产 dist" + FRONTEND_DIR="$FRONTEND_DIR" zsh -lic 'cd "$FRONTEND_DIR" && nvm use >/dev/null && npm run build:prod' + + if [ ! -f "$FRONTEND_DIST_DIR/index.html" ]; then + log_error "前端生产构建失败,未找到: $FRONTEND_DIST_DIR/index.html" + exit 1 + fi + + ( + cd "$FRONTEND_DIR" + zip -qr "$FRONTEND_DIST_ZIP" dist + ) + + if [ ! -f "$FRONTEND_DIST_ZIP" ]; then + log_error "未生成前端压缩包: $FRONTEND_DIST_ZIP" + exit 1 + fi +} + +package_release() { + cp "$BACKEND_JAR_SOURCE" "$WORK_DIR/ruoyi-admin.jar" + + rm -f "$RELEASE_ZIP" + ( + cd "$WORK_DIR" + zip -qr "$RELEASE_ZIP" ruoyi-admin.jar dist.zip + ) + + log_info "上线压缩包已生成: $RELEASE_ZIP" + log_info "压缩包根层内容: ruoyi-admin.jar, dist.zip" +} + +main() { + require_command mvn + require_command zsh + require_command zip + + reset_stage_dir + build_backend + build_frontend + package_release +} + +main "$@" diff --git a/ccdi-info-collection/pom.xml b/ccdi-info-collection/pom.xml index 8158c290..5c99e018 100644 --- a/ccdi-info-collection/pom.xml +++ b/ccdi-info-collection/pom.xml @@ -57,6 +57,12 @@ spring-boot-starter-test test + + com.ruoyi + ccdi-lsfx + 3.9.1 + compile + diff --git a/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/controller/CcdiAccountInfoController.java b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/controller/CcdiAccountInfoController.java index baf1fcd9..35ca27a8 100644 --- a/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/controller/CcdiAccountInfoController.java +++ b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/controller/CcdiAccountInfoController.java @@ -80,18 +80,6 @@ public class CcdiAccountInfoController extends BaseController { return success(accountInfoService.selectAccountInfoById(id)); } - /** - * 导出账户库列表 - */ - @Operation(summary = "导出账户库列表") - @PreAuthorize("@ss.hasPermi('ccdi:accountInfo:export')") - @Log(title = "账户库管理", businessType = BusinessType.EXPORT) - @PostMapping("/export") - public void export(HttpServletResponse response, CcdiAccountInfoQueryDTO queryDTO) { - List list = accountInfoService.selectAccountInfoListForExport(queryDTO); - EasyExcelUtil.exportExcel(response, list, CcdiAccountInfoExcel.class, "账户库管理"); - } - /** * 新增账户 */ diff --git a/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/controller/CcdiBaseStaffController.java b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/controller/CcdiBaseStaffController.java index 249f6915..a8a358eb 100644 --- a/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/controller/CcdiBaseStaffController.java +++ b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/controller/CcdiBaseStaffController.java @@ -4,8 +4,10 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.ruoyi.info.collection.domain.dto.CcdiBaseStaffAddDTO; import com.ruoyi.info.collection.domain.dto.CcdiBaseStaffEditDTO; import com.ruoyi.info.collection.domain.dto.CcdiBaseStaffQueryDTO; +import com.ruoyi.info.collection.domain.excel.CcdiBaseStaffAssetInfoExcel; import com.ruoyi.info.collection.domain.excel.CcdiBaseStaffExcel; import com.ruoyi.info.collection.domain.vo.*; +import com.ruoyi.info.collection.service.ICcdiBaseStaffAssetImportService; import com.ruoyi.info.collection.service.ICcdiBaseStaffImportService; import com.ruoyi.info.collection.service.ICcdiBaseStaffService; import com.ruoyi.info.collection.utils.EasyExcelUtil; @@ -45,6 +47,9 @@ public class CcdiBaseStaffController extends BaseController { @Resource private ICcdiBaseStaffImportService importAsyncService; + @Resource + private ICcdiBaseStaffAssetImportService baseStaffAssetImportService; + /** * 查询员工列表 */ @@ -70,18 +75,6 @@ public class CcdiBaseStaffController extends BaseController { return success(list); } - /** - * 导出员工列表 - */ - @Operation(summary = "导出员工列表") - @PreAuthorize("@ss.hasPermi('ccdi:baseStaff:export')") - @Log(title = "员工信息", businessType = BusinessType.EXPORT) - @PostMapping("/export") - public void export(HttpServletResponse response, CcdiBaseStaffQueryDTO queryDTO) { - List list = baseStaffService.selectBaseStaffListForExport(queryDTO); - EasyExcelUtil.exportExcel(response, list, CcdiBaseStaffExcel.class, "员工信息"); - } - /** * 获取员工详细信息 */ @@ -132,7 +125,14 @@ public class CcdiBaseStaffController extends BaseController { @Operation(summary = "下载导入模板") @PostMapping("/importTemplate") public void importTemplate(HttpServletResponse response) { - EasyExcelUtil.importTemplateWithDictDropdown(response, CcdiBaseStaffExcel.class, "员工信息"); + EasyExcelUtil.importTemplateWithDictDropdown( + response, + CcdiBaseStaffExcel.class, + "员工信息", + CcdiBaseStaffAssetInfoExcel.class, + "员工资产信息", + "员工信息维护导入模板" + ); } /** @@ -142,21 +142,33 @@ public class CcdiBaseStaffController extends BaseController { @PreAuthorize("@ss.hasPermi('ccdi:baseStaff:import')") @Log(title = "员工信息", businessType = BusinessType.IMPORT) @PostMapping("/importData") - public AjaxResult importData(MultipartFile file, boolean updateSupport) throws Exception { - List list = EasyExcelUtil.importExcel(file.getInputStream(), CcdiBaseStaffExcel.class); + public AjaxResult importData(MultipartFile file) throws Exception { + List staffList = EasyExcelUtil.importExcel( + file.getInputStream(), + CcdiBaseStaffExcel.class, + "员工信息" + ); + List assetList = EasyExcelUtil.importExcel( + file.getInputStream(), + CcdiBaseStaffAssetInfoExcel.class, + "员工资产信息" + ); - if (list == null || list.isEmpty()) { + boolean hasStaffRows = staffList != null && !staffList.isEmpty(); + boolean hasAssetRows = assetList != null && !assetList.isEmpty(); + + if (!hasStaffRows && !hasAssetRows) { return error("至少需要一条数据"); } - // 提交异步任务 - String taskId = baseStaffService.importBaseStaff(list, updateSupport); - - // 立即返回,不等待后台任务完成 - ImportResultVO result = new ImportResultVO(); - result.setTaskId(taskId); - result.setStatus("PROCESSING"); - result.setMessage("导入任务已提交,正在后台处理"); + BaseStaffImportSubmitResultVO result = new BaseStaffImportSubmitResultVO(); + if (hasStaffRows) { + result.setStaffTaskId(baseStaffService.importBaseStaff(staffList)); + } + if (hasAssetRows) { + result.setAssetTaskId(baseStaffAssetImportService.importAssetInfo(assetList)); + } + result.setMessage(buildImportSubmitMessage(hasStaffRows, hasAssetRows)); return AjaxResult.success("导入任务已提交,正在后台处理", result); } @@ -202,4 +214,14 @@ public class CcdiBaseStaffController extends BaseController { return getDataTable(pageData, failures.size()); } + + private String buildImportSubmitMessage(boolean hasStaffRows, boolean hasAssetRows) { + if (hasStaffRows && hasAssetRows) { + return "已提交员工信息和员工资产信息导入任务"; + } + if (hasStaffRows) { + return "已提交员工信息导入任务"; + } + return "已提交员工资产信息导入任务"; + } } diff --git a/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/controller/CcdiCustEnterpriseRelationController.java b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/controller/CcdiCustEnterpriseRelationController.java index 625edf1e..1e8d7d50 100644 --- a/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/controller/CcdiCustEnterpriseRelationController.java +++ b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/controller/CcdiCustEnterpriseRelationController.java @@ -63,18 +63,6 @@ public class CcdiCustEnterpriseRelationController extends BaseController { return getDataTable(result.getRecords(), result.getTotal()); } - /** - * 导出信贷客户实体关联列表 - */ - @Operation(summary = "导出信贷客户实体关联列表") - @PreAuthorize("@ss.hasPermi('ccdi:custEnterpriseRelation:export')") - @Log(title = "信贷客户实体关联信息", businessType = BusinessType.EXPORT) - @PostMapping("/export") - public void export(HttpServletResponse response, CcdiCustEnterpriseRelationQueryDTO queryDTO) { - List list = relationService.selectRelationListForExport(queryDTO); - EasyExcelUtil.exportExcel(response, list, CcdiCustEnterpriseRelationExcel.class, "信贷客户实体关联信息"); - } - /** * 获取信贷客户实体关联详细信息 */ diff --git a/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/controller/CcdiCustFmyRelationController.java b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/controller/CcdiCustFmyRelationController.java index 09a5a5e8..60b304da 100644 --- a/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/controller/CcdiCustFmyRelationController.java +++ b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/controller/CcdiCustFmyRelationController.java @@ -103,17 +103,6 @@ public class CcdiCustFmyRelationController extends BaseController { return toAjax(relationService.deleteRelationByIds(ids)); } - /** - * 导出信贷客户家庭关系 - */ - @Operation(summary = "导出信贷客户家庭关系") - @PreAuthorize("@ss.hasPermi('ccdi:custFmyRelation:export')") - @Log(title = "信贷客户家庭关系", businessType = BusinessType.EXPORT) - @PostMapping("/export") - public void export(HttpServletResponse response, CcdiCustFmyRelationQueryDTO query) { - relationService.exportRelations(query, response); - } - /** * 下载带字典下拉框的导入模板 * 使用@DictDropdown注解自动添加下拉框 diff --git a/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/controller/CcdiEnterpriseBaseInfoController.java b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/controller/CcdiEnterpriseBaseInfoController.java new file mode 100644 index 00000000..39680a36 --- /dev/null +++ b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/controller/CcdiEnterpriseBaseInfoController.java @@ -0,0 +1,146 @@ +package com.ruoyi.info.collection.controller; + +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +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.PageDomain; +import com.ruoyi.common.core.page.TableDataInfo; +import com.ruoyi.common.core.page.TableSupport; +import com.ruoyi.common.enums.BusinessType; +import com.ruoyi.info.collection.domain.dto.CcdiEnterpriseBaseInfoAddDTO; +import com.ruoyi.info.collection.domain.dto.CcdiEnterpriseBaseInfoEditDTO; +import com.ruoyi.info.collection.domain.dto.CcdiEnterpriseBaseInfoQueryDTO; +import com.ruoyi.info.collection.domain.excel.CcdiEnterpriseBaseInfoExcel; +import com.ruoyi.info.collection.domain.vo.CcdiEnterpriseBaseInfoVO; +import com.ruoyi.info.collection.domain.vo.EnterpriseBaseInfoImportFailureVO; +import com.ruoyi.info.collection.domain.vo.ImportResultVO; +import com.ruoyi.info.collection.domain.vo.ImportStatusVO; +import com.ruoyi.info.collection.service.ICcdiEnterpriseBaseInfoImportService; +import com.ruoyi.info.collection.service.ICcdiEnterpriseBaseInfoService; +import com.ruoyi.info.collection.utils.EasyExcelUtil; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; + +import java.util.ArrayList; +import java.util.List; + +/** + * 实体库管理 Controller + * + * @author ruoyi + * @date 2026-04-17 + */ +@Tag(name = "实体库管理") +@RestController +@RequestMapping("/ccdi/enterpriseBaseInfo") +public class CcdiEnterpriseBaseInfoController extends BaseController { + + @Resource + private ICcdiEnterpriseBaseInfoService enterpriseBaseInfoService; + + @Resource + private ICcdiEnterpriseBaseInfoImportService enterpriseBaseInfoImportService; + + @Operation(summary = "查询实体库列表") + @PreAuthorize("@ss.hasPermi('ccdi:enterpriseBaseInfo:list')") + @GetMapping("/list") + public TableDataInfo list(CcdiEnterpriseBaseInfoQueryDTO queryDTO) { + PageDomain pageDomain = TableSupport.buildPageRequest(); + Page page = new Page<>(pageDomain.getPageNum(), pageDomain.getPageSize()); + Page result = enterpriseBaseInfoService.selectEnterpriseBaseInfoPage(page, queryDTO); + return getDataTable(result.getRecords(), result.getTotal()); + } + + @Operation(summary = "获取实体库详细信息") + @PreAuthorize("@ss.hasPermi('ccdi:enterpriseBaseInfo:query')") + @GetMapping("/{socialCreditCode}") + public AjaxResult getInfo(@PathVariable String socialCreditCode) { + return success(enterpriseBaseInfoService.selectEnterpriseBaseInfoById(socialCreditCode)); + } + + @Operation(summary = "新增实体库信息") + @PreAuthorize("@ss.hasPermi('ccdi:enterpriseBaseInfo:add')") + @Log(title = "实体库管理", businessType = BusinessType.INSERT) + @PostMapping + public AjaxResult add(@Validated @RequestBody CcdiEnterpriseBaseInfoAddDTO addDTO) { + return toAjax(enterpriseBaseInfoService.insertEnterpriseBaseInfo(addDTO)); + } + + @Operation(summary = "修改实体库信息") + @PreAuthorize("@ss.hasPermi('ccdi:enterpriseBaseInfo:edit')") + @Log(title = "实体库管理", businessType = BusinessType.UPDATE) + @PutMapping + public AjaxResult edit(@Validated @RequestBody CcdiEnterpriseBaseInfoEditDTO editDTO) { + return toAjax(enterpriseBaseInfoService.updateEnterpriseBaseInfo(editDTO)); + } + + @Operation(summary = "删除实体库信息") + @PreAuthorize("@ss.hasPermi('ccdi:enterpriseBaseInfo:remove')") + @Log(title = "实体库管理", businessType = BusinessType.DELETE) + @DeleteMapping("/{socialCreditCodes}") + public AjaxResult remove(@PathVariable String[] socialCreditCodes) { + return toAjax(enterpriseBaseInfoService.deleteEnterpriseBaseInfoByIds(socialCreditCodes)); + } + + @Operation(summary = "下载导入模板") + @PostMapping("/importTemplate") + public void importTemplate(HttpServletResponse response) { + EasyExcelUtil.importTemplateWithDictDropdown(response, CcdiEnterpriseBaseInfoExcel.class, "实体库管理"); + } + + @Operation(summary = "导入实体库信息") + @PreAuthorize("@ss.hasPermi('ccdi:enterpriseBaseInfo:import')") + @Log(title = "实体库管理", businessType = BusinessType.IMPORT) + @PostMapping("/importData") + public AjaxResult importData(MultipartFile file) throws Exception { + List list = EasyExcelUtil.importExcel(file.getInputStream(), CcdiEnterpriseBaseInfoExcel.class); + if (list == null || list.isEmpty()) { + return error("至少需要一条数据"); + } + + String taskId = enterpriseBaseInfoService.importEnterpriseBaseInfo(list); + ImportResultVO result = new ImportResultVO(); + result.setTaskId(taskId); + result.setStatus("PROCESSING"); + result.setMessage("导入任务已提交,正在后台处理"); + return AjaxResult.success("导入任务已提交,正在后台处理", result); + } + + @Operation(summary = "查询导入状态") + @PreAuthorize("@ss.hasPermi('ccdi:enterpriseBaseInfo:import')") + @GetMapping("/importStatus/{taskId}") + public AjaxResult getImportStatus(@PathVariable String taskId) { + ImportStatusVO status = enterpriseBaseInfoImportService.getImportStatus(taskId); + return success(status); + } + + @Operation(summary = "查询导入失败记录") + @PreAuthorize("@ss.hasPermi('ccdi:enterpriseBaseInfo:import')") + @GetMapping("/importFailures/{taskId}") + public TableDataInfo getImportFailures(@PathVariable String taskId, + @RequestParam(defaultValue = "1") Integer pageNum, + @RequestParam(defaultValue = "10") Integer pageSize) { + List failures = enterpriseBaseInfoImportService.getImportFailures(taskId); + int fromIndex = (pageNum - 1) * pageSize; + if (fromIndex >= failures.size()) { + return getDataTable(new ArrayList<>(), failures.size()); + } + int toIndex = Math.min(fromIndex + pageSize, failures.size()); + return getDataTable(failures.subList(fromIndex, toIndex), failures.size()); + } +} diff --git a/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/controller/CcdiEnumController.java b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/controller/CcdiEnumController.java index 8acffc0e..3317623e 100644 --- a/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/controller/CcdiEnumController.java +++ b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/controller/CcdiEnumController.java @@ -138,4 +138,30 @@ public class CcdiEnumController { } return AjaxResult.success(options); } + + /** + * 获取实体风险等级选项 + */ + @Operation(summary = "获取实体风险等级选项") + @GetMapping("/enterpriseRiskLevel") + public AjaxResult getEnterpriseRiskLevelOptions() { + List options = new ArrayList<>(); + for (EnterpriseRiskLevel level : EnterpriseRiskLevel.values()) { + options.add(new EnumOptionVO(level.getCode(), level.getDesc())); + } + return AjaxResult.success(options); + } + + /** + * 获取企业来源选项 + */ + @Operation(summary = "获取企业来源选项") + @GetMapping("/enterpriseSource") + public AjaxResult getEnterpriseSourceOptions() { + List options = new ArrayList<>(); + for (EnterpriseSource source : EnterpriseSource.values()) { + options.add(new EnumOptionVO(source.getCode(), source.getDesc())); + } + return AjaxResult.success(options); + } } diff --git a/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/controller/CcdiIntermediaryController.java b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/controller/CcdiIntermediaryController.java index 070ff06d..6a5a4050 100644 --- a/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/controller/CcdiIntermediaryController.java +++ b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/controller/CcdiIntermediaryController.java @@ -2,10 +2,10 @@ package com.ruoyi.info.collection.controller; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.ruoyi.info.collection.domain.dto.*; -import com.ruoyi.info.collection.domain.excel.CcdiIntermediaryEntityExcel; +import com.ruoyi.info.collection.domain.excel.CcdiIntermediaryEnterpriseRelationExcel; import com.ruoyi.info.collection.domain.excel.CcdiIntermediaryPersonExcel; import com.ruoyi.info.collection.domain.vo.*; -import com.ruoyi.info.collection.service.ICcdiIntermediaryEntityImportService; +import com.ruoyi.info.collection.service.ICcdiIntermediaryEnterpriseRelationImportService; import com.ruoyi.info.collection.service.ICcdiIntermediaryPersonImportService; import com.ruoyi.info.collection.service.ICcdiIntermediaryService; import com.ruoyi.info.collection.utils.EasyExcelUtil; @@ -46,7 +46,7 @@ public class CcdiIntermediaryController extends BaseController { private ICcdiIntermediaryPersonImportService personImportService; @Resource - private ICcdiIntermediaryEntityImportService entityImportService; + private ICcdiIntermediaryEnterpriseRelationImportService enterpriseRelationImportService; /** * 查询中介列表 @@ -72,6 +72,26 @@ public class CcdiIntermediaryController extends BaseController { return success(vo); } + /** + * 查询中介亲属列表 + */ + @Operation(summary = "查询中介亲属列表") + @PreAuthorize("@ss.hasPermi('ccdi:intermediary:query')") + @GetMapping("/{bizId}/relatives") + public AjaxResult getRelativeList(@PathVariable String bizId) { + return success(intermediaryService.selectIntermediaryRelativeList(bizId)); + } + + /** + * 查询中介亲属详情 + */ + @Operation(summary = "查询中介亲属详情") + @PreAuthorize("@ss.hasPermi('ccdi:intermediary:query')") + @GetMapping("/relative/{relativeBizId}") + public AjaxResult getRelativeInfo(@PathVariable String relativeBizId) { + return success(intermediaryService.selectIntermediaryRelativeDetail(relativeBizId)); + } + /** * 查询实体中介详情 */ @@ -105,6 +125,28 @@ public class CcdiIntermediaryController extends BaseController { return toAjax(intermediaryService.updateIntermediaryPerson(editDTO)); } + /** + * 新增中介亲属 + */ + @Operation(summary = "新增中介亲属") + @PreAuthorize("@ss.hasPermi('ccdi:intermediary:add')") + @Log(title = "中介亲属", businessType = BusinessType.INSERT) + @PostMapping("/{bizId}/relative") + public AjaxResult addRelative(@PathVariable String bizId, @Validated @RequestBody CcdiIntermediaryRelativeAddDTO addDTO) { + return toAjax(intermediaryService.insertIntermediaryRelative(bizId, addDTO)); + } + + /** + * 修改中介亲属 + */ + @Operation(summary = "修改中介亲属") + @PreAuthorize("@ss.hasPermi('ccdi:intermediary:edit')") + @Log(title = "中介亲属", businessType = BusinessType.UPDATE) + @PutMapping("/relative") + public AjaxResult editRelative(@Validated @RequestBody CcdiIntermediaryRelativeEditDTO editDTO) { + return toAjax(intermediaryService.updateIntermediaryRelative(editDTO)); + } + /** * 新增实体中介 */ @@ -127,6 +169,49 @@ public class CcdiIntermediaryController extends BaseController { return toAjax(intermediaryService.updateIntermediaryEntity(editDTO)); } + /** + * 查询中介关联机构列表 + */ + @Operation(summary = "查询中介关联机构列表") + @PreAuthorize("@ss.hasPermi('ccdi:intermediary:query')") + @GetMapping("/{bizId}/enterprise-relations") + public AjaxResult getEnterpriseRelationList(@PathVariable String bizId) { + return success(intermediaryService.selectIntermediaryEnterpriseRelationList(bizId)); + } + + /** + * 查询中介关联机构详情 + */ + @Operation(summary = "查询中介关联机构详情") + @PreAuthorize("@ss.hasPermi('ccdi:intermediary:query')") + @GetMapping("/enterprise-relation/{id}") + public AjaxResult getEnterpriseRelationInfo(@PathVariable Long id) { + return success(intermediaryService.selectIntermediaryEnterpriseRelationDetail(id)); + } + + /** + * 新增中介关联机构 + */ + @Operation(summary = "新增中介关联机构") + @PreAuthorize("@ss.hasPermi('ccdi:intermediary:add')") + @Log(title = "中介关联机构", businessType = BusinessType.INSERT) + @PostMapping("/{bizId}/enterprise-relation") + public AjaxResult addEnterpriseRelation(@PathVariable String bizId, + @Validated @RequestBody CcdiIntermediaryEnterpriseRelationAddDTO addDTO) { + return toAjax(intermediaryService.insertIntermediaryEnterpriseRelation(bizId, addDTO)); + } + + /** + * 修改中介关联机构 + */ + @Operation(summary = "修改中介关联机构") + @PreAuthorize("@ss.hasPermi('ccdi:intermediary:edit')") + @Log(title = "中介关联机构", businessType = BusinessType.UPDATE) + @PutMapping("/enterprise-relation") + public AjaxResult editEnterpriseRelation(@Validated @RequestBody CcdiIntermediaryEnterpriseRelationEditDTO editDTO) { + return toAjax(intermediaryService.updateIntermediaryEnterpriseRelation(editDTO)); + } + /** * 删除中介 */ @@ -138,6 +223,28 @@ public class CcdiIntermediaryController extends BaseController { return toAjax(intermediaryService.deleteIntermediaryByIds(ids)); } + /** + * 删除中介亲属 + */ + @Operation(summary = "删除中介亲属") + @PreAuthorize("@ss.hasPermi('ccdi:intermediary:remove')") + @Log(title = "中介亲属", businessType = BusinessType.DELETE) + @DeleteMapping("/relative/{relativeBizId}") + public AjaxResult removeRelative(@PathVariable String relativeBizId) { + return toAjax(intermediaryService.deleteIntermediaryRelative(relativeBizId)); + } + + /** + * 删除中介关联机构 + */ + @Operation(summary = "删除中介关联机构") + @PreAuthorize("@ss.hasPermi('ccdi:intermediary:remove')") + @Log(title = "中介关联机构", businessType = BusinessType.DELETE) + @DeleteMapping("/enterprise-relation/{id}") + public AjaxResult removeEnterpriseRelation(@PathVariable Long id) { + return toAjax(intermediaryService.deleteIntermediaryEnterpriseRelation(id)); + } + /** * 校验人员ID唯一性 */ @@ -170,10 +277,10 @@ public class CcdiIntermediaryController extends BaseController { /** * 下载实体中介导入模板 */ - @Operation(summary = "下载实体中介导入模板") - @PostMapping("/importEntityTemplate") - public void importEntityTemplate(HttpServletResponse response) { - EasyExcelUtil.importTemplateWithDictDropdown(response, CcdiIntermediaryEntityExcel.class, "实体中介信息"); + @Operation(summary = "下载中介实体关联关系导入模板") + @PostMapping("/importEnterpriseRelationTemplate") + public void importEnterpriseRelationTemplate(HttpServletResponse response) { + EasyExcelUtil.importTemplateWithDictDropdown(response, CcdiIntermediaryEnterpriseRelationExcel.class, "中介实体关联关系信息"); } /** @@ -206,20 +313,19 @@ public class CcdiIntermediaryController extends BaseController { /** * 导入实体中介数据(异步) */ - @Operation(summary = "导入实体中介数据") + @Operation(summary = "导入中介实体关联关系数据") @PreAuthorize("@ss.hasPermi('ccdi:intermediary:import')") - @Log(title = "实体中介", businessType = BusinessType.IMPORT) - @PostMapping("/importEntityData") - public AjaxResult importEntityData(MultipartFile file) throws Exception { - List list = EasyExcelUtil.importExcel( - file.getInputStream(), CcdiIntermediaryEntityExcel.class); + @Log(title = "中介实体关联关系", businessType = BusinessType.IMPORT) + @PostMapping("/importEnterpriseRelationData") + public AjaxResult importEnterpriseRelationData(MultipartFile file) throws Exception { + List list = EasyExcelUtil.importExcel( + file.getInputStream(), CcdiIntermediaryEnterpriseRelationExcel.class); if (list == null || list.isEmpty()) { return error("至少需要一条数据"); } - // 提交异步任务 - String taskId = intermediaryService.importIntermediaryEntity(list); + String taskId = intermediaryService.importIntermediaryEnterpriseRelation(list); // 立即返回,不等待后台任务完成 ImportResultVO result = new ImportResultVO(); @@ -276,12 +382,12 @@ public class CcdiIntermediaryController extends BaseController { /** * 查询实体中介导入状态 */ - @Operation(summary = "查询实体中介导入状态") + @Operation(summary = "查询中介实体关联关系导入状态") @PreAuthorize("@ss.hasPermi('ccdi:intermediary:import')") - @GetMapping("/importEntityStatus/{taskId}") - public AjaxResult getEntityImportStatus(@PathVariable String taskId) { + @GetMapping("/importEnterpriseRelationStatus/{taskId}") + public AjaxResult getEnterpriseRelationImportStatus(@PathVariable String taskId) { try { - ImportStatusVO status = entityImportService.getImportStatus(taskId); + ImportStatusVO status = enterpriseRelationImportService.getImportStatus(taskId); return success(status); } catch (Exception e) { return error(e.getMessage()); @@ -289,18 +395,18 @@ public class CcdiIntermediaryController extends BaseController { } /** - * 查询实体中介导入失败记录 + * 查询中介实体关联关系导入失败记录 */ - @Operation(summary = "查询实体中介导入失败记录") + @Operation(summary = "查询中介实体关联关系导入失败记录") @PreAuthorize("@ss.hasPermi('ccdi:intermediary:import')") - @GetMapping("/importEntityFailures/{taskId}") - public TableDataInfo getEntityImportFailures( + @GetMapping("/importEnterpriseRelationFailures/{taskId}") + public TableDataInfo getEnterpriseRelationImportFailures( @PathVariable String taskId, @RequestParam(defaultValue = "1") Integer pageNum, @RequestParam(defaultValue = "10") Integer pageSize) { - List failures = - entityImportService.getImportFailures(taskId); + List failures = + enterpriseRelationImportService.getImportFailures(taskId); // 手动分页 int fromIndex = (pageNum - 1) * pageSize; @@ -311,7 +417,7 @@ public class CcdiIntermediaryController extends BaseController { return getDataTable(new ArrayList<>(), failures.size()); } - List pageData = failures.subList(fromIndex, toIndex); + List pageData = failures.subList(fromIndex, toIndex); return getDataTable(pageData, failures.size()); } diff --git a/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/controller/CcdiPurchaseTransactionController.java b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/controller/CcdiPurchaseTransactionController.java index 4abc08e5..3bb210d4 100644 --- a/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/controller/CcdiPurchaseTransactionController.java +++ b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/controller/CcdiPurchaseTransactionController.java @@ -5,6 +5,7 @@ import com.ruoyi.info.collection.domain.dto.CcdiPurchaseTransactionAddDTO; import com.ruoyi.info.collection.domain.dto.CcdiPurchaseTransactionEditDTO; import com.ruoyi.info.collection.domain.dto.CcdiPurchaseTransactionQueryDTO; import com.ruoyi.info.collection.domain.excel.CcdiPurchaseTransactionExcel; +import com.ruoyi.info.collection.domain.excel.CcdiPurchaseTransactionSupplierExcel; import com.ruoyi.info.collection.domain.vo.CcdiPurchaseTransactionVO; import com.ruoyi.info.collection.domain.vo.ImportResultVO; import com.ruoyi.info.collection.domain.vo.ImportStatusVO; @@ -33,12 +34,12 @@ import java.util.ArrayList; import java.util.List; /** - * 采购交易信息Controller + * 招投标信息维护Controller * * @author ruoyi * @date 2026-02-06 */ -@Tag(name = "采购交易信息管理") +@Tag(name = "招投标信息维护") @RestController @RequestMapping("/ccdi/purchaseTransaction") public class CcdiPurchaseTransactionController extends BaseController { @@ -50,9 +51,9 @@ public class CcdiPurchaseTransactionController extends BaseController { private ICcdiPurchaseTransactionImportService transactionImportService; /** - * 查询采购交易列表 + * 查询招投标信息列表 */ - @Operation(summary = "查询采购交易列表") + @Operation(summary = "查询招投标信息列表") @PreAuthorize("@ss.hasPermi('ccdi:purchaseTransaction:list')") @GetMapping("/list") public TableDataInfo list(CcdiPurchaseTransactionQueryDTO queryDTO) { @@ -64,21 +65,9 @@ public class CcdiPurchaseTransactionController extends BaseController { } /** - * 导出采购交易列表 + * 获取招投标信息详细信息 */ - @Operation(summary = "导出采购交易列表") - @PreAuthorize("@ss.hasPermi('ccdi:purchaseTransaction:export')") - @Log(title = "采购交易信息", businessType = BusinessType.EXPORT) - @PostMapping("/export") - public void export(HttpServletResponse response, CcdiPurchaseTransactionQueryDTO queryDTO) { - List list = transactionService.selectTransactionListForExport(queryDTO); - EasyExcelUtil.exportExcel(response, list, CcdiPurchaseTransactionExcel.class, "采购交易信息"); - } - - /** - * 获取采购交易详细信息 - */ - @Operation(summary = "获取采购交易详细信息") + @Operation(summary = "获取招投标信息详细信息") @Parameter(name = "purchaseId", description = "采购事项ID", required = true) @PreAuthorize("@ss.hasPermi('ccdi:purchaseTransaction:query')") @GetMapping(value = "/{purchaseId}") @@ -87,66 +76,81 @@ public class CcdiPurchaseTransactionController extends BaseController { } /** - * 新增采购交易 + * 新增招投标信息 */ - @Operation(summary = "新增采购交易") + @Operation(summary = "新增招投标信息") @PreAuthorize("@ss.hasPermi('ccdi:purchaseTransaction:add')") - @Log(title = "采购交易信息", businessType = BusinessType.INSERT) + @Log(title = "招投标信息维护", businessType = BusinessType.INSERT) @PostMapping public AjaxResult add(@Validated @RequestBody CcdiPurchaseTransactionAddDTO addDTO) { return toAjax(transactionService.insertTransaction(addDTO)); } /** - * 修改采购交易 + * 修改招投标信息 */ - @Operation(summary = "修改采购交易") + @Operation(summary = "修改招投标信息") @PreAuthorize("@ss.hasPermi('ccdi:purchaseTransaction:edit')") - @Log(title = "采购交易信息", businessType = BusinessType.UPDATE) + @Log(title = "招投标信息维护", businessType = BusinessType.UPDATE) @PutMapping public AjaxResult edit(@Validated @RequestBody CcdiPurchaseTransactionEditDTO editDTO) { return toAjax(transactionService.updateTransaction(editDTO)); } /** - * 删除采购交易 + * 删除招投标信息 */ - @Operation(summary = "删除采购交易") + @Operation(summary = "删除招投标信息") @Parameter(name = "purchaseIds", description = "采购事项ID数组", required = true) @PreAuthorize("@ss.hasPermi('ccdi:purchaseTransaction:remove')") - @Log(title = "采购交易信息", businessType = BusinessType.DELETE) + @Log(title = "招投标信息维护", businessType = BusinessType.DELETE) @DeleteMapping("/{purchaseIds}") public AjaxResult remove(@PathVariable String[] purchaseIds) { return toAjax(transactionService.deleteTransactionByIds(purchaseIds)); } /** - * 下载带字典下拉框的导入模板 - * 使用@DictDropdown注解自动添加下拉框 + * 下载双Sheet导入模板 */ @Operation(summary = "下载导入模板") @PostMapping("/importTemplate") public void importTemplate(HttpServletResponse response) { - EasyExcelUtil.importTemplateWithDictDropdown(response, CcdiPurchaseTransactionExcel.class, "采购交易信息"); + EasyExcelUtil.importTemplateWithDictDropdown( + response, + CcdiPurchaseTransactionExcel.class, + "招投标主信息", + CcdiPurchaseTransactionSupplierExcel.class, + "供应商明细", + "招投标信息维护导入模板" + ); } /** - * 异步导入采购交易 + * 异步导入招投标信息 */ - @Operation(summary = "异步导入采购交易") + @Operation(summary = "异步导入招投标信息") @Parameter(name = "file", description = "导入文件", required = true) @PreAuthorize("@ss.hasPermi('ccdi:purchaseTransaction:import')") - @Log(title = "采购交易信息", businessType = BusinessType.IMPORT) + @Log(title = "招投标信息维护", businessType = BusinessType.IMPORT) @PostMapping("/importData") public AjaxResult importData(@Parameter(description = "导入文件") MultipartFile file) throws Exception { - List list = EasyExcelUtil.importExcel(file.getInputStream(), CcdiPurchaseTransactionExcel.class); + List mainList = EasyExcelUtil.importExcel( + file.getInputStream(), + CcdiPurchaseTransactionExcel.class, + "招投标主信息" + ); + List supplierList = EasyExcelUtil.importExcel( + file.getInputStream(), + CcdiPurchaseTransactionSupplierExcel.class, + "供应商明细" + ); - if (list == null || list.isEmpty()) { + if ((mainList == null || mainList.isEmpty()) && (supplierList == null || supplierList.isEmpty())) { return error("至少需要一条数据"); } // 提交异步任务 - String taskId = transactionService.importTransaction(list); + String taskId = transactionService.importTransaction(mainList, supplierList); // 立即返回,不等待后台任务完成 ImportResultVO result = new ImportResultVO(); diff --git a/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/controller/CcdiStaffEnterpriseRelationController.java b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/controller/CcdiStaffEnterpriseRelationController.java index d5e3aef1..e8b738d6 100644 --- a/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/controller/CcdiStaffEnterpriseRelationController.java +++ b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/controller/CcdiStaffEnterpriseRelationController.java @@ -5,6 +5,7 @@ import com.ruoyi.info.collection.domain.dto.CcdiStaffEnterpriseRelationAddDTO; import com.ruoyi.info.collection.domain.dto.CcdiStaffEnterpriseRelationEditDTO; import com.ruoyi.info.collection.domain.dto.CcdiStaffEnterpriseRelationQueryDTO; import com.ruoyi.info.collection.domain.excel.CcdiStaffEnterpriseRelationExcel; +import com.ruoyi.info.collection.domain.vo.CcdiStaffEnterpriseRelationOptionVO; import com.ruoyi.info.collection.domain.vo.CcdiStaffEnterpriseRelationVO; import com.ruoyi.info.collection.domain.vo.ImportResultVO; import com.ruoyi.info.collection.domain.vo.ImportStatusVO; @@ -33,12 +34,12 @@ import java.util.ArrayList; import java.util.List; /** - * 员工实体关系信息Controller + * 员工亲属实体关联Controller * * @author ruoyi * @date 2026-02-09 */ -@Tag(name = "员工实体关系信息管理") +@Tag(name = "员工亲属实体关联管理") @RestController @RequestMapping("/ccdi/staffEnterpriseRelation") public class CcdiStaffEnterpriseRelationController extends BaseController { @@ -50,9 +51,9 @@ public class CcdiStaffEnterpriseRelationController extends BaseController { private ICcdiStaffEnterpriseRelationImportService relationImportService; /** - * 查询员工实体关系列表 + * 查询员工亲属实体关联列表 */ - @Operation(summary = "查询员工实体关系列表") + @Operation(summary = "查询员工亲属实体关联列表") @PreAuthorize("@ss.hasPermi('ccdi:staffEnterpriseRelation:list')") @GetMapping("/list") public TableDataInfo list(CcdiStaffEnterpriseRelationQueryDTO queryDTO) { @@ -64,21 +65,20 @@ public class CcdiStaffEnterpriseRelationController extends BaseController { } /** - * 导出员工实体关系列表 + * 查询有效员工亲属下拉列表 */ - @Operation(summary = "导出员工实体关系列表") - @PreAuthorize("@ss.hasPermi('ccdi:staffEnterpriseRelation:export')") - @Log(title = "员工实体关系信息", businessType = BusinessType.EXPORT) - @PostMapping("/export") - public void export(HttpServletResponse response, CcdiStaffEnterpriseRelationQueryDTO queryDTO) { - List list = relationService.selectRelationListForExport(queryDTO); - EasyExcelUtil.exportExcel(response, list, CcdiStaffEnterpriseRelationExcel.class, "员工实体关系信息"); + @Operation(summary = "查询有效员工亲属下拉列表") + @PreAuthorize("@ss.hasPermi('ccdi:staffEnterpriseRelation:list')") + @GetMapping("/familyOptions") + public AjaxResult familyOptions(@RequestParam(required = false) String query) { + List list = relationService.selectFamilyOptions(query); + return success(list); } /** - * 获取员工实体关系详细信息 + * 获取员工亲属实体关联详细信息 */ - @Operation(summary = "获取员工实体关系详细信息") + @Operation(summary = "获取员工亲属实体关联详细信息") @Parameter(name = "id", description = "主键ID", required = true) @PreAuthorize("@ss.hasPermi('ccdi:staffEnterpriseRelation:query')") @GetMapping(value = "/{id}") @@ -87,34 +87,34 @@ public class CcdiStaffEnterpriseRelationController extends BaseController { } /** - * 新增员工实体关系 + * 新增员工亲属实体关联 */ - @Operation(summary = "新增员工实体关系") + @Operation(summary = "新增员工亲属实体关联") @PreAuthorize("@ss.hasPermi('ccdi:staffEnterpriseRelation:add')") - @Log(title = "员工实体关系信息", businessType = BusinessType.INSERT) + @Log(title = "员工亲属实体关联", businessType = BusinessType.INSERT) @PostMapping public AjaxResult add(@Validated @RequestBody CcdiStaffEnterpriseRelationAddDTO addDTO) { return toAjax(relationService.insertRelation(addDTO)); } /** - * 修改员工实体关系 + * 修改员工亲属实体关联 */ - @Operation(summary = "修改员工实体关系") + @Operation(summary = "修改员工亲属实体关联") @PreAuthorize("@ss.hasPermi('ccdi:staffEnterpriseRelation:edit')") - @Log(title = "员工实体关系信息", businessType = BusinessType.UPDATE) + @Log(title = "员工亲属实体关联", businessType = BusinessType.UPDATE) @PutMapping public AjaxResult edit(@Validated @RequestBody CcdiStaffEnterpriseRelationEditDTO editDTO) { return toAjax(relationService.updateRelation(editDTO)); } /** - * 删除员工实体关系 + * 删除员工亲属实体关联 */ - @Operation(summary = "删除员工实体关系") + @Operation(summary = "删除员工亲属实体关联") @Parameter(name = "ids", description = "主键ID数组", required = true) @PreAuthorize("@ss.hasPermi('ccdi:staffEnterpriseRelation:remove')") - @Log(title = "员工实体关系信息", businessType = BusinessType.DELETE) + @Log(title = "员工亲属实体关联", businessType = BusinessType.DELETE) @DeleteMapping("/{ids}") public AjaxResult remove(@PathVariable Long[] ids) { return toAjax(relationService.deleteRelationByIds(ids)); @@ -127,16 +127,16 @@ public class CcdiStaffEnterpriseRelationController extends BaseController { @Operation(summary = "下载导入模板") @PostMapping("/importTemplate") public void importTemplate(HttpServletResponse response) { - EasyExcelUtil.importTemplateWithDictDropdown(response, CcdiStaffEnterpriseRelationExcel.class, "员工实体关系信息"); + EasyExcelUtil.importTemplateWithDictDropdown(response, CcdiStaffEnterpriseRelationExcel.class, "员工亲属实体关联"); } /** - * 异步导入员工实体关系 + * 异步导入员工亲属实体关联 */ - @Operation(summary = "异步导入员工实体关系") + @Operation(summary = "异步导入员工亲属实体关联") @Parameter(name = "file", description = "导入文件", required = true) @PreAuthorize("@ss.hasPermi('ccdi:staffEnterpriseRelation:import')") - @Log(title = "员工实体关系信息", businessType = BusinessType.IMPORT) + @Log(title = "员工亲属实体关联", businessType = BusinessType.IMPORT) @PostMapping("/importData") public AjaxResult importData(@Parameter(description = "导入文件") MultipartFile file) throws Exception { List list = EasyExcelUtil.importExcel(file.getInputStream(), CcdiStaffEnterpriseRelationExcel.class); @@ -152,9 +152,9 @@ public class CcdiStaffEnterpriseRelationController extends BaseController { ImportResultVO result = new ImportResultVO(); result.setTaskId(taskId); result.setStatus("PROCESSING"); - result.setMessage("导入任务已提交,正在后台处理"); + result.setMessage("员工亲属实体关联导入任务已提交,正在后台处理"); - return AjaxResult.success("导入任务已提交,正在后台处理", result); + return AjaxResult.success("员工亲属实体关联导入任务已提交,正在后台处理", result); } /** diff --git a/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/controller/CcdiStaffFmyRelationController.java b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/controller/CcdiStaffFmyRelationController.java index 3daad9f5..1eff571a 100644 --- a/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/controller/CcdiStaffFmyRelationController.java +++ b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/controller/CcdiStaffFmyRelationController.java @@ -4,11 +4,13 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.ruoyi.info.collection.domain.dto.CcdiStaffFmyRelationAddDTO; import com.ruoyi.info.collection.domain.dto.CcdiStaffFmyRelationEditDTO; import com.ruoyi.info.collection.domain.dto.CcdiStaffFmyRelationQueryDTO; +import com.ruoyi.info.collection.domain.excel.CcdiAssetInfoExcel; import com.ruoyi.info.collection.domain.excel.CcdiStaffFmyRelationExcel; import com.ruoyi.info.collection.domain.vo.CcdiStaffFmyRelationVO; -import com.ruoyi.info.collection.domain.vo.ImportResultVO; import com.ruoyi.info.collection.domain.vo.ImportStatusVO; import com.ruoyi.info.collection.domain.vo.StaffFmyRelationImportFailureVO; +import com.ruoyi.info.collection.domain.vo.StaffFmyRelationImportSubmitResultVO; +import com.ruoyi.info.collection.service.ICcdiAssetInfoImportService; import com.ruoyi.info.collection.service.ICcdiStaffFmyRelationImportService; import com.ruoyi.info.collection.service.ICcdiStaffFmyRelationService; import com.ruoyi.info.collection.utils.EasyExcelUtil; @@ -49,6 +51,9 @@ public class CcdiStaffFmyRelationController extends BaseController { @Resource private ICcdiStaffFmyRelationImportService relationImportService; + @Resource + private ICcdiAssetInfoImportService assetInfoImportService; + /** * 查询员工亲属关系列表 */ @@ -63,18 +68,6 @@ public class CcdiStaffFmyRelationController extends BaseController { return getDataTable(result.getRecords(), result.getTotal()); } - /** - * 导出员工亲属关系列表 - */ - @Operation(summary = "导出员工亲属关系列表") - @PreAuthorize("@ss.hasPermi('ccdi:staffFmyRelation:export')") - @Log(title = "员工亲属关系", businessType = BusinessType.EXPORT) - @PostMapping("/export") - public void export(HttpServletResponse response, CcdiStaffFmyRelationQueryDTO queryDTO) { - List list = relationService.selectRelationListForExport(queryDTO); - EasyExcelUtil.exportExcel(response, list, CcdiStaffFmyRelationExcel.class, "员工亲属关系信息"); - } - /** * 获取员工亲属关系详细信息 */ @@ -127,7 +120,14 @@ public class CcdiStaffFmyRelationController extends BaseController { @Operation(summary = "下载导入模板") @PostMapping("/importTemplate") public void importTemplate(HttpServletResponse response) { - EasyExcelUtil.importTemplateWithDictDropdown(response, CcdiStaffFmyRelationExcel.class, "员工亲属关系信息"); + EasyExcelUtil.importTemplateWithDictDropdown( + response, + CcdiStaffFmyRelationExcel.class, + "员工亲属关系信息", + CcdiAssetInfoExcel.class, + "亲属资产信息", + "员工亲属关系维护导入模板" + ); } /** @@ -139,20 +139,32 @@ public class CcdiStaffFmyRelationController extends BaseController { @Log(title = "员工亲属关系", businessType = BusinessType.IMPORT) @PostMapping("/importData") public AjaxResult importData(@Parameter(description = "导入文件") MultipartFile file) throws Exception { - List list = EasyExcelUtil.importExcel(file.getInputStream(), CcdiStaffFmyRelationExcel.class); + List relationList = EasyExcelUtil.importExcel( + file.getInputStream(), + CcdiStaffFmyRelationExcel.class, + "员工亲属关系信息" + ); + List assetList = EasyExcelUtil.importExcel( + file.getInputStream(), + CcdiAssetInfoExcel.class, + "亲属资产信息" + ); - if (list == null || list.isEmpty()) { + boolean hasRelationRows = relationList != null && !relationList.isEmpty(); + boolean hasAssetRows = assetList != null && !assetList.isEmpty(); + + if (!hasRelationRows && !hasAssetRows) { return error("至少需要一条数据"); } - // 提交异步任务 - String taskId = relationService.importRelation(list); - - // 立即返回,不等待后台任务完成 - ImportResultVO result = new ImportResultVO(); - result.setTaskId(taskId); - result.setStatus("PROCESSING"); - result.setMessage("导入任务已提交,正在后台处理"); + StaffFmyRelationImportSubmitResultVO result = new StaffFmyRelationImportSubmitResultVO(); + if (hasRelationRows) { + result.setRelationTaskId(relationService.importRelation(relationList)); + } + if (hasAssetRows) { + result.setAssetTaskId(assetInfoImportService.importAssetInfo(assetList)); + } + result.setMessage(buildImportSubmitMessage(hasRelationRows, hasAssetRows)); return AjaxResult.success("导入任务已提交,正在后台处理", result); } @@ -198,4 +210,14 @@ public class CcdiStaffFmyRelationController extends BaseController { return getDataTable(pageData, failures.size()); } + + private String buildImportSubmitMessage(boolean hasRelationRows, boolean hasAssetRows) { + if (hasRelationRows && hasAssetRows) { + return "已提交员工亲属关系和亲属资产信息导入任务"; + } + if (hasRelationRows) { + return "已提交员工亲属关系导入任务"; + } + return "已提交亲属资产信息导入任务"; + } } diff --git a/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/controller/CcdiStaffRecruitmentController.java b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/controller/CcdiStaffRecruitmentController.java index 4e800f6c..b3ac38b7 100644 --- a/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/controller/CcdiStaffRecruitmentController.java +++ b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/controller/CcdiStaffRecruitmentController.java @@ -64,18 +64,6 @@ public class CcdiStaffRecruitmentController extends BaseController { return getDataTable(result.getRecords(), result.getTotal()); } - /** - * 导出招聘信息列表 - */ - @Operation(summary = "导出招聘信息列表") - @PreAuthorize("@ss.hasPermi('ccdi:staffRecruitment:export')") - @Log(title = "员工招聘信息", businessType = BusinessType.EXPORT) - @PostMapping("/export") - public void export(HttpServletResponse response, CcdiStaffRecruitmentQueryDTO queryDTO) { - List list = recruitmentService.selectRecruitmentListForExport(queryDTO); - EasyExcelUtil.exportExcel(response, list, CcdiStaffRecruitmentExcel.class, "员工招聘信息"); - } - /** * 获取招聘信息详细信息 */ @@ -126,16 +114,14 @@ public class CcdiStaffRecruitmentController extends BaseController { @Operation(summary = "下载导入模板") @PostMapping("/importTemplate") public void importTemplate(HttpServletResponse response) { - EasyExcelUtil.importTemplateWithDictDropdown(response, CcdiStaffRecruitmentExcel.class, "员工招聘信息"); - } - - /** - * 下载历史工作经历导入模板 - */ - @Operation(summary = "下载历史工作经历导入模板") - @PostMapping("/workImportTemplate") - public void workImportTemplate(HttpServletResponse response) { - EasyExcelUtil.importTemplateWithDictDropdown(response, CcdiStaffRecruitmentWorkExcel.class, "历史工作经历"); + EasyExcelUtil.importTemplateWithDictDropdown( + response, + CcdiStaffRecruitmentExcel.class, + "招聘信息", + CcdiStaffRecruitmentWorkExcel.class, + "历史工作经历", + "招聘信息管理导入模板" + ); } /** @@ -147,16 +133,25 @@ public class CcdiStaffRecruitmentController extends BaseController { @Log(title = "员工招聘信息", businessType = BusinessType.IMPORT) @PostMapping("/importData") public AjaxResult importData(@Parameter(description = "导入文件") MultipartFile file) throws Exception { - List list = EasyExcelUtil.importExcel(file.getInputStream(), CcdiStaffRecruitmentExcel.class); + List recruitmentList = EasyExcelUtil.importExcel( + file.getInputStream(), + CcdiStaffRecruitmentExcel.class, + "招聘信息" + ); + List workList = EasyExcelUtil.importExcel( + file.getInputStream(), + CcdiStaffRecruitmentWorkExcel.class, + "历史工作经历" + ); - if (list == null || list.isEmpty()) { + boolean hasRecruitmentRows = recruitmentList != null && !recruitmentList.isEmpty(); + boolean hasWorkRows = workList != null && !workList.isEmpty(); + if (!hasRecruitmentRows && !hasWorkRows) { return error("至少需要一条数据"); } - // 提交异步任务 - String taskId = recruitmentService.importRecruitment(list); + String taskId = recruitmentService.importRecruitment(recruitmentList, workList); - // 立即返回,不等待后台任务完成 ImportResultVO result = new ImportResultVO(); result.setTaskId(taskId); result.setStatus("PROCESSING"); @@ -165,31 +160,6 @@ public class CcdiStaffRecruitmentController extends BaseController { return AjaxResult.success("导入任务已提交,正在后台处理", result); } - /** - * 异步导入历史工作经历 - */ - @Operation(summary = "异步导入历史工作经历") - @Parameter(name = "file", description = "导入文件", required = true) - @PreAuthorize("@ss.hasPermi('ccdi:staffRecruitment:import')") - @Log(title = "员工招聘历史工作经历", businessType = BusinessType.IMPORT) - @PostMapping("/importWorkData") - public AjaxResult importWorkData(@Parameter(description = "导入文件") MultipartFile file) throws Exception { - List list = EasyExcelUtil.importExcel(file.getInputStream(), CcdiStaffRecruitmentWorkExcel.class); - - if (list == null || list.isEmpty()) { - return error("至少需要一条数据"); - } - - String taskId = recruitmentService.importRecruitmentWork(list); - - ImportResultVO result = new ImportResultVO(); - result.setTaskId(taskId); - result.setStatus("PROCESSING"); - result.setMessage("历史工作经历导入任务已提交,正在后台处理"); - - return AjaxResult.success("历史工作经历导入任务已提交,正在后台处理", result); - } - /** * 查询导入状态 */ diff --git a/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/controller/CcdiStaffTransferController.java b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/controller/CcdiStaffTransferController.java index 62d9afe0..3b5080dc 100644 --- a/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/controller/CcdiStaffTransferController.java +++ b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/controller/CcdiStaffTransferController.java @@ -63,18 +63,6 @@ public class CcdiStaffTransferController extends BaseController { return getDataTable(result.getRecords(), result.getTotal()); } - /** - * 导出员工调动记录列表 - */ - @Operation(summary = "导出员工调动记录列表") - @PreAuthorize("@ss.hasPermi('ccdi:staffTransfer:export')") - @Log(title = "员工调动记录", businessType = BusinessType.EXPORT) - @PostMapping("/export") - public void export(HttpServletResponse response, CcdiStaffTransferQueryDTO queryDTO) { - List list = transferService.selectTransferListForExport(queryDTO); - EasyExcelUtil.exportExcel(response, list, CcdiStaffTransferExcel.class, "员工调动记录信息"); - } - /** * 获取员工调动记录详细信息 */ diff --git a/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/CcdiAccountInfo.java b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/CcdiAccountInfo.java index 4709db89..6d5467c6 100644 --- a/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/CcdiAccountInfo.java +++ b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/CcdiAccountInfo.java @@ -9,6 +9,7 @@ import lombok.Data; import java.io.Serial; import java.io.Serializable; +import java.math.BigDecimal; import java.util.Date; /** @@ -56,6 +57,42 @@ public class CcdiAccountInfo implements Serializable { /** 币种 */ private String currency; + /** 是否实控账户:0-否 1-是 */ + @TableField("is_self_account") + private Integer isActualControl; + + /** 月均交易笔数 */ + @TableField("monthly_avg_trans_count") + private Integer avgMonthTxnCount; + + /** 月均交易金额 */ + @TableField("monthly_avg_trans_amount") + private BigDecimal avgMonthTxnAmount; + + /** 交易频率等级 */ + @TableField("trans_freq_type") + private String txnFrequencyLevel; + + /** 借方单笔最高额 */ + @TableField("dr_max_single_amount") + private BigDecimal debitSingleMaxAmount; + + /** 贷方单笔最高额 */ + @TableField("cr_max_single_amount") + private BigDecimal creditSingleMaxAmount; + + /** 借方日累计最高额 */ + @TableField("dr_max_daily_amount") + private BigDecimal debitDailyMaxAmount; + + /** 贷方日累计最高额 */ + @TableField("cr_max_daily_amount") + private BigDecimal creditDailyMaxAmount; + + /** 风险等级 */ + @TableField("trans_risk_level") + private String txnRiskLevel; + /** 状态:1-正常 2-已销户 */ private Integer status; diff --git a/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/CcdiAccountResult.java b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/CcdiAccountResult.java deleted file mode 100644 index 0f76037e..00000000 --- a/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/CcdiAccountResult.java +++ /dev/null @@ -1,86 +0,0 @@ -package com.ruoyi.info.collection.domain; - -import com.baomidou.mybatisplus.annotation.FieldFill; -import com.baomidou.mybatisplus.annotation.IdType; -import com.baomidou.mybatisplus.annotation.TableField; -import com.baomidou.mybatisplus.annotation.TableId; -import com.baomidou.mybatisplus.annotation.TableName; -import lombok.Data; - -import java.io.Serial; -import java.io.Serializable; -import java.math.BigDecimal; -import java.util.Date; - -/** - * 账户分析结果对象 ccdi_account_result - * - * @author ruoyi - * @date 2026-04-13 - */ -@Data -@TableName("ccdi_account_result") -public class CcdiAccountResult implements Serializable { - - @Serial - private static final long serialVersionUID = 1L; - - /** 主键ID */ - @TableId(value = "result_id", type = IdType.AUTO) - private Long resultId; - - /** 账户号码 */ - private String accountNo; - - /** 是否实控账户:0-否 1-是 */ - @TableField("is_self_account") - private Integer isActualControl; - - /** 月均交易笔数 */ - @TableField("monthly_avg_trans_count") - private Integer avgMonthTxnCount; - - /** 月均交易金额 */ - @TableField("monthly_avg_trans_amount") - private BigDecimal avgMonthTxnAmount; - - /** 交易频率等级 */ - @TableField("trans_freq_type") - private String txnFrequencyLevel; - - /** 借方单笔最高额 */ - @TableField("dr_max_single_amount") - private BigDecimal debitSingleMaxAmount; - - /** 贷方单笔最高额 */ - @TableField("cr_max_single_amount") - private BigDecimal creditSingleMaxAmount; - - /** 借方日累计最高额 */ - @TableField("dr_max_daily_amount") - private BigDecimal debitDailyMaxAmount; - - /** 贷方日累计最高额 */ - @TableField("cr_max_daily_amount") - private BigDecimal creditDailyMaxAmount; - - /** 风险等级 */ - @TableField("trans_risk_level") - private String txnRiskLevel; - - /** 创建者 */ - @TableField(fill = FieldFill.INSERT) - private String createBy; - - /** 创建时间 */ - @TableField(fill = FieldFill.INSERT) - private Date createTime; - - /** 更新者 */ - @TableField(fill = FieldFill.INSERT_UPDATE) - private String updateBy; - - /** 更新时间 */ - @TableField(fill = FieldFill.INSERT_UPDATE) - private Date updateTime; -} diff --git a/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/CcdiBaseStaff.java b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/CcdiBaseStaff.java index bebc03c9..df434a1c 100644 --- a/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/CcdiBaseStaff.java +++ b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/CcdiBaseStaff.java @@ -43,6 +43,10 @@ public class CcdiBaseStaff implements Serializable { /** 入职时间 */ private Date hireDate; + /** 是否党员:0-否 1-是 */ + @TableField("is_party_member") + private Integer partyMember; + /** 状态 */ private String status; diff --git a/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/CcdiBizIntermediary.java b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/CcdiBizIntermediary.java index 2317ae0f..2972668d 100644 --- a/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/CcdiBizIntermediary.java +++ b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/CcdiBizIntermediary.java @@ -63,7 +63,7 @@ public class CcdiBizIntermediary implements Serializable { /** 职位 */ private String position; - /** 关联人员ID */ + /** 关联中介本人证件号码 */ private String relatedNumId; /** 数据来源,MANUAL:手动录入, SYSTEM:系统同步, IMPORT:批量导入, API:接口获取 */ diff --git a/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/CcdiIntermediaryEnterpriseRelation.java b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/CcdiIntermediaryEnterpriseRelation.java new file mode 100644 index 00000000..47c2a5a3 --- /dev/null +++ b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/CcdiIntermediaryEnterpriseRelation.java @@ -0,0 +1,46 @@ +package com.ruoyi.info.collection.domain; + +import com.baomidou.mybatisplus.annotation.FieldFill; +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; +import java.util.Date; + +/** + * 中介关联机构关系对象 ccdi_intermediary_enterprise_relation + */ +@Data +@TableName("ccdi_intermediary_enterprise_relation") +public class CcdiIntermediaryEnterpriseRelation implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + @TableId(type = IdType.AUTO) + private Long id; + + private String intermediaryBizId; + + private String socialCreditCode; + + private String relationPersonPost; + + private String remark; + + @TableField(fill = FieldFill.INSERT) + private String createdBy; + + @TableField(fill = FieldFill.INSERT) + private Date createTime; + + @TableField(fill = FieldFill.INSERT_UPDATE) + private String updatedBy; + + @TableField(fill = FieldFill.INSERT_UPDATE) + private Date updateTime; +} diff --git a/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/CcdiPurchaseTransactionSupplier.java b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/CcdiPurchaseTransactionSupplier.java new file mode 100644 index 00000000..a074008d --- /dev/null +++ b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/CcdiPurchaseTransactionSupplier.java @@ -0,0 +1,64 @@ +package com.ruoyi.info.collection.domain; + +import com.baomidou.mybatisplus.annotation.FieldFill; +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; +import java.util.Date; + +/** + * 招投标供应商明细对象 ccdi_purchase_transaction_supplier + */ +@Data +public class CcdiPurchaseTransactionSupplier implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + @TableId(type = IdType.AUTO) + private Long id; + + /** 采购事项ID */ + private String purchaseId; + + /** 供应商名称 */ + private String supplierName; + + /** 供应商统一信用代码 */ + private String supplierUscc; + + /** 供应商联系人 */ + private String contactPerson; + + /** 供应商联系电话 */ + private String contactPhone; + + /** 供应商银行账户 */ + private String supplierBankAccount; + + /** 是否中标:1-是,0-否 */ + private Integer isBidWinner; + + /** 排序 */ + private Integer sortOrder; + + /** 创建时间 */ + @TableField(fill = FieldFill.INSERT) + private Date createTime; + + /** 更新时间 */ + @TableField(fill = FieldFill.INSERT_UPDATE) + private Date updateTime; + + /** 创建人 */ + @TableField(fill = FieldFill.INSERT) + private String createdBy; + + /** 更新人 */ + @TableField(fill = FieldFill.INSERT_UPDATE) + private String updatedBy; +} diff --git a/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/dto/CcdiBaseStaffAddDTO.java b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/dto/CcdiBaseStaffAddDTO.java index 4c7b5ff3..590a766d 100644 --- a/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/dto/CcdiBaseStaffAddDTO.java +++ b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/dto/CcdiBaseStaffAddDTO.java @@ -53,6 +53,10 @@ public class CcdiBaseStaffAddDTO implements Serializable { /** 入职时间 */ private Date hireDate; + /** 是否党员:0-否 1-是 */ + @NotNull(message = "是否党员不能为空") + private Integer partyMember; + /** 状态 */ @NotBlank(message = "状态不能为空") private String status; diff --git a/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/dto/CcdiBaseStaffEditDTO.java b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/dto/CcdiBaseStaffEditDTO.java index 2dacbc36..8cfc727a 100644 --- a/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/dto/CcdiBaseStaffEditDTO.java +++ b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/dto/CcdiBaseStaffEditDTO.java @@ -52,6 +52,10 @@ public class CcdiBaseStaffEditDTO implements Serializable { /** 入职时间 */ private Date hireDate; + /** 是否党员:0-否 1-是 */ + @NotNull(message = "是否党员不能为空") + private Integer partyMember; + /** 状态 */ private String status; diff --git a/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/dto/CcdiEnterpriseBaseInfoAddDTO.java b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/dto/CcdiEnterpriseBaseInfoAddDTO.java new file mode 100644 index 00000000..ffea39ef --- /dev/null +++ b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/dto/CcdiEnterpriseBaseInfoAddDTO.java @@ -0,0 +1,105 @@ +package com.ruoyi.info.collection.domain.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; +import java.util.Date; + +/** + * 实体库管理新增 DTO + * + * @author ruoyi + * @date 2026-04-17 + */ +@Data +@Schema(description = "实体库管理新增DTO") +public class CcdiEnterpriseBaseInfoAddDTO implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + @Schema(description = "统一社会信用代码") + @NotBlank(message = "统一社会信用代码不能为空") + @Pattern(regexp = "^[0-9A-HJ-NPQRTUWXY]{2}\\d{6}[0-9A-HJ-NPQRTUWXY]{10}$", message = "统一社会信用代码格式不正确") + private String socialCreditCode; + + @Schema(description = "企业名称") + @NotBlank(message = "企业名称不能为空") + @Size(max = 200, message = "企业名称长度不能超过200个字符") + private String enterpriseName; + + @Schema(description = "企业类型") + @Size(max = 50, message = "企业类型长度不能超过50个字符") + private String enterpriseType; + + @Schema(description = "企业性质") + @Size(max = 50, message = "企业性质长度不能超过50个字符") + private String enterpriseNature; + + @Schema(description = "行业分类") + @Size(max = 100, message = "行业分类长度不能超过100个字符") + private String industryClass; + + @Schema(description = "所属行业") + @Size(max = 100, message = "所属行业长度不能超过100个字符") + private String industryName; + + @Schema(description = "成立日期") + private Date establishDate; + + @Schema(description = "注册地址") + @Size(max = 500, message = "注册地址长度不能超过500个字符") + private String registerAddress; + + @Schema(description = "法定代表人") + @Size(max = 100, message = "法定代表人长度不能超过100个字符") + private String legalRepresentative; + + @Schema(description = "法定代表人证件类型") + @Size(max = 50, message = "法定代表人证件类型长度不能超过50个字符") + private String legalCertType; + + @Schema(description = "法定代表人证件号码") + @Size(max = 50, message = "法定代表人证件号码长度不能超过50个字符") + private String legalCertNo; + + @Schema(description = "股东1") + @Size(max = 100, message = "股东1长度不能超过100个字符") + private String shareholder1; + + @Schema(description = "股东2") + @Size(max = 100, message = "股东2长度不能超过100个字符") + private String shareholder2; + + @Schema(description = "股东3") + @Size(max = 100, message = "股东3长度不能超过100个字符") + private String shareholder3; + + @Schema(description = "股东4") + @Size(max = 100, message = "股东4长度不能超过100个字符") + private String shareholder4; + + @Schema(description = "股东5") + @Size(max = 100, message = "股东5长度不能超过100个字符") + private String shareholder5; + + @Schema(description = "经营状态") + @Size(max = 50, message = "经营状态长度不能超过50个字符") + private String status; + + @Schema(description = "风险等级") + @NotBlank(message = "风险等级不能为空") + private String riskLevel; + + @Schema(description = "企业来源") + @NotBlank(message = "企业来源不能为空") + private String entSource; + + @Schema(description = "数据来源") + private String dataSource; +} diff --git a/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/dto/CcdiEnterpriseBaseInfoEditDTO.java b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/dto/CcdiEnterpriseBaseInfoEditDTO.java new file mode 100644 index 00000000..c16d9ce7 --- /dev/null +++ b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/dto/CcdiEnterpriseBaseInfoEditDTO.java @@ -0,0 +1,106 @@ +package com.ruoyi.info.collection.domain.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; +import java.util.Date; + +/** + * 实体库管理编辑 DTO + * + * @author ruoyi + * @date 2026-04-17 + */ +@Data +@Schema(description = "实体库管理编辑DTO") +public class CcdiEnterpriseBaseInfoEditDTO implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + @Schema(description = "统一社会信用代码") + @NotBlank(message = "统一社会信用代码不能为空") + @Pattern(regexp = "^[0-9A-HJ-NPQRTUWXY]{2}\\d{6}[0-9A-HJ-NPQRTUWXY]{10}$", message = "统一社会信用代码格式不正确") + private String socialCreditCode; + + @Schema(description = "企业名称") + @NotBlank(message = "企业名称不能为空") + @Size(max = 200, message = "企业名称长度不能超过200个字符") + private String enterpriseName; + + @Schema(description = "企业类型") + @Size(max = 50, message = "企业类型长度不能超过50个字符") + private String enterpriseType; + + @Schema(description = "企业性质") + @Size(max = 50, message = "企业性质长度不能超过50个字符") + private String enterpriseNature; + + @Schema(description = "行业分类") + @Size(max = 100, message = "行业分类长度不能超过100个字符") + private String industryClass; + + @Schema(description = "所属行业") + @Size(max = 100, message = "所属行业长度不能超过100个字符") + private String industryName; + + @Schema(description = "成立日期") + private Date establishDate; + + @Schema(description = "注册地址") + @Size(max = 500, message = "注册地址长度不能超过500个字符") + private String registerAddress; + + @Schema(description = "法定代表人") + @Size(max = 100, message = "法定代表人长度不能超过100个字符") + private String legalRepresentative; + + @Schema(description = "法定代表人证件类型") + @Size(max = 50, message = "法定代表人证件类型长度不能超过50个字符") + private String legalCertType; + + @Schema(description = "法定代表人证件号码") + @Size(max = 50, message = "法定代表人证件号码长度不能超过50个字符") + private String legalCertNo; + + @Schema(description = "股东1") + @Size(max = 100, message = "股东1长度不能超过100个字符") + private String shareholder1; + + @Schema(description = "股东2") + @Size(max = 100, message = "股东2长度不能超过100个字符") + private String shareholder2; + + @Schema(description = "股东3") + @Size(max = 100, message = "股东3长度不能超过100个字符") + private String shareholder3; + + @Schema(description = "股东4") + @Size(max = 100, message = "股东4长度不能超过100个字符") + private String shareholder4; + + @Schema(description = "股东5") + @Size(max = 100, message = "股东5长度不能超过100个字符") + private String shareholder5; + + @Schema(description = "经营状态") + @Size(max = 50, message = "经营状态长度不能超过50个字符") + private String status; + + @Schema(description = "风险等级") + @NotBlank(message = "风险等级不能为空") + private String riskLevel; + + @Schema(description = "企业来源") + @NotBlank(message = "企业来源不能为空") + private String entSource; + + @Schema(description = "数据来源") + @NotBlank(message = "数据来源不能为空") + private String dataSource; +} diff --git a/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/dto/CcdiEnterpriseBaseInfoQueryDTO.java b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/dto/CcdiEnterpriseBaseInfoQueryDTO.java new file mode 100644 index 00000000..c9cd7e80 --- /dev/null +++ b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/dto/CcdiEnterpriseBaseInfoQueryDTO.java @@ -0,0 +1,45 @@ +package com.ruoyi.info.collection.domain.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; + +/** + * 实体库管理查询 DTO + * + * @author ruoyi + * @date 2026-04-17 + */ +@Data +@Schema(description = "实体库管理查询DTO") +public class CcdiEnterpriseBaseInfoQueryDTO 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 industryClass; + + @Schema(description = "经营状态") + private String status; + + @Schema(description = "风险等级") + private String riskLevel; + + @Schema(description = "企业来源") + private String entSource; +} diff --git a/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/dto/CcdiIntermediaryEnterpriseRelationAddDTO.java b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/dto/CcdiIntermediaryEnterpriseRelationAddDTO.java new file mode 100644 index 00000000..4b71a070 --- /dev/null +++ b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/dto/CcdiIntermediaryEnterpriseRelationAddDTO.java @@ -0,0 +1,33 @@ +package com.ruoyi.info.collection.domain.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; + +/** + * 中介关联机构新增DTO + */ +@Data +@Schema(description = "中介关联机构新增DTO") +public class CcdiIntermediaryEnterpriseRelationAddDTO implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + @Schema(description = "统一社会信用代码") + @NotBlank(message = "统一社会信用代码不能为空") + @Size(max = 18, message = "统一社会信用代码长度不能超过18个字符") + private String socialCreditCode; + + @Schema(description = "关联角色/职务") + @Size(max = 100, message = "关联角色/职务长度不能超过100个字符") + private String relationPersonPost; + + @Schema(description = "备注") + @Size(max = 500, message = "备注长度不能超过500个字符") + private String remark; +} diff --git a/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/dto/CcdiIntermediaryEnterpriseRelationEditDTO.java b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/dto/CcdiIntermediaryEnterpriseRelationEditDTO.java new file mode 100644 index 00000000..2a10c201 --- /dev/null +++ b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/dto/CcdiIntermediaryEnterpriseRelationEditDTO.java @@ -0,0 +1,38 @@ +package com.ruoyi.info.collection.domain.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; + +/** + * 中介关联机构编辑DTO + */ +@Data +@Schema(description = "中介关联机构编辑DTO") +public class CcdiIntermediaryEnterpriseRelationEditDTO implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + @Schema(description = "主键ID") + @NotNull(message = "主键ID不能为空") + private Long id; + + @Schema(description = "统一社会信用代码") + @NotBlank(message = "统一社会信用代码不能为空") + @Size(max = 18, message = "统一社会信用代码长度不能超过18个字符") + private String socialCreditCode; + + @Schema(description = "关联角色/职务") + @Size(max = 100, message = "关联角色/职务长度不能超过100个字符") + private String relationPersonPost; + + @Schema(description = "备注") + @Size(max = 500, message = "备注长度不能超过500个字符") + private String remark; +} diff --git a/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/dto/CcdiIntermediaryPersonAddDTO.java b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/dto/CcdiIntermediaryPersonAddDTO.java index fb54d563..c67a19b0 100644 --- a/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/dto/CcdiIntermediaryPersonAddDTO.java +++ b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/dto/CcdiIntermediaryPersonAddDTO.java @@ -67,8 +67,8 @@ public class CcdiIntermediaryPersonAddDTO implements Serializable { @Size(max = 100, message = "职位长度不能超过100个字符") private String position; - @Schema(description = "关联人员ID") - @Size(max = 50, message = "关联人员ID长度不能超过50个字符") + @Schema(description = "关联中介本人证件号码") + @Size(max = 50, message = "关联中介本人证件号码长度不能超过50个字符") private String relatedNumId; @Schema(description = "关联关系") diff --git a/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/dto/CcdiIntermediaryPersonEditDTO.java b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/dto/CcdiIntermediaryPersonEditDTO.java index 9a7ae6c2..19ca6af6 100644 --- a/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/dto/CcdiIntermediaryPersonEditDTO.java +++ b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/dto/CcdiIntermediaryPersonEditDTO.java @@ -70,8 +70,8 @@ public class CcdiIntermediaryPersonEditDTO implements Serializable { @Size(max = 100, message = "职位长度不能超过100个字符") private String position; - @Schema(description = "关联人员ID") - @Size(max = 50, message = "关联人员ID长度不能超过50个字符") + @Schema(description = "关联中介本人证件号码") + @Size(max = 50, message = "关联中介本人证件号码长度不能超过50个字符") private String relatedNumId; @Schema(description = "关联关系") diff --git a/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/dto/CcdiIntermediaryQueryDTO.java b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/dto/CcdiIntermediaryQueryDTO.java index abf26407..fbf1e889 100644 --- a/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/dto/CcdiIntermediaryQueryDTO.java +++ b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/dto/CcdiIntermediaryQueryDTO.java @@ -19,12 +19,15 @@ public class CcdiIntermediaryQueryDTO implements Serializable { @Serial private static final long serialVersionUID = 1L; - @Schema(description = "姓名/机构名称") + @Schema(description = "名称") private String name; - @Schema(description = "证件号/统一社会信用代码") + @Schema(description = "证件号") private String certificateNo; - @Schema(description = "中介类型(1=个人, 2=实体)") - private String intermediaryType; + @Schema(description = "记录类型(INTERMEDIARY/RELATIVE/ENTERPRISE_RELATION)") + private String recordType; + + @Schema(description = "关联中介信息(姓名或证件号)") + private String relatedIntermediaryKeyword; } diff --git a/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/dto/CcdiIntermediaryRelativeAddDTO.java b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/dto/CcdiIntermediaryRelativeAddDTO.java new file mode 100644 index 00000000..3b8681da --- /dev/null +++ b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/dto/CcdiIntermediaryRelativeAddDTO.java @@ -0,0 +1,72 @@ +package com.ruoyi.info.collection.domain.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; + +/** + * 中介亲属新增DTO + */ +@Data +@Schema(description = "中介亲属新增DTO") +public class CcdiIntermediaryRelativeAddDTO implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + @Schema(description = "姓名") + @NotBlank(message = "姓名不能为空") + @Size(max = 100, message = "姓名长度不能超过100个字符") + private String name; + + @Schema(description = "人员类型") + private String personType; + + @Schema(description = "亲属关系") + @NotBlank(message = "亲属关系不能为空") + @Size(max = 50, message = "亲属关系长度不能超过50个字符") + private String personSubType; + + @Schema(description = "性别") + private String gender; + + @Schema(description = "证件类型") + private String idType; + + @Schema(description = "证件号码") + @NotBlank(message = "证件号码不能为空") + @Size(max = 50, message = "证件号码长度不能超过50个字符") + private String personId; + + @Schema(description = "手机号码") + @Size(max = 20, message = "手机号码长度不能超过20个字符") + private String mobile; + + @Schema(description = "微信号") + @Size(max = 50, message = "微信号长度不能超过50个字符") + private String wechatNo; + + @Schema(description = "联系地址") + @Size(max = 200, message = "联系地址长度不能超过200个字符") + private String contactAddress; + + @Schema(description = "所在公司") + @Size(max = 200, message = "所在公司长度不能超过200个字符") + private String company; + + @Schema(description = "企业统一信用码") + @Size(max = 50, message = "企业统一信用码长度不能超过50个字符") + private String socialCreditCode; + + @Schema(description = "职位") + @Size(max = 100, message = "职位长度不能超过100个字符") + private String position; + + @Schema(description = "备注") + @Size(max = 500, message = "备注长度不能超过500个字符") + private String remark; +} diff --git a/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/dto/CcdiIntermediaryRelativeEditDTO.java b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/dto/CcdiIntermediaryRelativeEditDTO.java new file mode 100644 index 00000000..12d5903a --- /dev/null +++ b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/dto/CcdiIntermediaryRelativeEditDTO.java @@ -0,0 +1,75 @@ +package com.ruoyi.info.collection.domain.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; + +/** + * 中介亲属编辑DTO + */ +@Data +@Schema(description = "中介亲属编辑DTO") +public class CcdiIntermediaryRelativeEditDTO implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + @Schema(description = "人员ID") + @NotBlank(message = "人员ID不能为空") + private String bizId; + + @Schema(description = "姓名") + @NotBlank(message = "姓名不能为空") + @Size(max = 100, message = "姓名长度不能超过100个字符") + private String name; + + @Schema(description = "人员类型") + private String personType; + + @Schema(description = "亲属关系") + @NotBlank(message = "亲属关系不能为空") + @Size(max = 50, message = "亲属关系长度不能超过50个字符") + private String personSubType; + + @Schema(description = "性别") + private String gender; + + @Schema(description = "证件类型") + private String idType; + + @Schema(description = "证件号码") + @Size(max = 50, message = "证件号码长度不能超过50个字符") + private String personId; + + @Schema(description = "手机号码") + @Size(max = 20, message = "手机号码长度不能超过20个字符") + private String mobile; + + @Schema(description = "微信号") + @Size(max = 50, message = "微信号长度不能超过50个字符") + private String wechatNo; + + @Schema(description = "联系地址") + @Size(max = 200, message = "联系地址长度不能超过200个字符") + private String contactAddress; + + @Schema(description = "所在公司") + @Size(max = 200, message = "所在公司长度不能超过200个字符") + private String company; + + @Schema(description = "企业统一信用码") + @Size(max = 50, message = "企业统一信用码长度不能超过50个字符") + private String socialCreditCode; + + @Schema(description = "职位") + @Size(max = 100, message = "职位长度不能超过100个字符") + private String position; + + @Schema(description = "备注") + @Size(max = 500, message = "备注长度不能超过500个字符") + private String remark; +} diff --git a/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/dto/CcdiPurchaseTransactionAddDTO.java b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/dto/CcdiPurchaseTransactionAddDTO.java index f6977913..a33a2878 100644 --- a/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/dto/CcdiPurchaseTransactionAddDTO.java +++ b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/dto/CcdiPurchaseTransactionAddDTO.java @@ -2,6 +2,7 @@ package com.ruoyi.info.collection.domain.dto; import com.fasterxml.jackson.annotation.JsonFormat; import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.Valid; import jakarta.validation.constraints.*; import lombok.Data; @@ -9,15 +10,16 @@ import java.io.Serial; import java.io.Serializable; import java.math.BigDecimal; import java.util.Date; +import java.util.List; /** - * 采购交易信息新增DTO + * 招投标信息新增DTO * * @author ruoyi * @date 2026-02-06 */ @Data -@Schema(description = "采购交易信息新增") +@Schema(description = "招投标信息新增") public class CcdiPurchaseTransactionAddDTO implements Serializable { @Serial @@ -88,30 +90,10 @@ public class CcdiPurchaseTransactionAddDTO implements Serializable { @Schema(description = "采购方式") private String purchaseMethod; - /** 中标供应商名称 */ - @Size(max = 200, message = "中标供应商名称长度不能超过200个字符") - @Schema(description = "中标供应商名称") - private String supplierName; - - /** 供应商联系人 */ - @Size(max = 50, message = "供应商联系人长度不能超过50个字符") - @Schema(description = "供应商联系人") - private String contactPerson; - - /** 供应商联系电话 */ - @Pattern(regexp = "^1[3-9]\\d{9}$|^0\\d{2,3}-?\\d{7,8}$", message = "供应商联系电话格式不正确") - @Schema(description = "供应商联系电话") - private String contactPhone; - - /** 供应商统一信用代码 */ - @Pattern(regexp = "^[0-9A-HJ-NPQRTUWXY]{2}\\d{6}[0-9A-HJ-NPQRTUWXY]{10}$", message = "供应商统一信用代码格式不正确") - @Schema(description = "供应商统一信用代码") - private String supplierUscc; - - /** 供应商银行账户 */ - @Size(max = 50, message = "供应商银行账户长度不能超过50个字符") - @Schema(description = "供应商银行账户") - private String supplierBankAccount; + /** 供应商明细 */ + @Valid + @Schema(description = "供应商明细列表") + private List supplierList; /** 采购申请日期(或立项日期) */ @NotNull(message = "采购申请日期不能为空") diff --git a/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/dto/CcdiPurchaseTransactionEditDTO.java b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/dto/CcdiPurchaseTransactionEditDTO.java index ac97d4c4..e4f10257 100644 --- a/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/dto/CcdiPurchaseTransactionEditDTO.java +++ b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/dto/CcdiPurchaseTransactionEditDTO.java @@ -2,6 +2,7 @@ package com.ruoyi.info.collection.domain.dto; import com.fasterxml.jackson.annotation.JsonFormat; import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.Valid; import jakarta.validation.constraints.*; import lombok.Data; @@ -9,15 +10,16 @@ import java.io.Serial; import java.io.Serializable; import java.math.BigDecimal; import java.util.Date; +import java.util.List; /** - * 采购交易信息编辑DTO + * 招投标信息编辑DTO * * @author ruoyi * @date 2026-02-06 */ @Data -@Schema(description = "采购交易信息编辑") +@Schema(description = "招投标信息编辑") public class CcdiPurchaseTransactionEditDTO implements Serializable { @Serial @@ -88,30 +90,10 @@ public class CcdiPurchaseTransactionEditDTO implements Serializable { @Schema(description = "采购方式") private String purchaseMethod; - /** 中标供应商名称 */ - @Size(max = 200, message = "中标供应商名称长度不能超过200个字符") - @Schema(description = "中标供应商名称") - private String supplierName; - - /** 供应商联系人 */ - @Size(max = 50, message = "供应商联系人长度不能超过50个字符") - @Schema(description = "供应商联系人") - private String contactPerson; - - /** 供应商联系电话 */ - @Pattern(regexp = "^1[3-9]\\d{9}$|^0\\d{2,3}-?\\d{7,8}$", message = "供应商联系电话格式不正确") - @Schema(description = "供应商联系电话") - private String contactPhone; - - /** 供应商统一信用代码 */ - @Pattern(regexp = "^[0-9A-HJ-NPQRTUWXY]{2}\\d{6}[0-9A-HJ-NPQRTUWXY]{10}$", message = "供应商统一信用代码格式不正确") - @Schema(description = "供应商统一信用代码") - private String supplierUscc; - - /** 供应商银行账户 */ - @Size(max = 50, message = "供应商银行账户长度不能超过50个字符") - @Schema(description = "供应商银行账户") - private String supplierBankAccount; + /** 供应商明细 */ + @Valid + @Schema(description = "供应商明细列表") + private List supplierList; /** 采购申请日期(或立项日期) */ @NotNull(message = "采购申请日期不能为空") diff --git a/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/dto/CcdiPurchaseTransactionQueryDTO.java b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/dto/CcdiPurchaseTransactionQueryDTO.java index 68f8c482..16e89290 100644 --- a/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/dto/CcdiPurchaseTransactionQueryDTO.java +++ b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/dto/CcdiPurchaseTransactionQueryDTO.java @@ -9,13 +9,13 @@ import java.io.Serializable; import java.util.Date; /** - * 采购交易信息查询DTO + * 招投标信息查询DTO * * @author ruoyi * @date 2026-02-06 */ @Data -@Schema(description = "采购交易信息查询条件") +@Schema(description = "招投标信息查询条件") public class CcdiPurchaseTransactionQueryDTO implements Serializable { @Serial diff --git a/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/dto/CcdiPurchaseTransactionSupplierDTO.java b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/dto/CcdiPurchaseTransactionSupplierDTO.java new file mode 100644 index 00000000..a828601d --- /dev/null +++ b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/dto/CcdiPurchaseTransactionSupplierDTO.java @@ -0,0 +1,42 @@ +package com.ruoyi.info.collection.domain.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; + +/** + * 招投标供应商明细DTO + */ +@Data +@Schema(description = "招投标供应商明细") +public class CcdiPurchaseTransactionSupplierDTO implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + @NotBlank(message = "供应商名称不能为空") + @Schema(description = "供应商名称") + private String supplierName; + + @NotBlank(message = "供应商统一信用代码不能为空") + @Schema(description = "供应商统一信用代码") + private String supplierUscc; + + @Schema(description = "供应商联系人") + private String contactPerson; + + @Schema(description = "供应商联系电话") + private String contactPhone; + + @Schema(description = "供应商银行账户") + private String supplierBankAccount; + + @Schema(description = "是否中标:1-是,0-否") + private Integer isBidWinner; + + @Schema(description = "排序") + private Integer sortOrder; +} diff --git a/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/dto/CcdiStaffEnterpriseRelationAddDTO.java b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/dto/CcdiStaffEnterpriseRelationAddDTO.java index 4e758ed7..326d7f3e 100644 --- a/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/dto/CcdiStaffEnterpriseRelationAddDTO.java +++ b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/dto/CcdiStaffEnterpriseRelationAddDTO.java @@ -10,22 +10,22 @@ import java.io.Serial; import java.io.Serializable; /** - * 员工实体关系信息新增DTO + * 员工亲属实体关联新增DTO * * @author ruoyi * @date 2026-02-09 */ @Data -@Schema(description = "员工实体关系信息新增") +@Schema(description = "员工亲属实体关联新增") public class CcdiStaffEnterpriseRelationAddDTO implements Serializable { @Serial private static final long serialVersionUID = 1L; - /** 身份证号 */ - @NotBlank(message = "身份证号不能为空") - @Pattern(regexp = "^[1-9]\\d{5}(18|19|20)\\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\\d|3[01])\\d{3}[0-9Xx]$", message = "身份证号格式不正确") - @Schema(description = "身份证号") + /** 亲属身份证号 */ + @NotBlank(message = "亲属身份证号不能为空") + @Pattern(regexp = "^[1-9]\\d{5}(18|19|20)\\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\\d|3[01])\\d{3}[0-9Xx]$", message = "亲属身份证号格式不正确") + @Schema(description = "亲属身份证号") private String personId; /** 关联人在企业的职务 */ diff --git a/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/dto/CcdiStaffEnterpriseRelationEditDTO.java b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/dto/CcdiStaffEnterpriseRelationEditDTO.java index 150e2865..d562832e 100644 --- a/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/dto/CcdiStaffEnterpriseRelationEditDTO.java +++ b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/dto/CcdiStaffEnterpriseRelationEditDTO.java @@ -10,13 +10,13 @@ import java.io.Serial; import java.io.Serializable; /** - * 员工实体关系信息编辑DTO + * 员工亲属实体关联编辑DTO * * @author ruoyi * @date 2026-02-09 */ @Data -@Schema(description = "员工实体关系信息编辑") +@Schema(description = "员工亲属实体关联编辑") public class CcdiStaffEnterpriseRelationEditDTO implements Serializable { @Serial @@ -27,8 +27,8 @@ public class CcdiStaffEnterpriseRelationEditDTO implements Serializable { @Schema(description = "主键ID") private Long id; - /** 身份证号 */ - @Schema(description = "身份证号(不可修改)") + /** 亲属身份证号 */ + @Schema(description = "亲属身份证号(不可修改)") private String personId; /** 关联人在企业的职务 */ diff --git a/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/dto/CcdiStaffEnterpriseRelationQueryDTO.java b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/dto/CcdiStaffEnterpriseRelationQueryDTO.java index b22195b6..f2902383 100644 --- a/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/dto/CcdiStaffEnterpriseRelationQueryDTO.java +++ b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/dto/CcdiStaffEnterpriseRelationQueryDTO.java @@ -7,22 +7,30 @@ import java.io.Serial; import java.io.Serializable; /** - * 员工实体关系信息查询DTO + * 员工亲属实体关联查询DTO * * @author ruoyi * @date 2026-02-09 */ @Data -@Schema(description = "员工实体关系信息查询条件") +@Schema(description = "员工亲属实体关联查询条件") public class CcdiStaffEnterpriseRelationQueryDTO implements Serializable { @Serial private static final long serialVersionUID = 1L; - /** 身份证号 */ - @Schema(description = "身份证号") + /** 亲属身份证号 */ + @Schema(description = "亲属身份证号") private String personId; + /** 亲属姓名 */ + @Schema(description = "亲属姓名") + private String relationName; + + /** 关联员工 */ + @Schema(description = "关联员工") + private String staffPersonName; + /** 统一社会信用代码 */ @Schema(description = "统一社会信用代码") private String socialCreditCode; diff --git a/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/dto/CcdiStaffRecruitmentEditDTO.java b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/dto/CcdiStaffRecruitmentEditDTO.java index ded9c315..4567379d 100644 --- a/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/dto/CcdiStaffRecruitmentEditDTO.java +++ b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/dto/CcdiStaffRecruitmentEditDTO.java @@ -7,10 +7,12 @@ import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Pattern; import jakarta.validation.constraints.Size; +import jakarta.validation.Valid; import lombok.Data; import java.io.Serial; import java.io.Serializable; +import java.util.List; /** * 员工招聘信息编辑DTO @@ -91,4 +93,8 @@ public class CcdiStaffRecruitmentEditDTO implements Serializable { /** 面试官2工号 */ @Size(max = 10, message = "面试官2工号长度不能超过10个字符") private String interviewerId2; + + /** 历史工作经历列表 */ + @Valid + private List workExperienceList; } diff --git a/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/dto/CcdiStaffRecruitmentWorkEditDTO.java b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/dto/CcdiStaffRecruitmentWorkEditDTO.java new file mode 100644 index 00000000..7841029b --- /dev/null +++ b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/dto/CcdiStaffRecruitmentWorkEditDTO.java @@ -0,0 +1,56 @@ +package com.ruoyi.info.collection.domain.dto; + +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; + +/** + * 招聘记录历史工作经历编辑DTO + * + * @author ruoyi + * @date 2026-04-22 + */ +@Data +public class CcdiStaffRecruitmentWorkEditDTO implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + /** 排序号 */ + private Integer sortOrder; + + /** 工作单位 */ + @Size(max = 200, message = "工作单位长度不能超过200个字符") + private String companyName; + + /** 所属部门 */ + @Size(max = 100, message = "所属部门长度不能超过100个字符") + private String departmentName; + + /** 岗位名称 */ + @Size(max = 100, message = "岗位名称长度不能超过100个字符") + private String positionName; + + /** 入职年月 */ + @Pattern(regexp = "^$|^((19|20)\\d{2})-(0[1-9]|1[0-2])$", message = "入职年月格式不正确,应为YYYY-MM") + private String jobStartMonth; + + /** 离职年月 */ + @Pattern(regexp = "^$|^((19|20)\\d{2})-(0[1-9]|1[0-2])$", message = "离职年月格式不正确,应为YYYY-MM") + private String jobEndMonth; + + /** 离职原因 */ + @Size(max = 500, message = "离职原因长度不能超过500个字符") + private String departureReason; + + /** 主要工作内容 */ + @Size(max = 1000, message = "主要工作内容长度不能超过1000个字符") + private String workContent; + + /** 备注 */ + @Size(max = 500, message = "备注长度不能超过500个字符") + private String remark; +} diff --git a/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/excel/CcdiBaseStaffExcel.java b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/excel/CcdiBaseStaffExcel.java index cd33c1ca..7dac9230 100644 --- a/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/excel/CcdiBaseStaffExcel.java +++ b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/excel/CcdiBaseStaffExcel.java @@ -63,8 +63,15 @@ public class CcdiBaseStaffExcel implements Serializable { @ColumnWidth(15) private Date hireDate; + /** 是否党员 */ + @ExcelProperty(value = "是否党员", index = 7) + @ColumnWidth(12) + @DictDropdown(dictType = "ccdi_yes_no_flag") + @Required + private Integer partyMember; + /** 状态 */ - @ExcelProperty(value = "状态", index = 7) + @ExcelProperty(value = "状态", index = 8) @ColumnWidth(10) @DictDropdown(dictType = "ccdi_employee_status") @Required diff --git a/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/excel/CcdiEnterpriseBaseInfoExcel.java b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/excel/CcdiEnterpriseBaseInfoExcel.java new file mode 100644 index 00000000..a3bc16fa --- /dev/null +++ b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/excel/CcdiEnterpriseBaseInfoExcel.java @@ -0,0 +1,102 @@ +package com.ruoyi.info.collection.domain.excel; + +import com.alibaba.excel.annotation.ExcelProperty; +import com.alibaba.excel.annotation.write.style.ColumnWidth; +import com.ruoyi.common.annotation.DictDropdown; +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; +import java.util.Date; + +/** + * 实体库管理 Excel 导入模板对象 + * + * @author ruoyi + * @date 2026-04-17 + */ +@Data +public class CcdiEnterpriseBaseInfoExcel implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + @ExcelProperty(value = "统一社会信用代码*", index = 0) + @ColumnWidth(24) + private String socialCreditCode; + + @ExcelProperty(value = "企业名称*", index = 1) + @ColumnWidth(30) + private String enterpriseName; + + @ExcelProperty(value = "企业类型", index = 2) + @ColumnWidth(18) + @DictDropdown(dictType = "ccdi_entity_type") + private String enterpriseType; + + @ExcelProperty(value = "企业性质", index = 3) + @ColumnWidth(18) + @DictDropdown(dictType = "ccdi_enterprise_nature") + private String enterpriseNature; + + @ExcelProperty(value = "行业分类", index = 4) + @ColumnWidth(18) + private String industryClass; + + @ExcelProperty(value = "所属行业", index = 5) + @ColumnWidth(18) + private String industryName; + + @ExcelProperty(value = "成立日期", index = 6) + @ColumnWidth(16) + private Date establishDate; + + @ExcelProperty(value = "注册地址", index = 7) + @ColumnWidth(36) + private String registerAddress; + + @ExcelProperty(value = "法定代表人", index = 8) + @ColumnWidth(18) + private String legalRepresentative; + + @ExcelProperty(value = "法定代表人证件类型", index = 9) + @ColumnWidth(22) + @DictDropdown(dictType = "ccdi_certificate_type") + private String legalCertType; + + @ExcelProperty(value = "法定代表人证件号码", index = 10) + @ColumnWidth(24) + private String legalCertNo; + + @ExcelProperty(value = "股东1", index = 11) + @ColumnWidth(18) + private String shareholder1; + + @ExcelProperty(value = "股东2", index = 12) + @ColumnWidth(18) + private String shareholder2; + + @ExcelProperty(value = "股东3", index = 13) + @ColumnWidth(18) + private String shareholder3; + + @ExcelProperty(value = "股东4", index = 14) + @ColumnWidth(18) + private String shareholder4; + + @ExcelProperty(value = "股东5", index = 15) + @ColumnWidth(18) + private String shareholder5; + + @ExcelProperty(value = "经营状态", index = 16) + @ColumnWidth(16) + private String status; + + @ExcelProperty(value = "风险等级*", index = 17) + @ColumnWidth(16) + private String riskLevel; + + @ExcelProperty(value = "企业来源*", index = 18) + @ColumnWidth(18) + private String entSource; +} diff --git a/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/excel/CcdiIntermediaryEnterpriseRelationExcel.java b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/excel/CcdiIntermediaryEnterpriseRelationExcel.java new file mode 100644 index 00000000..f6aed554 --- /dev/null +++ b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/excel/CcdiIntermediaryEnterpriseRelationExcel.java @@ -0,0 +1,38 @@ +package com.ruoyi.info.collection.domain.excel; + +import com.alibaba.excel.annotation.ExcelProperty; +import com.alibaba.excel.annotation.write.style.ColumnWidth; +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; + +/** + * 中介实体关联关系导入对象 + */ +@Data +public class CcdiIntermediaryEnterpriseRelationExcel implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + /** 中介本人证件号码 */ + @ExcelProperty(value = "中介本人证件号码*", index = 0) + @ColumnWidth(24) + private String ownerPersonId; + + /** 统一社会信用代码 */ + @ExcelProperty(value = "统一社会信用代码*", index = 1) + @ColumnWidth(24) + private String socialCreditCode; + + /** 关联人职务 */ + @ExcelProperty(value = "关联人职务", index = 2) + @ColumnWidth(20) + private String relationPersonPost; + + /** 备注 */ + @ExcelProperty(value = "备注", index = 3) + @ColumnWidth(30) + private String remark; +} diff --git a/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/excel/CcdiIntermediaryPersonExcel.java b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/excel/CcdiIntermediaryPersonExcel.java index a9a8c84e..8da1599a 100644 --- a/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/excel/CcdiIntermediaryPersonExcel.java +++ b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/excel/CcdiIntermediaryPersonExcel.java @@ -34,6 +34,7 @@ public class CcdiIntermediaryPersonExcel implements Serializable { /** 人员子类型 */ @ExcelProperty(value = "人员子类型", index = 2) @ColumnWidth(15) + @DictDropdown(dictType = "ccdi_person_sub_type") private String personSubType; /** 性别 */ @@ -83,19 +84,13 @@ public class CcdiIntermediaryPersonExcel implements Serializable { @ColumnWidth(15) private String position; - /** 关联人员ID */ - @ExcelProperty(value = "关联人员ID", index = 12) - @ColumnWidth(15) + /** 关联中介本人证件号码 */ + @ExcelProperty(value = "关联中介本人证件号码", index = 12) + @ColumnWidth(24) private String relatedNumId; - /** 关系类型 */ - @ExcelProperty(value = "关系类型", index = 13) - @ColumnWidth(15) - @DictDropdown(dictType = "ccdi_relation_type") - private String relationType; - /** 备注 */ - @ExcelProperty(value = "备注", index = 14) + @ExcelProperty(value = "备注", index = 13) @ColumnWidth(30) private String remark; } diff --git a/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/excel/CcdiPurchaseTransactionExcel.java b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/excel/CcdiPurchaseTransactionExcel.java index 7c996cfe..fa8e1ebe 100644 --- a/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/excel/CcdiPurchaseTransactionExcel.java +++ b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/excel/CcdiPurchaseTransactionExcel.java @@ -11,7 +11,7 @@ import java.math.BigDecimal; import java.util.Date; /** - * 采购交易信息Excel导入导出对象 + * 招投标主信息Excel导入导出对象 * * @author ruoyi * @date 2026-02-06 @@ -88,107 +88,82 @@ public class CcdiPurchaseTransactionExcel implements Serializable { @Required private String purchaseMethod; - /** 中标供应商名称 */ - @ExcelProperty(value = "中标供应商名称", index = 12) - @ColumnWidth(25) - private String supplierName; - - /** 供应商联系人 */ - @ExcelProperty(value = "供应商联系人", index = 13) - @ColumnWidth(15) - private String contactPerson; - - /** 供应商联系电话 */ - @ExcelProperty(value = "供应商联系电话", index = 14) - @ColumnWidth(18) - private String contactPhone; - - /** 供应商统一信用代码 */ - @ExcelProperty(value = "供应商统一信用代码", index = 15) - @ColumnWidth(25) - private String supplierUscc; - - /** 供应商银行账户 */ - @ExcelProperty(value = "供应商银行账户", index = 16) - @ColumnWidth(20) - private String supplierBankAccount; - /** 采购申请日期(或立项日期) */ - @ExcelProperty(value = "采购申请日期", index = 17) + @ExcelProperty(value = "采购申请日期", index = 12) @ColumnWidth(18) @Required private Date applyDate; /** 采购计划批准日期 */ - @ExcelProperty(value = "采购计划批准日期", index = 18) + @ExcelProperty(value = "采购计划批准日期", index = 13) @ColumnWidth(18) private Date planApproveDate; /** 采购公告发布日期 */ - @ExcelProperty(value = "采购公告发布日期", index = 19) + @ExcelProperty(value = "采购公告发布日期", index = 14) @ColumnWidth(18) private Date announceDate; /** 开标日期 */ - @ExcelProperty(value = "开标日期", index = 20) + @ExcelProperty(value = "开标日期", index = 15) @ColumnWidth(18) private Date bidOpenDate; /** 合同签订日期 */ - @ExcelProperty(value = "合同签订日期", index = 21) + @ExcelProperty(value = "合同签订日期", index = 16) @ColumnWidth(18) private Date contractSignDate; /** 预计交货日期 */ - @ExcelProperty(value = "预计交货日期", index = 22) + @ExcelProperty(value = "预计交货日期", index = 17) @ColumnWidth(18) private Date expectedDeliveryDate; /** 实际交货日期 */ - @ExcelProperty(value = "实际交货日期", index = 23) + @ExcelProperty(value = "实际交货日期", index = 18) @ColumnWidth(18) private Date actualDeliveryDate; /** 验收日期 */ - @ExcelProperty(value = "验收日期", index = 24) + @ExcelProperty(value = "验收日期", index = 19) @ColumnWidth(18) private Date acceptanceDate; /** 结算日期 */ - @ExcelProperty(value = "结算日期", index = 25) + @ExcelProperty(value = "结算日期", index = 20) @ColumnWidth(18) private Date settlementDate; /** 申请人工号 */ - @ExcelProperty(value = "申请人工号", index = 26) + @ExcelProperty(value = "申请人工号", index = 21) @ColumnWidth(15) @Required private String applicantId; /** 申请人姓名 */ - @ExcelProperty(value = "申请人姓名", index = 27) + @ExcelProperty(value = "申请人姓名", index = 22) @ColumnWidth(15) @Required private String applicantName; /** 申请部门 */ - @ExcelProperty(value = "申请部门", index = 28) + @ExcelProperty(value = "申请部门", index = 23) @ColumnWidth(18) @Required private String applyDepartment; /** 采购负责人工号 */ - @ExcelProperty(value = "采购负责人工号", index = 29) + @ExcelProperty(value = "采购负责人工号", index = 24) @ColumnWidth(15) private String purchaseLeaderId; /** 采购负责人姓名 */ - @ExcelProperty(value = "采购负责人姓名", index = 30) + @ExcelProperty(value = "采购负责人姓名", index = 25) @ColumnWidth(15) private String purchaseLeaderName; /** 采购部门 */ - @ExcelProperty(value = "采购部门", index = 31) + @ExcelProperty(value = "采购部门", index = 26) @ColumnWidth(18) private String purchaseDepartment; } diff --git a/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/excel/CcdiPurchaseTransactionSupplierExcel.java b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/excel/CcdiPurchaseTransactionSupplierExcel.java new file mode 100644 index 00000000..0655f64a --- /dev/null +++ b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/excel/CcdiPurchaseTransactionSupplierExcel.java @@ -0,0 +1,55 @@ +package com.ruoyi.info.collection.domain.excel; + +import com.alibaba.excel.annotation.ExcelProperty; +import com.alibaba.excel.annotation.write.style.ColumnWidth; +import com.ruoyi.common.annotation.DictDropdown; +import com.ruoyi.common.annotation.Required; +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; + +/** + * 招投标供应商明细Excel对象 + */ +@Data +public class CcdiPurchaseTransactionSupplierExcel implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + @ExcelProperty(value = "采购事项ID", index = 0) + @ColumnWidth(20) + @Required + private String purchaseId; + + @ExcelProperty(value = "供应商名称", index = 1) + @ColumnWidth(25) + @Required + private String supplierName; + + @ExcelProperty(value = "供应商统一信用代码", index = 2) + @ColumnWidth(25) + private String supplierUscc; + + @ExcelProperty(value = "供应商联系人", index = 3) + @ColumnWidth(15) + private String contactPerson; + + @ExcelProperty(value = "供应商联系电话", index = 4) + @ColumnWidth(18) + private String contactPhone; + + @ExcelProperty(value = "供应商银行账户", index = 5) + @ColumnWidth(20) + private String supplierBankAccount; + + @ExcelProperty(value = "是否中标", index = 6) + @ColumnWidth(12) + @DictDropdown(dictType = "ccdi_yes_no_flag") + private String isBidWinner; + + @ExcelProperty(value = "排序", index = 7) + @ColumnWidth(10) + private Integer sortOrder; +} diff --git a/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/excel/CcdiStaffEnterpriseRelationExcel.java b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/excel/CcdiStaffEnterpriseRelationExcel.java index 0038167d..8fcd35bd 100644 --- a/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/excel/CcdiStaffEnterpriseRelationExcel.java +++ b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/excel/CcdiStaffEnterpriseRelationExcel.java @@ -10,23 +10,23 @@ import java.io.Serial; import java.io.Serializable; /** - * 员工实体关系信息Excel导入导出对象 + * 员工亲属实体关联Excel导入导出对象 * * @author ruoyi * @date 2026-02-09 */ @Data -@Schema(description = "员工实体关系信息Excel导入导出对象") +@Schema(description = "员工亲属实体关联Excel导入导出对象") public class CcdiStaffEnterpriseRelationExcel implements Serializable { @Serial private static final long serialVersionUID = 1L; - /** 身份证号 */ - @ExcelProperty(value = "身份证号", index = 0) + /** 亲属身份证号 */ + @ExcelProperty(value = "亲属身份证号", index = 0) @ColumnWidth(20) @Required - @Schema(description = "身份证号") + @Schema(description = "亲属身份证号") private String personId; /** 统一社会信用代码 */ diff --git a/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/vo/AssetImportFailureVO.java b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/vo/AssetImportFailureVO.java index 4a23b583..ac40a33a 100644 --- a/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/vo/AssetImportFailureVO.java +++ b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/vo/AssetImportFailureVO.java @@ -15,6 +15,14 @@ import java.math.BigDecimal; @Schema(description = "亲属资产信息导入失败记录") public class AssetImportFailureVO { + /** Sheet名称 */ + @Schema(description = "Sheet名称") + private String sheetName; + + /** Excel行号 */ + @Schema(description = "Excel行号") + private Integer rowNum; + /** 亲属证件号 */ @Schema(description = "亲属证件号") private String personId; diff --git a/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/vo/BaseStaffAssetImportFailureVO.java b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/vo/BaseStaffAssetImportFailureVO.java index 90a7e558..7c53c501 100644 --- a/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/vo/BaseStaffAssetImportFailureVO.java +++ b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/vo/BaseStaffAssetImportFailureVO.java @@ -15,6 +15,14 @@ import java.math.BigDecimal; @Schema(description = "员工资产信息导入失败记录") public class BaseStaffAssetImportFailureVO { + /** Sheet名称 */ + @Schema(description = "Sheet名称") + private String sheetName; + + /** Excel行号 */ + @Schema(description = "Excel行号") + private Integer rowNum; + /** 员工身份证号 */ @Schema(description = "员工身份证号") private String personId; diff --git a/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/vo/BaseStaffImportSubmitResultVO.java b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/vo/BaseStaffImportSubmitResultVO.java new file mode 100644 index 00000000..a7a48551 --- /dev/null +++ b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/vo/BaseStaffImportSubmitResultVO.java @@ -0,0 +1,23 @@ +package com.ruoyi.info.collection.domain.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +/** + * 员工双Sheet导入提交结果 + * + * @author ruoyi + */ +@Data +@Schema(description = "员工双Sheet导入提交结果") +public class BaseStaffImportSubmitResultVO { + + @Schema(description = "员工信息导入任务ID") + private String staffTaskId; + + @Schema(description = "员工资产信息导入任务ID") + private String assetTaskId; + + @Schema(description = "提交说明") + private String message; +} diff --git a/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/vo/CcdiBaseStaffVO.java b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/vo/CcdiBaseStaffVO.java index d86ed151..77987d85 100644 --- a/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/vo/CcdiBaseStaffVO.java +++ b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/vo/CcdiBaseStaffVO.java @@ -44,6 +44,9 @@ public class CcdiBaseStaffVO implements Serializable { /** 入职时间 */ private Date hireDate; + /** 是否党员:0-否 1-是 */ + private Integer partyMember; + /** 状态 */ private String status; diff --git a/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/vo/CcdiEnterpriseBaseInfoVO.java b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/vo/CcdiEnterpriseBaseInfoVO.java new file mode 100644 index 00000000..e0d5e06f --- /dev/null +++ b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/vo/CcdiEnterpriseBaseInfoVO.java @@ -0,0 +1,85 @@ +package com.ruoyi.info.collection.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-04-17 + */ +@Data +@Schema(description = "实体库管理VO") +public class CcdiEnterpriseBaseInfoVO implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + @Schema(description = "统一社会信用代码") + private String socialCreditCode; + + @Schema(description = "企业名称") + private String enterpriseName; + + @Schema(description = "企业类型") + private String enterpriseType; + + @Schema(description = "企业性质") + private String enterpriseNature; + + @Schema(description = "行业分类") + private String industryClass; + + @Schema(description = "所属行业") + private String industryName; + + @Schema(description = "成立日期") + private Date establishDate; + + @Schema(description = "注册地址") + private String registerAddress; + + @Schema(description = "法定代表人") + private String legalRepresentative; + + @Schema(description = "法定代表人证件类型") + private String legalCertType; + + @Schema(description = "法定代表人证件号码") + private String legalCertNo; + + @Schema(description = "股东1") + private String shareholder1; + + @Schema(description = "股东2") + private String shareholder2; + + @Schema(description = "股东3") + private String shareholder3; + + @Schema(description = "股东4") + private String shareholder4; + + @Schema(description = "股东5") + private String shareholder5; + + @Schema(description = "经营状态") + private String status; + + @Schema(description = "风险等级") + private String riskLevel; + + @Schema(description = "企业来源") + private String entSource; + + @Schema(description = "数据来源") + private String dataSource; + + @Schema(description = "创建时间") + private Date createTime; +} diff --git a/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/vo/CcdiIntermediaryEnterpriseRelationVO.java b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/vo/CcdiIntermediaryEnterpriseRelationVO.java new file mode 100644 index 00000000..841561ef --- /dev/null +++ b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/vo/CcdiIntermediaryEnterpriseRelationVO.java @@ -0,0 +1,48 @@ +package com.ruoyi.info.collection.domain.vo; + +import com.fasterxml.jackson.annotation.JsonFormat; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; +import java.util.Date; + +/** + * 中介关联机构VO + */ +@Data +@Schema(description = "中介关联机构VO") +public class CcdiIntermediaryEnterpriseRelationVO implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + @Schema(description = "主键ID") + private Long id; + + @Schema(description = "所属中介ID") + private String intermediaryBizId; + + @Schema(description = "所属中介姓名") + private String intermediaryName; + + @Schema(description = "所属中介证件号") + private String intermediaryPersonId; + + @Schema(description = "统一社会信用代码") + private String socialCreditCode; + + @Schema(description = "机构名称") + private String enterpriseName; + + @Schema(description = "关联角色/职务") + private String relationPersonPost; + + @Schema(description = "备注") + private String remark; + + @Schema(description = "创建时间") + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private Date createTime; +} diff --git a/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/vo/CcdiIntermediaryPersonDetailVO.java b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/vo/CcdiIntermediaryPersonDetailVO.java index b0de70b4..def4d3a2 100644 --- a/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/vo/CcdiIntermediaryPersonDetailVO.java +++ b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/vo/CcdiIntermediaryPersonDetailVO.java @@ -63,7 +63,7 @@ public class CcdiIntermediaryPersonDetailVO implements Serializable { @Schema(description = "职位") private String position; - @Schema(description = "关联人员ID") + @Schema(description = "关联中介本人证件号码") private String relatedNumId; @Schema(description = "关联关系") diff --git a/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/vo/CcdiIntermediaryRelativeVO.java b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/vo/CcdiIntermediaryRelativeVO.java new file mode 100644 index 00000000..6d10ae43 --- /dev/null +++ b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/vo/CcdiIntermediaryRelativeVO.java @@ -0,0 +1,69 @@ +package com.ruoyi.info.collection.domain.vo; + +import com.fasterxml.jackson.annotation.JsonFormat; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; +import java.util.Date; + +/** + * 中介亲属VO + */ +@Data +@Schema(description = "中介亲属VO") +public class CcdiIntermediaryRelativeVO implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + @Schema(description = "人员ID") + private String bizId; + + @Schema(description = "关联中介本人证件号码") + private String relatedNumId; + + @Schema(description = "姓名") + private String name; + + @Schema(description = "人员类型") + private String personType; + + @Schema(description = "亲属关系") + private String personSubType; + + @Schema(description = "性别") + private String gender; + + @Schema(description = "证件类型") + private String idType; + + @Schema(description = "证件号码") + private String personId; + + @Schema(description = "手机号码") + private String mobile; + + @Schema(description = "微信号") + private String wechatNo; + + @Schema(description = "联系地址") + private String contactAddress; + + @Schema(description = "所在公司") + private String company; + + @Schema(description = "企业统一信用码") + private String socialCreditCode; + + @Schema(description = "职位") + private String position; + + @Schema(description = "备注") + private String remark; + + @Schema(description = "创建时间") + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private Date createTime; +} diff --git a/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/vo/CcdiIntermediaryVO.java b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/vo/CcdiIntermediaryVO.java index a875cdea..2f6aae6d 100644 --- a/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/vo/CcdiIntermediaryVO.java +++ b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/vo/CcdiIntermediaryVO.java @@ -21,32 +21,25 @@ public class CcdiIntermediaryVO implements Serializable { @Serial private static final long serialVersionUID = 1L; - @Schema(description = "ID") - private String id; + @Schema(description = "记录类型") + private String recordType; - @Schema(description = "姓名/机构名称") + @Schema(description = "记录ID") + private String recordId; + + @Schema(description = "名称") private String name; - @Schema(description = "证件号/统一社会信用代码") + @Schema(description = "证件号") private String certificateNo; - @Schema(description = "中介类型(1=个人, 2=实体)") - private String intermediaryType; + @Schema(description = "关联中介姓名") + private String relatedIntermediaryName; - @Schema(description = "人员类型") - private String personType; - - @Schema(description = "公司") - private String company; - - @Schema(description = "数据来源") - private String dataSource; + @Schema(description = "关联关系") + private String relationText; @Schema(description = "创建时间") @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") private Date createTime; - - @Schema(description = "修改时间") - @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") - private Date updateTime; } diff --git a/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/vo/CcdiPurchaseTransactionSupplierVO.java b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/vo/CcdiPurchaseTransactionSupplierVO.java new file mode 100644 index 00000000..4a56508c --- /dev/null +++ b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/vo/CcdiPurchaseTransactionSupplierVO.java @@ -0,0 +1,45 @@ +package com.ruoyi.info.collection.domain.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; + +/** + * 招投标供应商明细VO + */ +@Data +@Schema(description = "招投标供应商明细") +public class CcdiPurchaseTransactionSupplierVO implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + @Schema(description = "主键ID") + private Long id; + + @Schema(description = "采购事项ID") + private String purchaseId; + + @Schema(description = "供应商名称") + private String supplierName; + + @Schema(description = "供应商统一信用代码") + private String supplierUscc; + + @Schema(description = "供应商联系人") + private String contactPerson; + + @Schema(description = "供应商联系电话") + private String contactPhone; + + @Schema(description = "供应商银行账户") + private String supplierBankAccount; + + @Schema(description = "是否中标:1-是,0-否") + private Integer isBidWinner; + + @Schema(description = "排序") + private Integer sortOrder; +} diff --git a/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/vo/CcdiPurchaseTransactionVO.java b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/vo/CcdiPurchaseTransactionVO.java index 698ea3ef..3bd4664f 100644 --- a/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/vo/CcdiPurchaseTransactionVO.java +++ b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/vo/CcdiPurchaseTransactionVO.java @@ -8,15 +8,16 @@ import java.io.Serial; import java.io.Serializable; import java.math.BigDecimal; import java.util.Date; +import java.util.List; /** - * 采购交易信息VO + * 招投标信息VO * * @author ruoyi * @date 2026-02-06 */ @Data -@Schema(description = "采购交易信息") +@Schema(description = "招投标信息") public class CcdiPurchaseTransactionVO implements Serializable { @Serial @@ -90,6 +91,14 @@ public class CcdiPurchaseTransactionVO implements Serializable { @Schema(description = "供应商银行账户") private String supplierBankAccount; + /** 参与供应商数 */ + @Schema(description = "参与供应商数") + private Integer supplierCount; + + /** 供应商明细 */ + @Schema(description = "供应商明细列表") + private List supplierList; + /** 采购申请日期(或立项日期) */ @JsonFormat(pattern = "yyyy-MM-dd") @Schema(description = "采购申请日期") diff --git a/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/vo/CcdiStaffEnterpriseRelationOptionVO.java b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/vo/CcdiStaffEnterpriseRelationOptionVO.java new file mode 100644 index 00000000..aaf64b7f --- /dev/null +++ b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/vo/CcdiStaffEnterpriseRelationOptionVO.java @@ -0,0 +1,37 @@ +package com.ruoyi.info.collection.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-04-23 + */ +@Data +@Schema(description = "员工亲属实体关联下拉选项") +public class CcdiStaffEnterpriseRelationOptionVO implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + /** 亲属身份证号 */ + @Schema(description = "亲属身份证号") + private String relationCertNo; + + /** 亲属姓名 */ + @Schema(description = "亲属姓名") + private String relationName; + + /** 关联员工身份证号 */ + @Schema(description = "关联员工身份证号") + private String staffPersonId; + + /** 关联员工姓名 */ + @Schema(description = "关联员工姓名") + private String staffPersonName; +} diff --git a/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/vo/CcdiStaffEnterpriseRelationVO.java b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/vo/CcdiStaffEnterpriseRelationVO.java index 53fd8af9..95f02030 100644 --- a/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/vo/CcdiStaffEnterpriseRelationVO.java +++ b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/vo/CcdiStaffEnterpriseRelationVO.java @@ -9,13 +9,13 @@ import java.io.Serializable; import java.util.Date; /** - * 员工实体关系信息VO + * 员工亲属实体关联VO * * @author ruoyi * @date 2026-02-09 */ @Data -@Schema(description = "员工实体关系信息") +@Schema(description = "员工亲属实体关联") public class CcdiStaffEnterpriseRelationVO implements Serializable { @Serial @@ -25,13 +25,21 @@ public class CcdiStaffEnterpriseRelationVO implements Serializable { @Schema(description = "主键ID") private Long id; - /** 身份证号 */ - @Schema(description = "身份证号") + /** 亲属身份证号 */ + @Schema(description = "亲属身份证号") private String personId; - /** 员工姓名 */ - @Schema(description = "员工姓名") - private String personName; + /** 亲属姓名 */ + @Schema(description = "亲属姓名") + private String relationName; + + /** 关联员工身份证号 */ + @Schema(description = "关联员工身份证号") + private String staffPersonId; + + /** 关联员工姓名 */ + @Schema(description = "关联员工姓名") + private String staffPersonName; /** 关联人在企业的职务 */ @Schema(description = "关联人在企业的职务") diff --git a/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/vo/EnterpriseBaseInfoImportFailureVO.java b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/vo/EnterpriseBaseInfoImportFailureVO.java new file mode 100644 index 00000000..5f70bf6f --- /dev/null +++ b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/vo/EnterpriseBaseInfoImportFailureVO.java @@ -0,0 +1,51 @@ +package com.ruoyi.info.collection.domain.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +/** + * 实体库导入失败记录 VO + * + * @author ruoyi + * @date 2026-04-17 + */ +@Data +@Schema(description = "实体库导入失败记录") +public class EnterpriseBaseInfoImportFailureVO { + + @Schema(description = "企业名称") + private String enterpriseName; + + @Schema(description = "统一社会信用代码") + private String socialCreditCode; + + @Schema(description = "企业类型") + private String enterpriseType; + + @Schema(description = "企业性质") + private String enterpriseNature; + + @Schema(description = "行业分类") + private String industryClass; + + @Schema(description = "所属行业") + private String industryName; + + @Schema(description = "法定代表人") + private String legalRepresentative; + + @Schema(description = "经营状态") + private String status; + + @Schema(description = "风险等级") + private String riskLevel; + + @Schema(description = "企业来源") + private String entSource; + + @Schema(description = "数据来源") + private String dataSource; + + @Schema(description = "错误信息") + private String errorMessage; +} diff --git a/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/vo/ImportFailureVO.java b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/vo/ImportFailureVO.java index 6debffa7..bd4d351c 100644 --- a/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/vo/ImportFailureVO.java +++ b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/vo/ImportFailureVO.java @@ -14,8 +14,14 @@ import java.math.BigDecimal; @Schema(description = "导入失败记录") public class ImportFailureVO { + @Schema(description = "Sheet名称") + private String sheetName; + + @Schema(description = "Excel行号") + private Integer rowNum; + @Schema(description = "柜员号") - private Long employeeId; + private Long staffId; @Schema(description = "姓名") private String name; @@ -32,6 +38,9 @@ public class ImportFailureVO { @Schema(description = "年收入") private BigDecimal annualIncome; + @Schema(description = "是否党员:0-否 1-是") + private Integer partyMember; + @Schema(description = "状态") private String status; diff --git a/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/vo/IntermediaryEnterpriseRelationImportFailureVO.java b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/vo/IntermediaryEnterpriseRelationImportFailureVO.java new file mode 100644 index 00000000..a60f581d --- /dev/null +++ b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/vo/IntermediaryEnterpriseRelationImportFailureVO.java @@ -0,0 +1,33 @@ +package com.ruoyi.info.collection.domain.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; + +/** + * 中介实体关联关系导入失败记录 + */ +@Data +@Schema(description = "中介实体关联关系导入失败记录") +public class IntermediaryEnterpriseRelationImportFailureVO implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + @Schema(description = "中介本人证件号码") + private String ownerPersonId; + + @Schema(description = "统一社会信用代码") + private String socialCreditCode; + + @Schema(description = "关联人职务") + private String relationPersonPost; + + @Schema(description = "备注") + private String remark; + + @Schema(description = "错误信息") + private String errorMessage; +} diff --git a/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/vo/IntermediaryPersonImportFailureVO.java b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/vo/IntermediaryPersonImportFailureVO.java index 86cd9097..1917ae97 100644 --- a/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/vo/IntermediaryPersonImportFailureVO.java +++ b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/vo/IntermediaryPersonImportFailureVO.java @@ -22,21 +22,45 @@ public class IntermediaryPersonImportFailureVO implements Serializable { @Schema(description = "姓名") private String name; - @Schema(description = "证件号码") - private String personId; - @Schema(description = "人员类型") private String personType; + @Schema(description = "人员子类型") + private String personSubType; + @Schema(description = "性别") private String gender; + @Schema(description = "证件类型") + private String idType; + + @Schema(description = "证件号码") + private String personId; + @Schema(description = "手机号码") private String mobile; + @Schema(description = "微信号") + private String wechatNo; + + @Schema(description = "联系地址") + private String contactAddress; + @Schema(description = "所在公司") private String company; + @Schema(description = "企业统一信用码") + private String socialCreditCode; + + @Schema(description = "职位") + private String position; + + @Schema(description = "关联中介本人证件号码") + private String relatedNumId; + + @Schema(description = "备注") + private String remark; + @Schema(description = "错误信息") private String errorMessage; } diff --git a/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/vo/PurchaseTransactionImportFailureVO.java b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/vo/PurchaseTransactionImportFailureVO.java index 3f8fddd7..7e165b90 100644 --- a/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/vo/PurchaseTransactionImportFailureVO.java +++ b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/vo/PurchaseTransactionImportFailureVO.java @@ -6,15 +6,23 @@ import lombok.Data; import java.math.BigDecimal; /** - * 采购交易信息导入失败记录VO + * 招投标信息导入失败记录VO * * @author ruoyi * @date 2026-02-06 */ @Data -@Schema(description = "采购交易信息导入失败记录") +@Schema(description = "招投标信息导入失败记录") public class PurchaseTransactionImportFailureVO { + /** 失败来源Sheet */ + @Schema(description = "失败来源Sheet") + private String sheetName; + + /** 失败行号 */ + @Schema(description = "失败行号") + private String sheetRowNum; + /** 采购事项ID */ @Schema(description = "采购事项ID") private String purchaseId; diff --git a/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/vo/RecruitmentImportFailureVO.java b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/vo/RecruitmentImportFailureVO.java index 4775dfff..e88f740d 100644 --- a/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/vo/RecruitmentImportFailureVO.java +++ b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/vo/RecruitmentImportFailureVO.java @@ -13,6 +13,12 @@ import lombok.Data; @Schema(description = "招聘信息导入失败记录") public class RecruitmentImportFailureVO { + @Schema(description = "失败Sheet") + private String sheetName; + + @Schema(description = "失败行号") + private String sheetRowNum; + @Schema(description = "招聘项目编号") private String recruitId; diff --git a/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/vo/StaffEnterpriseRelationImportFailureVO.java b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/vo/StaffEnterpriseRelationImportFailureVO.java index 8da53428..7acc4d02 100644 --- a/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/vo/StaffEnterpriseRelationImportFailureVO.java +++ b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/vo/StaffEnterpriseRelationImportFailureVO.java @@ -7,22 +7,26 @@ import java.io.Serial; import java.io.Serializable; /** - * 员工实体关系信息导入失败记录VO + * 员工亲属实体关联导入失败记录VO * * @author ruoyi * @date 2026-02-09 */ @Data -@Schema(description = "员工实体关系信息导入失败记录") +@Schema(description = "员工亲属实体关联导入失败记录") public class StaffEnterpriseRelationImportFailureVO implements Serializable { @Serial private static final long serialVersionUID = 1L; - /** 身份证号 */ - @Schema(description = "身份证号") + /** 亲属身份证号 */ + @Schema(description = "亲属身份证号") private String personId; + /** 亲属姓名 */ + @Schema(description = "亲属姓名") + private String relationName; + /** 统一社会信用代码 */ @Schema(description = "统一社会信用代码") private String socialCreditCode; diff --git a/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/vo/StaffFmyRelationImportFailureVO.java b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/vo/StaffFmyRelationImportFailureVO.java index 89e7ffe7..b0dcdc26 100644 --- a/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/vo/StaffFmyRelationImportFailureVO.java +++ b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/vo/StaffFmyRelationImportFailureVO.java @@ -15,6 +15,14 @@ import java.math.BigDecimal; @Schema(description = "员工亲属关系信息导入失败记录") public class StaffFmyRelationImportFailureVO { + /** Sheet名称 */ + @Schema(description = "Sheet名称") + private String sheetName; + + /** Excel行号 */ + @Schema(description = "Excel行号") + private Integer rowNum; + /** 员工身份证号 */ @Schema(description = "员工身份证号") private String personId; diff --git a/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/vo/StaffFmyRelationImportSubmitResultVO.java b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/vo/StaffFmyRelationImportSubmitResultVO.java new file mode 100644 index 00000000..dab16109 --- /dev/null +++ b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/vo/StaffFmyRelationImportSubmitResultVO.java @@ -0,0 +1,24 @@ +package com.ruoyi.info.collection.domain.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +/** + * 员工亲属关系双Sheet导入提交结果 + * + * @author ruoyi + * @date 2026-04-22 + */ +@Data +@Schema(description = "员工亲属关系双Sheet导入提交结果") +public class StaffFmyRelationImportSubmitResultVO { + + @Schema(description = "员工亲属关系导入任务ID") + private String relationTaskId; + + @Schema(description = "亲属资产信息导入任务ID") + private String assetTaskId; + + @Schema(description = "提交结果提示") + private String message; +} diff --git a/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/enums/EnterpriseRiskLevel.java b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/enums/EnterpriseRiskLevel.java new file mode 100644 index 00000000..be808f2e --- /dev/null +++ b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/enums/EnterpriseRiskLevel.java @@ -0,0 +1,56 @@ +package com.ruoyi.info.collection.enums; + +/** + * 实体风险等级枚举 + * + * @author ruoyi + */ +public enum EnterpriseRiskLevel { + + HIGH("1", "高风险"), + MEDIUM("2", "中风险"), + LOW("3", "低风险"); + + private final String code; + private final String desc; + + EnterpriseRiskLevel(String code, String desc) { + this.code = code; + this.desc = desc; + } + + public String getCode() { + return code; + } + + public String getDesc() { + return desc; + } + + public static String getDescByCode(String code) { + for (EnterpriseRiskLevel value : values()) { + if (value.code.equals(code)) { + return value.desc; + } + } + return null; + } + + public static boolean contains(String code) { + for (EnterpriseRiskLevel value : values()) { + if (value.code.equals(code)) { + return true; + } + } + return false; + } + + public static String resolveCode(String value) { + for (EnterpriseRiskLevel item : values()) { + if (item.code.equals(value) || item.desc.equals(value)) { + return item.code; + } + } + return null; + } +} diff --git a/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/enums/EnterpriseSource.java b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/enums/EnterpriseSource.java new file mode 100644 index 00000000..b778173d --- /dev/null +++ b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/enums/EnterpriseSource.java @@ -0,0 +1,58 @@ +package com.ruoyi.info.collection.enums; + +/** + * 企业来源枚举 + * + * @author ruoyi + */ +public enum EnterpriseSource { + + GENERAL("GENERAL", "一般企业"), + EMP_RELATION("EMP_RELATION", "员工关系人"), + CREDIT_CUSTOMER("CREDIT_CUSTOMER", "信贷客户"), + INTERMEDIARY("INTERMEDIARY", "中介"), + BOTH("BOTH", "兼有"); + + private final String code; + private final String desc; + + EnterpriseSource(String code, String desc) { + this.code = code; + this.desc = desc; + } + + public String getCode() { + return code; + } + + public String getDesc() { + return desc; + } + + public static String getDescByCode(String code) { + for (EnterpriseSource value : values()) { + if (value.code.equals(code)) { + return value.desc; + } + } + return null; + } + + public static boolean contains(String code) { + for (EnterpriseSource value : values()) { + if (value.code.equals(code)) { + return true; + } + } + return false; + } + + public static String resolveCode(String value) { + for (EnterpriseSource item : values()) { + if (item.code.equals(value) || item.desc.equals(value)) { + return item.code; + } + } + return null; + } +} diff --git a/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/mapper/CcdiAccountResultMapper.java b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/mapper/CcdiAccountResultMapper.java deleted file mode 100644 index 29c84f71..00000000 --- a/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/mapper/CcdiAccountResultMapper.java +++ /dev/null @@ -1,13 +0,0 @@ -package com.ruoyi.info.collection.mapper; - -import com.baomidou.mybatisplus.core.mapper.BaseMapper; -import com.ruoyi.info.collection.domain.CcdiAccountResult; - -/** - * 账户分析结果数据层 - * - * @author ruoyi - * @date 2026-04-13 - */ -public interface CcdiAccountResultMapper extends BaseMapper { -} diff --git a/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/mapper/CcdiEnterpriseBaseInfoMapper.java b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/mapper/CcdiEnterpriseBaseInfoMapper.java index 0fe7552e..7d714546 100644 --- a/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/mapper/CcdiEnterpriseBaseInfoMapper.java +++ b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/mapper/CcdiEnterpriseBaseInfoMapper.java @@ -1,7 +1,10 @@ package com.ruoyi.info.collection.mapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.ruoyi.info.collection.domain.CcdiEnterpriseBaseInfo; +import com.ruoyi.info.collection.domain.dto.CcdiEnterpriseBaseInfoQueryDTO; +import com.ruoyi.info.collection.domain.vo.CcdiEnterpriseBaseInfoVO; import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Param; @@ -16,6 +19,16 @@ import java.util.List; @Mapper public interface CcdiEnterpriseBaseInfoMapper extends BaseMapper { + /** + * 分页查询实体库列表 + * + * @param page 分页参数 + * @param queryDTO 查询条件 + * @return 分页结果 + */ + Page selectEnterpriseBaseInfoPage(Page page, + @Param("queryDTO") CcdiEnterpriseBaseInfoQueryDTO queryDTO); + /** * 批量插入实体中介 * diff --git a/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/mapper/CcdiIntermediaryEnterpriseRelationMapper.java b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/mapper/CcdiIntermediaryEnterpriseRelationMapper.java new file mode 100644 index 00000000..c1bffbeb --- /dev/null +++ b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/mapper/CcdiIntermediaryEnterpriseRelationMapper.java @@ -0,0 +1,27 @@ +package com.ruoyi.info.collection.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.ruoyi.info.collection.domain.CcdiIntermediaryEnterpriseRelation; +import com.ruoyi.info.collection.domain.vo.CcdiIntermediaryEnterpriseRelationVO; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +import java.util.List; + +/** + * 中介关联机构关系Mapper + */ +@Mapper +public interface CcdiIntermediaryEnterpriseRelationMapper extends BaseMapper { + + int insertBatch(@Param("list") List list); + + List selectByIntermediaryBizId(@Param("bizId") String bizId); + + CcdiIntermediaryEnterpriseRelationVO selectDetailById(@Param("id") Long id); + + boolean existsByIntermediaryBizIdAndSocialCreditCode(@Param("bizId") String bizId, + @Param("socialCreditCode") String socialCreditCode); + + List batchExistsByCombinations(@Param("combinations") List combinations); +} diff --git a/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/mapper/CcdiPurchaseTransactionMapper.java b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/mapper/CcdiPurchaseTransactionMapper.java index bc83d918..f5009e3e 100644 --- a/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/mapper/CcdiPurchaseTransactionMapper.java +++ b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/mapper/CcdiPurchaseTransactionMapper.java @@ -35,6 +35,14 @@ public interface CcdiPurchaseTransactionMapper extends BaseMapper purchaseIds); + /** * 批量插入采购交易数据 * diff --git a/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/mapper/CcdiPurchaseTransactionSupplierMapper.java b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/mapper/CcdiPurchaseTransactionSupplierMapper.java new file mode 100644 index 00000000..3599e6d3 --- /dev/null +++ b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/mapper/CcdiPurchaseTransactionSupplierMapper.java @@ -0,0 +1,12 @@ +package com.ruoyi.info.collection.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.ruoyi.info.collection.domain.CcdiPurchaseTransactionSupplier; +import org.apache.ibatis.annotations.Mapper; + +/** + * 招投标供应商明细Mapper + */ +@Mapper +public interface CcdiPurchaseTransactionSupplierMapper extends BaseMapper { +} diff --git a/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/mapper/CcdiStaffEnterpriseRelationMapper.java b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/mapper/CcdiStaffEnterpriseRelationMapper.java index 8aeaaa96..f9c23087 100644 --- a/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/mapper/CcdiStaffEnterpriseRelationMapper.java +++ b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/mapper/CcdiStaffEnterpriseRelationMapper.java @@ -4,6 +4,7 @@ import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.ruoyi.info.collection.domain.CcdiStaffEnterpriseRelation; import com.ruoyi.info.collection.domain.dto.CcdiStaffEnterpriseRelationQueryDTO; +import com.ruoyi.info.collection.domain.vo.CcdiStaffEnterpriseRelationOptionVO; import com.ruoyi.info.collection.domain.vo.CcdiStaffEnterpriseRelationVO; import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Param; @@ -38,6 +39,14 @@ public interface CcdiStaffEnterpriseRelationMapper extends BaseMapper selectFamilyOptions(@Param("query") String query); + /** * 判断身份证号和统一社会信用代码的组合是否已存在 * @@ -57,6 +66,14 @@ public interface CcdiStaffEnterpriseRelationMapper extends BaseMapper batchExistsByCombinations(@Param("combinations") List combinations); + /** + * 根据亲属身份证号批量置无效 + * + * @param personId 亲属身份证号 + * @return 影响行数 + */ + int invalidateByFamilyCertNo(@Param("personId") String personId); + /** * 批量插入员工实体关系数据 * diff --git a/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/ICcdiBaseStaffImportService.java b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/ICcdiBaseStaffImportService.java index 8763db2d..a2b15508 100644 --- a/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/ICcdiBaseStaffImportService.java +++ b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/ICcdiBaseStaffImportService.java @@ -15,10 +15,9 @@ public interface ICcdiBaseStaffImportService { /** * 异步导入员工数据 * - * @param excelList Excel数据列表 - * @param isUpdateSupport 是否更新已存在的数据 + * @param excelList Excel数据列表 */ - void importBaseStaffAsync(List excelList, Boolean isUpdateSupport, String taskId); + void importBaseStaffAsync(List excelList, String taskId); /** * 查询导入状态 diff --git a/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/ICcdiBaseStaffService.java b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/ICcdiBaseStaffService.java index 938cf74a..400804e1 100644 --- a/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/ICcdiBaseStaffService.java +++ b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/ICcdiBaseStaffService.java @@ -78,11 +78,10 @@ public interface ICcdiBaseStaffService { /** * 导入员工数据 * - * @param excelList Excel实体列表 - * @param isUpdateSupport 是否更新支持 + * @param excelList Excel实体列表 * @return 结果 */ - String importBaseStaff(List excelList, Boolean isUpdateSupport); + String importBaseStaff(List excelList); /** * 查询员工下拉列表 diff --git a/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/ICcdiEnterpriseBaseInfoImportService.java b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/ICcdiEnterpriseBaseInfoImportService.java new file mode 100644 index 00000000..d84e8e4e --- /dev/null +++ b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/ICcdiEnterpriseBaseInfoImportService.java @@ -0,0 +1,29 @@ +package com.ruoyi.info.collection.service; + +import com.ruoyi.info.collection.domain.CcdiEnterpriseBaseInfo; +import com.ruoyi.info.collection.domain.excel.CcdiEnterpriseBaseInfoExcel; +import com.ruoyi.info.collection.domain.vo.EnterpriseBaseInfoImportFailureVO; +import com.ruoyi.info.collection.domain.vo.ImportStatusVO; + +import java.util.List; +import java.util.Set; + +/** + * 实体库管理导入 Service 接口 + * + * @author ruoyi + * @date 2026-04-17 + */ +public interface ICcdiEnterpriseBaseInfoImportService { + + void importEnterpriseBaseInfoAsync(List excelList, String taskId, String userName); + + ImportStatusVO getImportStatus(String taskId); + + List getImportFailures(String taskId); + + CcdiEnterpriseBaseInfo validateAndBuildEntity(CcdiEnterpriseBaseInfoExcel excel, + Set existingCreditCodes, + Set processedCreditCodes, + String userName); +} diff --git a/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/ICcdiEnterpriseBaseInfoService.java b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/ICcdiEnterpriseBaseInfoService.java new file mode 100644 index 00000000..f0c7dbb7 --- /dev/null +++ b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/ICcdiEnterpriseBaseInfoService.java @@ -0,0 +1,34 @@ +package com.ruoyi.info.collection.service; + +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.ruoyi.info.collection.domain.dto.CcdiEnterpriseBaseInfoAddDTO; +import com.ruoyi.info.collection.domain.dto.CcdiEnterpriseBaseInfoEditDTO; +import com.ruoyi.info.collection.domain.dto.CcdiEnterpriseBaseInfoQueryDTO; +import com.ruoyi.info.collection.domain.excel.CcdiEnterpriseBaseInfoExcel; +import com.ruoyi.info.collection.domain.vo.CcdiEnterpriseBaseInfoVO; + +import java.util.List; + +/** + * 实体库管理 Service 接口 + * + * @author ruoyi + * @date 2026-04-17 + */ +public interface ICcdiEnterpriseBaseInfoService { + + Page selectEnterpriseBaseInfoPage(Page page, + CcdiEnterpriseBaseInfoQueryDTO queryDTO); + + CcdiEnterpriseBaseInfoVO selectEnterpriseBaseInfoById(String socialCreditCode); + + int insertEnterpriseBaseInfo(CcdiEnterpriseBaseInfoAddDTO addDTO); + + int updateEnterpriseBaseInfo(CcdiEnterpriseBaseInfoEditDTO editDTO); + + int deleteEnterpriseBaseInfoByIds(String[] socialCreditCodes); + + List selectEnterpriseBaseInfoListForExport(CcdiEnterpriseBaseInfoQueryDTO queryDTO); + + String importEnterpriseBaseInfo(List excelList); +} diff --git a/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/ICcdiIntermediaryEnterpriseRelationImportService.java b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/ICcdiIntermediaryEnterpriseRelationImportService.java new file mode 100644 index 00000000..9e9df4aa --- /dev/null +++ b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/ICcdiIntermediaryEnterpriseRelationImportService.java @@ -0,0 +1,38 @@ +package com.ruoyi.info.collection.service; + +import com.ruoyi.info.collection.domain.excel.CcdiIntermediaryEnterpriseRelationExcel; +import com.ruoyi.info.collection.domain.vo.ImportStatusVO; +import com.ruoyi.info.collection.domain.vo.IntermediaryEnterpriseRelationImportFailureVO; + +import java.util.List; + +/** + * 中介实体关联关系异步导入服务接口 + */ +public interface ICcdiIntermediaryEnterpriseRelationImportService { + + /** + * 异步导入中介实体关联关系 + * + * @param excelList Excel数据 + * @param taskId 任务ID + * @param userName 当前用户名 + */ + void importAsync(List excelList, String taskId, String userName); + + /** + * 查询导入状态 + * + * @param taskId 任务ID + * @return 导入状态 + */ + ImportStatusVO getImportStatus(String taskId); + + /** + * 查询导入失败记录 + * + * @param taskId 任务ID + * @return 失败记录 + */ + List getImportFailures(String taskId); +} diff --git a/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/ICcdiIntermediaryPersonImportService.java b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/ICcdiIntermediaryPersonImportService.java index af2981e7..838db1f7 100644 --- a/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/ICcdiIntermediaryPersonImportService.java +++ b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/ICcdiIntermediaryPersonImportService.java @@ -7,7 +7,7 @@ import com.ruoyi.info.collection.domain.vo.IntermediaryPersonImportFailureVO; import java.util.List; /** - * 个人中介异步导入Service接口 + * 中介信息异步导入Service接口 * * @author ruoyi * @date 2026-02-06 @@ -15,7 +15,7 @@ import java.util.List; public interface ICcdiIntermediaryPersonImportService { /** - * 异步导入个人中介数据 + * 异步导入中介信息 * * @param excelList Excel数据列表 * @param taskId 任务ID diff --git a/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/ICcdiIntermediaryService.java b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/ICcdiIntermediaryService.java index 07eeb727..24a52664 100644 --- a/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/ICcdiIntermediaryService.java +++ b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/ICcdiIntermediaryService.java @@ -2,10 +2,13 @@ package com.ruoyi.info.collection.service; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.ruoyi.info.collection.domain.dto.*; +import com.ruoyi.info.collection.domain.excel.CcdiIntermediaryEnterpriseRelationExcel; import com.ruoyi.info.collection.domain.excel.CcdiIntermediaryEntityExcel; import com.ruoyi.info.collection.domain.excel.CcdiIntermediaryPersonExcel; +import com.ruoyi.info.collection.domain.vo.CcdiIntermediaryEnterpriseRelationVO; import com.ruoyi.info.collection.domain.vo.CcdiIntermediaryEntityDetailVO; import com.ruoyi.info.collection.domain.vo.CcdiIntermediaryPersonDetailVO; +import com.ruoyi.info.collection.domain.vo.CcdiIntermediaryRelativeVO; import com.ruoyi.info.collection.domain.vo.CcdiIntermediaryVO; import java.util.List; @@ -35,6 +38,22 @@ public interface ICcdiIntermediaryService { */ CcdiIntermediaryPersonDetailVO selectIntermediaryPersonDetail(String bizId); + /** + * 查询中介亲属列表 + * + * @param bizId 中介本人ID + * @return 亲属列表 + */ + List selectIntermediaryRelativeList(String bizId); + + /** + * 查询中介亲属详情 + * + * @param relativeBizId 亲属ID + * @return 亲属详情 + */ + CcdiIntermediaryRelativeVO selectIntermediaryRelativeDetail(String relativeBizId); + /** * 查询实体中介详情 * @@ -59,6 +78,31 @@ public interface ICcdiIntermediaryService { */ int updateIntermediaryPerson(CcdiIntermediaryPersonEditDTO editDTO); + /** + * 新增中介亲属 + * + * @param bizId 中介本人ID + * @param addDTO 新增DTO + * @return 结果 + */ + int insertIntermediaryRelative(String bizId, CcdiIntermediaryRelativeAddDTO addDTO); + + /** + * 修改中介亲属 + * + * @param editDTO 编辑DTO + * @return 结果 + */ + int updateIntermediaryRelative(CcdiIntermediaryRelativeEditDTO editDTO); + + /** + * 删除中介亲属 + * + * @param relativeBizId 亲属ID + * @return 结果 + */ + int deleteIntermediaryRelative(String relativeBizId); + /** * 新增实体中介 * @@ -75,6 +119,47 @@ public interface ICcdiIntermediaryService { */ int updateIntermediaryEntity(CcdiIntermediaryEntityEditDTO editDTO); + /** + * 查询中介关联机构列表 + * + * @param bizId 中介本人ID + * @return 关联机构列表 + */ + List selectIntermediaryEnterpriseRelationList(String bizId); + + /** + * 查询中介关联机构详情 + * + * @param id 主键ID + * @return 关联机构详情 + */ + CcdiIntermediaryEnterpriseRelationVO selectIntermediaryEnterpriseRelationDetail(Long id); + + /** + * 新增中介关联机构 + * + * @param bizId 中介本人ID + * @param addDTO 新增DTO + * @return 结果 + */ + int insertIntermediaryEnterpriseRelation(String bizId, CcdiIntermediaryEnterpriseRelationAddDTO addDTO); + + /** + * 修改中介关联机构 + * + * @param editDTO 编辑DTO + * @return 结果 + */ + int updateIntermediaryEnterpriseRelation(CcdiIntermediaryEnterpriseRelationEditDTO editDTO); + + /** + * 删除中介关联机构 + * + * @param id 主键ID + * @return 结果 + */ + int deleteIntermediaryEnterpriseRelation(Long id); + /** * 批量删除中介 * @@ -84,7 +169,7 @@ public interface ICcdiIntermediaryService { int deleteIntermediaryByIds(String[] ids); /** - * 校验人员ID唯一性 + * 校验中介本人证件号码唯一性 * * @param personId 人员ID * @param bizId 排除的人员ID @@ -109,6 +194,14 @@ public interface ICcdiIntermediaryService { */ String importIntermediaryPerson(List list); + /** + * 导入中介实体关联关系 + * + * @param list Excel实体列表 + * @return 任务ID + */ + String importIntermediaryEnterpriseRelation(List list); + /** * 导入实体中介数据 * diff --git a/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/ICcdiPurchaseTransactionImportService.java b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/ICcdiPurchaseTransactionImportService.java index 4bcbec5d..5913e2ec 100644 --- a/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/ICcdiPurchaseTransactionImportService.java +++ b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/ICcdiPurchaseTransactionImportService.java @@ -1,6 +1,7 @@ package com.ruoyi.info.collection.service; import com.ruoyi.info.collection.domain.excel.CcdiPurchaseTransactionExcel; +import com.ruoyi.info.collection.domain.excel.CcdiPurchaseTransactionSupplierExcel; import com.ruoyi.info.collection.domain.vo.PurchaseTransactionImportFailureVO; import com.ruoyi.info.collection.domain.vo.ImportStatusVO; @@ -17,11 +18,17 @@ public interface ICcdiPurchaseTransactionImportService { /** * 异步导入采购交易数据 * - * @param excelList Excel数据列表 - * @param taskId 任务ID - * @param userName 当前用户名 + * @param mainExcelList 主信息Excel数据列表 + * @param supplierExcelList 供应商明细Excel数据列表 + * @param taskId 任务ID + * @param userName 当前用户名 */ - void importTransactionAsync(List excelList, String taskId, String userName); + void importTransactionAsync( + List mainExcelList, + List supplierExcelList, + String taskId, + String userName + ); /** * 查询导入状态 diff --git a/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/ICcdiPurchaseTransactionService.java b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/ICcdiPurchaseTransactionService.java index ec8f1c50..c8cecc2c 100644 --- a/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/ICcdiPurchaseTransactionService.java +++ b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/ICcdiPurchaseTransactionService.java @@ -5,6 +5,7 @@ import com.ruoyi.info.collection.domain.dto.CcdiPurchaseTransactionAddDTO; import com.ruoyi.info.collection.domain.dto.CcdiPurchaseTransactionEditDTO; import com.ruoyi.info.collection.domain.dto.CcdiPurchaseTransactionQueryDTO; import com.ruoyi.info.collection.domain.excel.CcdiPurchaseTransactionExcel; +import com.ruoyi.info.collection.domain.excel.CcdiPurchaseTransactionSupplierExcel; import com.ruoyi.info.collection.domain.vo.CcdiPurchaseTransactionVO; import java.util.List; @@ -77,8 +78,12 @@ public interface ICcdiPurchaseTransactionService { /** * 导入采购交易数据(异步) * - * @param excelList Excel实体列表 + * @param mainExcelList 主信息Excel实体列表 + * @param supplierExcelList 供应商明细Excel实体列表 * @return 任务ID */ - String importTransaction(List excelList); + String importTransaction( + List mainExcelList, + List supplierExcelList + ); } diff --git a/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/ICcdiStaffEnterpriseRelationImportService.java b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/ICcdiStaffEnterpriseRelationImportService.java index ab75d2c2..c2a24903 100644 --- a/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/ICcdiStaffEnterpriseRelationImportService.java +++ b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/ICcdiStaffEnterpriseRelationImportService.java @@ -7,7 +7,7 @@ import com.ruoyi.info.collection.domain.vo.StaffEnterpriseRelationImportFailureV import java.util.List; /** - * 员工实体关系信息异步导入服务层 + * 员工亲属实体关联异步导入服务层 * * @author ruoyi * @date 2026-02-09 diff --git a/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/ICcdiStaffEnterpriseRelationService.java b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/ICcdiStaffEnterpriseRelationService.java index 87f5766b..039939d6 100644 --- a/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/ICcdiStaffEnterpriseRelationService.java +++ b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/ICcdiStaffEnterpriseRelationService.java @@ -5,6 +5,7 @@ import com.ruoyi.info.collection.domain.dto.CcdiStaffEnterpriseRelationAddDTO; import com.ruoyi.info.collection.domain.dto.CcdiStaffEnterpriseRelationEditDTO; import com.ruoyi.info.collection.domain.dto.CcdiStaffEnterpriseRelationQueryDTO; import com.ruoyi.info.collection.domain.excel.CcdiStaffEnterpriseRelationExcel; +import com.ruoyi.info.collection.domain.vo.CcdiStaffEnterpriseRelationOptionVO; import com.ruoyi.info.collection.domain.vo.CcdiStaffEnterpriseRelationVO; import java.util.List; @@ -42,6 +43,14 @@ public interface ICcdiStaffEnterpriseRelationService { */ List selectRelationListForExport(CcdiStaffEnterpriseRelationQueryDTO queryDTO); + /** + * 查询有效员工亲属下拉选项 + * + * @param query 搜索关键词 + * @return 下拉选项 + */ + List selectFamilyOptions(String query); + /** * 查询员工实体关系详情 * diff --git a/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/ICcdiStaffRecruitmentImportService.java b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/ICcdiStaffRecruitmentImportService.java index cb1f251e..adf25445 100644 --- a/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/ICcdiStaffRecruitmentImportService.java +++ b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/ICcdiStaffRecruitmentImportService.java @@ -22,21 +22,11 @@ public interface ICcdiStaffRecruitmentImportService { * @param taskId 任务ID * @param userName 用户名 */ - void importRecruitmentAsync(List excelList, + void importRecruitmentAsync(List recruitmentList, + List workList, String taskId, String userName); - /** - * 异步导入招聘记录历史工作经历数据 - * - * @param excelList Excel数据列表 - * @param taskId 任务ID - * @param userName 用户名 - */ - void importRecruitmentWorkAsync(List excelList, - String taskId, - String userName); - /** * 查询导入状态 * diff --git a/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/ICcdiStaffRecruitmentService.java b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/ICcdiStaffRecruitmentService.java index eaa09ab3..dd017d36 100644 --- a/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/ICcdiStaffRecruitmentService.java +++ b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/ICcdiStaffRecruitmentService.java @@ -81,13 +81,6 @@ public interface ICcdiStaffRecruitmentService { * @param excelList Excel实体列表 * @return 结果 */ - String importRecruitment(List excelList); - - /** - * 导入招聘记录历史工作经历数据(异步) - * - * @param excelList Excel实体列表 - * @return 任务ID - */ - String importRecruitmentWork(List excelList); + String importRecruitment(List recruitmentList, + List workList); } diff --git a/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/impl/CcdiAccountInfoServiceImpl.java b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/impl/CcdiAccountInfoServiceImpl.java index 3383b5ad..afb1e1c4 100644 --- a/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/impl/CcdiAccountInfoServiceImpl.java +++ b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/impl/CcdiAccountInfoServiceImpl.java @@ -4,7 +4,6 @@ import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.ruoyi.common.utils.StringUtils; import com.ruoyi.info.collection.domain.CcdiAccountInfo; -import com.ruoyi.info.collection.domain.CcdiAccountResult; import com.ruoyi.info.collection.domain.CcdiBaseStaff; import com.ruoyi.info.collection.domain.CcdiStaffFmyRelation; import com.ruoyi.info.collection.domain.dto.CcdiAccountInfoAddDTO; @@ -16,7 +15,6 @@ import com.ruoyi.info.collection.domain.vo.CcdiAccountInfoVO; import com.ruoyi.info.collection.domain.vo.CcdiAccountRelationOptionVO; import com.ruoyi.info.collection.domain.vo.ImportResult; import com.ruoyi.info.collection.mapper.CcdiAccountInfoMapper; -import com.ruoyi.info.collection.mapper.CcdiAccountResultMapper; import com.ruoyi.info.collection.mapper.CcdiBaseStaffMapper; import com.ruoyi.info.collection.mapper.CcdiStaffFmyRelationMapper; import com.ruoyi.info.collection.service.ICcdiAccountInfoService; @@ -56,9 +54,6 @@ public class CcdiAccountInfoServiceImpl implements ICcdiAccountInfoService { @Resource private CcdiAccountInfoMapper accountInfoMapper; - @Resource - private CcdiAccountResultMapper accountResultMapper; - @Resource private CcdiBaseStaffMapper baseStaffMapper; @@ -87,9 +82,8 @@ public class CcdiAccountInfoServiceImpl implements ICcdiAccountInfoService { CcdiAccountInfo accountInfo = new CcdiAccountInfo(); BeanUtils.copyProperties(addDTO, accountInfo); - int result = accountInfoMapper.insert(accountInfo); - syncAccountResult(accountInfo.getBankScope(), null, accountInfo.getAccountNo(), addDTO); - return result; + prepareAnalysisFields(accountInfo); + return accountInfoMapper.insert(accountInfo); } @Override @@ -110,26 +104,13 @@ public class CcdiAccountInfoServiceImpl implements ICcdiAccountInfoService { CcdiAccountInfo accountInfo = new CcdiAccountInfo(); BeanUtils.copyProperties(editDTO, accountInfo); - int result = accountInfoMapper.updateById(accountInfo); - syncAccountResult(accountInfo.getBankScope(), existing, accountInfo.getAccountNo(), editDTO); - return result; + prepareAnalysisFields(accountInfo); + return accountInfoMapper.updateById(accountInfo); } @Override @Transactional public int deleteAccountInfoByIds(Long[] ids) { - List accountList = accountInfoMapper.selectBatchIds(Arrays.asList(ids)); - if (!accountList.isEmpty()) { - List accountNos = accountList.stream() - .map(CcdiAccountInfo::getAccountNo) - .filter(StringUtils::isNotEmpty) - .toList(); - if (!accountNos.isEmpty()) { - LambdaQueryWrapper resultWrapper = new LambdaQueryWrapper<>(); - resultWrapper.in(CcdiAccountResult::getAccountNo, accountNos); - accountResultMapper.delete(resultWrapper); - } - } return accountInfoMapper.deleteBatchIds(Arrays.asList(ids)); } @@ -250,51 +231,38 @@ public class CcdiAccountInfoServiceImpl implements ICcdiAccountInfoService { } } - private void syncAccountResult(String newBankScope, CcdiAccountInfo existing, String accountNo, Object dto) { - String oldBankScope = existing == null ? null : existing.getBankScope(); - String oldAccountNo = existing == null ? null : existing.getAccountNo(); - - if (existing != null && "EXTERNAL".equals(oldBankScope) - && (!"EXTERNAL".equals(newBankScope) || !StringUtils.equals(oldAccountNo, accountNo))) { - LambdaQueryWrapper deleteWrapper = new LambdaQueryWrapper<>(); - deleteWrapper.eq(CcdiAccountResult::getAccountNo, oldAccountNo); - accountResultMapper.delete(deleteWrapper); - } - - if (!"EXTERNAL".equals(newBankScope)) { + private void prepareAnalysisFields(CcdiAccountInfo accountInfo) { + if (!"EXTERNAL".equals(accountInfo.getBankScope())) { + clearAnalysisFields(accountInfo); return; } + if (accountInfo.getIsActualControl() == null) { + accountInfo.setIsActualControl(1); + } + if (accountInfo.getAvgMonthTxnCount() == null) { + accountInfo.setAvgMonthTxnCount(0); + } + if (accountInfo.getAvgMonthTxnAmount() == null) { + accountInfo.setAvgMonthTxnAmount(BigDecimal.ZERO); + } + if (StringUtils.isEmpty(accountInfo.getTxnFrequencyLevel())) { + accountInfo.setTxnFrequencyLevel("MEDIUM"); + } + if (StringUtils.isEmpty(accountInfo.getTxnRiskLevel())) { + accountInfo.setTxnRiskLevel("LOW"); + } + } - LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); - wrapper.eq(CcdiAccountResult::getAccountNo, accountNo); - CcdiAccountResult existingResult = accountResultMapper.selectOne(wrapper); - - CcdiAccountResult accountResult = new CcdiAccountResult(); - BeanUtils.copyProperties(dto, accountResult); - accountResult.setAccountNo(accountNo); - if (accountResult.getIsActualControl() == null) { - accountResult.setIsActualControl(1); - } - if (accountResult.getAvgMonthTxnCount() == null) { - accountResult.setAvgMonthTxnCount(0); - } - if (accountResult.getAvgMonthTxnAmount() == null) { - accountResult.setAvgMonthTxnAmount(BigDecimal.ZERO); - } - if (StringUtils.isEmpty(accountResult.getTxnFrequencyLevel())) { - accountResult.setTxnFrequencyLevel("MEDIUM"); - } - if (StringUtils.isEmpty(accountResult.getTxnRiskLevel())) { - accountResult.setTxnRiskLevel("LOW"); - } - - if (existingResult == null) { - accountResultMapper.insert(accountResult); - return; - } - - accountResult.setResultId(existingResult.getResultId()); - accountResultMapper.updateById(accountResult); + private void clearAnalysisFields(CcdiAccountInfo accountInfo) { + accountInfo.setIsActualControl(null); + accountInfo.setAvgMonthTxnCount(null); + accountInfo.setAvgMonthTxnAmount(null); + accountInfo.setTxnFrequencyLevel(null); + accountInfo.setDebitSingleMaxAmount(null); + accountInfo.setCreditSingleMaxAmount(null); + accountInfo.setDebitDailyMaxAmount(null); + accountInfo.setCreditDailyMaxAmount(null); + accountInfo.setTxnRiskLevel(null); } private void validateAmount(BigDecimal amount, String fieldLabel) { diff --git a/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/impl/CcdiAssetInfoImportServiceImpl.java b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/impl/CcdiAssetInfoImportServiceImpl.java index 30790548..3dc26e1b 100644 --- a/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/impl/CcdiAssetInfoImportServiceImpl.java +++ b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/impl/CcdiAssetInfoImportServiceImpl.java @@ -39,6 +39,8 @@ import java.util.concurrent.TimeUnit; public class CcdiAssetInfoImportServiceImpl implements ICcdiAssetInfoImportService { private static final String STATUS_KEY_PREFIX = "import:assetInfo:"; + private static final String SHEET_NAME = "亲属资产信息"; + private static final int EXCEL_DATA_START_ROW = 2; @Resource private CcdiAssetInfoMapper assetInfoMapper; @@ -91,7 +93,8 @@ public class CcdiAssetInfoImportServiceImpl implements ICcdiAssetInfoImportServi Map> ownerMap = buildOwnerMap(personIds); - for (CcdiAssetInfoExcel excel : excelList) { + for (int i = 0; i < excelList.size(); i++) { + CcdiAssetInfoExcel excel = excelList.get(i); try { validateExcel(excel); Set familyIds = ownerMap.get(excel.getPersonId()); @@ -111,6 +114,8 @@ public class CcdiAssetInfoImportServiceImpl implements ICcdiAssetInfoImportServi } catch (Exception e) { AssetImportFailureVO failureVO = new AssetImportFailureVO(); BeanUtils.copyProperties(excel, failureVO); + failureVO.setSheetName(SHEET_NAME); + failureVO.setRowNum(i + EXCEL_DATA_START_ROW); failureVO.setErrorMessage(e.getMessage()); failures.add(failureVO); } diff --git a/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/impl/CcdiBaseStaffAssetImportServiceImpl.java b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/impl/CcdiBaseStaffAssetImportServiceImpl.java index 1d21ec6f..3f7aa3c4 100644 --- a/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/impl/CcdiBaseStaffAssetImportServiceImpl.java +++ b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/impl/CcdiBaseStaffAssetImportServiceImpl.java @@ -1,6 +1,7 @@ package com.ruoyi.info.collection.service.impl; import com.alibaba.fastjson2.JSON; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.ruoyi.common.utils.SecurityUtils; import com.ruoyi.common.utils.StringUtils; import com.ruoyi.info.collection.domain.CcdiAssetInfo; @@ -90,14 +91,24 @@ public class CcdiBaseStaffAssetImportServiceImpl implements ICcdiBaseStaffAssetI .toList(); Map> ownerMap = buildOwnerMap(personIds); + Set existingAssetKeys = buildExistingAssetKeys(personIds); + Set importedAssetKeys = new java.util.LinkedHashSet<>(); - for (CcdiBaseStaffAssetInfoExcel excel : excelList) { + for (int i = 0; i < excelList.size(); i++) { + CcdiBaseStaffAssetInfoExcel excel = excelList.get(i); try { validateExcel(excel); Set familyIds = ownerMap.get(excel.getPersonId()); if (familyIds == null || familyIds.isEmpty()) { throw new RuntimeException("员工资产导入仅支持员工本人证件号"); } + String assetKey = buildAssetKey(excel.getPersonId(), excel.getAssetMainType(), excel.getAssetSubType(), excel.getAssetName()); + if (existingAssetKeys.contains(assetKey)) { + throw new RuntimeException("资产记录已存在"); + } + if (!importedAssetKeys.add(assetKey)) { + throw new RuntimeException("资产记录在导入文件中重复"); + } CcdiAssetInfo assetInfo = new CcdiAssetInfo(); BeanUtils.copyProperties(excel, assetInfo); @@ -109,6 +120,8 @@ public class CcdiBaseStaffAssetImportServiceImpl implements ICcdiBaseStaffAssetI } catch (Exception e) { BaseStaffAssetImportFailureVO failureVO = new BaseStaffAssetImportFailureVO(); BeanUtils.copyProperties(excel, failureVO); + failureVO.setSheetName("员工资产信息"); + failureVO.setRowNum(i + 2); failureVO.setErrorMessage(e.getMessage()); failures.add(failureVO); } @@ -168,6 +181,18 @@ public class CcdiBaseStaffAssetImportServiceImpl implements ICcdiBaseStaffAssetI return result; } + private Set buildExistingAssetKeys(List personIds) { + if (personIds == null || personIds.isEmpty()) { + return Set.of(); + } + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.in(CcdiAssetInfo::getPersonId, personIds); + return assetInfoMapper.selectList(wrapper).stream() + .filter(asset -> StringUtils.equals(asset.getFamilyId(), asset.getPersonId())) + .map(asset -> buildAssetKey(asset.getPersonId(), asset.getAssetMainType(), asset.getAssetSubType(), asset.getAssetName())) + .collect(java.util.stream.Collectors.toCollection(java.util.LinkedHashSet::new)); + } + private void mergeOwnerMappings(Map> result, List> mappings) { if (mappings == null) { return; @@ -203,6 +228,14 @@ public class CcdiBaseStaffAssetImportServiceImpl implements ICcdiBaseStaffAssetI } } + private String buildAssetKey(String personId, String assetMainType, String assetSubType, String assetName) { + return String.join("|", + StringUtils.nvl(personId, ""), + StringUtils.nvl(assetMainType, ""), + StringUtils.nvl(assetSubType, ""), + StringUtils.nvl(assetName, "")); + } + private void updateImportStatus(String taskId, String status, ImportResult result) { Map statusData = new HashMap<>(); statusData.put("status", status); diff --git a/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/impl/CcdiBaseStaffImportServiceImpl.java b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/impl/CcdiBaseStaffImportServiceImpl.java index ff3684ea..bf594c61 100644 --- a/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/impl/CcdiBaseStaffImportServiceImpl.java +++ b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/impl/CcdiBaseStaffImportServiceImpl.java @@ -2,6 +2,7 @@ package com.ruoyi.info.collection.service.impl; import com.alibaba.fastjson2.JSON; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.ruoyi.common.core.domain.entity.SysDept; import com.ruoyi.info.collection.domain.CcdiBaseStaff; import com.ruoyi.info.collection.domain.dto.CcdiBaseStaffAddDTO; import com.ruoyi.info.collection.domain.excel.CcdiBaseStaffExcel; @@ -13,6 +14,7 @@ import com.ruoyi.info.collection.service.ICcdiBaseStaffImportService; import com.ruoyi.info.collection.utils.ImportLogUtils; import com.ruoyi.common.utils.IdCardUtil; import com.ruoyi.common.utils.StringUtils; +import com.ruoyi.system.mapper.SysDeptMapper; import jakarta.annotation.Resource; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -43,16 +45,18 @@ public class CcdiBaseStaffImportServiceImpl implements ICcdiBaseStaffImportServi @Resource private RedisTemplate redisTemplate; + @Resource + private SysDeptMapper deptMapper; + @Override @Async - public void importBaseStaffAsync(List excelList, Boolean isUpdateSupport, String taskId) { + public void importBaseStaffAsync(List excelList, String taskId) { long startTime = System.currentTimeMillis(); // 记录导入开始 ImportLogUtils.logImportStart(log, taskId, "员工基础信息", excelList.size(), "系统"); List newRecords = new ArrayList<>(); - List updateRecords = new ArrayList<>(); List failures = new ArrayList<>(); // 批量查询已存在的员工ID和身份证号 @@ -75,13 +79,12 @@ public class CcdiBaseStaffImportServiceImpl implements ICcdiBaseStaffImportServi CcdiBaseStaffAddDTO addDTO = new CcdiBaseStaffAddDTO(); BeanUtils.copyProperties(excel, addDTO); - // 验证数据(支持更新模式) - validateStaffData(addDTO, isUpdateSupport, existingIds, existingIdCards); + validateStaffData(addDTO, existingIds, existingIdCards); CcdiBaseStaff staff = new CcdiBaseStaff(); BeanUtils.copyProperties(excel, staff); - // 统一检查Excel内重复(更新和新增两个分支都需要检查) + // 统一检查Excel内重复 if (processedStaffIds.contains(excel.getStaffId())) { throw new RuntimeException(String.format("员工ID[%d]在导入文件中重复,已跳过此条记录", excel.getStaffId())); } @@ -90,20 +93,7 @@ public class CcdiBaseStaffImportServiceImpl implements ICcdiBaseStaffImportServi throw new RuntimeException(String.format("身份证号[%s]在导入文件中重复,已跳过此条记录", excel.getIdCard())); } - // 检查员工ID是否在数据库中已存在 - if (existingIds.contains(excel.getStaffId())) { - // 员工ID已存在于数据库 - if (!isUpdateSupport) { - throw new RuntimeException("员工ID已存在且未启用更新支持"); - } - - // 通过检查,添加到更新列表 - updateRecords.add(staff); - - } else { - // 员工ID不存在,添加到新增列表 - newRecords.add(staff); - } + newRecords.add(staff); // 统一标记为已处理(只有成功添加到列表后才会执行到这里) if (excel.getStaffId() != null) { @@ -115,11 +105,13 @@ public class CcdiBaseStaffImportServiceImpl implements ICcdiBaseStaffImportServi // 记录进度 ImportLogUtils.logProgress(log, taskId, i + 1, excelList.size(), - newRecords.size() + updateRecords.size(), failures.size()); + newRecords.size(), failures.size()); } catch (Exception e) { ImportFailureVO failure = new ImportFailureVO(); BeanUtils.copyProperties(excel, failure); + failure.setSheetName("员工信息"); + failure.setRowNum(i + 2); failure.setErrorMessage(e.getMessage()); failures.add(failure); @@ -137,13 +129,6 @@ public class CcdiBaseStaffImportServiceImpl implements ICcdiBaseStaffImportServi saveBatch(newRecords, 500); } - // 批量更新已有数据(先删除再插入) - if (!updateRecords.isEmpty() && isUpdateSupport) { - ImportLogUtils.logBatchOperationStart(log, taskId, "更新", - (updateRecords.size() + 499) / 500, 500); - baseStaffMapper.insertOrUpdateBatch(updateRecords); - } - // 保存失败记录到Redis if (!failures.isEmpty()) { try { @@ -157,7 +142,7 @@ public class CcdiBaseStaffImportServiceImpl implements ICcdiBaseStaffImportServi ImportResult result = new ImportResult(); result.setTotalCount(excelList.size()); - result.setSuccessCount(newRecords.size() + updateRecords.size()); + result.setSuccessCount(newRecords.size()); result.setFailureCount(failures.size()); // 更新最终状态 @@ -299,11 +284,10 @@ public class CcdiBaseStaffImportServiceImpl implements ICcdiBaseStaffImportServi * 验证员工数据 * * @param addDTO 新增DTO - * @param isUpdateSupport 是否支持更新 * @param existingIds 已存在的员工ID集合(导入场景使用,传null表示单条新增) * @param existingIdCards 已存在的身份证号集合(导入场景使用,传null表示单条新增) */ - public void validateStaffData(CcdiBaseStaffAddDTO addDTO, Boolean isUpdateSupport, Set existingIds, Set existingIdCards) { + public void validateStaffData(CcdiBaseStaffAddDTO addDTO, Set existingIds, Set existingIdCards) { // 验证必填字段 if (StringUtils.isEmpty(addDTO.getName())) { throw new RuntimeException("姓名不能为空"); @@ -320,9 +304,13 @@ public class CcdiBaseStaffImportServiceImpl implements ICcdiBaseStaffImportServi if (StringUtils.isEmpty(addDTO.getPhone())) { throw new RuntimeException("电话不能为空"); } + if (addDTO.getPartyMember() == null) { + throw new RuntimeException("是否党员不能为空"); + } if (StringUtils.isEmpty(addDTO.getStatus())) { throw new RuntimeException("状态不能为空"); } + validateDeptId(addDTO.getDeptId()); // 验证身份证号格式 String idCardError = IdCardUtil.getErrorMessage(addDTO.getIdCard()); @@ -344,12 +332,11 @@ public class CcdiBaseStaffImportServiceImpl implements ICcdiBaseStaffImportServi throw new RuntimeException("该身份证号已存在"); } } else { - // 导入场景:如果员工ID不存在,才检查身份证号唯一性 - if (!existingIds.contains(addDTO.getStaffId())) { - // 使用批量查询的结果检查身份证号唯一性 - if (existingIdCards != null && existingIdCards.contains(addDTO.getIdCard())) { - throw new RuntimeException("该身份证号已存在"); - } + if (existingIds.contains(addDTO.getStaffId())) { + throw new RuntimeException("该员工ID已存在"); + } + if (existingIdCards != null && existingIdCards.contains(addDTO.getIdCard())) { + throw new RuntimeException("该身份证号已存在"); } } @@ -357,6 +344,9 @@ public class CcdiBaseStaffImportServiceImpl implements ICcdiBaseStaffImportServi if (!"0".equals(addDTO.getStatus()) && !"1".equals(addDTO.getStatus())) { throw new RuntimeException("状态只能填写'在职'或'离职'"); } + if (addDTO.getPartyMember() != 0 && addDTO.getPartyMember() != 1) { + throw new RuntimeException("是否党员只能填写'0'或'1'"); + } validateAnnualIncome(addDTO.getAnnualIncome(), "年收入"); } @@ -372,4 +362,11 @@ public class CcdiBaseStaffImportServiceImpl implements ICcdiBaseStaffImportServi throw new RuntimeException(fieldLabel + "最多保留2位小数"); } } + + private void validateDeptId(Long deptId) { + SysDept dept = deptMapper.selectDeptById(deptId); + if (dept == null || !"0".equals(dept.getStatus()) || !"0".equals(dept.getDelFlag())) { + throw new RuntimeException(String.format("所属部门ID[%d]不存在或已停用/删除,请检查机构号", deptId)); + } + } } diff --git a/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/impl/CcdiBaseStaffServiceImpl.java b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/impl/CcdiBaseStaffServiceImpl.java index d06920e2..435e3c66 100644 --- a/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/impl/CcdiBaseStaffServiceImpl.java +++ b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/impl/CcdiBaseStaffServiceImpl.java @@ -112,7 +112,7 @@ public class CcdiBaseStaffServiceImpl implements ICcdiBaseStaffService { CcdiBaseStaff staff = baseStaffMapper.selectById(staffId); CcdiBaseStaffVO vo = convertToVO(staff); if (staff != null) { - vo.setAssetInfoList(assetInfoService.selectByFamilyId(staff.getIdCard()).stream().map(asset -> { + vo.setAssetInfoList(assetInfoService.selectByFamilyIdAndPersonId(staff.getIdCard(), staff.getIdCard()).stream().map(asset -> { CcdiAssetInfoVO assetInfoVO = new CcdiAssetInfoVO(); BeanUtils.copyProperties(asset, assetInfoVO); return assetInfoVO; @@ -131,6 +131,7 @@ public class CcdiBaseStaffServiceImpl implements ICcdiBaseStaffService { @Transactional public int insertBaseStaff(CcdiBaseStaffAddDTO addDTO) { validateAnnualIncome(addDTO.getAnnualIncome(), "年收入"); + validatePartyMember(addDTO.getPartyMember(), "是否党员"); // 检查员工ID唯一性 if (baseStaffMapper.selectById(addDTO.getStaffId()) != null) { throw new RuntimeException("该员工ID已存在"); @@ -161,6 +162,7 @@ public class CcdiBaseStaffServiceImpl implements ICcdiBaseStaffService { @Transactional public int updateBaseStaff(CcdiBaseStaffEditDTO editDTO) { validateAnnualIncome(editDTO.getAnnualIncome(), "年收入"); + validatePartyMember(editDTO.getPartyMember(), "是否党员"); CcdiBaseStaff existing = baseStaffMapper.selectById(editDTO.getStaffId()); if (existing == null) { throw new RuntimeException("员工不存在"); @@ -209,13 +211,12 @@ public class CcdiBaseStaffServiceImpl implements ICcdiBaseStaffService { /** * 导入员工数据 * - * @param excelList Excel实体列表 - * @param isUpdateSupport 是否更新支持 + * @param excelList Excel实体列表 * @return 结果 */ @Override @Transactional - public String importBaseStaff(List excelList, Boolean isUpdateSupport) { + public String importBaseStaff(List excelList) { String taskId = UUID.randomUUID().toString(); long startTime = System.currentTimeMillis(); @@ -234,7 +235,7 @@ public class CcdiBaseStaffServiceImpl implements ICcdiBaseStaffService { redisTemplate.opsForHash().putAll(statusKey, statusData); redisTemplate.expire(statusKey, 7, java.util.concurrent.TimeUnit.DAYS); - importAsyncService.importBaseStaffAsync(excelList, isUpdateSupport, taskId); + importAsyncService.importBaseStaffAsync(excelList, taskId); return taskId; } @@ -291,4 +292,13 @@ public class CcdiBaseStaffServiceImpl implements ICcdiBaseStaffService { } } + private void validatePartyMember(Integer partyMember, String fieldLabel) { + if (partyMember == null) { + throw new RuntimeException(fieldLabel + "不能为空"); + } + if (partyMember != 0 && partyMember != 1) { + throw new RuntimeException(fieldLabel + "只能填写'0'或'1'"); + } + } + } diff --git a/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/impl/CcdiEnterpriseBaseInfoImportServiceImpl.java b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/impl/CcdiEnterpriseBaseInfoImportServiceImpl.java new file mode 100644 index 00000000..9480001a --- /dev/null +++ b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/impl/CcdiEnterpriseBaseInfoImportServiceImpl.java @@ -0,0 +1,215 @@ +package com.ruoyi.info.collection.service.impl; + +import com.alibaba.fastjson2.JSON; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.ruoyi.common.utils.StringUtils; +import com.ruoyi.info.collection.domain.CcdiEnterpriseBaseInfo; +import com.ruoyi.info.collection.domain.excel.CcdiEnterpriseBaseInfoExcel; +import com.ruoyi.info.collection.domain.vo.EnterpriseBaseInfoImportFailureVO; +import com.ruoyi.info.collection.domain.vo.ImportResult; +import com.ruoyi.info.collection.domain.vo.ImportStatusVO; +import com.ruoyi.info.collection.enums.DataSource; +import com.ruoyi.info.collection.enums.EnterpriseRiskLevel; +import com.ruoyi.info.collection.enums.EnterpriseSource; +import com.ruoyi.info.collection.mapper.CcdiEnterpriseBaseInfoMapper; +import com.ruoyi.info.collection.service.ICcdiEnterpriseBaseInfoImportService; +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 java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; + +/** + * 实体库管理导入 Service 实现 + * + * @author ruoyi + * @date 2026-04-17 + */ +@Service +@EnableAsync +public class CcdiEnterpriseBaseInfoImportServiceImpl implements ICcdiEnterpriseBaseInfoImportService { + + @Resource + private CcdiEnterpriseBaseInfoMapper enterpriseBaseInfoMapper; + + @Resource + private RedisTemplate redisTemplate; + + @Override + @Async + public void importEnterpriseBaseInfoAsync(List excelList, String taskId, String userName) { + List successRecords = new ArrayList<>(); + List failures = new ArrayList<>(); + Set existingCreditCodes = getExistingCreditCodes(excelList); + Set processedCreditCodes = new HashSet<>(); + + for (CcdiEnterpriseBaseInfoExcel excel : excelList) { + try { + CcdiEnterpriseBaseInfo entity = validateAndBuildEntity(excel, existingCreditCodes, processedCreditCodes, userName); + successRecords.add(entity); + processedCreditCodes.add(entity.getSocialCreditCode()); + } catch (Exception e) { + EnterpriseBaseInfoImportFailureVO failureVO = new EnterpriseBaseInfoImportFailureVO(); + BeanUtils.copyProperties(excel, failureVO); + failureVO.setErrorMessage(e.getMessage()); + failures.add(failureVO); + } + } + + if (!successRecords.isEmpty()) { + saveBatch(successRecords, 500); + } + + if (!failures.isEmpty()) { + redisTemplate.opsForValue().set(buildFailuresKey(taskId), failures, 7, TimeUnit.DAYS); + } + + ImportResult result = new ImportResult(); + result.setTotalCount(excelList.size()); + result.setSuccessCount(successRecords.size()); + result.setFailureCount(failures.size()); + updateImportStatus(taskId, failures.isEmpty() ? "SUCCESS" : "PARTIAL_SUCCESS", result); + } + + @Override + public ImportStatusVO getImportStatus(String taskId) { + String key = buildStatusKey(taskId); + Boolean exists = redisTemplate.hasKey(key); + if (Boolean.FALSE.equals(exists)) { + 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) { + Object failuresObj = redisTemplate.opsForValue().get(buildFailuresKey(taskId)); + if (failuresObj == null) { + return Collections.emptyList(); + } + return JSON.parseArray(JSON.toJSONString(failuresObj), EnterpriseBaseInfoImportFailureVO.class); + } + + @Override + public CcdiEnterpriseBaseInfo validateAndBuildEntity(CcdiEnterpriseBaseInfoExcel excel, + Set existingCreditCodes, + Set processedCreditCodes, + String userName) { + if (excel == null) { + throw new RuntimeException("导入数据不能为空"); + } + if (StringUtils.isEmpty(excel.getEnterpriseName())) { + throw new RuntimeException("企业名称不能为空"); + } + if (StringUtils.isEmpty(excel.getSocialCreditCode())) { + throw new RuntimeException("统一社会信用代码不能为空"); + } + if (!excel.getSocialCreditCode().matches("^[0-9A-HJ-NPQRTUWXY]{2}\\d{6}[0-9A-HJ-NPQRTUWXY]{10}$")) { + throw new RuntimeException("统一社会信用代码格式不正确"); + } + String riskLevel = EnterpriseRiskLevel.resolveCode(StringUtils.trim(excel.getRiskLevel())); + if (riskLevel == null) { + throw new RuntimeException("风险等级不在允许范围内"); + } + String entSource = EnterpriseSource.resolveCode(StringUtils.trim(excel.getEntSource())); + if (entSource == null) { + throw new RuntimeException("企业来源不在允许范围内"); + } + + if (existingCreditCodes.contains(excel.getSocialCreditCode())) { + throw new RuntimeException(String.format("统一社会信用代码[%s]已存在,请勿重复导入", excel.getSocialCreditCode())); + } + if (processedCreditCodes.contains(excel.getSocialCreditCode())) { + throw new RuntimeException(String.format("统一社会信用代码[%s]在导入文件中重复,已跳过此条记录", excel.getSocialCreditCode())); + } + + CcdiEnterpriseBaseInfo entity = new CcdiEnterpriseBaseInfo(); + BeanUtils.copyProperties(excel, entity); + entity.setRiskLevel(riskLevel); + entity.setEntSource(entSource); + entity.setDataSource(DataSource.IMPORT.getCode()); + entity.setStatus(trimToNull(excel.getStatus())); + entity.setCreatedBy(userName); + entity.setUpdatedBy(userName); + return entity; + } + + private Set getExistingCreditCodes(List excelList) { + List creditCodes = excelList.stream() + .map(CcdiEnterpriseBaseInfoExcel::getSocialCreditCode) + .filter(StringUtils::isNotEmpty) + .collect(Collectors.toList()); + if (creditCodes.isEmpty()) { + return Collections.emptySet(); + } + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.in(CcdiEnterpriseBaseInfo::getSocialCreditCode, creditCodes); + return enterpriseBaseInfoMapper.selectList(wrapper).stream() + .map(CcdiEnterpriseBaseInfo::getSocialCreditCode) + .filter(Objects::nonNull) + .collect(Collectors.toSet()); + } + + private int saveBatch(List list, int batchSize) { + int total = 0; + for (int i = 0; i < list.size(); i += batchSize) { + int end = Math.min(i + batchSize, list.size()); + total += enterpriseBaseInfoMapper.insertBatch(list.subList(i, end)); + } + return total; + } + + private void updateImportStatus(String taskId, String status, ImportResult result) { + 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(buildStatusKey(taskId), statusData); + } + + private String buildStatusKey(String taskId) { + return "import:enterpriseBaseInfo:" + taskId; + } + + private String buildFailuresKey(String taskId) { + return "import:enterpriseBaseInfo:" + taskId + ":failures"; + } + + private String trimToNull(String value) { + if (StringUtils.isEmpty(value)) { + return null; + } + return value.trim(); + } +} diff --git a/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/impl/CcdiEnterpriseBaseInfoServiceImpl.java b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/impl/CcdiEnterpriseBaseInfoServiceImpl.java new file mode 100644 index 00000000..91bc4aad --- /dev/null +++ b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/impl/CcdiEnterpriseBaseInfoServiceImpl.java @@ -0,0 +1,235 @@ +package com.ruoyi.info.collection.service.impl; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.ruoyi.common.utils.SecurityUtils; +import com.ruoyi.common.utils.StringUtils; +import com.ruoyi.info.collection.domain.CcdiCustEnterpriseRelation; +import com.ruoyi.info.collection.domain.CcdiEnterpriseBaseInfo; +import com.ruoyi.info.collection.domain.CcdiIntermediaryEnterpriseRelation; +import com.ruoyi.info.collection.domain.CcdiStaffEnterpriseRelation; +import com.ruoyi.info.collection.domain.dto.CcdiEnterpriseBaseInfoAddDTO; +import com.ruoyi.info.collection.domain.dto.CcdiEnterpriseBaseInfoEditDTO; +import com.ruoyi.info.collection.domain.dto.CcdiEnterpriseBaseInfoQueryDTO; +import com.ruoyi.info.collection.domain.excel.CcdiEnterpriseBaseInfoExcel; +import com.ruoyi.info.collection.domain.vo.CcdiEnterpriseBaseInfoVO; +import com.ruoyi.info.collection.enums.DataSource; +import com.ruoyi.info.collection.enums.EnterpriseRiskLevel; +import com.ruoyi.info.collection.enums.EnterpriseSource; +import com.ruoyi.info.collection.mapper.CcdiEnterpriseBaseInfoMapper; +import com.ruoyi.info.collection.mapper.CcdiCustEnterpriseRelationMapper; +import com.ruoyi.info.collection.mapper.CcdiIntermediaryEnterpriseRelationMapper; +import com.ruoyi.info.collection.mapper.CcdiStaffEnterpriseRelationMapper; +import com.ruoyi.info.collection.service.ICcdiEnterpriseBaseInfoImportService; +import com.ruoyi.info.collection.service.ICcdiEnterpriseBaseInfoService; +import jakarta.annotation.Resource; +import org.springframework.beans.BeanUtils; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.StringJoiner; +import java.util.UUID; +import java.util.concurrent.TimeUnit; + +/** + * 实体库管理 Service 实现 + * + * @author ruoyi + * @date 2026-04-17 + */ +@Service +public class CcdiEnterpriseBaseInfoServiceImpl implements ICcdiEnterpriseBaseInfoService { + + @Resource + private CcdiEnterpriseBaseInfoMapper enterpriseBaseInfoMapper; + + @Resource + private CcdiStaffEnterpriseRelationMapper staffEnterpriseRelationMapper; + + @Resource + private CcdiCustEnterpriseRelationMapper custEnterpriseRelationMapper; + + @Resource + private CcdiIntermediaryEnterpriseRelationMapper intermediaryEnterpriseRelationMapper; + + @Resource + private ICcdiEnterpriseBaseInfoImportService enterpriseBaseInfoImportService; + + @Resource + private RedisTemplate redisTemplate; + + @Override + public Page selectEnterpriseBaseInfoPage(Page page, + CcdiEnterpriseBaseInfoQueryDTO queryDTO) { + return enterpriseBaseInfoMapper.selectEnterpriseBaseInfoPage(page, queryDTO); + } + + @Override + public CcdiEnterpriseBaseInfoVO selectEnterpriseBaseInfoById(String socialCreditCode) { + CcdiEnterpriseBaseInfo entity = enterpriseBaseInfoMapper.selectById(socialCreditCode); + if (entity == null) { + return null; + } + CcdiEnterpriseBaseInfoVO vo = new CcdiEnterpriseBaseInfoVO(); + BeanUtils.copyProperties(entity, vo); + return vo; + } + + @Override + @Transactional + public int insertEnterpriseBaseInfo(CcdiEnterpriseBaseInfoAddDTO addDTO) { + if (enterpriseBaseInfoMapper.selectById(addDTO.getSocialCreditCode()) != null) { + throw new RuntimeException("该统一社会信用代码已存在"); + } + validateRiskLevelAndEnterpriseSource(addDTO.getRiskLevel(), addDTO.getEntSource()); + + CcdiEnterpriseBaseInfo entity = new CcdiEnterpriseBaseInfo(); + BeanUtils.copyProperties(addDTO, entity); + entity.setStatus(trimToNull(addDTO.getStatus())); + entity.setDataSource(DataSource.MANUAL.getCode()); + return enterpriseBaseInfoMapper.insert(entity); + } + + @Override + @Transactional + public int updateEnterpriseBaseInfo(CcdiEnterpriseBaseInfoEditDTO editDTO) { + CcdiEnterpriseBaseInfo existing = enterpriseBaseInfoMapper.selectById(editDTO.getSocialCreditCode()); + if (existing == null) { + throw new RuntimeException("实体库记录不存在"); + } + validateEnumFields(editDTO.getStatus(), editDTO.getRiskLevel(), editDTO.getEntSource(), editDTO.getDataSource()); + + CcdiEnterpriseBaseInfo entity = new CcdiEnterpriseBaseInfo(); + BeanUtils.copyProperties(editDTO, entity); + entity.setStatus(trimToNull(editDTO.getStatus())); + entity.setDataSource(existing.getDataSource()); + return enterpriseBaseInfoMapper.updateById(entity); + } + + @Override + @Transactional + public int deleteEnterpriseBaseInfoByIds(String[] socialCreditCodes) { + if (socialCreditCodes == null || socialCreditCodes.length == 0) { + return 0; + } + for (String socialCreditCode : socialCreditCodes) { + validateDeleteRelations(socialCreditCode); + } + return enterpriseBaseInfoMapper.deleteBatchIds(List.of(socialCreditCodes)); + } + + @Override + public List selectEnterpriseBaseInfoListForExport(CcdiEnterpriseBaseInfoQueryDTO queryDTO) { + LambdaQueryWrapper wrapper = buildQueryWrapper(queryDTO); + return enterpriseBaseInfoMapper.selectList(wrapper).stream().map(entity -> { + CcdiEnterpriseBaseInfoExcel excel = new CcdiEnterpriseBaseInfoExcel(); + BeanUtils.copyProperties(entity, excel); + return excel; + }).toList(); + } + + @Override + @Transactional + public String importEnterpriseBaseInfo(List excelList) { + String taskId = UUID.randomUUID().toString(); + String statusKey = "import:enterpriseBaseInfo:" + taskId; + + Map statusData = new HashMap<>(); + statusData.put("taskId", taskId); + statusData.put("status", "PROCESSING"); + statusData.put("totalCount", excelList.size()); + statusData.put("successCount", 0); + statusData.put("failureCount", 0); + statusData.put("progress", 0); + statusData.put("startTime", System.currentTimeMillis()); + statusData.put("message", "正在处理..."); + + redisTemplate.opsForHash().putAll(statusKey, statusData); + redisTemplate.expire(statusKey, 7, TimeUnit.DAYS); + + enterpriseBaseInfoImportService.importEnterpriseBaseInfoAsync(excelList, taskId, SecurityUtils.getUsername()); + return taskId; + } + + private LambdaQueryWrapper buildQueryWrapper(CcdiEnterpriseBaseInfoQueryDTO queryDTO) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + if (queryDTO == null) { + return wrapper.orderByDesc(CcdiEnterpriseBaseInfo::getCreateTime); + } + wrapper.like(StringUtils.isNotEmpty(queryDTO.getEnterpriseName()), + CcdiEnterpriseBaseInfo::getEnterpriseName, queryDTO.getEnterpriseName()); + wrapper.eq(StringUtils.isNotEmpty(queryDTO.getSocialCreditCode()), + CcdiEnterpriseBaseInfo::getSocialCreditCode, queryDTO.getSocialCreditCode()); + wrapper.eq(StringUtils.isNotEmpty(queryDTO.getEnterpriseType()), + CcdiEnterpriseBaseInfo::getEnterpriseType, queryDTO.getEnterpriseType()); + wrapper.eq(StringUtils.isNotEmpty(queryDTO.getEnterpriseNature()), + CcdiEnterpriseBaseInfo::getEnterpriseNature, queryDTO.getEnterpriseNature()); + wrapper.like(StringUtils.isNotEmpty(queryDTO.getIndustryClass()), + CcdiEnterpriseBaseInfo::getIndustryClass, queryDTO.getIndustryClass()); + wrapper.eq(StringUtils.isNotEmpty(queryDTO.getStatus()), + CcdiEnterpriseBaseInfo::getStatus, queryDTO.getStatus()); + wrapper.eq(StringUtils.isNotEmpty(queryDTO.getRiskLevel()), + CcdiEnterpriseBaseInfo::getRiskLevel, queryDTO.getRiskLevel()); + wrapper.eq(StringUtils.isNotEmpty(queryDTO.getEntSource()), + CcdiEnterpriseBaseInfo::getEntSource, queryDTO.getEntSource()); + return wrapper.orderByDesc(CcdiEnterpriseBaseInfo::getCreateTime); + } + + private void validateEnumFields(String status, String riskLevel, String entSource, String dataSource) { + validateRiskLevelAndEnterpriseSource(riskLevel, entSource); + if (StringUtils.isNotEmpty(status) && StringUtils.trim(status).length() > 50) { + throw new RuntimeException("经营状态长度不能超过50个字符"); + } + if (!containsDataSource(dataSource)) { + throw new RuntimeException("数据来源不在允许范围内"); + } + } + + private void validateRiskLevelAndEnterpriseSource(String riskLevel, String entSource) { + if (!EnterpriseRiskLevel.contains(riskLevel)) { + throw new RuntimeException("风险等级不在允许范围内"); + } + if (!EnterpriseSource.contains(entSource)) { + throw new RuntimeException("企业来源不在允许范围内"); + } + } + + private boolean containsDataSource(String code) { + for (DataSource source : DataSource.values()) { + if (source.getCode().equals(code)) { + return true; + } + } + return false; + } + + private String trimToNull(String value) { + if (StringUtils.isEmpty(value)) { + return null; + } + return value.trim(); + } + + private void validateDeleteRelations(String socialCreditCode) { + StringJoiner relationTypes = new StringJoiner("、"); + if (staffEnterpriseRelationMapper.selectCount(new LambdaQueryWrapper() + .eq(CcdiStaffEnterpriseRelation::getSocialCreditCode, socialCreditCode)) > 0) { + relationTypes.add("员工"); + } + if (custEnterpriseRelationMapper.selectCount(new LambdaQueryWrapper() + .eq(CcdiCustEnterpriseRelation::getSocialCreditCode, socialCreditCode)) > 0) { + relationTypes.add("信贷客户"); + } + if (intermediaryEnterpriseRelationMapper.selectCount(new LambdaQueryWrapper() + .eq(CcdiIntermediaryEnterpriseRelation::getSocialCreditCode, socialCreditCode)) > 0) { + relationTypes.add("中介"); + } + if (relationTypes.length() > 0) { + throw new RuntimeException("统一社会信用代码[" + socialCreditCode + "]已关联" + relationTypes + ",删除失败"); + } + } +} diff --git a/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/impl/CcdiIntermediaryEnterpriseRelationImportServiceImpl.java b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/impl/CcdiIntermediaryEnterpriseRelationImportServiceImpl.java new file mode 100644 index 00000000..dd9c7c06 --- /dev/null +++ b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/impl/CcdiIntermediaryEnterpriseRelationImportServiceImpl.java @@ -0,0 +1,266 @@ +package com.ruoyi.info.collection.service.impl; + +import com.alibaba.fastjson2.JSON; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.ruoyi.info.collection.domain.CcdiBizIntermediary; +import com.ruoyi.info.collection.domain.CcdiEnterpriseBaseInfo; +import com.ruoyi.info.collection.domain.CcdiIntermediaryEnterpriseRelation; +import com.ruoyi.info.collection.domain.excel.CcdiIntermediaryEnterpriseRelationExcel; +import com.ruoyi.info.collection.domain.vo.ImportResult; +import com.ruoyi.info.collection.domain.vo.ImportStatusVO; +import com.ruoyi.info.collection.domain.vo.IntermediaryEnterpriseRelationImportFailureVO; +import com.ruoyi.info.collection.mapper.CcdiBizIntermediaryMapper; +import com.ruoyi.info.collection.mapper.CcdiEnterpriseBaseInfoMapper; +import com.ruoyi.info.collection.mapper.CcdiIntermediaryEnterpriseRelationMapper; +import com.ruoyi.info.collection.service.ICcdiIntermediaryEnterpriseRelationImportService; +import com.ruoyi.info.collection.utils.ImportLogUtils; +import com.ruoyi.common.utils.IdCardUtil; +import com.ruoyi.common.utils.StringUtils; +import jakarta.annotation.Resource; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +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.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; + +/** + * 中介实体关联关系异步导入实现 + */ +@Service +@EnableAsync +public class CcdiIntermediaryEnterpriseRelationImportServiceImpl implements ICcdiIntermediaryEnterpriseRelationImportService { + + private static final Logger log = LoggerFactory.getLogger(CcdiIntermediaryEnterpriseRelationImportServiceImpl.class); + + private static final String STATUS_KEY_PREFIX = "import:intermediary-enterprise-relation:"; + + @Resource + private CcdiIntermediaryEnterpriseRelationMapper relationMapper; + + @Resource + private CcdiBizIntermediaryMapper intermediaryMapper; + + @Resource + private CcdiEnterpriseBaseInfoMapper enterpriseBaseInfoMapper; + + @Resource + private RedisTemplate redisTemplate; + + @Override + @Async + @Transactional(rollbackFor = Exception.class) + public void importAsync(List excelList, String taskId, String userName) { + long startTime = System.currentTimeMillis(); + ImportLogUtils.logImportStart(log, taskId, "中介实体关联关系", excelList.size(), userName); + + Map ownerBizIdByPersonId = getOwnerBizIdByPersonId(excelList); + Set existingEnterpriseCodes = getExistingEnterpriseCodes(excelList); + Set existingCombinations = getExistingRelationCombinations(ownerBizIdByPersonId, excelList); + + List successRecords = new ArrayList<>(); + List failures = new ArrayList<>(); + Set processedCombinations = new HashSet<>(); + + for (int i = 0; i < excelList.size(); i++) { + CcdiIntermediaryEnterpriseRelationExcel excel = excelList.get(i); + try { + validateExcel(excel); + + String ownerBizId = ownerBizIdByPersonId.get(excel.getOwnerPersonId()); + if (StringUtils.isEmpty(ownerBizId)) { + throw new RuntimeException("中介本人不存在,请先导入或维护中介本人信息"); + } + if (!existingEnterpriseCodes.contains(excel.getSocialCreditCode())) { + throw new RuntimeException("统一社会信用代码不存在于系统机构表"); + } + + String combination = ownerBizId + "|" + excel.getSocialCreditCode(); + if (existingCombinations.contains(combination)) { + throw new RuntimeException("中介实体关联关系已存在,请勿重复导入"); + } + if (!processedCombinations.add(combination)) { + throw new RuntimeException("同一中介本人与统一社会信用代码组合在导入文件中重复"); + } + + CcdiIntermediaryEnterpriseRelation relation = new CcdiIntermediaryEnterpriseRelation(); + BeanUtils.copyProperties(excel, relation); + relation.setIntermediaryBizId(ownerBizId); + relation.setCreatedBy(userName); + relation.setUpdatedBy(userName); + successRecords.add(relation); + } catch (Exception e) { + failures.add(createFailureVO(excel, e.getMessage())); + ImportLogUtils.logValidationError(log, taskId, i + 1, e.getMessage(), + String.format("中介本人证件号码=%s, 统一社会信用代码=%s", excel.getOwnerPersonId(), excel.getSocialCreditCode())); + } + } + + if (!successRecords.isEmpty()) { + saveBatch(successRecords, 500); + } + if (!failures.isEmpty()) { + redisTemplate.opsForValue().set(failureKey(taskId), failures, 7, TimeUnit.DAYS); + } + + ImportResult result = new ImportResult(); + result.setTotalCount(excelList.size()); + result.setSuccessCount(successRecords.size()); + result.setFailureCount(failures.size()); + updateImportStatus(taskId, result); + + long duration = System.currentTimeMillis() - startTime; + ImportLogUtils.logImportComplete(log, taskId, "中介实体关联关系", + excelList.size(), result.getSuccessCount(), result.getFailureCount(), duration); + } + + @Override + public ImportStatusVO getImportStatus(String taskId) { + String key = statusKey(taskId); + if (Boolean.FALSE.equals(redisTemplate.hasKey(key))) { + 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) { + Object failuresObj = redisTemplate.opsForValue().get(failureKey(taskId)); + if (failuresObj == null) { + return Collections.emptyList(); + } + return JSON.parseArray(JSON.toJSONString(failuresObj), IntermediaryEnterpriseRelationImportFailureVO.class); + } + + private Map getOwnerBizIdByPersonId(List excelList) { + List ownerPersonIds = excelList.stream() + .map(CcdiIntermediaryEnterpriseRelationExcel::getOwnerPersonId) + .filter(StringUtils::isNotEmpty) + .distinct() + .collect(Collectors.toList()); + if (ownerPersonIds.isEmpty()) { + return Collections.emptyMap(); + } + + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(CcdiBizIntermediary::getPersonSubType, "本人") + .in(CcdiBizIntermediary::getPersonId, ownerPersonIds); + return intermediaryMapper.selectList(wrapper).stream() + .collect(Collectors.toMap(CcdiBizIntermediary::getPersonId, CcdiBizIntermediary::getBizId, (left, right) -> left)); + } + + private Set getExistingEnterpriseCodes(List excelList) { + List socialCreditCodes = excelList.stream() + .map(CcdiIntermediaryEnterpriseRelationExcel::getSocialCreditCode) + .filter(StringUtils::isNotEmpty) + .distinct() + .collect(Collectors.toList()); + if (socialCreditCodes.isEmpty()) { + return Collections.emptySet(); + } + + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.in(CcdiEnterpriseBaseInfo::getSocialCreditCode, socialCreditCodes); + return enterpriseBaseInfoMapper.selectList(wrapper).stream() + .map(CcdiEnterpriseBaseInfo::getSocialCreditCode) + .collect(Collectors.toSet()); + } + + private Set getExistingRelationCombinations(Map ownerBizIdByPersonId, + List excelList) { + List combinations = excelList.stream() + .map(excel -> { + String ownerBizId = ownerBizIdByPersonId.get(excel.getOwnerPersonId()); + if (StringUtils.isEmpty(ownerBizId) || StringUtils.isEmpty(excel.getSocialCreditCode())) { + return null; + } + return ownerBizId + "|" + excel.getSocialCreditCode(); + }) + .filter(StringUtils::isNotEmpty) + .distinct() + .collect(Collectors.toList()); + if (combinations.isEmpty()) { + return Collections.emptySet(); + } + return new HashSet<>(relationMapper.batchExistsByCombinations(combinations)); + } + + private void validateExcel(CcdiIntermediaryEnterpriseRelationExcel excel) { + if (StringUtils.isEmpty(excel.getOwnerPersonId())) { + throw new RuntimeException("中介本人证件号码不能为空"); + } + if (StringUtils.isEmpty(excel.getSocialCreditCode())) { + throw new RuntimeException("统一社会信用代码不能为空"); + } + String ownerPersonIdError = IdCardUtil.getErrorMessage(excel.getOwnerPersonId()); + if (ownerPersonIdError != null) { + throw new RuntimeException("中介本人证件号码" + ownerPersonIdError); + } + if (StringUtils.isNotEmpty(excel.getRelationPersonPost()) && excel.getRelationPersonPost().length() > 100) { + throw new RuntimeException("关联人职务长度不能超过100个字符"); + } + if (StringUtils.isNotEmpty(excel.getRemark()) && excel.getRemark().length() > 500) { + throw new RuntimeException("备注长度不能超过500个字符"); + } + } + + private IntermediaryEnterpriseRelationImportFailureVO createFailureVO(CcdiIntermediaryEnterpriseRelationExcel excel, + String errorMessage) { + IntermediaryEnterpriseRelationImportFailureVO failure = new IntermediaryEnterpriseRelationImportFailureVO(); + BeanUtils.copyProperties(excel, failure); + failure.setErrorMessage(errorMessage); + return failure; + } + + private void saveBatch(List list, int batchSize) { + for (int i = 0; i < list.size(); i += batchSize) { + int end = Math.min(i + batchSize, list.size()); + relationMapper.insertBatch(list.subList(i, end)); + } + } + + private void updateImportStatus(String taskId, ImportResult result) { + Map statusData = new HashMap<>(); + statusData.put("status", result.getFailureCount() == 0 ? "SUCCESS" : "PARTIAL_SUCCESS"); + statusData.put("successCount", result.getSuccessCount()); + statusData.put("failureCount", result.getFailureCount()); + statusData.put("progress", 100); + statusData.put("endTime", System.currentTimeMillis()); + statusData.put("message", result.getFailureCount() == 0 + ? "全部成功!共导入" + result.getTotalCount() + "条数据" + : "成功" + result.getSuccessCount() + "条,失败" + result.getFailureCount() + "条"); + redisTemplate.opsForHash().putAll(statusKey(taskId), statusData); + } + + private String statusKey(String taskId) { + return STATUS_KEY_PREFIX + taskId; + } + + private String failureKey(String taskId) { + return statusKey(taskId) + ":failures"; + } +} diff --git a/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/impl/CcdiIntermediaryPersonImportServiceImpl.java b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/impl/CcdiIntermediaryPersonImportServiceImpl.java index d0614bee..56466f58 100644 --- a/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/impl/CcdiIntermediaryPersonImportServiceImpl.java +++ b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/impl/CcdiIntermediaryPersonImportServiceImpl.java @@ -22,15 +22,18 @@ import org.springframework.scheduling.annotation.EnableAsync; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import java.util.*; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; import java.util.concurrent.TimeUnit; import java.util.stream.Collectors; /** - * 个人中介异步导入Service实现 - * - * @author ruoyi - * @date 2026-02-06 + * 中介信息异步导入实现 */ @Service @EnableAsync @@ -38,6 +41,8 @@ public class CcdiIntermediaryPersonImportServiceImpl implements ICcdiIntermediar private static final Logger log = LoggerFactory.getLogger(CcdiIntermediaryPersonImportServiceImpl.class); + private static final String STATUS_KEY_PREFIX = "import:intermediary:"; + @Resource private CcdiBizIntermediaryMapper intermediaryMapper; @@ -47,110 +52,104 @@ public class CcdiIntermediaryPersonImportServiceImpl implements ICcdiIntermediar @Override @Async @Transactional(rollbackFor = Exception.class) - public void importPersonAsync(List excelList, - String taskId, - String userName) { + public void importPersonAsync(List excelList, String taskId, String userName) { long startTime = System.currentTimeMillis(); + ImportLogUtils.logImportStart(log, taskId, "中介信息", excelList.size(), userName); - // 记录导入开始 - ImportLogUtils.logImportStart(log, taskId, "个人中介", excelList.size(), userName); - - List newRecords = new ArrayList<>(); + List ownerRows = new ArrayList<>(); + List relativeRows = new ArrayList<>(); List failures = new ArrayList<>(); - // 批量查询已存在的证件号 - ImportLogUtils.logBatchQueryStart(log, taskId, "已存在的证件号", excelList.size()); - Set existingPersonIds = getExistingPersonIds(excelList); - ImportLogUtils.logBatchQueryComplete(log, taskId, "证件号", existingPersonIds.size()); - - // 用于检测Excel内部的重复ID - Set excelProcessedIds = new HashSet<>(); - - // 分类数据 for (int i = 0; i < excelList.size(); i++) { CcdiIntermediaryPersonExcel excel = excelList.get(i); - try { - // 验证数据 - validatePersonData(excel, existingPersonIds); - - CcdiBizIntermediary intermediary = new CcdiBizIntermediary(); - BeanUtils.copyProperties(excel, intermediary); - - // 设置数据来源和审计字段 - intermediary.setDataSource("IMPORT"); - intermediary.setCreatedBy(userName); - intermediary.setUpdatedBy(userName); - - if (existingPersonIds.contains(excel.getPersonId())) { - // 证件号码在数据库中已存在,直接报错 - throw new RuntimeException(String.format("证件号码[%s]已存在,请勿重复导入", excel.getPersonId())); - } else if (excelProcessedIds.contains(excel.getPersonId())) { - // 证件号码在Excel文件内部重复 - throw new RuntimeException(String.format("证件号码[%s]在导入文件中重复,已跳过此条记录", excel.getPersonId())); + validateCommonRow(excel); + if (isOwnerRow(excel)) { + validateOwnerRow(excel); + ownerRows.add(excel); } else { - newRecords.add(intermediary); - excelProcessedIds.add(excel.getPersonId()); // 标记为已处理 + validateRelativeRow(excel); + relativeRows.add(excel); } - - // 记录进度 - ImportLogUtils.logProgress(log, taskId, i + 1, excelList.size(), - newRecords.size(), failures.size()); - } catch (Exception e) { failures.add(createFailureVO(excel, e.getMessage())); - - // 记录验证失败日志 - String keyData = String.format("姓名=%s, 证件号码=%s", - excel.getName(), excel.getPersonId()); - ImportLogUtils.logValidationError(log, taskId, i + 1, e.getMessage(), keyData); + ImportLogUtils.logValidationError(log, taskId, i + 1, e.getMessage(), + String.format("姓名=%s, 证件号码=%s", excel.getName(), excel.getPersonId())); } } - // 批量插入新数据 - if (!newRecords.isEmpty()) { - ImportLogUtils.logBatchOperationStart(log, taskId, "插入", - (newRecords.size() + 499) / 500, 500); - saveBatch(newRecords, 500); - } + Set existingOwnerPersonIds = getExistingOwnerPersonIds(ownerRows); + Set existingOwnerRefs = getExistingOwnerRefs(relativeRows); + Set existingRelativeCombinations = getExistingRelativeCombinations(relativeRows); - // 保存失败记录到Redis - if (!failures.isEmpty()) { + List successRecords = new ArrayList<>(); + Set importedOwnerPersonIds = new HashSet<>(); + + for (CcdiIntermediaryPersonExcel ownerExcel : ownerRows) { try { - String failuresKey = "import:intermediary:" + taskId + ":failures"; - redisTemplate.opsForValue().set(failuresKey, failures, 7, TimeUnit.DAYS); - ImportLogUtils.logRedisOperation(log, taskId, "保存失败记录", failures.size()); + String ownerPersonId = ownerExcel.getPersonId(); + if (existingOwnerPersonIds.contains(ownerPersonId)) { + throw new RuntimeException(String.format("中介本人证件号码[%s]已存在,请勿重复导入", ownerPersonId)); + } + if (!importedOwnerPersonIds.add(ownerPersonId)) { + throw new RuntimeException(String.format("中介本人证件号码[%s]在导入文件中重复", ownerPersonId)); + } + successRecords.add(buildRecord(ownerExcel, userName, null)); } catch (Exception e) { - ImportLogUtils.logRedisError(log, taskId, "保存失败记录", e); + failures.add(createFailureVO(ownerExcel, e.getMessage())); } } + Set validOwnerRefs = new HashSet<>(existingOwnerRefs); + validOwnerRefs.addAll(importedOwnerPersonIds); + Set processedRelativeCombinations = new HashSet<>(); + + for (CcdiIntermediaryPersonExcel relativeExcel : relativeRows) { + try { + String ownerPersonId = relativeExcel.getRelatedNumId(); + String combination = ownerPersonId + "|" + relativeExcel.getPersonId(); + if (!validOwnerRefs.contains(ownerPersonId)) { + throw new RuntimeException(String.format("关联中介本人证件号码[%s]不存在", ownerPersonId)); + } + if (existingRelativeCombinations.contains(combination)) { + throw new RuntimeException(String.format("同一中介本人名下证件号码[%s]的亲属已存在,请勿重复导入", relativeExcel.getPersonId())); + } + if (!processedRelativeCombinations.add(combination)) { + throw new RuntimeException(String.format("同一中介本人名下证件号码[%s]的亲属在导入文件中重复", relativeExcel.getPersonId())); + } + successRecords.add(buildRecord(relativeExcel, userName, ownerPersonId)); + } catch (Exception e) { + failures.add(createFailureVO(relativeExcel, e.getMessage())); + } + } + + if (!successRecords.isEmpty()) { + saveBatch(successRecords, 500); + } + + if (!failures.isEmpty()) { + redisTemplate.opsForValue().set(failureKey(taskId), failures, 7, TimeUnit.DAYS); + } + ImportResult result = new ImportResult(); result.setTotalCount(excelList.size()); - result.setSuccessCount(newRecords.size()); + result.setSuccessCount(successRecords.size()); result.setFailureCount(failures.size()); + updateImportStatus(taskId, result); - // 更新最终状态 - String finalStatus = result.getFailureCount() == 0 ? "SUCCESS" : "PARTIAL_SUCCESS"; - updateImportStatus(taskId, finalStatus, result); - - // 记录导入完成 long duration = System.currentTimeMillis() - startTime; - ImportLogUtils.logImportComplete(log, taskId, "个人中介", - excelList.size(), result.getSuccessCount(), result.getFailureCount(), duration); + ImportLogUtils.logImportComplete(log, taskId, "中介信息", + excelList.size(), result.getSuccessCount(), result.getFailureCount(), duration); } @Override public ImportStatusVO getImportStatus(String taskId) { - String key = "import:intermediary:" + taskId; - Boolean hasKey = redisTemplate.hasKey(key); - - if (Boolean.FALSE.equals(hasKey)) { + String key = statusKey(taskId); + if (Boolean.FALSE.equals(redisTemplate.hasKey(key))) { 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")); @@ -161,83 +160,120 @@ public class CcdiIntermediaryPersonImportServiceImpl implements ICcdiIntermediar 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); - + Object failuresObj = redisTemplate.opsForValue().get(failureKey(taskId)); 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()); + private boolean isOwnerRow(CcdiIntermediaryPersonExcel excel) { + return "本人".equals(excel.getPersonSubType()); + } - if (personIds.isEmpty()) { + private void validateCommonRow(CcdiIntermediaryPersonExcel excel) { + if (StringUtils.isEmpty(excel.getName())) { + throw new RuntimeException("姓名不能为空"); + } + if (StringUtils.isEmpty(excel.getPersonSubType())) { + throw new RuntimeException("人员子类型不能为空"); + } + if (StringUtils.isEmpty(excel.getPersonId())) { + throw new RuntimeException("证件号码不能为空"); + } + String idCardError = IdCardUtil.getErrorMessage(excel.getPersonId()); + if (idCardError != null) { + throw new RuntimeException("证件号码" + idCardError); + } + } + + private void validateOwnerRow(CcdiIntermediaryPersonExcel excel) { + if (StringUtils.isNotEmpty(excel.getRelatedNumId())) { + throw new RuntimeException("本人行关联中介本人证件号码必须为空"); + } + } + + private void validateRelativeRow(CcdiIntermediaryPersonExcel excel) { + if (StringUtils.isEmpty(excel.getRelatedNumId())) { + throw new RuntimeException("亲属行必须填写关联中介本人证件号码"); + } + } + + private Set getExistingOwnerPersonIds(List ownerRows) { + List ownerPersonIds = ownerRows.stream() + .map(CcdiIntermediaryPersonExcel::getPersonId) + .filter(StringUtils::isNotEmpty) + .distinct() + .collect(Collectors.toList()); + if (ownerPersonIds.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()); + wrapper.eq(CcdiBizIntermediary::getPersonSubType, "本人") + .in(CcdiBizIntermediary::getPersonId, ownerPersonIds); + return intermediaryMapper.selectList(wrapper).stream() + .map(CcdiBizIntermediary::getPersonId) + .collect(Collectors.toSet()); } - /** - * 批量保存(使用ON DUPLICATE KEY UPDATE) - */ - private int saveBatchWithUpsert(List list, int batchSize) { - int totalCount = 0; - for (int i = 0; i < list.size(); i += batchSize) { - int end = Math.min(i + batchSize, list.size()); - List subList = list.subList(i, end); - int count = intermediaryMapper.importPersonBatch(subList); - totalCount += count; - } - return totalCount; - } - - /** - * 从数据库获取已存在的证件号 - */ - private Set getExistingPersonIdsFromDb(List records) { - List personIds = records.stream() - .map(CcdiBizIntermediary::getPersonId) - .filter(StringUtils::isNotEmpty) - .collect(Collectors.toList()); - - if (personIds.isEmpty()) { + private Set getExistingOwnerRefs(List relativeRows) { + List ownerRefs = relativeRows.stream() + .map(CcdiIntermediaryPersonExcel::getRelatedNumId) + .filter(StringUtils::isNotEmpty) + .distinct() + .collect(Collectors.toList()); + if (ownerRefs.isEmpty()) { return Collections.emptySet(); } LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); - wrapper.in(CcdiBizIntermediary::getPersonId, personIds); - List existing = intermediaryMapper.selectList(wrapper); - - return existing.stream() - .map(CcdiBizIntermediary::getPersonId) - .collect(Collectors.toSet()); + wrapper.eq(CcdiBizIntermediary::getPersonSubType, "本人") + .in(CcdiBizIntermediary::getPersonId, ownerRefs); + return intermediaryMapper.selectList(wrapper).stream() + .map(CcdiBizIntermediary::getPersonId) + .collect(Collectors.toSet()); + } + + private Set getExistingRelativeCombinations(List relativeRows) { + List ownerRefs = relativeRows.stream() + .map(CcdiIntermediaryPersonExcel::getRelatedNumId) + .filter(StringUtils::isNotEmpty) + .distinct() + .collect(Collectors.toList()); + List relativePersonIds = relativeRows.stream() + .map(CcdiIntermediaryPersonExcel::getPersonId) + .filter(StringUtils::isNotEmpty) + .distinct() + .collect(Collectors.toList()); + if (ownerRefs.isEmpty() || relativePersonIds.isEmpty()) { + return Collections.emptySet(); + } + + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.ne(CcdiBizIntermediary::getPersonSubType, "本人") + .in(CcdiBizIntermediary::getRelatedNumId, ownerRefs) + .in(CcdiBizIntermediary::getPersonId, relativePersonIds); + return intermediaryMapper.selectList(wrapper).stream() + .map(item -> item.getRelatedNumId() + "|" + item.getPersonId()) + .collect(Collectors.toSet()); + } + + private CcdiBizIntermediary buildRecord(CcdiIntermediaryPersonExcel excel, String userName, String ownerPersonId) { + CcdiBizIntermediary intermediary = new CcdiBizIntermediary(); + BeanUtils.copyProperties(excel, intermediary); + intermediary.setRelatedNumId(ownerPersonId); + intermediary.setDataSource("IMPORT"); + intermediary.setCreatedBy(userName); + intermediary.setUpdatedBy(userName); + return intermediary; } - /** - * 创建失败记录VO - */ private IntermediaryPersonImportFailureVO createFailureVO(CcdiIntermediaryPersonExcel excel, String errorMsg) { IntermediaryPersonImportFailureVO failure = new IntermediaryPersonImportFailureVO(); BeanUtils.copyProperties(excel, failure); @@ -245,73 +281,31 @@ public class CcdiIntermediaryPersonImportServiceImpl implements ICcdiIntermediar return failure; } - /** - * 创建失败记录VO(重载方法) - */ - private IntermediaryPersonImportFailureVO createFailureVO(CcdiBizIntermediary record, String errorMsg) { - CcdiIntermediaryPersonExcel excel = new CcdiIntermediaryPersonExcel(); - BeanUtils.copyProperties(record, excel); - return createFailureVO(excel, errorMsg); - } - - /** - * 批量保存 - */ - private int saveBatch(List list, int batchSize) { - // 使用真正的批量插入,分批次执行以提高性能 - int totalCount = 0; + 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); - int count = intermediaryMapper.insertBatch(subList); - totalCount += count; + intermediaryMapper.insertBatch(list.subList(i, end)); } - return totalCount; } - /** - * 更新导入状态 - */ - private void updateImportStatus(String taskId, String status, ImportResult result) { - String key = "import:intermediary:" + taskId; + private void updateImportStatus(String taskId, ImportResult result) { Map statusData = new HashMap<>(); - statusData.put("status", status); + statusData.put("status", result.getFailureCount() == 0 ? "SUCCESS" : "PARTIAL_SUCCESS"); 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); + statusData.put("message", result.getFailureCount() == 0 + ? "全部成功!共导入" + result.getTotalCount() + "条数据" + : "成功" + result.getSuccessCount() + "条,失败" + result.getFailureCount() + "条"); + redisTemplate.opsForHash().putAll(statusKey(taskId), statusData); } - /** - * 验证个人中介数据 - * - * @param excel Excel数据 - * @param existingPersonIds 已存在的证件号集合 - */ - private void validatePersonData(CcdiIntermediaryPersonExcel excel, - Set existingPersonIds) { - // 验证必填字段:姓名 - if (StringUtils.isEmpty(excel.getName())) { - throw new RuntimeException("姓名不能为空"); - } + private String statusKey(String taskId) { + return STATUS_KEY_PREFIX + taskId; + } - // 验证必填字段:证件号码 - if (StringUtils.isEmpty(excel.getPersonId())) { - throw new RuntimeException("证件号码不能为空"); - } - - // 验证证件号码格式 - String idCardError = IdCardUtil.getErrorMessage(excel.getPersonId()); - if (idCardError != null) { - throw new RuntimeException("证件号码" + idCardError); - } + private String failureKey(String taskId) { + return statusKey(taskId) + ":failures"; } } diff --git a/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/impl/CcdiIntermediaryServiceImpl.java b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/impl/CcdiIntermediaryServiceImpl.java index 8ae5dcf2..766de88d 100644 --- a/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/impl/CcdiIntermediaryServiceImpl.java +++ b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/impl/CcdiIntermediaryServiceImpl.java @@ -4,15 +4,21 @@ import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.ruoyi.info.collection.domain.CcdiBizIntermediary; import com.ruoyi.info.collection.domain.CcdiEnterpriseBaseInfo; +import com.ruoyi.info.collection.domain.CcdiIntermediaryEnterpriseRelation; import com.ruoyi.info.collection.domain.dto.*; +import com.ruoyi.info.collection.domain.excel.CcdiIntermediaryEnterpriseRelationExcel; import com.ruoyi.info.collection.domain.excel.CcdiIntermediaryEntityExcel; import com.ruoyi.info.collection.domain.excel.CcdiIntermediaryPersonExcel; +import com.ruoyi.info.collection.domain.vo.CcdiIntermediaryEnterpriseRelationVO; import com.ruoyi.info.collection.domain.vo.CcdiIntermediaryEntityDetailVO; import com.ruoyi.info.collection.domain.vo.CcdiIntermediaryPersonDetailVO; +import com.ruoyi.info.collection.domain.vo.CcdiIntermediaryRelativeVO; import com.ruoyi.info.collection.domain.vo.CcdiIntermediaryVO; import com.ruoyi.info.collection.mapper.CcdiBizIntermediaryMapper; import com.ruoyi.info.collection.mapper.CcdiEnterpriseBaseInfoMapper; +import com.ruoyi.info.collection.mapper.CcdiIntermediaryEnterpriseRelationMapper; import com.ruoyi.info.collection.mapper.CcdiIntermediaryMapper; +import com.ruoyi.info.collection.service.ICcdiIntermediaryEnterpriseRelationImportService; import com.ruoyi.info.collection.service.ICcdiIntermediaryEntityImportService; import com.ruoyi.info.collection.service.ICcdiIntermediaryPersonImportService; import com.ruoyi.info.collection.service.ICcdiIntermediaryService; @@ -48,12 +54,18 @@ public class CcdiIntermediaryServiceImpl implements ICcdiIntermediaryService { @Resource private CcdiIntermediaryMapper intermediaryMapper; + @Resource + private CcdiIntermediaryEnterpriseRelationMapper enterpriseRelationMapper; + @Resource private ICcdiIntermediaryPersonImportService personImportService; @Resource private ICcdiIntermediaryEntityImportService entityImportService; + @Resource + private ICcdiIntermediaryEnterpriseRelationImportService enterpriseRelationImportService; + @Resource private RedisTemplate redisTemplate; @@ -81,7 +93,7 @@ public class CcdiIntermediaryServiceImpl implements ICcdiIntermediaryService { @Override public CcdiIntermediaryPersonDetailVO selectIntermediaryPersonDetail(String bizId) { CcdiBizIntermediary person = bizIntermediaryMapper.selectById(bizId); - if (person == null) { + if (person == null || !isIntermediaryPerson(person)) { return null; } @@ -92,6 +104,25 @@ public class CcdiIntermediaryServiceImpl implements ICcdiIntermediaryService { return vo; } + @Override + public List selectIntermediaryRelativeList(String bizId) { + CcdiBizIntermediary owner = requireIntermediaryPerson(bizId); + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(CcdiBizIntermediary::getRelatedNumId, owner.getPersonId()) + .ne(CcdiBizIntermediary::getPersonSubType, "本人") + .orderByDesc(CcdiBizIntermediary::getCreateTime); + return bizIntermediaryMapper.selectList(wrapper).stream().map(this::buildRelativeVo).toList(); + } + + @Override + public CcdiIntermediaryRelativeVO selectIntermediaryRelativeDetail(String relativeBizId) { + CcdiBizIntermediary relative = bizIntermediaryMapper.selectById(relativeBizId); + if (relative == null || isIntermediaryPerson(relative)) { + return null; + } + return buildRelativeVo(relative); + } + /** * 查询实体中介详情 * @@ -130,6 +161,8 @@ public class CcdiIntermediaryServiceImpl implements ICcdiIntermediaryService { CcdiBizIntermediary person = new CcdiBizIntermediary(); BeanUtils.copyProperties(addDTO, person); + person.setPersonSubType("本人"); + person.setRelatedNumId(null); person.setDataSource("MANUAL"); return bizIntermediaryMapper.insert(person); @@ -151,10 +184,63 @@ public class CcdiIntermediaryServiceImpl implements ICcdiIntermediaryService { } } + CcdiBizIntermediary existing = bizIntermediaryMapper.selectById(editDTO.getBizId()); + if (existing == null || !isIntermediaryPerson(existing)) { + throw new RuntimeException("中介本人不存在"); + } + CcdiBizIntermediary person = new CcdiBizIntermediary(); BeanUtils.copyProperties(editDTO, person); + person.setPersonSubType("本人"); + person.setRelatedNumId(null); + int updated = bizIntermediaryMapper.updateById(person); + syncRelativeOwnerPersonId(existing.getPersonId(), editDTO.getPersonId()); + return updated; + } - return bizIntermediaryMapper.updateById(person); + @Override + @Transactional + public int insertIntermediaryRelative(String bizId, CcdiIntermediaryRelativeAddDTO addDTO) { + CcdiBizIntermediary owner = requireIntermediaryPerson(bizId); + validateRelativePersonSubType(addDTO.getPersonSubType()); + if (!checkRelativePersonUnique(owner.getPersonId(), addDTO.getPersonId(), null)) { + throw new RuntimeException("该中介本人下已存在相同证件号亲属"); + } + + CcdiBizIntermediary relative = new CcdiBizIntermediary(); + BeanUtils.copyProperties(addDTO, relative); + relative.setRelatedNumId(owner.getPersonId()); + relative.setDataSource("MANUAL"); + return bizIntermediaryMapper.insert(relative); + } + + @Override + @Transactional + public int updateIntermediaryRelative(CcdiIntermediaryRelativeEditDTO editDTO) { + CcdiBizIntermediary existing = bizIntermediaryMapper.selectById(editDTO.getBizId()); + if (existing == null || isIntermediaryPerson(existing)) { + throw new RuntimeException("中介亲属不存在"); + } + validateRelativePersonSubType(editDTO.getPersonSubType()); + if (StringUtils.isNotEmpty(editDTO.getPersonId()) + && !checkRelativePersonUnique(existing.getRelatedNumId(), editDTO.getPersonId(), editDTO.getBizId())) { + throw new RuntimeException("该中介本人下已存在相同证件号亲属"); + } + + CcdiBizIntermediary relative = new CcdiBizIntermediary(); + BeanUtils.copyProperties(editDTO, relative); + relative.setRelatedNumId(existing.getRelatedNumId()); + return bizIntermediaryMapper.updateById(relative); + } + + @Override + @Transactional + public int deleteIntermediaryRelative(String relativeBizId) { + CcdiBizIntermediary existing = bizIntermediaryMapper.selectById(relativeBizId); + if (existing == null || isIntermediaryPerson(existing)) { + throw new RuntimeException("中介亲属不存在"); + } + return bizIntermediaryMapper.deleteById(relativeBizId); } /** @@ -197,6 +283,49 @@ public class CcdiIntermediaryServiceImpl implements ICcdiIntermediaryService { return enterpriseBaseInfoMapper.updateById(entity); } + @Override + public List selectIntermediaryEnterpriseRelationList(String bizId) { + return enterpriseRelationMapper.selectByIntermediaryBizId(bizId); + } + + @Override + public CcdiIntermediaryEnterpriseRelationVO selectIntermediaryEnterpriseRelationDetail(Long id) { + return enterpriseRelationMapper.selectDetailById(id); + } + + @Override + @Transactional + public int insertIntermediaryEnterpriseRelation(String bizId, CcdiIntermediaryEnterpriseRelationAddDTO addDTO) { + CcdiBizIntermediary owner = requireIntermediaryPerson(bizId); + validateEnterpriseRelation(owner.getBizId(), addDTO.getSocialCreditCode(), null); + + CcdiIntermediaryEnterpriseRelation relation = new CcdiIntermediaryEnterpriseRelation(); + BeanUtils.copyProperties(addDTO, relation); + relation.setIntermediaryBizId(owner.getBizId()); + return enterpriseRelationMapper.insert(relation); + } + + @Override + @Transactional + public int updateIntermediaryEnterpriseRelation(CcdiIntermediaryEnterpriseRelationEditDTO editDTO) { + CcdiIntermediaryEnterpriseRelation existing = enterpriseRelationMapper.selectById(editDTO.getId()); + if (existing == null) { + throw new RuntimeException("中介关联机构不存在"); + } + validateEnterpriseRelation(existing.getIntermediaryBizId(), editDTO.getSocialCreditCode(), existing.getId()); + + CcdiIntermediaryEnterpriseRelation relation = new CcdiIntermediaryEnterpriseRelation(); + BeanUtils.copyProperties(editDTO, relation); + relation.setIntermediaryBizId(existing.getIntermediaryBizId()); + return enterpriseRelationMapper.updateById(relation); + } + + @Override + @Transactional + public int deleteIntermediaryEnterpriseRelation(Long id) { + return enterpriseRelationMapper.deleteById(id); + } + /** * 批量删除中介 * @@ -208,12 +337,20 @@ public class CcdiIntermediaryServiceImpl implements ICcdiIntermediaryService { public int deleteIntermediaryByIds(String[] ids) { int count = 0; for (String id : ids) { - // 判断是个人还是实体(个人ID长度较长,实体统一社会信用代码18位) - if (id.length() > 18) { - // 个人中介 + CcdiBizIntermediary intermediary = bizIntermediaryMapper.selectById(id); + if (intermediary != null) { + if (isIntermediaryPerson(intermediary)) { + bizIntermediaryMapper.delete(new LambdaQueryWrapper() + .eq(CcdiBizIntermediary::getRelatedNumId, intermediary.getPersonId()) + .ne(CcdiBizIntermediary::getPersonSubType, "本人")); + enterpriseRelationMapper.delete(new LambdaQueryWrapper() + .eq(CcdiIntermediaryEnterpriseRelation::getIntermediaryBizId, id)); + } count += bizIntermediaryMapper.deleteById(id); - } else { - // 实体中介 + continue; + } + + if (enterpriseBaseInfoMapper.selectById(id) != null) { count += enterpriseBaseInfoMapper.deleteById(id); } } @@ -230,7 +367,8 @@ public class CcdiIntermediaryServiceImpl implements ICcdiIntermediaryService { @Override public boolean checkPersonIdUnique(String personId, String bizId) { LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); - wrapper.eq(CcdiBizIntermediary::getPersonId, personId); + wrapper.eq(CcdiBizIntermediary::getPersonId, personId) + .eq(CcdiBizIntermediary::getPersonSubType, "本人"); if (StringUtils.isNotEmpty(bizId)) { wrapper.ne(CcdiBizIntermediary::getBizId, bizId); } @@ -290,6 +428,31 @@ public class CcdiIntermediaryServiceImpl implements ICcdiIntermediaryService { return taskId; } + @Override + @Transactional + public String importIntermediaryEnterpriseRelation(List list) { + String taskId = UUID.randomUUID().toString(); + long startTime = System.currentTimeMillis(); + + String statusKey = "import:intermediary-enterprise-relation:" + taskId; + Map statusData = new HashMap<>(); + statusData.put("taskId", taskId); + statusData.put("status", "PROCESSING"); + statusData.put("totalCount", list.size()); + statusData.put("successCount", 0); + statusData.put("failureCount", 0); + statusData.put("progress", 0); + statusData.put("startTime", startTime); + statusData.put("message", "正在处理..."); + + redisTemplate.opsForHash().putAll(statusKey, statusData); + redisTemplate.expire(statusKey, 7, TimeUnit.DAYS); + + String userName = SecurityUtils.getUsername(); + enterpriseRelationImportService.importAsync(list, taskId, userName); + return taskId; + } + /** * 导入实体中介数据(异步) * @@ -325,4 +488,70 @@ public class CcdiIntermediaryServiceImpl implements ICcdiIntermediaryService { return taskId; } + + private boolean isIntermediaryPerson(CcdiBizIntermediary person) { + return "本人".equals(person.getPersonSubType()); + } + + private CcdiBizIntermediary requireIntermediaryPerson(String bizId) { + CcdiBizIntermediary owner = bizIntermediaryMapper.selectById(bizId); + if (owner == null || !isIntermediaryPerson(owner)) { + throw new RuntimeException("中介本人不存在"); + } + return owner; + } + + private void validateRelativePersonSubType(String personSubType) { + if ("本人".equals(personSubType)) { + throw new RuntimeException("亲属关系不能为本人"); + } + } + + private boolean checkRelativePersonUnique(String ownerPersonId, String personId, String excludeBizId) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(CcdiBizIntermediary::getRelatedNumId, ownerPersonId) + .eq(CcdiBizIntermediary::getPersonId, personId) + .ne(CcdiBizIntermediary::getPersonSubType, "本人"); + if (StringUtils.isNotEmpty(excludeBizId)) { + wrapper.ne(CcdiBizIntermediary::getBizId, excludeBizId); + } + return bizIntermediaryMapper.selectCount(wrapper) == 0; + } + + private void validateEnterpriseRelation(String bizId, String socialCreditCode, Long excludeId) { + requireIntermediaryPerson(bizId); + if (enterpriseBaseInfoMapper.selectById(socialCreditCode) == null) { + throw new RuntimeException("关联机构不存在"); + } + boolean exists = enterpriseRelationMapper.existsByIntermediaryBizIdAndSocialCreditCode(bizId, socialCreditCode); + if (exists) { + if (excludeId == null) { + throw new RuntimeException("该中介已关联此机构"); + } + CcdiIntermediaryEnterpriseRelation existing = enterpriseRelationMapper.selectById(excludeId); + if (existing == null || !socialCreditCode.equals(existing.getSocialCreditCode())) { + throw new RuntimeException("该中介已关联此机构"); + } + } + } + + private void syncRelativeOwnerPersonId(String oldOwnerPersonId, String newOwnerPersonId) { + if (StringUtils.isEmpty(oldOwnerPersonId) + || StringUtils.isEmpty(newOwnerPersonId) + || oldOwnerPersonId.equals(newOwnerPersonId)) { + return; + } + + CcdiBizIntermediary relative = new CcdiBizIntermediary(); + relative.setRelatedNumId(newOwnerPersonId); + bizIntermediaryMapper.update(relative, new LambdaQueryWrapper() + .eq(CcdiBizIntermediary::getRelatedNumId, oldOwnerPersonId) + .ne(CcdiBizIntermediary::getPersonSubType, "本人")); + } + + private CcdiIntermediaryRelativeVO buildRelativeVo(CcdiBizIntermediary relative) { + CcdiIntermediaryRelativeVO vo = new CcdiIntermediaryRelativeVO(); + BeanUtils.copyProperties(relative, vo); + return vo; + } } diff --git a/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/impl/CcdiPurchaseTransactionImportServiceImpl.java b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/impl/CcdiPurchaseTransactionImportServiceImpl.java index 5aa58483..2221986a 100644 --- a/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/impl/CcdiPurchaseTransactionImportServiceImpl.java +++ b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/impl/CcdiPurchaseTransactionImportServiceImpl.java @@ -2,12 +2,15 @@ package com.ruoyi.info.collection.service.impl; import com.alibaba.fastjson2.JSON; import com.ruoyi.info.collection.domain.CcdiPurchaseTransaction; +import com.ruoyi.info.collection.domain.CcdiPurchaseTransactionSupplier; import com.ruoyi.info.collection.domain.dto.CcdiPurchaseTransactionAddDTO; import com.ruoyi.info.collection.domain.excel.CcdiPurchaseTransactionExcel; +import com.ruoyi.info.collection.domain.excel.CcdiPurchaseTransactionSupplierExcel; import com.ruoyi.info.collection.domain.vo.ImportResult; import com.ruoyi.info.collection.domain.vo.ImportStatusVO; import com.ruoyi.info.collection.domain.vo.PurchaseTransactionImportFailureVO; import com.ruoyi.info.collection.mapper.CcdiPurchaseTransactionMapper; +import com.ruoyi.info.collection.mapper.CcdiPurchaseTransactionSupplierMapper; import com.ruoyi.info.collection.service.ICcdiPurchaseTransactionImportService; import com.ruoyi.info.collection.utils.ImportLogUtils; import com.ruoyi.common.utils.StringUtils; @@ -37,83 +40,155 @@ import java.util.stream.Collectors; public class CcdiPurchaseTransactionImportServiceImpl implements ICcdiPurchaseTransactionImportService { private static final Logger log = LoggerFactory.getLogger(CcdiPurchaseTransactionImportServiceImpl.class); + private static final String MAIN_SHEET_NAME = "招投标主信息"; + private static final String SUPPLIER_SHEET_NAME = "供应商明细"; + private static final int EXCEL_DATA_START_ROW = 2; @Resource private CcdiPurchaseTransactionMapper transactionMapper; + @Resource + private CcdiPurchaseTransactionSupplierMapper supplierMapper; + @Resource private RedisTemplate redisTemplate; @Override @Async @Transactional - public void importTransactionAsync(List excelList, String taskId, String userName) { + public void importTransactionAsync( + List mainExcelList, + List supplierExcelList, + String taskId, + String userName + ) { long startTime = System.currentTimeMillis(); + List safeMainList = mainExcelList == null ? List.of() : mainExcelList; + List safeSupplierList = supplierExcelList == null ? List.of() : supplierExcelList; + List indexedMainRows = buildMainImportRows(safeMainList); + List indexedSupplierRows = buildSupplierImportRows(safeSupplierList); + int totalCount = countImportUnits(safeMainList, safeSupplierList); // 记录导入开始 - ImportLogUtils.logImportStart(log, taskId, "采购交易信息", excelList.size(), userName); + ImportLogUtils.logImportStart(log, taskId, "招投标信息维护", totalCount, userName); - List newRecords = new ArrayList<>(); + List newTransactions = new ArrayList<>(); + List newSuppliers = new ArrayList<>(); List failures = new ArrayList<>(); // 批量查询已存在的采购事项ID - ImportLogUtils.logBatchQueryStart(log, taskId, "已存在的采购事项ID", excelList.size()); - Set existingIds = getExistingPurchaseIds(excelList); + ImportLogUtils.logBatchQueryStart(log, taskId, "已存在的采购事项ID", safeMainList.size()); + Set existingIds = getExistingPurchaseIds(safeMainList); ImportLogUtils.logBatchQueryComplete(log, taskId, "采购事项ID", existingIds.size()); - // 用于跟踪Excel文件内已处理的采购事项ID - Set processedIds = new HashSet<>(); + Map> mainGroupMap = indexedMainRows.stream() + .filter(item -> StringUtils.isNotEmpty(item.data().getPurchaseId())) + .collect(Collectors.groupingBy( + item -> item.data().getPurchaseId(), + LinkedHashMap::new, + Collectors.toList() + )); + Map> supplierGroupMap = indexedSupplierRows.stream() + .filter(item -> StringUtils.isNotEmpty(item.data().getPurchaseId())) + .collect(Collectors.groupingBy( + item -> item.data().getPurchaseId(), + LinkedHashMap::new, + Collectors.toList() + )); + LinkedHashSet purchaseIds = new LinkedHashSet<>(); + purchaseIds.addAll(mainGroupMap.keySet()); + purchaseIds.addAll(supplierGroupMap.keySet()); - // 分类数据 - for (int i = 0; i < excelList.size(); i++) { - CcdiPurchaseTransactionExcel excel = excelList.get(i); + for (SupplierImportRow supplierExcel : indexedSupplierRows) { + if (StringUtils.isEmpty(supplierExcel.data().getPurchaseId())) { + failures.add(buildFailure( + null, + null, + SUPPLIER_SHEET_NAME, + String.valueOf(supplierExcel.sheetRowNum()), + "供应商明细Sheet中的采购事项ID不能为空" + )); + } + } + + int index = 0; + for (String purchaseId : purchaseIds) { + index++; + List mainRows = mainGroupMap.getOrDefault(purchaseId, List.of()); + List supplierRows = supplierGroupMap.getOrDefault(purchaseId, List.of()); try { - // 转换为AddDTO进行验证 - CcdiPurchaseTransactionAddDTO addDTO = new CcdiPurchaseTransactionAddDTO(); - BeanUtils.copyProperties(excel, addDTO); - - // 验证数据 - validateTransactionData(addDTO, existingIds); - - CcdiPurchaseTransaction transaction = new CcdiPurchaseTransaction(); - BeanUtils.copyProperties(excel, transaction); - - if (existingIds.contains(excel.getPurchaseId())) { - // 采购事项ID已存在,直接报错 - throw new RuntimeException(String.format("采购事项ID[%s]已存在,请勿重复导入", excel.getPurchaseId())); - } else if (processedIds.contains(excel.getPurchaseId())) { - // Excel文件内部重复 - throw new RuntimeException(String.format("采购事项ID[%s]在导入文件中重复,已跳过此条记录", excel.getPurchaseId())); - } else { - transaction.setCreatedBy(userName); - transaction.setUpdatedBy(userName); - newRecords.add(transaction); - processedIds.add(excel.getPurchaseId()); // 标记为已处理 + if (existingIds.contains(purchaseId)) { + throw buildValidationException( + MAIN_SHEET_NAME, + extractMainRowNums(mainRows), + String.format("采购事项ID[%s]已存在,请勿重复导入", purchaseId) + ); + } + if (mainRows.isEmpty()) { + throw buildValidationException( + SUPPLIER_SHEET_NAME, + extractSupplierRowNums(supplierRows), + String.format("采购事项ID[%s]缺少招投标主信息", purchaseId) + ); + } + if (mainRows.size() > 1) { + throw buildValidationException( + MAIN_SHEET_NAME, + extractMainRowNums(mainRows), + String.format("采购事项ID[%s]在招投标主信息Sheet中重复", purchaseId) + ); } + MainImportRow mainRow = mainRows.getFirst(); + CcdiPurchaseTransactionExcel mainExcel = mainRow.data(); + CcdiPurchaseTransactionAddDTO addDTO = new CcdiPurchaseTransactionAddDTO(); + BeanUtils.copyProperties(mainExcel, addDTO); + + validateTransactionData(addDTO, mainRow.sheetRowNum()); + List suppliers = buildSupplierEntities(purchaseId, supplierRows, userName); + + CcdiPurchaseTransaction transaction = new CcdiPurchaseTransaction(); + BeanUtils.copyProperties(mainExcel, transaction); + fillWinnerSummary(transaction, suppliers); + transaction.setCreatedBy(userName); + transaction.setUpdatedBy(userName); + newTransactions.add(transaction); + newSuppliers.addAll(suppliers); + // 记录进度 - ImportLogUtils.logProgress(log, taskId, i + 1, excelList.size(), - newRecords.size(), failures.size()); + ImportLogUtils.logProgress(log, taskId, index, Math.max(totalCount, purchaseIds.size()), + newTransactions.size(), failures.size()); } catch (Exception e) { - PurchaseTransactionImportFailureVO failure = new PurchaseTransactionImportFailureVO(); - BeanUtils.copyProperties(excel, failure); - failure.setErrorMessage(e.getMessage()); - failures.add(failure); + MainImportRow mainRow = mainRows.isEmpty() ? null : mainRows.getFirst(); + CcdiPurchaseTransactionExcel mainExcel = mainRow == null ? null : mainRow.data(); + FailureMeta failureMeta = resolveFailureMeta(e, mainRows, supplierRows); + failures.add(buildFailure( + mainExcel, + purchaseId, + failureMeta.sheetName(), + failureMeta.sheetRowNum(), + e.getMessage() + )); // 记录验证失败日志 String keyData = String.format("采购事项ID=%s, 采购类别=%s, 标的物=%s", - excel.getPurchaseId(), excel.getPurchaseCategory(), excel.getSubjectName()); - ImportLogUtils.logValidationError(log, taskId, i + 1, e.getMessage(), keyData); + purchaseId, + mainExcel == null ? "" : mainExcel.getPurchaseCategory(), + mainExcel == null ? "" : mainExcel.getSubjectName()); + ImportLogUtils.logValidationError(log, taskId, index, e.getMessage(), keyData); } } // 批量插入新数据 - if (!newRecords.isEmpty()) { + if (!newTransactions.isEmpty()) { ImportLogUtils.logBatchOperationStart(log, taskId, "插入", - (newRecords.size() + 499) / 500, 500); - saveBatch(newRecords, 500); + (newTransactions.size() + 499) / 500, 500); + saveBatch(newTransactions, 500); + } + if (!newSuppliers.isEmpty()) { + saveSupplierBatch(newSuppliers, 500); } // 保存失败记录到Redis @@ -128,8 +203,8 @@ public class CcdiPurchaseTransactionImportServiceImpl implements ICcdiPurchaseTr } ImportResult result = new ImportResult(); - result.setTotalCount(excelList.size()); - result.setSuccessCount(newRecords.size()); + result.setTotalCount(totalCount); + result.setSuccessCount(newTransactions.size()); result.setFailureCount(failures.size()); // 更新最终状态 @@ -138,8 +213,8 @@ public class CcdiPurchaseTransactionImportServiceImpl implements ICcdiPurchaseTr // 记录导入完成 long duration = System.currentTimeMillis() - startTime; - ImportLogUtils.logImportComplete(log, taskId, "采购交易信息", - excelList.size(), result.getSuccessCount(), result.getFailureCount(), duration); + ImportLogUtils.logImportComplete(log, taskId, "招投标信息维护", + totalCount, result.getSuccessCount(), result.getFailureCount(), duration); } /** @@ -243,71 +318,338 @@ public class CcdiPurchaseTransactionImportServiceImpl implements ICcdiPurchaseTr } } + private void saveSupplierBatch(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); + for (CcdiPurchaseTransactionSupplier supplier : subList) { + supplierMapper.insert(supplier); + } + } + } + /** * 验证采购交易数据 * - * @param addDTO 新增DTO - * @param existingIds 已存在的采购事项ID集合 + * @param addDTO 新增DTO */ - private void validateTransactionData(CcdiPurchaseTransactionAddDTO addDTO, Set existingIds) { + private void validateTransactionData(CcdiPurchaseTransactionAddDTO addDTO, int sheetRowNum) { // 验证必填字段 if (StringUtils.isEmpty(addDTO.getPurchaseId())) { - throw new RuntimeException("采购事项ID不能为空"); + throw buildValidationException(MAIN_SHEET_NAME, List.of(sheetRowNum), "采购事项ID不能为空"); } if (StringUtils.isEmpty(addDTO.getPurchaseCategory())) { - throw new RuntimeException("采购类别不能为空"); + throw buildValidationException(MAIN_SHEET_NAME, List.of(sheetRowNum), "采购类别不能为空"); } if (StringUtils.isEmpty(addDTO.getSubjectName())) { - throw new RuntimeException("标的物名称不能为空"); + throw buildValidationException(MAIN_SHEET_NAME, List.of(sheetRowNum), "标的物名称不能为空"); } if (addDTO.getPurchaseQty() == null) { - throw new RuntimeException("采购数量不能为空"); + throw buildValidationException(MAIN_SHEET_NAME, List.of(sheetRowNum), "采购数量不能为空"); } if (addDTO.getBudgetAmount() == null) { - throw new RuntimeException("预算金额不能为空"); + throw buildValidationException(MAIN_SHEET_NAME, List.of(sheetRowNum), "预算金额不能为空"); } if (StringUtils.isEmpty(addDTO.getPurchaseMethod())) { - throw new RuntimeException("采购方式不能为空"); + throw buildValidationException(MAIN_SHEET_NAME, List.of(sheetRowNum), "采购方式不能为空"); } if (addDTO.getApplyDate() == null) { - throw new RuntimeException("采购申请日期不能为空"); + throw buildValidationException(MAIN_SHEET_NAME, List.of(sheetRowNum), "采购申请日期不能为空"); } if (StringUtils.isEmpty(addDTO.getApplicantId())) { - throw new RuntimeException("申请人工号不能为空"); + throw buildValidationException(MAIN_SHEET_NAME, List.of(sheetRowNum), "申请人工号不能为空"); } if (StringUtils.isEmpty(addDTO.getApplicantName())) { - throw new RuntimeException("申请人姓名不能为空"); + throw buildValidationException(MAIN_SHEET_NAME, List.of(sheetRowNum), "申请人姓名不能为空"); } if (StringUtils.isEmpty(addDTO.getApplyDepartment())) { - throw new RuntimeException("申请部门不能为空"); + throw buildValidationException(MAIN_SHEET_NAME, List.of(sheetRowNum), "申请部门不能为空"); } // 验证工号格式(7位数字) if (!addDTO.getApplicantId().matches("^\\d{7}$")) { - throw new RuntimeException("申请人工号必须为7位数字"); + throw buildValidationException(MAIN_SHEET_NAME, List.of(sheetRowNum), "申请人工号必须为7位数字"); } if (StringUtils.isNotEmpty(addDTO.getPurchaseLeaderId()) && !addDTO.getPurchaseLeaderId().matches("^\\d{7}$")) { - throw new RuntimeException("采购负责人工号必须为7位数字"); + throw buildValidationException(MAIN_SHEET_NAME, List.of(sheetRowNum), "采购负责人工号必须为7位数字"); } // 验证金额非负 if (addDTO.getPurchaseQty().compareTo(BigDecimal.ZERO) <= 0) { - throw new RuntimeException("采购数量必须大于0"); + throw buildValidationException(MAIN_SHEET_NAME, List.of(sheetRowNum), "采购数量必须大于0"); } if (addDTO.getBudgetAmount().compareTo(BigDecimal.ZERO) <= 0) { - throw new RuntimeException("预算金额必须大于0"); + throw buildValidationException(MAIN_SHEET_NAME, List.of(sheetRowNum), "预算金额必须大于0"); } if (addDTO.getBidAmount() != null && addDTO.getBidAmount().compareTo(BigDecimal.ZERO) <= 0) { - throw new RuntimeException("中标金额必须大于0"); + throw buildValidationException(MAIN_SHEET_NAME, List.of(sheetRowNum), "中标金额必须大于0"); } if (addDTO.getActualAmount() != null && addDTO.getActualAmount().compareTo(BigDecimal.ZERO) <= 0) { - throw new RuntimeException("实际采购金额必须大于0"); + throw buildValidationException(MAIN_SHEET_NAME, List.of(sheetRowNum), "实际采购金额必须大于0"); } if (addDTO.getContractAmount() != null && addDTO.getContractAmount().compareTo(BigDecimal.ZERO) <= 0) { - throw new RuntimeException("合同金额必须大于0"); + throw buildValidationException(MAIN_SHEET_NAME, List.of(sheetRowNum), "合同金额必须大于0"); } if (addDTO.getSettlementAmount() != null && addDTO.getSettlementAmount().compareTo(BigDecimal.ZERO) <= 0) { - throw new RuntimeException("结算金额必须大于0"); + throw buildValidationException(MAIN_SHEET_NAME, List.of(sheetRowNum), "结算金额必须大于0"); + } + } + + private List buildSupplierEntities( + String purchaseId, + List supplierRows, + String userName + ) { + List normalizedRows = supplierRows == null + ? List.of() + : supplierRows.stream() + .filter(Objects::nonNull) + .filter(item -> hasAnySupplierValue(item.data())) + .toList(); + + List winnerRowNums = new ArrayList<>(); + Map supplierKeyRowMap = new LinkedHashMap<>(); + List result = new ArrayList<>(); + for (SupplierImportRow supplierImportRow : normalizedRows) { + CcdiPurchaseTransactionSupplierExcel supplierRow = supplierImportRow.data(); + int isBidWinner = validateSupplierRow(supplierImportRow); + if (isBidWinner == 1) { + winnerRowNums.add(supplierImportRow.sheetRowNum()); + } + + String duplicateKey = StringUtils.trimToEmpty(supplierRow.getSupplierName()) + "|" + + StringUtils.trimToEmpty(supplierRow.getSupplierUscc()); + Integer firstRowNum = supplierKeyRowMap.putIfAbsent(duplicateKey, supplierImportRow.sheetRowNum()); + if (firstRowNum != null) { + throw buildValidationException( + SUPPLIER_SHEET_NAME, + List.of(firstRowNum, supplierImportRow.sheetRowNum()), + String.format("采购事项ID[%s]存在重复供应商", purchaseId) + ); + } + + CcdiPurchaseTransactionSupplier supplier = new CcdiPurchaseTransactionSupplier(); + supplier.setPurchaseId(purchaseId); + supplier.setSupplierName(StringUtils.trim(supplierRow.getSupplierName())); + supplier.setSupplierUscc(StringUtils.trimToNull(supplierRow.getSupplierUscc())); + supplier.setContactPerson(StringUtils.trimToNull(supplierRow.getContactPerson())); + supplier.setContactPhone(StringUtils.trimToNull(supplierRow.getContactPhone())); + supplier.setSupplierBankAccount(StringUtils.trimToNull(supplierRow.getSupplierBankAccount())); + supplier.setIsBidWinner(isBidWinner); + supplier.setSortOrder(supplierRow.getSortOrder() == null ? result.size() + 1 : supplierRow.getSortOrder()); + supplier.setCreatedBy(userName); + supplier.setUpdatedBy(userName); + result.add(supplier); + } + if (winnerRowNums.size() > 1) { + throw buildValidationException( + SUPPLIER_SHEET_NAME, + winnerRowNums, + String.format("采购事项ID[%s]存在多条中标供应商", purchaseId) + ); + } + return result; + } + + private int validateSupplierRow(SupplierImportRow supplierImportRow) { + CcdiPurchaseTransactionSupplierExcel supplierRow = supplierImportRow.data(); + if (StringUtils.isEmpty(supplierRow.getSupplierName())) { + throw buildValidationException(SUPPLIER_SHEET_NAME, List.of(supplierImportRow.sheetRowNum()), "供应商名称不能为空"); + } + if (StringUtils.length(supplierRow.getSupplierName()) > 200) { + throw buildValidationException(SUPPLIER_SHEET_NAME, List.of(supplierImportRow.sheetRowNum()), "供应商名称长度不能超过200个字符"); + } + if (StringUtils.length(supplierRow.getContactPerson()) > 50) { + throw buildValidationException(SUPPLIER_SHEET_NAME, List.of(supplierImportRow.sheetRowNum()), "供应商联系人长度不能超过50个字符"); + } + if (StringUtils.length(supplierRow.getSupplierBankAccount()) > 50) { + throw buildValidationException(SUPPLIER_SHEET_NAME, List.of(supplierImportRow.sheetRowNum()), "供应商银行账户长度不能超过50个字符"); + } + if (StringUtils.isNotEmpty(supplierRow.getContactPhone()) + && !supplierRow.getContactPhone().matches("^1[3-9]\\d{9}$|^0\\d{2,3}-?\\d{7,8}$")) { + throw buildValidationException(SUPPLIER_SHEET_NAME, List.of(supplierImportRow.sheetRowNum()), "供应商联系电话格式不正确"); + } + if (StringUtils.isNotEmpty(supplierRow.getSupplierUscc()) + && !supplierRow.getSupplierUscc().matches("^[0-9A-HJ-NPQRTUWXY]{2}\\d{6}[0-9A-HJ-NPQRTUWXY]{10}$")) { + throw buildValidationException(SUPPLIER_SHEET_NAME, List.of(supplierImportRow.sheetRowNum()), "供应商统一信用代码格式不正确"); + } + return parseIsBidWinner(supplierRow.getIsBidWinner(), supplierImportRow.sheetRowNum()); + } + + private boolean hasAnySupplierValue(CcdiPurchaseTransactionSupplierExcel supplierRow) { + return StringUtils.isNotEmpty(supplierRow.getPurchaseId()) + || StringUtils.isNotEmpty(supplierRow.getSupplierName()) + || StringUtils.isNotEmpty(supplierRow.getSupplierUscc()) + || StringUtils.isNotEmpty(supplierRow.getContactPerson()) + || StringUtils.isNotEmpty(supplierRow.getContactPhone()) + || StringUtils.isNotEmpty(supplierRow.getSupplierBankAccount()) + || StringUtils.isNotEmpty(supplierRow.getIsBidWinner()) + || supplierRow.getSortOrder() != null; + } + + private int parseIsBidWinner(String rawValue, Integer sheetRowNum) { + if (StringUtils.isEmpty(rawValue)) { + return 0; + } + String normalized = StringUtils.trim(rawValue); + if ("1".equals(normalized) || "是".equals(normalized) || "Y".equalsIgnoreCase(normalized) + || "TRUE".equalsIgnoreCase(normalized)) { + return 1; + } + if ("0".equals(normalized) || "否".equals(normalized) || "N".equalsIgnoreCase(normalized) + || "FALSE".equalsIgnoreCase(normalized)) { + return 0; + } + throw buildValidationException( + SUPPLIER_SHEET_NAME, + sheetRowNum == null ? List.of() : List.of(sheetRowNum), + "是否中标仅支持填写“是/否”或“1/0”" + ); + } + + private void fillWinnerSummary( + CcdiPurchaseTransaction transaction, + List supplierList + ) { + CcdiPurchaseTransactionSupplier winner = supplierList.stream() + .filter(item -> Objects.equals(item.getIsBidWinner(), 1)) + .findFirst() + .orElse(null); + if (winner == null) { + transaction.setSupplierName(null); + transaction.setSupplierUscc(null); + transaction.setContactPerson(null); + transaction.setContactPhone(null); + transaction.setSupplierBankAccount(null); + return; + } + transaction.setSupplierName(winner.getSupplierName()); + transaction.setSupplierUscc(winner.getSupplierUscc()); + transaction.setContactPerson(winner.getContactPerson()); + transaction.setContactPhone(winner.getContactPhone()); + transaction.setSupplierBankAccount(winner.getSupplierBankAccount()); + } + + private PurchaseTransactionImportFailureVO buildFailure( + CcdiPurchaseTransactionExcel mainExcel, + String purchaseId, + String sheetName, + String sheetRowNum, + String errorMessage + ) { + PurchaseTransactionImportFailureVO failure = new PurchaseTransactionImportFailureVO(); + if (mainExcel != null) { + BeanUtils.copyProperties(mainExcel, failure); + } + failure.setSheetName(sheetName); + failure.setSheetRowNum(sheetRowNum); + if (StringUtils.isNotEmpty(purchaseId)) { + failure.setPurchaseId(purchaseId); + } + failure.setErrorMessage(errorMessage); + return failure; + } + + private int countImportUnits( + List mainExcelList, + List supplierExcelList + ) { + LinkedHashSet purchaseIds = new LinkedHashSet<>(); + purchaseIds.addAll( + mainExcelList.stream() + .map(CcdiPurchaseTransactionExcel::getPurchaseId) + .filter(StringUtils::isNotEmpty) + .collect(Collectors.toCollection(LinkedHashSet::new)) + ); + purchaseIds.addAll( + supplierExcelList.stream() + .map(CcdiPurchaseTransactionSupplierExcel::getPurchaseId) + .filter(StringUtils::isNotEmpty) + .collect(Collectors.toCollection(LinkedHashSet::new)) + ); + return purchaseIds.size(); + } + + private List buildMainImportRows(List mainExcelList) { + List rows = new ArrayList<>(); + for (int i = 0; i < mainExcelList.size(); i++) { + rows.add(new MainImportRow(mainExcelList.get(i), i + EXCEL_DATA_START_ROW)); + } + return rows; + } + + private List buildSupplierImportRows(List supplierExcelList) { + List rows = new ArrayList<>(); + for (int i = 0; i < supplierExcelList.size(); i++) { + rows.add(new SupplierImportRow(supplierExcelList.get(i), i + EXCEL_DATA_START_ROW)); + } + return rows; + } + + private List extractMainRowNums(List rows) { + return rows.stream().map(MainImportRow::sheetRowNum).toList(); + } + + private List extractSupplierRowNums(List rows) { + return rows.stream().map(SupplierImportRow::sheetRowNum).toList(); + } + + private ImportValidationException buildValidationException(String sheetName, List rowNums, String message) { + return new ImportValidationException(sheetName, formatSheetRowNum(rowNums), message); + } + + private FailureMeta resolveFailureMeta( + Exception exception, + List mainRows, + List supplierRows + ) { + if (exception instanceof ImportValidationException validationException) { + return new FailureMeta(validationException.getSheetName(), validationException.getSheetRowNum()); + } + if (!mainRows.isEmpty()) { + return new FailureMeta(MAIN_SHEET_NAME, formatSheetRowNum(extractMainRowNums(mainRows))); + } + if (!supplierRows.isEmpty()) { + return new FailureMeta(SUPPLIER_SHEET_NAME, formatSheetRowNum(extractSupplierRowNums(supplierRows))); + } + return new FailureMeta("", ""); + } + + private String formatSheetRowNum(List rowNums) { + if (rowNums == null || rowNums.isEmpty()) { + return ""; + } + return rowNums.stream() + .filter(Objects::nonNull) + .distinct() + .sorted() + .map(String::valueOf) + .collect(Collectors.joining("、")); + } + + private record MainImportRow(CcdiPurchaseTransactionExcel data, int sheetRowNum) {} + + private record SupplierImportRow(CcdiPurchaseTransactionSupplierExcel data, int sheetRowNum) {} + + private record FailureMeta(String sheetName, String sheetRowNum) {} + + private static class ImportValidationException extends RuntimeException { + + private final String sheetName; + private final String sheetRowNum; + + private ImportValidationException(String sheetName, String sheetRowNum, String message) { + super(message); + this.sheetName = sheetName; + this.sheetRowNum = sheetRowNum; + } + + public String getSheetName() { + return sheetName; + } + + public String getSheetRowNum() { + return sheetRowNum; } } } diff --git a/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/impl/CcdiPurchaseTransactionServiceImpl.java b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/impl/CcdiPurchaseTransactionServiceImpl.java index bbe61d79..1b6cd039 100644 --- a/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/impl/CcdiPurchaseTransactionServiceImpl.java +++ b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/impl/CcdiPurchaseTransactionServiceImpl.java @@ -2,24 +2,34 @@ package com.ruoyi.info.collection.service.impl; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.ruoyi.info.collection.domain.CcdiPurchaseTransaction; +import com.ruoyi.info.collection.domain.CcdiPurchaseTransactionSupplier; import com.ruoyi.info.collection.domain.dto.CcdiPurchaseTransactionAddDTO; import com.ruoyi.info.collection.domain.dto.CcdiPurchaseTransactionEditDTO; import com.ruoyi.info.collection.domain.dto.CcdiPurchaseTransactionQueryDTO; +import com.ruoyi.info.collection.domain.dto.CcdiPurchaseTransactionSupplierDTO; import com.ruoyi.info.collection.domain.excel.CcdiPurchaseTransactionExcel; +import com.ruoyi.info.collection.domain.excel.CcdiPurchaseTransactionSupplierExcel; import com.ruoyi.info.collection.domain.vo.CcdiPurchaseTransactionVO; +import com.ruoyi.info.collection.domain.vo.CcdiPurchaseTransactionSupplierVO; import com.ruoyi.info.collection.mapper.CcdiPurchaseTransactionMapper; +import com.ruoyi.info.collection.mapper.CcdiPurchaseTransactionSupplierMapper; import com.ruoyi.info.collection.service.ICcdiPurchaseTransactionImportService; import com.ruoyi.info.collection.service.ICcdiPurchaseTransactionService; import com.ruoyi.common.utils.SecurityUtils; import com.ruoyi.common.utils.StringUtils; import jakarta.annotation.Resource; import org.springframework.beans.BeanUtils; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.util.ArrayList; import java.util.HashMap; +import java.util.LinkedHashSet; +import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.UUID; import java.util.concurrent.TimeUnit; import java.util.stream.Collectors; @@ -39,6 +49,9 @@ public class CcdiPurchaseTransactionServiceImpl implements ICcdiPurchaseTransact @Resource private ICcdiPurchaseTransactionImportService transactionImportService; + @Resource + private CcdiPurchaseTransactionSupplierMapper supplierMapper; + @Resource private RedisTemplate redisTemplate; @@ -93,7 +106,14 @@ public class CcdiPurchaseTransactionServiceImpl implements ICcdiPurchaseTransact */ @Override public CcdiPurchaseTransactionVO selectTransactionById(String purchaseId) { - return transactionMapper.selectTransactionById(purchaseId); + CcdiPurchaseTransactionVO detail = transactionMapper.selectTransactionById(purchaseId); + if (detail == null) { + return null; + } + List supplierList = selectSupplierListByPurchaseId(purchaseId); + detail.setSupplierList(supplierList); + detail.setSupplierCount(supplierList.size()); + return detail; } /** @@ -110,9 +130,12 @@ public class CcdiPurchaseTransactionServiceImpl implements ICcdiPurchaseTransact throw new RuntimeException("该采购事项ID已存在"); } + List supplierList = buildSupplierEntities(addDTO.getPurchaseId(), addDTO.getSupplierList()); CcdiPurchaseTransaction transaction = new CcdiPurchaseTransaction(); BeanUtils.copyProperties(addDTO, transaction); + fillWinnerSummary(transaction, supplierList); int result = transactionMapper.insert(transaction); + saveSuppliers(supplierList); return result; } @@ -126,9 +149,13 @@ public class CcdiPurchaseTransactionServiceImpl implements ICcdiPurchaseTransact @Override @Transactional public int updateTransaction(CcdiPurchaseTransactionEditDTO editDTO) { + List supplierList = buildSupplierEntities(editDTO.getPurchaseId(), editDTO.getSupplierList()); CcdiPurchaseTransaction transaction = new CcdiPurchaseTransaction(); BeanUtils.copyProperties(editDTO, transaction); + fillWinnerSummary(transaction, supplierList); int result = transactionMapper.updateById(transaction); + transactionMapper.deleteSuppliersByPurchaseIds(List.of(editDTO.getPurchaseId())); + saveSuppliers(supplierList); return result; } @@ -142,19 +169,24 @@ public class CcdiPurchaseTransactionServiceImpl implements ICcdiPurchaseTransact @Override @Transactional public int deleteTransactionByIds(String[] purchaseIds) { + transactionMapper.deleteSuppliersByPurchaseIds(List.of(purchaseIds)); return transactionMapper.deleteBatchIds(java.util.List.of(purchaseIds)); } /** * 导入采购交易数据(异步) * - * @param excelList Excel实体列表 + * @param mainExcelList 主信息Excel实体列表 + * @param supplierExcelList 供应商明细Excel实体列表 * @return 任务ID */ @Override @Transactional - public String importTransaction(java.util.List excelList) { - if (StringUtils.isNull(excelList) || excelList.isEmpty()) { + public String importTransaction( + List mainExcelList, + List supplierExcelList + ) { + if ((mainExcelList == null || mainExcelList.isEmpty()) && (supplierExcelList == null || supplierExcelList.isEmpty())) { throw new RuntimeException("至少需要一条数据"); } @@ -170,7 +202,7 @@ public class CcdiPurchaseTransactionServiceImpl implements ICcdiPurchaseTransact Map statusData = new HashMap<>(); statusData.put("taskId", taskId); statusData.put("status", "PROCESSING"); - statusData.put("totalCount", excelList.size()); + statusData.put("totalCount", countImportUnits(mainExcelList, supplierExcelList)); statusData.put("successCount", 0); statusData.put("failureCount", 0); statusData.put("progress", 0); @@ -181,8 +213,134 @@ public class CcdiPurchaseTransactionServiceImpl implements ICcdiPurchaseTransact redisTemplate.expire(statusKey, 7, TimeUnit.DAYS); // 调用异步导入服务 - transactionImportService.importTransactionAsync(excelList, taskId, userName); + transactionImportService.importTransactionAsync(mainExcelList, supplierExcelList, taskId, userName); return taskId; } + + private int countImportUnits( + List mainExcelList, + List supplierExcelList + ) { + LinkedHashSet purchaseIds = new LinkedHashSet<>(); + if (mainExcelList != null) { + purchaseIds.addAll( + mainExcelList.stream() + .map(CcdiPurchaseTransactionExcel::getPurchaseId) + .filter(StringUtils::isNotEmpty) + .collect(Collectors.toCollection(LinkedHashSet::new)) + ); + } + if (supplierExcelList != null) { + purchaseIds.addAll( + supplierExcelList.stream() + .map(CcdiPurchaseTransactionSupplierExcel::getPurchaseId) + .filter(StringUtils::isNotEmpty) + .collect(Collectors.toCollection(LinkedHashSet::new)) + ); + } + return purchaseIds.isEmpty() ? 0 : purchaseIds.size(); + } + + private List buildSupplierEntities( + String purchaseId, + List supplierDTOList + ) { + List normalizedList = normalizeSupplierList(supplierDTOList); + validateSupplierList(normalizedList); + + List supplierList = new ArrayList<>(); + for (int i = 0; i < normalizedList.size(); i++) { + CcdiPurchaseTransactionSupplierDTO dto = normalizedList.get(i); + CcdiPurchaseTransactionSupplier supplier = new CcdiPurchaseTransactionSupplier(); + BeanUtils.copyProperties(dto, supplier); + supplier.setPurchaseId(purchaseId); + supplier.setIsBidWinner(Objects.equals(dto.getIsBidWinner(), 1) ? 1 : 0); + supplier.setSortOrder(dto.getSortOrder() == null ? i + 1 : dto.getSortOrder()); + supplierList.add(supplier); + } + return supplierList; + } + + private List normalizeSupplierList( + List supplierDTOList + ) { + if (supplierDTOList == null) { + return List.of(); + } + return supplierDTOList.stream() + .filter(Objects::nonNull) + .filter(this::hasAnySupplierValue) + .toList(); + } + + private boolean hasAnySupplierValue(CcdiPurchaseTransactionSupplierDTO supplierDTO) { + return StringUtils.isNotEmpty(supplierDTO.getSupplierName()) + || StringUtils.isNotEmpty(supplierDTO.getSupplierUscc()) + || StringUtils.isNotEmpty(supplierDTO.getContactPerson()) + || StringUtils.isNotEmpty(supplierDTO.getContactPhone()) + || StringUtils.isNotEmpty(supplierDTO.getSupplierBankAccount()) + || supplierDTO.getIsBidWinner() != null + || supplierDTO.getSortOrder() != null; + } + + private void validateSupplierList(List supplierList) { + long winnerCount = supplierList.stream() + .filter(item -> Objects.equals(item.getIsBidWinner(), 1)) + .count(); + if (winnerCount > 1) { + throw new RuntimeException("同一招投标事项仅允许维护一条中标供应商"); + } + + LinkedHashSet duplicateKeys = new LinkedHashSet<>(); + for (CcdiPurchaseTransactionSupplierDTO supplier : supplierList) { + String duplicateKey = StringUtils.trimToEmpty(supplier.getSupplierName()) + "|" + + StringUtils.trimToEmpty(supplier.getSupplierUscc()); + if (!duplicateKeys.add(duplicateKey)) { + throw new RuntimeException("同一招投标事项存在重复供应商,请检查供应商名称和统一信用代码"); + } + } + } + + private void fillWinnerSummary( + CcdiPurchaseTransaction transaction, + List supplierList + ) { + CcdiPurchaseTransactionSupplier winnerSupplier = supplierList.stream() + .filter(item -> Objects.equals(item.getIsBidWinner(), 1)) + .findFirst() + .orElse(null); + if (winnerSupplier == null) { + transaction.setSupplierName(null); + transaction.setSupplierUscc(null); + transaction.setContactPerson(null); + transaction.setContactPhone(null); + transaction.setSupplierBankAccount(null); + return; + } + transaction.setSupplierName(winnerSupplier.getSupplierName()); + transaction.setSupplierUscc(winnerSupplier.getSupplierUscc()); + transaction.setContactPerson(winnerSupplier.getContactPerson()); + transaction.setContactPhone(winnerSupplier.getContactPhone()); + transaction.setSupplierBankAccount(winnerSupplier.getSupplierBankAccount()); + } + + private void saveSuppliers(List supplierList) { + for (CcdiPurchaseTransactionSupplier supplier : supplierList) { + supplierMapper.insert(supplier); + } + } + + private List selectSupplierListByPurchaseId(String purchaseId) { + return supplierMapper.selectList( + new LambdaQueryWrapper() + .eq(CcdiPurchaseTransactionSupplier::getPurchaseId, purchaseId) + .orderByAsc(CcdiPurchaseTransactionSupplier::getSortOrder) + .orderByAsc(CcdiPurchaseTransactionSupplier::getId) + ).stream().map(entity -> { + CcdiPurchaseTransactionSupplierVO vo = new CcdiPurchaseTransactionSupplierVO(); + BeanUtils.copyProperties(entity, vo); + return vo; + }).collect(Collectors.toList()); + } } diff --git a/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/impl/CcdiStaffEnterpriseRelationImportServiceImpl.java b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/impl/CcdiStaffEnterpriseRelationImportServiceImpl.java index dfd55d3e..e80a01fc 100644 --- a/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/impl/CcdiStaffEnterpriseRelationImportServiceImpl.java +++ b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/impl/CcdiStaffEnterpriseRelationImportServiceImpl.java @@ -2,14 +2,14 @@ package com.ruoyi.info.collection.service.impl; import com.alibaba.fastjson2.JSON; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; -import com.ruoyi.info.collection.domain.CcdiBaseStaff; import com.ruoyi.info.collection.domain.CcdiStaffEnterpriseRelation; +import com.ruoyi.info.collection.domain.CcdiStaffFmyRelation; import com.ruoyi.info.collection.domain.dto.CcdiStaffEnterpriseRelationAddDTO; import com.ruoyi.info.collection.domain.excel.CcdiStaffEnterpriseRelationExcel; import com.ruoyi.info.collection.domain.vo.ImportResult; import com.ruoyi.info.collection.domain.vo.ImportStatusVO; import com.ruoyi.info.collection.domain.vo.StaffEnterpriseRelationImportFailureVO; -import com.ruoyi.info.collection.mapper.CcdiBaseStaffMapper; +import com.ruoyi.info.collection.mapper.CcdiStaffFmyRelationMapper; import com.ruoyi.info.collection.mapper.CcdiStaffEnterpriseRelationMapper; import com.ruoyi.info.collection.service.ICcdiStaffEnterpriseRelationImportService; import com.ruoyi.info.collection.utils.ImportLogUtils; @@ -29,7 +29,7 @@ import java.util.concurrent.TimeUnit; import java.util.stream.Collectors; /** - * 员工实体关系信息异步导入服务层处理 + * 员工亲属实体关联异步导入服务层处理 * * @author ruoyi * @date 2026-02-09 @@ -47,7 +47,7 @@ public class CcdiStaffEnterpriseRelationImportServiceImpl implements ICcdiStaffE private RedisTemplate redisTemplate; @Resource - private CcdiBaseStaffMapper baseStaffMapper; + private CcdiStaffFmyRelationMapper familyRelationMapper; @Override @Async @@ -56,37 +56,48 @@ public class CcdiStaffEnterpriseRelationImportServiceImpl implements ICcdiStaffE long startTime = System.currentTimeMillis(); // 记录导入开始 - ImportLogUtils.logImportStart(log, taskId, "员工实体关系", excelList.size(), userName); + ImportLogUtils.logImportStart(log, taskId, "员工亲属实体关联", excelList.size(), userName); List newRecords = new ArrayList<>(); List failures = new ArrayList<>(); - // 批量验证员工身份证号是否存在 Set excelPersonIds = excelList.stream() .map(CcdiStaffEnterpriseRelationExcel::getPersonId) .filter(StringUtils::isNotEmpty) .collect(Collectors.toSet()); - Set existingPersonIds = new HashSet<>(); + Map validFamilies = new HashMap<>(); + Set knownFamilyCertNos = new HashSet<>(); + Map familyNameMap = new HashMap<>(); if (!excelPersonIds.isEmpty()) { - ImportLogUtils.logBatchQueryStart(log, taskId, "员工身份证号", excelPersonIds.size()); + ImportLogUtils.logBatchQueryStart(log, taskId, "员工亲属关系", excelPersonIds.size()); - LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); - wrapper.select(CcdiBaseStaff::getIdCard) - .in(CcdiBaseStaff::getIdCard, excelPersonIds); + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.select( + CcdiStaffFmyRelation::getRelationCertNo, + CcdiStaffFmyRelation::getRelationName, + CcdiStaffFmyRelation::getPersonId, + CcdiStaffFmyRelation::getStatus, + CcdiStaffFmyRelation::getIsEmpFamily + ) + .in(CcdiStaffFmyRelation::getRelationCertNo, excelPersonIds); - List existingStaff = baseStaffMapper.selectList(wrapper); - existingPersonIds = existingStaff.stream() - .map(CcdiBaseStaff::getIdCard) - .collect(Collectors.toSet()); + List familyRelations = familyRelationMapper.selectList(wrapper); + for (CcdiStaffFmyRelation familyRelation : familyRelations) { + knownFamilyCertNos.add(familyRelation.getRelationCertNo()); + familyNameMap.putIfAbsent(familyRelation.getRelationCertNo(), familyRelation.getRelationName()); + if (Boolean.TRUE.equals(familyRelation.getIsEmpFamily()) && Integer.valueOf(1).equals(familyRelation.getStatus())) { + validFamilies.putIfAbsent(familyRelation.getRelationCertNo(), familyRelation); + } + } - ImportLogUtils.logBatchQueryComplete(log, taskId, "员工身份证号", existingPersonIds.size()); + ImportLogUtils.logBatchQueryComplete(log, taskId, "员工亲属关系", familyRelations.size()); } // 批量查询已存在的person_id + social_credit_code组合 - ImportLogUtils.logBatchQueryStart(log, taskId, "已存在的员工企业关系组合", excelList.size()); + ImportLogUtils.logBatchQueryStart(log, taskId, "已存在的员工亲属实体关联组合", excelList.size()); Set existingCombinations = getExistingCombinations(excelList); - ImportLogUtils.logBatchQueryComplete(log, taskId, "员工企业关系组合", existingCombinations.size()); + ImportLogUtils.logBatchQueryComplete(log, taskId, "员工亲属实体关联组合", existingCombinations.size()); // 用于跟踪Excel文件内已处理的组合 Set processedCombinations = new HashSet<>(); @@ -103,41 +114,18 @@ public class CcdiStaffEnterpriseRelationImportServiceImpl implements ICcdiStaffE // 验证数据 validateRelationData(addDTO); - // 身份证号存在性检查(在基本验证之后) - if (!existingPersonIds.contains(excel.getPersonId())) { - throw new RuntimeException(String.format( - "第%d行: 身份证号[%s]不存在于员工信息表中,请先添加员工信息", - i + 1, excel.getPersonId())); - } + CcdiStaffFmyRelation familyRelation = validFamilies.get(excel.getPersonId()); + CcdiStaffEnterpriseRelation relation = validateAndBuildEntity( + excel, + familyRelation, + knownFamilyCertNos, + existingCombinations, + processedCombinations, + userName + ); - String combination = excel.getPersonId() + "|" + excel.getSocialCreditCode(); - - CcdiStaffEnterpriseRelation relation = new CcdiStaffEnterpriseRelation(); - BeanUtils.copyProperties(excel, relation); - - if (existingCombinations.contains(combination)) { - // 组合已存在,直接报错 - throw new RuntimeException(String.format("身份证号[%s]和统一社会信用代码[%s]的组合已存在,请勿重复导入", - excel.getPersonId(), excel.getSocialCreditCode())); - } else if (processedCombinations.contains(combination)) { - // Excel文件内部重复 - throw new RuntimeException(String.format("身份证号[%s]和统一社会信用代码[%s]的组合在导入文件中重复,已跳过此条记录", - excel.getPersonId(), excel.getSocialCreditCode())); - } else { - relation.setCreatedBy(userName); - relation.setUpdatedBy(userName); - - // 设置默认值 - relation.setStatus(1); - relation.setIsEmployee(0); - relation.setIsEmpFamily(1); - relation.setIsCustomer(0); - relation.setIsCustFamily(0); - relation.setDataSource("IMPORT"); - - newRecords.add(relation); - processedCombinations.add(combination); // 标记为已处理 - } + newRecords.add(relation); + processedCombinations.add(excel.getPersonId() + "|" + excel.getSocialCreditCode()); // 记录进度 ImportLogUtils.logProgress(log, taskId, i + 1, excelList.size(), @@ -146,11 +134,12 @@ public class CcdiStaffEnterpriseRelationImportServiceImpl implements ICcdiStaffE } catch (Exception e) { StaffEnterpriseRelationImportFailureVO failure = new StaffEnterpriseRelationImportFailureVO(); BeanUtils.copyProperties(excel, failure); + failure.setRelationName(familyNameMap.get(excel.getPersonId())); failure.setErrorMessage(e.getMessage()); failures.add(failure); // 记录验证失败日志 - String keyData = String.format("身份证号=%s, 统一社会信用代码=%s, 企业名称=%s", + String keyData = String.format("亲属身份证号=%s, 统一社会信用代码=%s, 企业名称=%s", excel.getPersonId(), excel.getSocialCreditCode(), excel.getEnterpriseName()); ImportLogUtils.logValidationError(log, taskId, i + 1, e.getMessage(), keyData); } @@ -166,7 +155,7 @@ public class CcdiStaffEnterpriseRelationImportServiceImpl implements ICcdiStaffE // 保存失败记录到Redis if (!failures.isEmpty()) { try { - String failuresKey = "import:staffEnterpriseRelation:" + taskId + ":failures"; + String failuresKey = "import:staffEnterpriseRelation:" + taskId + ":failures"; redisTemplate.opsForValue().set(failuresKey, failures, 7, TimeUnit.DAYS); ImportLogUtils.logRedisOperation(log, taskId, "保存失败记录", failures.size()); } catch (Exception e) { @@ -185,7 +174,7 @@ public class CcdiStaffEnterpriseRelationImportServiceImpl implements ICcdiStaffE // 记录导入完成 long duration = System.currentTimeMillis() - startTime; - ImportLogUtils.logImportComplete(log, taskId, "员工实体关系", + ImportLogUtils.logImportComplete(log, taskId, "员工亲属实体关联", excelList.size(), result.getSuccessCount(), result.getFailureCount(), duration); } @@ -251,9 +240,9 @@ public class CcdiStaffEnterpriseRelationImportServiceImpl implements ICcdiStaffE statusData.put("endTime", System.currentTimeMillis()); if ("SUCCESS".equals(status)) { - statusData.put("message", "全部成功!共导入" + result.getTotalCount() + "条数据"); + statusData.put("message", "员工亲属实体关联导入全部成功!共导入" + result.getTotalCount() + "条数据"); } else { - statusData.put("message", "成功" + result.getSuccessCount() + "条,失败" + result.getFailureCount() + "条"); + statusData.put("message", "员工亲属实体关联导入成功" + result.getSuccessCount() + "条,失败" + result.getFailureCount() + "条"); } redisTemplate.opsForHash().putAll(key, statusData); @@ -297,14 +286,14 @@ public class CcdiStaffEnterpriseRelationImportServiceImpl implements ICcdiStaffE } /** - * 验证员工实体关系数据 + * 验证员工亲属实体关联基础数据 * * @param addDTO 新增DTO */ private void validateRelationData(CcdiStaffEnterpriseRelationAddDTO addDTO) { // 验证必填字段 if (StringUtils.isEmpty(addDTO.getPersonId())) { - throw new RuntimeException("身份证号不能为空"); + throw new RuntimeException("亲属身份证号不能为空"); } if (StringUtils.isEmpty(addDTO.getSocialCreditCode())) { throw new RuntimeException("统一社会信用代码不能为空"); @@ -313,9 +302,9 @@ public class CcdiStaffEnterpriseRelationImportServiceImpl implements ICcdiStaffE throw new RuntimeException("企业名称不能为空"); } - // 验证身份证号格式(18位) + // 验证亲属身份证号格式(18位) if (!addDTO.getPersonId().matches("^[1-9]\\d{5}(18|19|20)\\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\\d|3[01])\\d{3}[0-9Xx]$")) { - throw new RuntimeException("身份证号格式不正确,必须为18位有效身份证号"); + throw new RuntimeException("亲属身份证号格式不正确,必须为18位有效身份证号"); } // 验证统一社会信用代码格式(18位) @@ -331,4 +320,38 @@ public class CcdiStaffEnterpriseRelationImportServiceImpl implements ICcdiStaffE throw new RuntimeException("企业名称长度不能超过200个字符"); } } + + CcdiStaffEnterpriseRelation validateAndBuildEntity(CcdiStaffEnterpriseRelationExcel excel, + CcdiStaffFmyRelation familyRelation, + Set knownFamilyCertNos, + Set existingCombinations, + Set processedCombinations, + String userName) { + String combination = excel.getPersonId() + "|" + excel.getSocialCreditCode(); + + if (familyRelation == null) { + if (knownFamilyCertNos.contains(excel.getPersonId())) { + throw new RuntimeException("亲属身份证号[" + excel.getPersonId() + "]不是有效员工亲属,请先维护有效的员工亲属关系"); + } + throw new RuntimeException("亲属身份证号[" + excel.getPersonId() + "]不存在,请先维护员工亲属关系"); + } + if (existingCombinations.contains(combination)) { + throw new RuntimeException("亲属身份证号[" + excel.getPersonId() + "]和统一社会信用代码[" + excel.getSocialCreditCode() + "]的组合已存在,请勿重复导入"); + } + if (processedCombinations.contains(combination)) { + throw new RuntimeException("亲属身份证号[" + excel.getPersonId() + "]和统一社会信用代码[" + excel.getSocialCreditCode() + "]的组合在导入文件中重复,已跳过此条记录"); + } + + CcdiStaffEnterpriseRelation relation = new CcdiStaffEnterpriseRelation(); + BeanUtils.copyProperties(excel, relation); + relation.setCreatedBy(userName); + relation.setUpdatedBy(userName); + relation.setStatus(1); + relation.setIsEmployee(0); + relation.setIsEmpFamily(1); + relation.setIsCustomer(0); + relation.setIsCustFamily(0); + relation.setDataSource("IMPORT"); + return relation; + } } diff --git a/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/impl/CcdiStaffEnterpriseRelationServiceImpl.java b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/impl/CcdiStaffEnterpriseRelationServiceImpl.java index 6c6fa0fb..69e2c8da 100644 --- a/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/impl/CcdiStaffEnterpriseRelationServiceImpl.java +++ b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/impl/CcdiStaffEnterpriseRelationServiceImpl.java @@ -3,11 +3,14 @@ package com.ruoyi.info.collection.service.impl; import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.ruoyi.info.collection.domain.CcdiStaffEnterpriseRelation; +import com.ruoyi.info.collection.domain.CcdiStaffFmyRelation; import com.ruoyi.info.collection.domain.dto.CcdiStaffEnterpriseRelationAddDTO; import com.ruoyi.info.collection.domain.dto.CcdiStaffEnterpriseRelationEditDTO; import com.ruoyi.info.collection.domain.dto.CcdiStaffEnterpriseRelationQueryDTO; import com.ruoyi.info.collection.domain.excel.CcdiStaffEnterpriseRelationExcel; +import com.ruoyi.info.collection.domain.vo.CcdiStaffEnterpriseRelationOptionVO; import com.ruoyi.info.collection.domain.vo.CcdiStaffEnterpriseRelationVO; +import com.ruoyi.info.collection.mapper.CcdiStaffFmyRelationMapper; import com.ruoyi.info.collection.mapper.CcdiStaffEnterpriseRelationMapper; import com.ruoyi.info.collection.service.ICcdiStaffEnterpriseRelationImportService; import com.ruoyi.info.collection.service.ICcdiStaffEnterpriseRelationService; @@ -37,6 +40,9 @@ public class CcdiStaffEnterpriseRelationServiceImpl implements ICcdiStaffEnterpr @Resource private CcdiStaffEnterpriseRelationMapper relationMapper; + @Resource + private CcdiStaffFmyRelationMapper familyRelationMapper; + @Resource private ICcdiStaffEnterpriseRelationImportService relationImportService; @@ -86,6 +92,11 @@ public class CcdiStaffEnterpriseRelationServiceImpl implements ICcdiStaffEnterpr }).collect(Collectors.toList()); } + @Override + public java.util.List selectFamilyOptions(String query) { + return relationMapper.selectFamilyOptions(query); + } + /** * 查询员工实体关系详情 * @@ -106,16 +117,15 @@ public class CcdiStaffEnterpriseRelationServiceImpl implements ICcdiStaffEnterpr @Override @Transactional public int insertRelation(CcdiStaffEnterpriseRelationAddDTO addDTO) { - // 检查身份证号+统一社会信用代码唯一性 + validateEffectiveFamily(addDTO.getPersonId()); + if (relationMapper.existsByPersonIdAndSocialCreditCode(addDTO.getPersonId(), addDTO.getSocialCreditCode())) { - throw new RuntimeException("该身份证号和统一社会信用代码组合已存在"); + throw new RuntimeException("该亲属身份证号和统一社会信用代码组合已存在"); } CcdiStaffEnterpriseRelation relation = new CcdiStaffEnterpriseRelation(); BeanUtils.copyProperties(addDTO, relation); - // 设置默认值 - // 新增时强制设置状态为有效 relation.setStatus(1); if (relation.getIsEmployee() == null) { @@ -159,7 +169,7 @@ public class CcdiStaffEnterpriseRelationServiceImpl implements ICcdiStaffEnterpr updateWrapper.set(editDTO.getRemark() != null, CcdiStaffEnterpriseRelation::getRemark, editDTO.getRemark()); // 注意:以下字段不可修改 - // - personId(身份证号,业务主键) + // - personId(亲属身份证号,业务主键) // - socialCreditCode(统一社会信用代码,业务主键) // - dataSource(数据来源,系统字段) // - isEmployee(是否为员工,系统字段) @@ -224,4 +234,28 @@ public class CcdiStaffEnterpriseRelationServiceImpl implements ICcdiStaffEnterpr return taskId; } + + private CcdiStaffFmyRelation validateEffectiveFamily(String familyCertNo) { + CcdiStaffFmyRelation validFamily = familyRelationMapper.selectOne( + new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper() + .eq(CcdiStaffFmyRelation::getRelationCertNo, familyCertNo) + .eq(CcdiStaffFmyRelation::getIsEmpFamily, Boolean.TRUE) + .eq(CcdiStaffFmyRelation::getStatus, 1) + .last("LIMIT 1") + ); + if (validFamily != null) { + return validFamily; + } + + CcdiStaffFmyRelation existingFamily = familyRelationMapper.selectOne( + new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper() + .select(CcdiStaffFmyRelation::getId) + .eq(CcdiStaffFmyRelation::getRelationCertNo, familyCertNo) + .last("LIMIT 1") + ); + if (existingFamily == null) { + throw new RuntimeException("亲属身份证号[" + familyCertNo + "]不存在,请先维护员工亲属关系"); + } + throw new RuntimeException("亲属身份证号[" + familyCertNo + "]不是有效员工亲属,请先维护有效的员工亲属关系"); + } } diff --git a/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/impl/CcdiStaffFmyRelationImportServiceImpl.java b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/impl/CcdiStaffFmyRelationImportServiceImpl.java index f8c16c78..5d7e3588 100644 --- a/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/impl/CcdiStaffFmyRelationImportServiceImpl.java +++ b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/impl/CcdiStaffFmyRelationImportServiceImpl.java @@ -41,6 +41,8 @@ import java.util.stream.Collectors; public class CcdiStaffFmyRelationImportServiceImpl implements ICcdiStaffFmyRelationImportService { private static final Logger log = LoggerFactory.getLogger(CcdiStaffFmyRelationImportServiceImpl.class); + private static final String SHEET_NAME = "员工亲属关系信息"; + private static final int EXCEL_DATA_START_ROW = 2; @Resource private CcdiStaffFmyRelationMapper relationMapper; @@ -168,6 +170,8 @@ public class CcdiStaffFmyRelationImportServiceImpl implements ICcdiStaffFmyRelat } catch (Exception e) { StaffFmyRelationImportFailureVO failure = new StaffFmyRelationImportFailureVO(); BeanUtils.copyProperties(excel, failure); + failure.setSheetName(SHEET_NAME); + failure.setRowNum(i + EXCEL_DATA_START_ROW); failure.setErrorMessage(e.getMessage()); failures.add(failure); diff --git a/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/impl/CcdiStaffFmyRelationServiceImpl.java b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/impl/CcdiStaffFmyRelationServiceImpl.java index 4eea61c2..29b14b10 100644 --- a/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/impl/CcdiStaffFmyRelationServiceImpl.java +++ b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/impl/CcdiStaffFmyRelationServiceImpl.java @@ -3,12 +3,13 @@ package com.ruoyi.info.collection.service.impl; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.ruoyi.info.collection.domain.CcdiAssetInfo; import com.ruoyi.info.collection.domain.CcdiStaffFmyRelation; -import com.ruoyi.info.collection.domain.vo.CcdiAssetInfoVO; import com.ruoyi.info.collection.domain.dto.CcdiStaffFmyRelationAddDTO; import com.ruoyi.info.collection.domain.dto.CcdiStaffFmyRelationEditDTO; import com.ruoyi.info.collection.domain.dto.CcdiStaffFmyRelationQueryDTO; import com.ruoyi.info.collection.domain.excel.CcdiStaffFmyRelationExcel; +import com.ruoyi.info.collection.domain.vo.CcdiAssetInfoVO; import com.ruoyi.info.collection.domain.vo.CcdiStaffFmyRelationVO; +import com.ruoyi.info.collection.mapper.CcdiStaffEnterpriseRelationMapper; import com.ruoyi.info.collection.mapper.CcdiStaffFmyRelationMapper; import com.ruoyi.info.collection.service.ICcdiAssetInfoService; import com.ruoyi.info.collection.service.ICcdiStaffFmyRelationImportService; @@ -50,6 +51,9 @@ public class CcdiStaffFmyRelationServiceImpl implements ICcdiStaffFmyRelationSer @Resource private ICcdiAssetInfoService assetInfoService; + @Resource + private CcdiStaffEnterpriseRelationMapper staffEnterpriseRelationMapper; + /** * 查询员工亲属关系列表 * @@ -161,6 +165,9 @@ public class CcdiStaffFmyRelationServiceImpl implements ICcdiStaffFmyRelationSer CcdiStaffFmyRelation relation = new CcdiStaffFmyRelation(); BeanUtils.copyProperties(editDTO, relation); int result = relationMapper.updateById(relation); + if (Integer.valueOf(1).equals(existing.getStatus()) && Integer.valueOf(0).equals(editDTO.getStatus())) { + staffEnterpriseRelationMapper.invalidateByFamilyCertNo(existing.getRelationCertNo()); + } assetInfoService.replaceByFamilyIdAndPersonId(editDTO.getPersonId(), editDTO.getRelationCertNo(), editDTO.getAssetInfoList()); return result; } diff --git a/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/impl/CcdiStaffRecruitmentImportServiceImpl.java b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/impl/CcdiStaffRecruitmentImportServiceImpl.java index 697c3a84..86c82b95 100644 --- a/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/impl/CcdiStaffRecruitmentImportServiceImpl.java +++ b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/impl/CcdiStaffRecruitmentImportServiceImpl.java @@ -2,6 +2,8 @@ package com.ruoyi.info.collection.service.impl; import com.alibaba.fastjson2.JSON; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.ruoyi.common.utils.IdCardUtil; +import com.ruoyi.common.utils.StringUtils; import com.ruoyi.info.collection.domain.CcdiStaffRecruitment; import com.ruoyi.info.collection.domain.CcdiStaffRecruitmentWork; import com.ruoyi.info.collection.domain.dto.CcdiStaffRecruitmentAddDTO; @@ -16,9 +18,19 @@ import com.ruoyi.info.collection.mapper.CcdiStaffRecruitmentMapper; import com.ruoyi.info.collection.mapper.CcdiStaffRecruitmentWorkMapper; import com.ruoyi.info.collection.service.ICcdiStaffRecruitmentImportService; import com.ruoyi.info.collection.utils.ImportLogUtils; -import com.ruoyi.common.utils.IdCardUtil; -import com.ruoyi.common.utils.StringUtils; import jakarta.annotation.Resource; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.BeanUtils; @@ -28,10 +40,6 @@ 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实现 * @@ -44,6 +52,10 @@ public class CcdiStaffRecruitmentImportServiceImpl implements ICcdiStaffRecruitm private static final Logger log = LoggerFactory.getLogger(CcdiStaffRecruitmentImportServiceImpl.class); + private static final String MAIN_SHEET_NAME = "招聘信息"; + private static final String WORK_SHEET_NAME = "历史工作经历"; + private static final int EXCEL_DATA_START_ROW = 2; + @Resource private CcdiStaffRecruitmentMapper recruitmentMapper; @@ -56,181 +68,56 @@ public class CcdiStaffRecruitmentImportServiceImpl implements ICcdiStaffRecruitm @Override @Async @Transactional - public void importRecruitmentAsync(List excelList, + public void importRecruitmentAsync(List recruitmentList, + List workList, String taskId, String userName) { + List safeRecruitmentList = recruitmentList == null + ? Collections.emptyList() + : recruitmentList; + List safeWorkList = workList == null + ? Collections.emptyList() + : workList; + int totalCount = safeRecruitmentList.size() + safeWorkList.size(); long startTime = System.currentTimeMillis(); - // 记录导入开始 - ImportLogUtils.logImportStart(log, taskId, "招聘信息", excelList.size(), userName); + ImportLogUtils.logImportStart(log, taskId, "招聘信息双Sheet", totalCount, userName); - List newRecords = new ArrayList<>(); List failures = new ArrayList<>(); + List indexedMainRows = buildMainImportRows(safeRecruitmentList); + List indexedWorkRows = buildWorkImportRows(safeWorkList); - // 批量查询已存在的招聘记录编号 - ImportLogUtils.logBatchQueryStart(log, taskId, "已存在的招聘记录编号", excelList.size()); - Set existingRecruitIds = getExistingRecruitIds(excelList); - ImportLogUtils.logBatchQueryComplete(log, taskId, "招聘记录编号", existingRecruitIds.size()); + MainImportResult mainImportResult = importMainSheet(indexedMainRows, failures, userName, taskId); + int workSuccessCount = importWorkSheet( + indexedWorkRows, + mainImportResult.importedRecruitmentMap(), + failures, + userName, + taskId + ); - // 用于检测Excel内部的重复ID - Set excelProcessedIds = new HashSet<>(); - - // 分类数据 - for (int i = 0; i < excelList.size(); i++) { - CcdiStaffRecruitmentExcel excel = excelList.get(i); - - try { - // 转换为AddDTO进行验证 - CcdiStaffRecruitmentAddDTO addDTO = new CcdiStaffRecruitmentAddDTO(); - BeanUtils.copyProperties(excel, addDTO); - addDTO.setRecruitType(RecruitType.inferCode(addDTO.getRecruitName())); - - // 验证数据 - validateRecruitmentData(addDTO, existingRecruitIds); - - CcdiStaffRecruitment recruitment = new CcdiStaffRecruitment(); - BeanUtils.copyProperties(excel, recruitment); - recruitment.setRecruitType(addDTO.getRecruitType()); - - if (existingRecruitIds.contains(excel.getRecruitId())) { - // 招聘记录编号在数据库中已存在,直接报错 - throw new RuntimeException(String.format("招聘记录编号[%s]已存在,请勿重复导入", excel.getRecruitId())); - } else if (excelProcessedIds.contains(excel.getRecruitId())) { - // 招聘记录编号在Excel文件内部重复 - throw new RuntimeException(String.format("招聘记录编号[%s]在导入文件中重复,已跳过此条记录", excel.getRecruitId())); - } else { - recruitment.setCreatedBy(userName); - recruitment.setUpdatedBy(userName); - newRecords.add(recruitment); - excelProcessedIds.add(excel.getRecruitId()); // 标记为已处理 - } - - // 记录进度 - ImportLogUtils.logProgress(log, taskId, i + 1, excelList.size(), - newRecords.size(), failures.size()); - - } catch (Exception e) { - RecruitmentImportFailureVO failure = new RecruitmentImportFailureVO(); - BeanUtils.copyProperties(excel, failure); - failure.setErrorMessage(e.getMessage()); - failures.add(failure); - - // 记录验证失败日志 - String keyData = String.format("招聘记录编号=%s, 项目名称=%s, 应聘人员=%s", - excel.getRecruitId(), excel.getRecruitName(), excel.getCandName()); - ImportLogUtils.logValidationError(log, taskId, i + 1, e.getMessage(), keyData); - } - } - - // 批量插入新数据 - if (!newRecords.isEmpty()) { - ImportLogUtils.logBatchOperationStart(log, taskId, "插入", - (newRecords.size() + 499) / 500, 500); - saveBatch(newRecords, 500); - } - - // 保存失败记录到Redis if (!failures.isEmpty()) { - try { - String failuresKey = "import:recruitment:" + taskId + ":failures"; - redisTemplate.opsForValue().set(failuresKey, failures, 7, TimeUnit.DAYS); - ImportLogUtils.logRedisOperation(log, taskId, "保存失败记录", failures.size()); - } catch (Exception e) { - ImportLogUtils.logRedisError(log, taskId, "保存失败记录", e); - } + saveFailures(taskId, failures); } ImportResult result = new ImportResult(); - result.setTotalCount(excelList.size()); - result.setSuccessCount(newRecords.size()); - result.setFailureCount(failures.size()); + result.setTotalCount(totalCount); + result.setSuccessCount(mainImportResult.successCount() + workSuccessCount); + result.setFailureCount(Math.max(totalCount - result.getSuccessCount(), 0)); - // 更新最终状态 - String finalStatus = result.getFailureCount() == 0 ? "SUCCESS" : "PARTIAL_SUCCESS"; - updateImportStatus(taskId, finalStatus, result); - - // 记录导入完成 - long duration = System.currentTimeMillis() - startTime; - ImportLogUtils.logImportComplete(log, taskId, "招聘信息", - excelList.size(), result.getSuccessCount(), result.getFailureCount(), duration); - } - - @Override - @Async - @Transactional - public void importRecruitmentWorkAsync(List excelList, - String taskId, - String userName) { - long startTime = System.currentTimeMillis(); - ImportLogUtils.logImportStart(log, taskId, "招聘历史工作经历", excelList.size(), userName); - - List failures = new ArrayList<>(); - List validRecords = new ArrayList<>(); - Set failedRecruitIds = new HashSet<>(); - Set processedRecruitSortKeys = new HashSet<>(); - - Map recruitmentMap = getRecruitmentMap(excelList); - - for (int i = 0; i < excelList.size(); i++) { - CcdiStaffRecruitmentWorkExcel excel = excelList.get(i); - try { - CcdiStaffRecruitment recruitment = recruitmentMap.get(trim(excel.getRecruitId())); - validateRecruitmentWorkData(excel, recruitment, processedRecruitSortKeys); - - CcdiStaffRecruitmentWork work = new CcdiStaffRecruitmentWork(); - BeanUtils.copyProperties(excel, work); - work.setRecruitId(trim(excel.getRecruitId())); - work.setCreatedBy(userName); - work.setUpdatedBy(userName); - validRecords.add(work); - - ImportLogUtils.logProgress(log, taskId, i + 1, excelList.size(), - validRecords.size(), failures.size()); - } catch (Exception e) { - failedRecruitIds.add(trim(excel.getRecruitId())); - failures.add(buildWorkFailure(excel, e.getMessage())); - String keyData = String.format("招聘记录编号=%s, 候选人=%s, 工作单位=%s", - excel.getRecruitId(), excel.getCandName(), excel.getCompanyName()); - ImportLogUtils.logValidationError(log, taskId, i + 1, e.getMessage(), keyData); - } - } - - List importRecords = validRecords.stream() - .filter(work -> !failedRecruitIds.contains(work.getRecruitId())) - .toList(); - appendSkippedFailures(validRecords, failedRecruitIds, failures); - - if (!importRecords.isEmpty()) { - Set importRecruitIds = importRecords.stream() - .map(CcdiStaffRecruitmentWork::getRecruitId) - .collect(Collectors.toSet()); - LambdaQueryWrapper deleteWrapper = new LambdaQueryWrapper<>(); - deleteWrapper.in(CcdiStaffRecruitmentWork::getRecruitId, importRecruitIds); - recruitmentWorkMapper.delete(deleteWrapper); - - importRecords.forEach(recruitmentWorkMapper::insert); - } - - if (!failures.isEmpty()) { - try { - String failuresKey = "import:recruitment:" + taskId + ":failures"; - redisTemplate.opsForValue().set(failuresKey, failures, 7, TimeUnit.DAYS); - ImportLogUtils.logRedisOperation(log, taskId, "保存失败记录", failures.size()); - } catch (Exception e) { - ImportLogUtils.logRedisError(log, taskId, "保存失败记录", e); - } - } - - ImportResult result = new ImportResult(); - result.setTotalCount(excelList.size()); - result.setSuccessCount(importRecords.size()); - result.setFailureCount(failures.size()); - String finalStatus = result.getFailureCount() == 0 ? "SUCCESS" : "PARTIAL_SUCCESS"; + String finalStatus = resolveFinalStatus(result); updateImportStatus(taskId, finalStatus, result); long duration = System.currentTimeMillis() - startTime; - ImportLogUtils.logImportComplete(log, taskId, "招聘历史工作经历", - excelList.size(), result.getSuccessCount(), result.getFailureCount(), duration); + ImportLogUtils.logImportComplete( + log, + taskId, + "招聘信息双Sheet", + totalCount, + result.getSuccessCount(), + result.getFailureCount(), + duration + ); } @Override @@ -270,14 +157,188 @@ public class CcdiStaffRecruitmentImportServiceImpl implements ICcdiStaffRecruitm return JSON.parseArray(JSON.toJSONString(failuresObj), RecruitmentImportFailureVO.class); } - /** - * 批量查询已存在的招聘记录编号 - */ - private Set getExistingRecruitIds(List excelList) { - List recruitIds = excelList.stream() - .map(CcdiStaffRecruitmentExcel::getRecruitId) - .filter(StringUtils::isNotEmpty) - .collect(Collectors.toList()); + private MainImportResult importMainSheet(List mainRows, + List failures, + String userName, + String taskId) { + if (mainRows.isEmpty()) { + return new MainImportResult(Collections.emptyMap(), 0); + } + + Set existingRecruitIds = getExistingRecruitIds( + mainRows.stream().map(MainImportRow::data).toList() + ); + Set processedRecruitIds = new HashSet<>(); + List newRecords = new ArrayList<>(); + Map importedRecruitmentMap = new LinkedHashMap<>(); + + for (int index = 0; index < mainRows.size(); index++) { + MainImportRow mainRow = mainRows.get(index); + CcdiStaffRecruitmentExcel excel = mainRow.data(); + try { + CcdiStaffRecruitmentAddDTO addDTO = new CcdiStaffRecruitmentAddDTO(); + BeanUtils.copyProperties(excel, addDTO); + addDTO.setRecruitType(RecruitType.inferCode(addDTO.getRecruitName())); + + validateRecruitmentData(addDTO, mainRow.sheetRowNum()); + + String recruitId = trim(excel.getRecruitId()); + if (existingRecruitIds.contains(recruitId)) { + throw buildValidationException( + MAIN_SHEET_NAME, + List.of(mainRow.sheetRowNum()), + String.format("招聘记录编号[%s]已存在,请勿重复导入", recruitId) + ); + } + if (!processedRecruitIds.add(recruitId)) { + throw buildValidationException( + MAIN_SHEET_NAME, + List.of(mainRow.sheetRowNum()), + String.format("招聘记录编号[%s]在导入文件中重复,已跳过此条记录", recruitId) + ); + } + + CcdiStaffRecruitment recruitment = new CcdiStaffRecruitment(); + BeanUtils.copyProperties(excel, recruitment); + recruitment.setRecruitId(recruitId); + recruitment.setRecruitType(addDTO.getRecruitType()); + recruitment.setCreatedBy(userName); + recruitment.setUpdatedBy(userName); + newRecords.add(recruitment); + importedRecruitmentMap.put(recruitId, recruitment); + + ImportLogUtils.logProgress(log, taskId, index + 1, mainRows.size(), newRecords.size(), failures.size()); + } catch (Exception exception) { + FailureMeta failureMeta = resolveFailureMeta(exception, List.of(mainRow.sheetRowNum()), MAIN_SHEET_NAME); + failures.add(buildFailure(excel, failureMeta.sheetName(), failureMeta.sheetRowNum(), exception.getMessage())); + ImportLogUtils.logValidationError( + log, + taskId, + index + 1, + exception.getMessage(), + String.format("招聘记录编号=%s, 项目名称=%s, 应聘人员=%s", excel.getRecruitId(), excel.getRecruitName(), excel.getCandName()) + ); + } + } + + if (!newRecords.isEmpty()) { + ImportLogUtils.logBatchOperationStart(log, taskId, "插入招聘信息", (newRecords.size() + 499) / 500, 500); + saveBatch(newRecords, 500); + } + + return new MainImportResult(importedRecruitmentMap, newRecords.size()); + } + + private int importWorkSheet(List workRows, + Map importedRecruitmentMap, + List failures, + String userName, + String taskId) { + if (workRows.isEmpty()) { + return 0; + } + + Map existingRecruitmentMap = + getExistingRecruitmentMap(workRows, importedRecruitmentMap); + Map> groupedRows = groupWorkRows(workRows); + int successCount = 0; + int processedGroups = 0; + + for (List recruitWorkRows : groupedRows.values()) { + processedGroups++; + WorkImportRow firstRow = recruitWorkRows.get(0); + String recruitId = trim(firstRow.data().getRecruitId()); + CcdiStaffRecruitment recruitment = importedRecruitmentMap.get(recruitId); + if (recruitment == null) { + recruitment = existingRecruitmentMap.get(recruitId); + } + + try { + validateWorkGroup(recruitWorkRows, recruitment); + + if (StringUtils.isNotEmpty(recruitId) && hasExistingWorkHistory(recruitId)) { + throw buildValidationException( + WORK_SHEET_NAME, + extractWorkRowNums(recruitWorkRows), + String.format("招聘记录编号[%s]已存在历史工作经历,不允许重复导入", recruitId) + ); + } + + List entities = buildWorkEntities(recruitWorkRows, userName); + entities.forEach(entity -> recruitmentWorkMapper.insert(entity)); + successCount += recruitWorkRows.size(); + + ImportLogUtils.logProgress(log, taskId, processedGroups, groupedRows.size(), successCount, failures.size()); + } catch (Exception exception) { + FailureMeta failureMeta = resolveFailureMeta(exception, extractWorkRowNums(recruitWorkRows), WORK_SHEET_NAME); + failures.add(buildFailure(firstRow.data(), failureMeta.sheetName(), failureMeta.sheetRowNum(), exception.getMessage())); + ImportLogUtils.logValidationError( + log, + taskId, + processedGroups, + exception.getMessage(), + String.format( + "招聘记录编号=%s, 候选人=%s, 工作单位=%s", + firstRow.data().getRecruitId(), + firstRow.data().getCandName(), + firstRow.data().getCompanyName() + ) + ); + } + } + + return successCount; + } + + private Map> groupWorkRows(List workRows) { + Map> groupedRows = new LinkedHashMap<>(); + for (WorkImportRow workRow : workRows) { + groupedRows.computeIfAbsent(buildWorkGroupKey(workRow), key -> new ArrayList<>()).add(workRow); + } + return groupedRows; + } + + private String buildWorkGroupKey(WorkImportRow workRow) { + String recruitId = trim(workRow.data().getRecruitId()); + if (StringUtils.isNotEmpty(recruitId)) { + return recruitId; + } + return "__ROW__" + workRow.sheetRowNum(); + } + + private Map getExistingRecruitmentMap(List workRows, + Map importedRecruitmentMap) { + LinkedHashSet recruitIds = workRows.stream() + .map(row -> trim(row.data().getRecruitId())) + .filter(StringUtils::isNotEmpty) + .filter(recruitId -> !importedRecruitmentMap.containsKey(recruitId)) + .collect(Collectors.toCollection(LinkedHashSet::new)); + if (recruitIds.isEmpty()) { + return Collections.emptyMap(); + } + List recruitments = recruitmentMapper.selectBatchIds(recruitIds); + return recruitments.stream().collect(Collectors.toMap(CcdiStaffRecruitment::getRecruitId, item -> item)); + } + + private List buildWorkEntities(List workRows, String userName) { + List entities = new ArrayList<>(); + for (WorkImportRow workRow : workRows) { + CcdiStaffRecruitmentWork entity = new CcdiStaffRecruitmentWork(); + BeanUtils.copyProperties(workRow.data(), entity); + entity.setRecruitId(trim(workRow.data().getRecruitId())); + entity.setCreatedBy(userName); + entity.setUpdatedBy(userName); + entities.add(entity); + } + return entities; + } + + private Set getExistingRecruitIds(List recruitmentList) { + List recruitIds = recruitmentList.stream() + .map(CcdiStaffRecruitmentExcel::getRecruitId) + .map(this::trim) + .filter(StringUtils::isNotEmpty) + .toList(); if (recruitIds.isEmpty()) { return Collections.emptySet(); @@ -288,148 +349,138 @@ public class CcdiStaffRecruitmentImportServiceImpl implements ICcdiStaffRecruitm List existingRecruitments = recruitmentMapper.selectList(wrapper); return existingRecruitments.stream() - .map(CcdiStaffRecruitment::getRecruitId) - .collect(Collectors.toSet()); + .map(CcdiStaffRecruitment::getRecruitId) + .collect(Collectors.toSet()); } - /** - * 验证招聘信息数据 - */ - private void validateRecruitmentData(CcdiStaffRecruitmentAddDTO addDTO, - Set existingRecruitIds) { - // 验证必填字段 + private boolean hasExistingWorkHistory(String recruitId) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(CcdiStaffRecruitmentWork::getRecruitId, recruitId); + return recruitmentWorkMapper.selectCount(wrapper) > 0; + } + + private void validateRecruitmentData(CcdiStaffRecruitmentAddDTO addDTO, int sheetRowNum) { if (StringUtils.isEmpty(addDTO.getRecruitId())) { - throw new RuntimeException("招聘记录编号不能为空"); + throw buildValidationException(MAIN_SHEET_NAME, List.of(sheetRowNum), "招聘记录编号不能为空"); } if (StringUtils.isEmpty(addDTO.getRecruitName())) { - throw new RuntimeException("招聘项目名称不能为空"); + throw buildValidationException(MAIN_SHEET_NAME, List.of(sheetRowNum), "招聘项目名称不能为空"); } if (StringUtils.isEmpty(addDTO.getPosName())) { - throw new RuntimeException("职位名称不能为空"); + throw buildValidationException(MAIN_SHEET_NAME, List.of(sheetRowNum), "职位名称不能为空"); } if (StringUtils.isEmpty(addDTO.getPosCategory())) { - throw new RuntimeException("职位类别不能为空"); + throw buildValidationException(MAIN_SHEET_NAME, List.of(sheetRowNum), "职位类别不能为空"); } if (StringUtils.isEmpty(addDTO.getPosDesc())) { - throw new RuntimeException("职位描述不能为空"); + throw buildValidationException(MAIN_SHEET_NAME, List.of(sheetRowNum), "职位描述不能为空"); } if (StringUtils.isEmpty(addDTO.getCandName())) { - throw new RuntimeException("应聘人员姓名不能为空"); + throw buildValidationException(MAIN_SHEET_NAME, List.of(sheetRowNum), "应聘人员姓名不能为空"); } if (StringUtils.isEmpty(addDTO.getCandEdu())) { - throw new RuntimeException("应聘人员学历不能为空"); + throw buildValidationException(MAIN_SHEET_NAME, List.of(sheetRowNum), "应聘人员学历不能为空"); } if (StringUtils.isEmpty(addDTO.getCandId())) { - throw new RuntimeException("证件号码不能为空"); + throw buildValidationException(MAIN_SHEET_NAME, List.of(sheetRowNum), "证件号码不能为空"); } if (StringUtils.isEmpty(addDTO.getCandSchool())) { - throw new RuntimeException("应聘人员毕业院校不能为空"); + throw buildValidationException(MAIN_SHEET_NAME, List.of(sheetRowNum), "应聘人员毕业院校不能为空"); } if (StringUtils.isEmpty(addDTO.getCandMajor())) { - throw new RuntimeException("应聘人员专业不能为空"); + throw buildValidationException(MAIN_SHEET_NAME, List.of(sheetRowNum), "应聘人员专业不能为空"); } if (StringUtils.isEmpty(addDTO.getCandGrad())) { - throw new RuntimeException("应聘人员毕业年月不能为空"); + throw buildValidationException(MAIN_SHEET_NAME, List.of(sheetRowNum), "应聘人员毕业年月不能为空"); } if (StringUtils.isEmpty(addDTO.getAdmitStatus())) { - throw new RuntimeException("录用情况不能为空"); + throw buildValidationException(MAIN_SHEET_NAME, List.of(sheetRowNum), "录用情况不能为空"); } if (StringUtils.isEmpty(addDTO.getRecruitType())) { - throw new RuntimeException("招聘类型不能为空"); + throw buildValidationException(MAIN_SHEET_NAME, List.of(sheetRowNum), "招聘类型不能为空"); } - // 验证证件号码格式 String idCardError = IdCardUtil.getErrorMessage(addDTO.getCandId()); if (idCardError != null) { - throw new RuntimeException("证件号码" + idCardError); + throw buildValidationException(MAIN_SHEET_NAME, List.of(sheetRowNum), "证件号码" + idCardError); } - // 验证毕业年月格式(YYYYMM) if (!addDTO.getCandGrad().matches("^((19|20)\\d{2})(0[1-9]|1[0-2])$")) { - throw new RuntimeException("毕业年月格式不正确,应为YYYYMM"); + throw buildValidationException(MAIN_SHEET_NAME, List.of(sheetRowNum), "毕业年月格式不正确,应为YYYYMM"); } - // 验证录用状态 if (AdmitStatus.getDescByCode(addDTO.getAdmitStatus()) == null) { - throw new RuntimeException("录用情况只能填写'录用'、'未录用'或'放弃'"); + throw buildValidationException(MAIN_SHEET_NAME, List.of(sheetRowNum), "录用情况只能填写'录用'、'未录用'或'放弃'"); } if (RecruitType.getDescByCode(addDTO.getRecruitType()) == null) { - throw new RuntimeException("招聘类型只能填写'SOCIAL'或'CAMPUS'"); + throw buildValidationException(MAIN_SHEET_NAME, List.of(sheetRowNum), "招聘类型只能填写'SOCIAL'或'CAMPUS'"); } } - private Map getRecruitmentMap(List excelList) { - List recruitIds = excelList.stream() - .map(CcdiStaffRecruitmentWorkExcel::getRecruitId) - .map(this::trim) - .filter(StringUtils::isNotEmpty) - .distinct() - .toList(); - if (recruitIds.isEmpty()) { - return Collections.emptyMap(); + private void validateWorkGroup(List workRows, CcdiStaffRecruitment recruitment) { + Set processedSortOrders = new HashSet<>(); + for (WorkImportRow workRow : workRows) { + validateRecruitmentWorkData(workRow.data(), recruitment, processedSortOrders, workRow.sheetRowNum()); } - List recruitments = recruitmentMapper.selectBatchIds(recruitIds); - return recruitments.stream() - .collect(Collectors.toMap(CcdiStaffRecruitment::getRecruitId, item -> item)); } private void validateRecruitmentWorkData(CcdiStaffRecruitmentWorkExcel excel, CcdiStaffRecruitment recruitment, - Set processedRecruitSortKeys) { + Set processedSortOrders, + int sheetRowNum) { if (StringUtils.isEmpty(trim(excel.getRecruitId()))) { - throw new RuntimeException("招聘记录编号不能为空"); + throw buildValidationException(WORK_SHEET_NAME, List.of(sheetRowNum), "招聘记录编号不能为空"); } if (StringUtils.isEmpty(trim(excel.getCandName()))) { - throw new RuntimeException("候选人姓名不能为空"); + throw buildValidationException(WORK_SHEET_NAME, List.of(sheetRowNum), "候选人姓名不能为空"); } if (StringUtils.isEmpty(trim(excel.getRecruitName()))) { - throw new RuntimeException("招聘项目名称不能为空"); + throw buildValidationException(WORK_SHEET_NAME, List.of(sheetRowNum), "招聘项目名称不能为空"); } if (StringUtils.isEmpty(trim(excel.getPosName()))) { - throw new RuntimeException("职位名称不能为空"); + throw buildValidationException(WORK_SHEET_NAME, List.of(sheetRowNum), "职位名称不能为空"); } if (excel.getSortOrder() == null || excel.getSortOrder() <= 0) { - throw new RuntimeException("排序号不能为空且必须大于0"); + throw buildValidationException(WORK_SHEET_NAME, List.of(sheetRowNum), "排序号不能为空且必须大于0"); + } + if (!processedSortOrders.add(excel.getSortOrder())) { + throw buildValidationException(WORK_SHEET_NAME, List.of(sheetRowNum), "同一招聘记录编号下排序号重复"); } if (StringUtils.isEmpty(trim(excel.getCompanyName()))) { - throw new RuntimeException("工作单位不能为空"); + throw buildValidationException(WORK_SHEET_NAME, List.of(sheetRowNum), "工作单位不能为空"); } if (StringUtils.isEmpty(trim(excel.getPositionName()))) { - throw new RuntimeException("岗位不能为空"); + throw buildValidationException(WORK_SHEET_NAME, List.of(sheetRowNum), "岗位不能为空"); } if (StringUtils.isEmpty(trim(excel.getJobStartMonth()))) { - throw new RuntimeException("入职年月不能为空"); + throw buildValidationException(WORK_SHEET_NAME, List.of(sheetRowNum), "入职年月不能为空"); } - validateMonth(excel.getJobStartMonth(), "入职年月"); + validateMonth(excel.getJobStartMonth(), "入职年月", sheetRowNum); if (StringUtils.isNotEmpty(trim(excel.getJobEndMonth()))) { - validateMonth(excel.getJobEndMonth(), "离职年月"); + validateMonth(excel.getJobEndMonth(), "离职年月", sheetRowNum); } if (recruitment == null) { - throw new RuntimeException("招聘记录编号不存在,请先维护招聘主信息"); + throw buildValidationException(WORK_SHEET_NAME, List.of(sheetRowNum), "招聘记录编号不存在,请先维护招聘主信息"); } if (!"SOCIAL".equals(recruitment.getRecruitType())) { - throw new RuntimeException("该招聘记录不是社招,不允许导入历史工作经历"); + throw buildValidationException(WORK_SHEET_NAME, List.of(sheetRowNum), "该招聘记录不是社招,不允许导入历史工作经历"); } if (!sameText(excel.getCandName(), recruitment.getCandName())) { - throw new RuntimeException("招聘记录编号与候选人姓名不匹配"); + throw buildValidationException(WORK_SHEET_NAME, List.of(sheetRowNum), "招聘记录编号与候选人姓名不匹配"); } if (!sameText(excel.getRecruitName(), recruitment.getRecruitName())) { - throw new RuntimeException("招聘记录编号与招聘项目名称不匹配"); + throw buildValidationException(WORK_SHEET_NAME, List.of(sheetRowNum), "招聘记录编号与招聘项目名称不匹配"); } if (!sameText(excel.getPosName(), recruitment.getPosName())) { - throw new RuntimeException("招聘记录编号与职位名称不匹配"); - } - String duplicateKey = trim(excel.getRecruitId()) + "#" + excel.getSortOrder(); - if (!processedRecruitSortKeys.add(duplicateKey)) { - throw new RuntimeException("同一招聘记录编号下排序号重复"); + throw buildValidationException(WORK_SHEET_NAME, List.of(sheetRowNum), "招聘记录编号与职位名称不匹配"); } } - private void validateMonth(String value, String fieldName) { + private void validateMonth(String value, String fieldName, int sheetRowNum) { String month = trim(value); if (!month.matches("^((19|20)\\d{2})-(0[1-9]|1[0-2])$")) { - throw new RuntimeException(fieldName + "格式不正确,应为YYYY-MM"); + throw buildValidationException(WORK_SHEET_NAME, List.of(sheetRowNum), fieldName + "格式不正确,应为YYYY-MM"); } } @@ -441,32 +492,50 @@ public class CcdiStaffRecruitmentImportServiceImpl implements ICcdiStaffRecruitm return value == null ? null : value.trim(); } - private RecruitmentImportFailureVO buildWorkFailure(CcdiStaffRecruitmentWorkExcel excel, String errorMessage) { + private void saveFailures(String taskId, List failures) { + try { + String failuresKey = "import:recruitment:" + taskId + ":failures"; + redisTemplate.opsForValue().set(failuresKey, failures, 7, TimeUnit.DAYS); + ImportLogUtils.logRedisOperation(log, taskId, "保存失败记录", failures.size()); + } catch (Exception exception) { + ImportLogUtils.logRedisError(log, taskId, "保存失败记录", exception); + } + } + + private RecruitmentImportFailureVO buildFailure(CcdiStaffRecruitmentExcel excel, + String sheetName, + String sheetRowNum, + String errorMessage) { RecruitmentImportFailureVO failure = new RecruitmentImportFailureVO(); BeanUtils.copyProperties(excel, failure); + failure.setSheetName(sheetName); + failure.setSheetRowNum(sheetRowNum); failure.setErrorMessage(errorMessage); return failure; } - private void appendSkippedFailures(List validRecords, - Set failedRecruitIds, - List failures) { - Set appendedRecruitIds = new HashSet<>(); - for (CcdiStaffRecruitmentWork work : validRecords) { - if (failedRecruitIds.contains(work.getRecruitId()) && appendedRecruitIds.add(work.getRecruitId())) { - RecruitmentImportFailureVO failure = new RecruitmentImportFailureVO(); - failure.setRecruitId(work.getRecruitId()); - failure.setCompanyName(work.getCompanyName()); - failure.setPositionName(work.getPositionName()); - failure.setErrorMessage("同一招聘记录编号存在失败行,已跳过该编号下全部工作经历,避免覆盖旧数据"); - failures.add(failure); - } - } + private RecruitmentImportFailureVO buildFailure(CcdiStaffRecruitmentWorkExcel excel, + String sheetName, + String sheetRowNum, + String errorMessage) { + RecruitmentImportFailureVO failure = new RecruitmentImportFailureVO(); + BeanUtils.copyProperties(excel, failure); + failure.setSheetName(sheetName); + failure.setSheetRowNum(sheetRowNum); + failure.setErrorMessage(errorMessage); + return failure; + } + + private String resolveFinalStatus(ImportResult result) { + if (result.getFailureCount() == 0) { + return "SUCCESS"; + } + if (result.getSuccessCount() == 0) { + return "FAILED"; + } + return "PARTIAL_SUCCESS"; } - /** - * 更新导入状态 - */ private void updateImportStatus(String taskId, String status, ImportResult result) { String key = "import:recruitment:" + taskId; Map statusData = new HashMap<>(); @@ -486,35 +555,100 @@ public class CcdiStaffRecruitmentImportServiceImpl implements ICcdiStaffRecruitm redisTemplate.opsForHash().putAll(key, statusData); } - /** - * 批量保存 - */ 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); - // 过滤掉已存在的记录,防止主键冲突 List recruitIds = subList.stream() - .map(CcdiStaffRecruitment::getRecruitId) - .collect(Collectors.toList()); + .map(CcdiStaffRecruitment::getRecruitId) + .toList(); + if (recruitIds.isEmpty()) { + continue; + } - if (!recruitIds.isEmpty()) { - List existingRecords = recruitmentMapper.selectBatchIds(recruitIds); - Set existingIds = existingRecords.stream() - .map(CcdiStaffRecruitment::getRecruitId) - .collect(Collectors.toSet()); + List existingRecords = recruitmentMapper.selectBatchIds(recruitIds); + Set existingIds = existingRecords.stream() + .map(CcdiStaffRecruitment::getRecruitId) + .collect(Collectors.toSet()); - // 只插入不存在的记录 - List toInsert = subList.stream() - .filter(r -> !existingIds.contains(r.getRecruitId())) - .collect(Collectors.toList()); - - if (!toInsert.isEmpty()) { - recruitmentMapper.insertBatch(toInsert); - } + List toInsert = subList.stream() + .filter(record -> !existingIds.contains(record.getRecruitId())) + .toList(); + if (!toInsert.isEmpty()) { + recruitmentMapper.insertBatch(toInsert); } } } + + private List buildMainImportRows(List recruitmentList) { + List rows = new ArrayList<>(); + for (int i = 0; i < recruitmentList.size(); i++) { + rows.add(new MainImportRow(recruitmentList.get(i), i + EXCEL_DATA_START_ROW)); + } + return rows; + } + + private List buildWorkImportRows(List workList) { + List rows = new ArrayList<>(); + for (int i = 0; i < workList.size(); i++) { + rows.add(new WorkImportRow(workList.get(i), i + EXCEL_DATA_START_ROW)); + } + return rows; + } + + private List extractWorkRowNums(List rows) { + return rows.stream().map(WorkImportRow::sheetRowNum).toList(); + } + + private FailureMeta resolveFailureMeta(Exception exception, List rowNums, String defaultSheetName) { + if (exception instanceof ImportValidationException validationException) { + return new FailureMeta(validationException.getSheetName(), validationException.getSheetRowNum()); + } + return new FailureMeta(defaultSheetName, formatSheetRowNum(rowNums)); + } + + private ImportValidationException buildValidationException(String sheetName, List rowNums, String message) { + return new ImportValidationException(sheetName, formatSheetRowNum(rowNums), message); + } + + private String formatSheetRowNum(List rowNums) { + if (rowNums == null || rowNums.isEmpty()) { + return ""; + } + return rowNums.stream() + .filter(Objects::nonNull) + .distinct() + .sorted() + .map(String::valueOf) + .collect(Collectors.joining("、")); + } + + private record MainImportRow(CcdiStaffRecruitmentExcel data, int sheetRowNum) {} + + private record WorkImportRow(CcdiStaffRecruitmentWorkExcel data, int sheetRowNum) {} + + private record MainImportResult(Map importedRecruitmentMap, int successCount) {} + + private record FailureMeta(String sheetName, String sheetRowNum) {} + + private static class ImportValidationException extends RuntimeException { + + private final String sheetName; + private final String sheetRowNum; + + private ImportValidationException(String sheetName, String sheetRowNum, String message) { + super(message); + this.sheetName = sheetName; + this.sheetRowNum = sheetRowNum; + } + + public String getSheetName() { + return sheetName; + } + + public String getSheetRowNum() { + return sheetRowNum; + } + } } diff --git a/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/impl/CcdiStaffRecruitmentServiceImpl.java b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/impl/CcdiStaffRecruitmentServiceImpl.java index 336dd1cb..c8a799a8 100644 --- a/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/impl/CcdiStaffRecruitmentServiceImpl.java +++ b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/impl/CcdiStaffRecruitmentServiceImpl.java @@ -7,11 +7,13 @@ import com.ruoyi.info.collection.domain.CcdiStaffRecruitmentWork; import com.ruoyi.info.collection.domain.dto.CcdiStaffRecruitmentAddDTO; import com.ruoyi.info.collection.domain.dto.CcdiStaffRecruitmentEditDTO; import com.ruoyi.info.collection.domain.dto.CcdiStaffRecruitmentQueryDTO; +import com.ruoyi.info.collection.domain.dto.CcdiStaffRecruitmentWorkEditDTO; import com.ruoyi.info.collection.domain.excel.CcdiStaffRecruitmentExcel; import com.ruoyi.info.collection.domain.excel.CcdiStaffRecruitmentWorkExcel; import com.ruoyi.info.collection.domain.vo.CcdiStaffRecruitmentVO; import com.ruoyi.info.collection.domain.vo.CcdiStaffRecruitmentWorkVO; import com.ruoyi.info.collection.enums.AdmitStatus; +import com.ruoyi.info.collection.enums.RecruitType; import com.ruoyi.info.collection.mapper.CcdiStaffRecruitmentWorkMapper; import com.ruoyi.info.collection.mapper.CcdiStaffRecruitmentMapper; import com.ruoyi.info.collection.service.ICcdiStaffRecruitmentImportService; @@ -28,6 +30,7 @@ import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.UUID; import java.util.concurrent.TimeUnit; @@ -151,6 +154,7 @@ public class CcdiStaffRecruitmentServiceImpl implements ICcdiStaffRecruitmentSer CcdiStaffRecruitment recruitment = new CcdiStaffRecruitment(); BeanUtils.copyProperties(editDTO, recruitment); int result = recruitmentMapper.updateById(recruitment); + replaceWorkExperienceList(editDTO); return result; } @@ -178,24 +182,26 @@ public class CcdiStaffRecruitmentServiceImpl implements ICcdiStaffRecruitmentSer */ @Override @Transactional - public String importRecruitment(java.util.List excelList) { - if (StringUtils.isNull(excelList) || excelList.isEmpty()) { + public String importRecruitment(List recruitmentList, + List workList) { + recruitmentList = recruitmentList == null ? List.of() : recruitmentList; + workList = workList == null ? List.of() : workList; + boolean noRecruitmentRows = StringUtils.isNull(recruitmentList) || recruitmentList.isEmpty(); + boolean noWorkRows = StringUtils.isNull(workList) || workList.isEmpty(); + if (noRecruitmentRows && noWorkRows) { throw new RuntimeException("至少需要一条数据"); } - // 生成任务ID String taskId = UUID.randomUUID().toString(); long startTime = System.currentTimeMillis(); - - // 获取当前用户名 String userName = SecurityUtils.getUsername(); + int totalCount = recruitmentList.size() + workList.size(); - // 初始化Redis状态 String statusKey = "import:recruitment:" + taskId; Map statusData = new HashMap<>(); statusData.put("taskId", taskId); statusData.put("status", "PROCESSING"); - statusData.put("totalCount", excelList.size()); + statusData.put("totalCount", totalCount); statusData.put("successCount", 0); statusData.put("failureCount", 0); statusData.put("progress", 0); @@ -205,44 +211,7 @@ public class CcdiStaffRecruitmentServiceImpl implements ICcdiStaffRecruitmentSer redisTemplate.opsForHash().putAll(statusKey, statusData); redisTemplate.expire(statusKey, 7, TimeUnit.DAYS); - // 调用异步导入服务 - recruitmentImportService.importRecruitmentAsync(excelList, taskId, userName); - - return taskId; - } - - /** - * 导入招聘记录历史工作经历数据(异步) - * - * @param excelList Excel实体列表 - * @return 任务ID - */ - @Override - @Transactional - public String importRecruitmentWork(List excelList) { - if (StringUtils.isNull(excelList) || excelList.isEmpty()) { - throw new RuntimeException("至少需要一条数据"); - } - - String taskId = UUID.randomUUID().toString(); - long startTime = System.currentTimeMillis(); - String userName = SecurityUtils.getUsername(); - - String statusKey = "import:recruitment:" + taskId; - Map statusData = new HashMap<>(); - statusData.put("taskId", taskId); - statusData.put("status", "PROCESSING"); - statusData.put("totalCount", excelList.size()); - statusData.put("successCount", 0); - statusData.put("failureCount", 0); - statusData.put("progress", 0); - statusData.put("startTime", startTime); - statusData.put("message", "正在处理历史工作经历..."); - - redisTemplate.opsForHash().putAll(statusKey, statusData); - redisTemplate.expire(statusKey, 7, TimeUnit.DAYS); - - recruitmentImportService.importRecruitmentWorkAsync(excelList, taskId, userName); + recruitmentImportService.importRecruitmentAsync(recruitmentList, workList, taskId, userName); return taskId; } @@ -262,4 +231,43 @@ public class CcdiStaffRecruitmentServiceImpl implements ICcdiStaffRecruitmentSer return vo; }).toList(); } + + private void replaceWorkExperienceList(CcdiStaffRecruitmentEditDTO editDTO) { + LambdaQueryWrapper deleteWrapper = new LambdaQueryWrapper<>(); + deleteWrapper.eq(CcdiStaffRecruitmentWork::getRecruitId, editDTO.getRecruitId()); + + if (!Objects.equals(RecruitType.SOCIAL.getCode(), editDTO.getRecruitType())) { + recruitmentWorkMapper.delete(deleteWrapper); + return; + } + + if (editDTO.getWorkExperienceList() == null) { + return; + } + + recruitmentWorkMapper.delete(deleteWrapper); + List workList = buildWorkExperienceEntities(editDTO); + workList.forEach(recruitmentWorkMapper::insert); + } + + private List buildWorkExperienceEntities(CcdiStaffRecruitmentEditDTO editDTO) { + List workExperienceList = editDTO.getWorkExperienceList(); + if (workExperienceList == null || workExperienceList.isEmpty()) { + return new ArrayList<>(); + } + + List entityList = new ArrayList<>(); + for (int i = 0; i < workExperienceList.size(); i++) { + CcdiStaffRecruitmentWorkEditDTO item = workExperienceList.get(i); + if (item == null || StringUtils.isBlank(item.getCompanyName()) || StringUtils.isBlank(item.getJobStartMonth())) { + continue; + } + CcdiStaffRecruitmentWork work = new CcdiStaffRecruitmentWork(); + BeanUtils.copyProperties(item, work); + work.setRecruitId(editDTO.getRecruitId()); + work.setSortOrder(i + 1); + entityList.add(work); + } + return entityList; + } } diff --git a/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/utils/EasyExcelUtil.java b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/utils/EasyExcelUtil.java index 463f749b..d1df8570 100644 --- a/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/utils/EasyExcelUtil.java +++ b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/utils/EasyExcelUtil.java @@ -1,6 +1,8 @@ package com.ruoyi.info.collection.utils; import com.alibaba.excel.EasyExcel; +import com.alibaba.excel.ExcelWriter; +import com.alibaba.excel.write.metadata.WriteSheet; import com.alibaba.excel.write.handler.WriteHandler; import com.alibaba.excel.write.style.column.LongestMatchColumnWidthStyleStrategy; import com.ruoyi.info.collection.handler.DictDropdownWriteHandler; @@ -98,6 +100,23 @@ public class EasyExcelUtil { } } + /** + * 导入Excel(按指定Sheet名称) + * + * @param inputStream 输入流 + * @param clazz 实体类 + * @param sheetName 工作表名称 + * @param 泛型 + * @return 数据列表 + */ + public static List importExcel(java.io.InputStream inputStream, Class clazz, String sheetName) { + try { + return EasyExcel.read(inputStream).head(clazz).sheet(sheetName).doReadSync(); + } catch (Exception e) { + throw new RuntimeException("导入Excel失败", e); + } + } + /** * 下载导入模板 * @@ -210,6 +229,45 @@ public class EasyExcelUtil { } } + /** + * 下载双Sheet导入模板(带字典下拉框) + * + * @param response 响应对象 + * @param firstClazz 第一张Sheet实体类 + * @param firstSheetName 第一张Sheet名称 + * @param secondClazz 第二张Sheet实体类 + * @param secondSheetName 第二张Sheet名称 + * @param fileName 文件名称 + * @param 第一张Sheet泛型 + * @param 第二张Sheet泛型 + */ + public static void importTemplateWithDictDropdown( + HttpServletResponse response, + Class firstClazz, + String firstSheetName, + Class secondClazz, + String secondSheetName, + String fileName + ) { + setResponseHeader(response, fileName); + try (ExcelWriter writer = EasyExcel.write(response.getOutputStream()).build()) { + writer.write(List.of(), buildTemplateSheet(0, firstClazz, firstSheetName)); + writer.write(List.of(), buildTemplateSheet(1, secondClazz, secondSheetName)); + } catch (IOException e) { + throw new RuntimeException("下载双Sheet导入模板失败", e); + } + } + + private static WriteSheet buildTemplateSheet(int sheetNo, Class clazz, String sheetName) { + return EasyExcel.writerSheet(sheetNo, sheetName) + .head(clazz) + .registerWriteHandler(new LongestMatchColumnWidthStyleStrategy()) + .registerWriteHandler(new DictDropdownWriteHandler(clazz)) + .registerWriteHandler(new TextFormatWriteHandler(clazz)) + .registerWriteHandler(new RequiredFieldWriteHandler(clazz)) + .build(); + } + /** * 导出Excel(带字典下拉框) * 导出的数据包含实际值,但模板中有下拉框供后续编辑使用 diff --git a/ccdi-info-collection/src/main/resources/mapper/info/collection/CcdiAccountInfoMapper.xml b/ccdi-info-collection/src/main/resources/mapper/info/collection/CcdiAccountInfoMapper.xml index 29297ae7..51d0729a 100644 --- a/ccdi-info-collection/src/main/resources/mapper/info/collection/CcdiAccountInfoMapper.xml +++ b/ccdi-info-collection/src/main/resources/mapper/info/collection/CcdiAccountInfoMapper.xml @@ -67,15 +67,15 @@ ai.status AS status, ai.effective_date AS effectiveDate, ai.invalid_date AS invalidDate, - ar.is_self_account AS isActualControl, - ar.monthly_avg_trans_count AS avgMonthTxnCount, - ar.monthly_avg_trans_amount AS avgMonthTxnAmount, - ar.trans_freq_type AS txnFrequencyLevel, - ar.dr_max_single_amount AS debitSingleMaxAmount, - ar.cr_max_single_amount AS creditSingleMaxAmount, - ar.dr_max_daily_amount AS debitDailyMaxAmount, - ar.cr_max_daily_amount AS creditDailyMaxAmount, - ar.trans_risk_level AS txnRiskLevel, + ai.is_self_account AS isActualControl, + ai.monthly_avg_trans_count AS avgMonthTxnCount, + ai.monthly_avg_trans_amount AS avgMonthTxnAmount, + ai.trans_freq_type AS txnFrequencyLevel, + ai.dr_max_single_amount AS debitSingleMaxAmount, + ai.cr_max_single_amount AS creditSingleMaxAmount, + ai.dr_max_daily_amount AS debitDailyMaxAmount, + ai.cr_max_daily_amount AS creditDailyMaxAmount, + ai.trans_risk_level AS txnRiskLevel, ai.create_by AS createBy, ai.create_time AS createTime, ai.update_by AS updateBy, @@ -107,10 +107,10 @@ AND ai.account_type = #{query.accountType} - AND ar.is_self_account = #{query.isActualControl} + AND ai.is_self_account = #{query.isActualControl} - AND ar.trans_risk_level = #{query.riskLevel} + AND ai.trans_risk_level = #{query.riskLevel} AND ai.status = #{query.status} @@ -121,7 +121,6 @@ SELECT FROM ccdi_account_info ai - LEFT JOIN ccdi_account_result ar ON ai.account_no = ar.account_no LEFT JOIN ccdi_base_staff bs ON ai.owner_type = 'EMPLOYEE' AND ai.owner_id = bs.id_card LEFT JOIN ccdi_staff_fmy_relation fr ON ai.owner_type = 'RELATION' AND ai.owner_id = fr.relation_cert_no LEFT JOIN ccdi_base_staff bsRel ON fr.person_id = bsRel.id_card @@ -133,7 +132,6 @@ SELECT FROM ccdi_account_info ai - LEFT JOIN ccdi_account_result ar ON ai.account_no = ar.account_no LEFT JOIN ccdi_base_staff bs ON ai.owner_type = 'EMPLOYEE' AND ai.owner_id = bs.id_card LEFT JOIN ccdi_staff_fmy_relation fr ON ai.owner_type = 'RELATION' AND ai.owner_id = fr.relation_cert_no LEFT JOIN ccdi_base_staff bsRel ON fr.person_id = bsRel.id_card @@ -145,7 +143,6 @@ SELECT FROM ccdi_account_info ai - LEFT JOIN ccdi_account_result ar ON ai.account_no = ar.account_no LEFT JOIN ccdi_base_staff bs ON ai.owner_type = 'EMPLOYEE' AND ai.owner_id = bs.id_card LEFT JOIN ccdi_staff_fmy_relation fr ON ai.owner_type = 'RELATION' AND ai.owner_id = fr.relation_cert_no LEFT JOIN ccdi_base_staff bsRel ON fr.person_id = bsRel.id_card diff --git a/ccdi-info-collection/src/main/resources/mapper/info/collection/CcdiBaseStaffMapper.xml b/ccdi-info-collection/src/main/resources/mapper/info/collection/CcdiBaseStaffMapper.xml index d2e28274..ebd31dc2 100644 --- a/ccdi-info-collection/src/main/resources/mapper/info/collection/CcdiBaseStaffMapper.xml +++ b/ccdi-info-collection/src/main/resources/mapper/info/collection/CcdiBaseStaffMapper.xml @@ -14,13 +14,14 @@ + + SELECT + social_credit_code, + enterprise_name, + enterprise_type, + enterprise_nature, + industry_class, + industry_name, + establish_date, + register_address, + legal_representative, + legal_cert_type, + legal_cert_no, + shareholder1, + shareholder2, + shareholder3, + shareholder4, + shareholder5, + status, + risk_level, + ent_source, + data_source, + create_time + FROM ccdi_enterprise_base_info + + + AND enterprise_name LIKE CONCAT('%', #{queryDTO.enterpriseName}, '%') + + + AND social_credit_code = #{queryDTO.socialCreditCode} + + + AND enterprise_type = #{queryDTO.enterpriseType} + + + AND enterprise_nature = #{queryDTO.enterpriseNature} + + + AND industry_class LIKE CONCAT('%', #{queryDTO.industryClass}, '%') + + + AND status = #{queryDTO.status} + + + AND risk_level = #{queryDTO.riskLevel} + + + AND ent_source = #{queryDTO.entSource} + + + ORDER BY create_time DESC + + INSERT INTO ccdi_enterprise_base_info ( @@ -21,7 +98,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" #{item.legalRepresentative}, #{item.legalCertType}, #{item.legalCertNo}, #{item.shareholder1}, #{item.shareholder2}, #{item.shareholder3}, #{item.shareholder4}, #{item.shareholder5}, #{item.status}, #{item.riskLevel}, #{item.entSource}, #{item.dataSource}, - #{item.createdBy}, #{item.updatedBy}, #{item.createTime}, #{item.updateTime} + #{item.createdBy}, #{item.updatedBy}, NOW(), NOW() ) @@ -43,7 +120,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" #{item.legalRepresentative}, #{item.legalCertType}, #{item.legalCertNo}, #{item.shareholder1}, #{item.shareholder2}, #{item.shareholder3}, #{item.shareholder4}, #{item.shareholder5}, #{item.status}, #{item.riskLevel}, #{item.entSource}, #{item.dataSource}, - #{item.createdBy}, #{item.updatedBy}, #{item.createTime}, #{item.updateTime} + #{item.createdBy}, #{item.updatedBy}, NOW(), NOW() ) ON DUPLICATE KEY UPDATE @@ -67,7 +144,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" ent_source = VALUES(ent_source), data_source = VALUES(data_source), updated_by = VALUES(updated_by), - update_time = VALUES(update_time) + update_time = NOW() @@ -95,7 +172,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" ent_source = #{item.entSource}, data_source = #{item.dataSource}, updated_by = #{item.updatedBy}, - update_time = #{item.updateTime} + update_time = NOW() WHERE social_credit_code = #{item.socialCreditCode} diff --git a/ccdi-info-collection/src/main/resources/mapper/info/collection/CcdiIntermediaryEnterpriseRelationMapper.xml b/ccdi-info-collection/src/main/resources/mapper/info/collection/CcdiIntermediaryEnterpriseRelationMapper.xml new file mode 100644 index 00000000..9f172c7c --- /dev/null +++ b/ccdi-info-collection/src/main/resources/mapper/info/collection/CcdiIntermediaryEnterpriseRelationMapper.xml @@ -0,0 +1,88 @@ + + + + + + INSERT INTO ccdi_intermediary_enterprise_relation ( + intermediary_biz_id, social_credit_code, relation_person_post, remark, + created_by, updated_by, create_time, update_time + ) VALUES + + ( + #{item.intermediaryBizId}, #{item.socialCreditCode}, #{item.relationPersonPost}, #{item.remark}, + #{item.createdBy}, #{item.updatedBy}, NOW(), NOW() + ) + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ccdi-info-collection/src/main/resources/mapper/info/collection/CcdiIntermediaryMapper.xml b/ccdi-info-collection/src/main/resources/mapper/info/collection/CcdiIntermediaryMapper.xml index 3601d0bd..2a40bdfb 100644 --- a/ccdi-info-collection/src/main/resources/mapper/info/collection/CcdiIntermediaryMapper.xml +++ b/ccdi-info-collection/src/main/resources/mapper/info/collection/CcdiIntermediaryMapper.xml @@ -4,57 +4,86 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd"> - + diff --git a/ccdi-info-collection/src/main/resources/mapper/info/collection/CcdiPurchaseTransactionMapper.xml b/ccdi-info-collection/src/main/resources/mapper/info/collection/CcdiPurchaseTransactionMapper.xml index 0d1a80da..c2273071 100644 --- a/ccdi-info-collection/src/main/resources/mapper/info/collection/CcdiPurchaseTransactionMapper.xml +++ b/ccdi-info-collection/src/main/resources/mapper/info/collection/CcdiPurchaseTransactionMapper.xml @@ -23,6 +23,7 @@ + @@ -47,49 +48,61 @@ @@ -137,4 +150,12 @@ + + DELETE FROM ccdi_purchase_transaction_supplier + WHERE purchase_id IN + + #{purchaseId} + + + diff --git a/ccdi-info-collection/src/main/resources/mapper/info/collection/CcdiStaffEnterpriseRelationMapper.xml b/ccdi-info-collection/src/main/resources/mapper/info/collection/CcdiStaffEnterpriseRelationMapper.xml index 54e000a5..9a246319 100644 --- a/ccdi-info-collection/src/main/resources/mapper/info/collection/CcdiStaffEnterpriseRelationMapper.xml +++ b/ccdi-info-collection/src/main/resources/mapper/info/collection/CcdiStaffEnterpriseRelationMapper.xml @@ -8,7 +8,9 @@ - + + + @@ -28,17 +30,28 @@ SELECT - ser.id, ser.person_id, bs.name as person_name, ser.relation_person_post, + ser.id, ser.person_id, sfr.relation_name, sfr.person_id AS staff_person_id, bs.name AS staff_person_name, + ser.relation_person_post, ser.social_credit_code, ser.enterprise_name, ser.status, ser.remark, ser.data_source, ser.is_employee, ser.is_emp_family, ser.is_customer, ser.is_cust_family, ser.created_by, ser.create_time, ser.updated_by, ser.update_time FROM ccdi_staff_enterprise_relation ser - LEFT JOIN ccdi_base_staff bs ON ser.person_id = bs.id_card + LEFT JOIN ccdi_staff_fmy_relation sfr + ON ser.person_id = sfr.relation_cert_no + AND sfr.is_emp_family = 1 + LEFT JOIN ccdi_base_staff bs ON sfr.person_id = bs.id_card WHERE ser.id = #{id} + + + + + UPDATE ccdi_staff_enterprise_relation + SET status = 0, + update_time = NOW() + WHERE person_id = #{personId} + AND status != 0 + + INSERT INTO ccdi_staff_enterprise_relation diff --git a/ccdi-info-collection/src/main/resources/mapper/info/collection/CcdiStaffRecruitmentMapper.xml b/ccdi-info-collection/src/main/resources/mapper/info/collection/CcdiStaffRecruitmentMapper.xml index 452bf01b..466d3aad 100644 --- a/ccdi-info-collection/src/main/resources/mapper/info/collection/CcdiStaffRecruitmentMapper.xml +++ b/ccdi-info-collection/src/main/resources/mapper/info/collection/CcdiStaffRecruitmentMapper.xml @@ -40,10 +40,10 @@ r.created_by, r.create_time, r.updated_by, r.update_time FROM ccdi_staff_recruitment r LEFT JOIN ( - SELECT recruit_id, COUNT(1) AS work_experience_count + SELECT recruit_id COLLATE utf8mb4_general_ci AS recruit_id, COUNT(1) AS work_experience_count FROM ccdi_staff_recruitment_work - GROUP BY recruit_id - ) w ON w.recruit_id = r.recruit_id + GROUP BY recruit_id COLLATE utf8mb4_general_ci + ) w ON w.recruit_id COLLATE utf8mb4_general_ci = r.recruit_id COLLATE utf8mb4_general_ci AND r.recruit_name LIKE CONCAT('%', #{query.recruitName}, '%') diff --git a/ccdi-info-collection/src/test/java/com/ruoyi/info/collection/controller/CcdiAssetInfoControllerTest.java b/ccdi-info-collection/src/test/java/com/ruoyi/info/collection/controller/CcdiAssetInfoControllerTest.java index 79b145f7..a2bd74ac 100644 --- a/ccdi-info-collection/src/test/java/com/ruoyi/info/collection/controller/CcdiAssetInfoControllerTest.java +++ b/ccdi-info-collection/src/test/java/com/ruoyi/info/collection/controller/CcdiAssetInfoControllerTest.java @@ -96,8 +96,12 @@ class CcdiAssetInfoControllerTest { @Test void getImportFailures_shouldReturnPagedRows() { AssetImportFailureVO failure1 = new AssetImportFailureVO(); + failure1.setSheetName("亲属资产信息"); + failure1.setRowNum(2); failure1.setPersonId("A1"); AssetImportFailureVO failure2 = new AssetImportFailureVO(); + failure2.setSheetName("亲属资产信息"); + failure2.setRowNum(3); failure2.setPersonId("A2"); when(assetInfoImportService.getImportFailures("task-3")).thenReturn(List.of(failure1, failure2)); @@ -105,7 +109,10 @@ class CcdiAssetInfoControllerTest { assertEquals(2, result.getTotal()); assertEquals(1, result.getRows().size()); - assertEquals("A2", ((AssetImportFailureVO) result.getRows().get(0)).getPersonId()); + AssetImportFailureVO row = (AssetImportFailureVO) result.getRows().get(0); + assertEquals("亲属资产信息", row.getSheetName()); + assertEquals(3, row.getRowNum()); + assertEquals("A2", row.getPersonId()); } @Test diff --git a/ccdi-info-collection/src/test/java/com/ruoyi/info/collection/controller/CcdiIntermediaryControllerTest.java b/ccdi-info-collection/src/test/java/com/ruoyi/info/collection/controller/CcdiIntermediaryControllerTest.java new file mode 100644 index 00000000..ce48450b --- /dev/null +++ b/ccdi-info-collection/src/test/java/com/ruoyi/info/collection/controller/CcdiIntermediaryControllerTest.java @@ -0,0 +1,172 @@ +package com.ruoyi.info.collection.controller; + +import com.ruoyi.common.constant.HttpStatus; +import com.ruoyi.common.core.domain.AjaxResult; +import com.ruoyi.common.core.page.TableDataInfo; +import com.ruoyi.info.collection.domain.excel.CcdiIntermediaryEnterpriseRelationExcel; +import com.ruoyi.info.collection.domain.excel.CcdiIntermediaryPersonExcel; +import com.ruoyi.info.collection.domain.vo.ImportResultVO; +import com.ruoyi.info.collection.domain.vo.ImportStatusVO; +import com.ruoyi.info.collection.domain.vo.IntermediaryEnterpriseRelationImportFailureVO; +import com.ruoyi.info.collection.domain.vo.IntermediaryPersonImportFailureVO; +import com.ruoyi.info.collection.service.ICcdiIntermediaryEnterpriseRelationImportService; +import com.ruoyi.info.collection.service.ICcdiIntermediaryPersonImportService; +import com.ruoyi.info.collection.service.ICcdiIntermediaryService; +import com.ruoyi.info.collection.utils.EasyExcelUtil; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.MockedStatic; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.mock.web.MockMultipartFile; + +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class CcdiIntermediaryControllerTest { + + @InjectMocks + private CcdiIntermediaryController controller; + + @Mock + private ICcdiIntermediaryService intermediaryService; + + @Mock + private ICcdiIntermediaryPersonImportService personImportService; + + @Mock + private ICcdiIntermediaryEnterpriseRelationImportService enterpriseRelationImportService; + + @Test + void importPersonData_shouldReturnWarnWhenExcelHasNoRows() throws Exception { + MockMultipartFile file = new MockMultipartFile( + "file", + "intermediary-empty.xlsx", + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + "empty".getBytes(StandardCharsets.UTF_8) + ); + + try (MockedStatic mocked = mockStatic(EasyExcelUtil.class)) { + mocked.when(() -> EasyExcelUtil.importExcel(any(InputStream.class), eq(CcdiIntermediaryPersonExcel.class))) + .thenReturn(List.of()); + + AjaxResult result = controller.importPersonData(file); + + assertEquals(HttpStatus.ERROR, result.get(AjaxResult.CODE_TAG)); + assertEquals("至少需要一条数据", result.get(AjaxResult.MSG_TAG)); + } + } + + @Test + void importPersonData_shouldReturnSuccessWhenTaskCreated() throws Exception { + MockMultipartFile file = new MockMultipartFile( + "file", + "intermediary-person.xlsx", + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + "person".getBytes(StandardCharsets.UTF_8) + ); + CcdiIntermediaryPersonExcel excel = new CcdiIntermediaryPersonExcel(); + excel.setPersonId("320101199001010011"); + when(intermediaryService.importIntermediaryPerson(List.of(excel))).thenReturn("task-person"); + + try (MockedStatic mocked = mockStatic(EasyExcelUtil.class)) { + mocked.when(() -> EasyExcelUtil.importExcel(any(InputStream.class), eq(CcdiIntermediaryPersonExcel.class))) + .thenReturn(List.of(excel)); + + AjaxResult result = controller.importPersonData(file); + + assertEquals(HttpStatus.SUCCESS, result.get(AjaxResult.CODE_TAG)); + ImportResultVO data = (ImportResultVO) result.get(AjaxResult.DATA_TAG); + assertEquals("task-person", data.getTaskId()); + } + } + + @Test + void importEnterpriseRelationData_shouldReturnSuccessWhenTaskCreated() throws Exception { + MockMultipartFile file = new MockMultipartFile( + "file", + "intermediary-relation.xlsx", + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + "relation".getBytes(StandardCharsets.UTF_8) + ); + CcdiIntermediaryEnterpriseRelationExcel excel = new CcdiIntermediaryEnterpriseRelationExcel(); + excel.setOwnerPersonId("320101199001010011"); + excel.setSocialCreditCode("91330100MA27X12345"); + when(intermediaryService.importIntermediaryEnterpriseRelation(List.of(excel))).thenReturn("task-relation"); + + try (MockedStatic mocked = mockStatic(EasyExcelUtil.class)) { + mocked.when(() -> EasyExcelUtil.importExcel(any(InputStream.class), eq(CcdiIntermediaryEnterpriseRelationExcel.class))) + .thenReturn(List.of(excel)); + + AjaxResult result = controller.importEnterpriseRelationData(file); + + assertEquals(HttpStatus.SUCCESS, result.get(AjaxResult.CODE_TAG)); + ImportResultVO data = (ImportResultVO) result.get(AjaxResult.DATA_TAG); + assertEquals("task-relation", data.getTaskId()); + } + } + + @Test + void getEnterpriseRelationImportStatus_shouldDelegateToRelationImportService() { + ImportStatusVO statusVO = new ImportStatusVO(); + statusVO.setTaskId("task-status"); + when(enterpriseRelationImportService.getImportStatus("task-status")).thenReturn(statusVO); + + AjaxResult result = controller.getEnterpriseRelationImportStatus("task-status"); + + assertEquals(HttpStatus.SUCCESS, result.get(AjaxResult.CODE_TAG)); + assertEquals(statusVO, result.get(AjaxResult.DATA_TAG)); + } + + @Test + void getEnterpriseRelationImportFailures_shouldReturnPagedRows() { + IntermediaryEnterpriseRelationImportFailureVO failure1 = new IntermediaryEnterpriseRelationImportFailureVO(); + failure1.setOwnerPersonId("A1"); + IntermediaryEnterpriseRelationImportFailureVO failure2 = new IntermediaryEnterpriseRelationImportFailureVO(); + failure2.setOwnerPersonId("A2"); + when(enterpriseRelationImportService.getImportFailures("task-failures")).thenReturn(List.of(failure1, failure2)); + + TableDataInfo result = controller.getEnterpriseRelationImportFailures("task-failures", 2, 1); + + assertEquals(2, result.getTotal()); + assertEquals(1, result.getRows().size()); + assertEquals("A2", ((IntermediaryEnterpriseRelationImportFailureVO) result.getRows().get(0)).getOwnerPersonId()); + } + + @Test + void getPersonImportFailures_shouldReturnPagedRows() { + IntermediaryPersonImportFailureVO failure1 = new IntermediaryPersonImportFailureVO(); + failure1.setPersonId("A1"); + IntermediaryPersonImportFailureVO failure2 = new IntermediaryPersonImportFailureVO(); + failure2.setPersonId("A2"); + when(personImportService.getImportFailures("task-person-failures")).thenReturn(List.of(failure1, failure2)); + + TableDataInfo result = controller.getPersonImportFailures("task-person-failures", 2, 1); + + assertEquals(2, result.getTotal()); + assertEquals(1, result.getRows().size()); + assertEquals("A2", ((IntermediaryPersonImportFailureVO) result.getRows().get(0)).getPersonId()); + } + + @Test + void importEnterpriseRelationTemplate_shouldUseRelationTemplateName() { + try (MockedStatic mocked = mockStatic(EasyExcelUtil.class)) { + controller.importEnterpriseRelationTemplate(null); + + mocked.verify(() -> EasyExcelUtil.importTemplateWithDictDropdown( + null, + CcdiIntermediaryEnterpriseRelationExcel.class, + "中介实体关联关系信息" + )); + } + } +} diff --git a/ccdi-info-collection/src/test/java/com/ruoyi/info/collection/mapper/CcdiAccountInfoMapperTest.java b/ccdi-info-collection/src/test/java/com/ruoyi/info/collection/mapper/CcdiAccountInfoMapperTest.java new file mode 100644 index 00000000..3863c4b5 --- /dev/null +++ b/ccdi-info-collection/src/test/java/com/ruoyi/info/collection/mapper/CcdiAccountInfoMapperTest.java @@ -0,0 +1,109 @@ +package com.ruoyi.info.collection.mapper; + +import com.ruoyi.info.collection.domain.dto.CcdiAccountInfoQueryDTO; +import org.apache.ibatis.builder.xml.XMLMapperBuilder; +import org.apache.ibatis.mapping.BoundSql; +import org.apache.ibatis.mapping.Environment; +import org.apache.ibatis.mapping.MappedStatement; +import org.apache.ibatis.scripting.xmltags.XMLLanguageDriver; +import org.apache.ibatis.session.Configuration; +import org.apache.ibatis.transaction.jdbc.JdbcTransactionFactory; +import org.apache.ibatis.type.TypeAliasRegistry; +import org.junit.jupiter.api.Test; + +import javax.sql.DataSource; +import java.io.InputStream; +import java.util.HashMap; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class CcdiAccountInfoMapperTest { + + private static final String RESOURCE = "mapper/info/collection/CcdiAccountInfoMapper.xml"; + + @Test + void selectAccountInfoPage_shouldReadAnalysisColumnsFromAccountInfoTableOnly() throws Exception { + MappedStatement mappedStatement = loadMappedStatement( + "com.ruoyi.info.collection.mapper.CcdiAccountInfoMapper.selectAccountInfoPage"); + + String sql = renderSql(mappedStatement, Map.of("query", new CcdiAccountInfoQueryDTO())).toLowerCase(); + + assertTrue(sql.contains("from ccdi_account_info ai"), sql); + assertFalse(sql.contains("ccdi_account_result"), sql); + assertTrue(sql.contains("ai.is_self_account as isactualcontrol"), sql); + assertTrue(sql.contains("ai.monthly_avg_trans_count as avgmonthtxncount"), sql); + assertTrue(sql.contains("ai.trans_risk_level as txnrisklevel"), sql); + } + + private MappedStatement loadMappedStatement(String statementId) throws Exception { + Configuration configuration = new Configuration(); + configuration.setEnvironment(new Environment("test", new JdbcTransactionFactory(), new NoOpDataSource())); + registerTypeAliases(configuration.getTypeAliasRegistry()); + configuration.getLanguageRegistry().register(XMLLanguageDriver.class); + configuration.addMapper(CcdiAccountInfoMapper.class); + + try (InputStream inputStream = getClass().getClassLoader().getResourceAsStream(RESOURCE)) { + XMLMapperBuilder xmlMapperBuilder = + new XMLMapperBuilder(inputStream, configuration, RESOURCE, configuration.getSqlFragments()); + xmlMapperBuilder.parse(); + } + return configuration.getMappedStatement(statementId); + } + + private String renderSql(MappedStatement mappedStatement, Map params) { + BoundSql boundSql = mappedStatement.getBoundSql(new HashMap<>(params)); + return boundSql.getSql().replaceAll("\\s+", " ").trim(); + } + + private void registerTypeAliases(TypeAliasRegistry typeAliasRegistry) { + typeAliasRegistry.registerAlias("map", Map.class); + } + + private static class NoOpDataSource implements DataSource { + + @Override + public java.sql.Connection getConnection() { + throw new UnsupportedOperationException("Not required for SQL rendering tests"); + } + + @Override + public java.sql.Connection getConnection(String username, String password) { + throw new UnsupportedOperationException("Not required for SQL rendering tests"); + } + + @Override + public java.io.PrintWriter getLogWriter() { + return null; + } + + @Override + public void setLogWriter(java.io.PrintWriter out) { + } + + @Override + public void setLoginTimeout(int seconds) { + } + + @Override + public int getLoginTimeout() { + return 0; + } + + @Override + public java.util.logging.Logger getParentLogger() { + return java.util.logging.Logger.getGlobal(); + } + + @Override + public T unwrap(Class iface) { + throw new UnsupportedOperationException("Not supported"); + } + + @Override + public boolean isWrapperFor(Class iface) { + return false; + } + } +} diff --git a/ccdi-info-collection/src/test/java/com/ruoyi/info/collection/mapper/CcdiBaseStaffMapperTest.java b/ccdi-info-collection/src/test/java/com/ruoyi/info/collection/mapper/CcdiBaseStaffMapperTest.java index 7cc55f20..d1443071 100644 --- a/ccdi-info-collection/src/test/java/com/ruoyi/info/collection/mapper/CcdiBaseStaffMapperTest.java +++ b/ccdi-info-collection/src/test/java/com/ruoyi/info/collection/mapper/CcdiBaseStaffMapperTest.java @@ -17,6 +17,8 @@ class CcdiBaseStaffMapperTest { assertTrue(xml.contains("annual_income"), xml); assertTrue(xml.contains("#{item.annualIncome}"), xml); + assertTrue(xml.contains("is_party_member"), xml); + assertTrue(xml.contains("#{item.partyMember}"), xml); } } } diff --git a/ccdi-info-collection/src/test/java/com/ruoyi/info/collection/mapper/CcdiStaffEnterpriseRelationMapperTest.java b/ccdi-info-collection/src/test/java/com/ruoyi/info/collection/mapper/CcdiStaffEnterpriseRelationMapperTest.java new file mode 100644 index 00000000..44d2e019 --- /dev/null +++ b/ccdi-info-collection/src/test/java/com/ruoyi/info/collection/mapper/CcdiStaffEnterpriseRelationMapperTest.java @@ -0,0 +1,148 @@ +package com.ruoyi.info.collection.mapper; + +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.github.pagehelper.parser.defaults.DefaultCountSqlParser; +import com.ruoyi.info.collection.domain.dto.CcdiStaffEnterpriseRelationQueryDTO; +import org.apache.ibatis.builder.xml.XMLMapperBuilder; +import org.apache.ibatis.mapping.BoundSql; +import org.apache.ibatis.mapping.Environment; +import org.apache.ibatis.mapping.MappedStatement; +import org.apache.ibatis.scripting.xmltags.XMLLanguageDriver; +import org.apache.ibatis.session.Configuration; +import org.apache.ibatis.transaction.jdbc.JdbcTransactionFactory; +import org.apache.ibatis.type.TypeAliasRegistry; +import org.junit.jupiter.api.Test; + +import javax.sql.DataSource; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.util.HashMap; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class CcdiStaffEnterpriseRelationMapperTest { + + private static final String RESOURCE = "mapper/info/collection/CcdiStaffEnterpriseRelationMapper.xml"; + + @Test + void selectRelationPage_shouldJoinFamilyRelationAndStaff() throws Exception { + MappedStatement mappedStatement = loadMappedStatement( + "com.ruoyi.info.collection.mapper.CcdiStaffEnterpriseRelationMapper.selectRelationPage"); + + CcdiStaffEnterpriseRelationQueryDTO queryDTO = new CcdiStaffEnterpriseRelationQueryDTO(); + queryDTO.setRelationName("李"); + queryDTO.setStaffPersonName("张"); + String sql = renderSql(mappedStatement, Map.of( + "page", new Page<>(1, 10), + "query", queryDTO + )); + String countSql = normalizeSql(new DefaultCountSqlParser().getSmartCountSql(sql, "0")); + + assertTrue(sql.contains("LEFT JOIN ccdi_staff_fmy_relation sfr"), sql); + assertTrue(sql.contains("LEFT JOIN ccdi_base_staff bs ON sfr.person_id = bs.id_card"), sql); + assertTrue(sql.contains("sfr.relation_name"), sql); + assertTrue(sql.contains("staff_person_id"), sql); + assertTrue(sql.contains("bs.name AS staff_person_name"), sql); + assertTrue(sql.contains("sfr.relation_name LIKE CONCAT('%', ?, '%')"), sql); + assertTrue(sql.contains("bs.name LIKE CONCAT('%', ?, '%')"), sql); + assertFalse(countSql.contains("1AND"), countSql); + } + + @Test + void selectFamilyOptions_shouldOnlyQueryEffectiveEmployeeFamilies() throws Exception { + MappedStatement mappedStatement = loadMappedStatement( + "com.ruoyi.info.collection.mapper.CcdiStaffEnterpriseRelationMapper.selectFamilyOptions"); + + String sql = renderSql(mappedStatement, Map.of("query", "320101")); + + assertTrue(sql.contains("sfr.is_emp_family = 1"), sql); + assertTrue(sql.contains("sfr.status = 1"), sql); + assertTrue(sql.contains("sfr.relation_cert_no LIKE CONCAT('%', ?, '%')"), sql); + } + + @Test + void mapperXml_shouldContainInvalidateByFamilyCertNo() throws Exception { + try (InputStream inputStream = getClass().getClassLoader().getResourceAsStream(RESOURCE)) { + String xml = new String(inputStream.readAllBytes(), StandardCharsets.UTF_8); + assertTrue(xml.contains(""), xml); + assertTrue(xml.contains("WHERE person_id = #{personId}"), xml); + assertTrue(xml.contains("SET status = 0"), xml); + } + } + + private MappedStatement loadMappedStatement(String statementId) throws Exception { + Configuration configuration = new Configuration(); + configuration.setEnvironment(new Environment("test", new JdbcTransactionFactory(), new NoOpDataSource())); + registerTypeAliases(configuration.getTypeAliasRegistry()); + configuration.getLanguageRegistry().register(XMLLanguageDriver.class); + configuration.addMapper(CcdiStaffEnterpriseRelationMapper.class); + + try (InputStream inputStream = getClass().getClassLoader().getResourceAsStream(RESOURCE)) { + XMLMapperBuilder xmlMapperBuilder = + new XMLMapperBuilder(inputStream, configuration, RESOURCE, configuration.getSqlFragments()); + xmlMapperBuilder.parse(); + } + return configuration.getMappedStatement(statementId); + } + + private String renderSql(MappedStatement mappedStatement, Map params) { + BoundSql boundSql = mappedStatement.getBoundSql(new HashMap<>(params)); + return normalizeSql(boundSql.getSql()); + } + + private String normalizeSql(String sql) { + return sql.replaceAll("\\s+", " ").trim(); + } + + private void registerTypeAliases(TypeAliasRegistry typeAliasRegistry) { + typeAliasRegistry.registerAlias("map", Map.class); + } + + private static class NoOpDataSource implements DataSource { + + @Override + public java.sql.Connection getConnection() { + throw new UnsupportedOperationException("Not required for SQL rendering tests"); + } + + @Override + public java.sql.Connection getConnection(String username, String password) { + throw new UnsupportedOperationException("Not required for SQL rendering tests"); + } + + @Override + public java.io.PrintWriter getLogWriter() { + return null; + } + + @Override + public void setLogWriter(java.io.PrintWriter out) { + } + + @Override + public void setLoginTimeout(int seconds) { + } + + @Override + public int getLoginTimeout() { + return 0; + } + + @Override + public java.util.logging.Logger getParentLogger() { + return java.util.logging.Logger.getGlobal(); + } + + @Override + public T unwrap(Class iface) { + throw new UnsupportedOperationException("Not supported"); + } + + @Override + public boolean isWrapperFor(Class iface) { + return false; + } + } +} diff --git a/ccdi-info-collection/src/test/java/com/ruoyi/info/collection/service/CcdiAccountInfoServiceImplTest.java b/ccdi-info-collection/src/test/java/com/ruoyi/info/collection/service/CcdiAccountInfoServiceImplTest.java new file mode 100644 index 00000000..965f40a0 --- /dev/null +++ b/ccdi-info-collection/src/test/java/com/ruoyi/info/collection/service/CcdiAccountInfoServiceImplTest.java @@ -0,0 +1,124 @@ +package com.ruoyi.info.collection.service; + +import com.ruoyi.info.collection.domain.CcdiAccountInfo; +import com.ruoyi.info.collection.domain.CcdiBaseStaff; +import com.ruoyi.info.collection.domain.dto.CcdiAccountInfoAddDTO; +import com.ruoyi.info.collection.mapper.CcdiAccountInfoMapper; +import com.ruoyi.info.collection.mapper.CcdiBaseStaffMapper; +import com.ruoyi.info.collection.mapper.CcdiStaffFmyRelationMapper; +import com.ruoyi.info.collection.service.impl.CcdiAccountInfoServiceImpl; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.beans.BeanWrapperImpl; + +import java.math.BigDecimal; +import java.util.Date; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class CcdiAccountInfoServiceImplTest { + + @InjectMocks + private CcdiAccountInfoServiceImpl service; + + @Mock + private CcdiAccountInfoMapper accountInfoMapper; + + @Mock + private CcdiBaseStaffMapper baseStaffMapper; + + @Mock + private CcdiStaffFmyRelationMapper staffFmyRelationMapper; + + @Test + void insertExternalAccount_shouldPersistAnalysisFieldsOnAccountInfo() { + CcdiAccountInfoAddDTO dto = buildBaseAddDto(); + dto.setOwnerType("EXTERNAL"); + dto.setOwnerId("330101199001010011"); + dto.setBankScope("EXTERNAL"); + dto.setIsActualControl(0); + dto.setAvgMonthTxnCount(6); + dto.setAvgMonthTxnAmount(new BigDecimal("1234.56")); + dto.setTxnFrequencyLevel("HIGH"); + dto.setDebitSingleMaxAmount(new BigDecimal("100.00")); + dto.setCreditSingleMaxAmount(new BigDecimal("200.00")); + dto.setDebitDailyMaxAmount(new BigDecimal("300.00")); + dto.setCreditDailyMaxAmount(new BigDecimal("400.00")); + dto.setTxnRiskLevel("MEDIUM"); + + when(accountInfoMapper.selectCount(any())).thenReturn(0L); + when(accountInfoMapper.insert(any(CcdiAccountInfo.class))).thenReturn(1); + + service.insertAccountInfo(dto); + + ArgumentCaptor captor = ArgumentCaptor.forClass(CcdiAccountInfo.class); + verify(accountInfoMapper).insert(captor.capture()); + BeanWrapperImpl wrapper = new BeanWrapperImpl(captor.getValue()); + assertEquals(0, wrapper.getPropertyValue("isActualControl")); + assertEquals(6, wrapper.getPropertyValue("avgMonthTxnCount")); + assertEquals(new BigDecimal("1234.56"), wrapper.getPropertyValue("avgMonthTxnAmount")); + assertEquals("HIGH", wrapper.getPropertyValue("txnFrequencyLevel")); + assertEquals("MEDIUM", wrapper.getPropertyValue("txnRiskLevel")); + } + + @Test + void insertInternalAccount_shouldClearAnalysisFieldsOnAccountInfo() { + CcdiAccountInfoAddDTO dto = buildBaseAddDto(); + dto.setOwnerType("EMPLOYEE"); + dto.setOwnerId("330101199001010022"); + dto.setBankScope("INTERNAL"); + dto.setIsActualControl(1); + dto.setAvgMonthTxnCount(8); + dto.setAvgMonthTxnAmount(new BigDecimal("9988.66")); + dto.setTxnFrequencyLevel("HIGH"); + dto.setDebitSingleMaxAmount(new BigDecimal("111.11")); + dto.setCreditSingleMaxAmount(new BigDecimal("222.22")); + dto.setDebitDailyMaxAmount(new BigDecimal("333.33")); + dto.setCreditDailyMaxAmount(new BigDecimal("444.44")); + dto.setTxnRiskLevel("HIGH"); + + CcdiBaseStaff staff = new CcdiBaseStaff(); + staff.setIdCard(dto.getOwnerId()); + + when(baseStaffMapper.selectOne(any())).thenReturn(staff); + when(accountInfoMapper.selectCount(any())).thenReturn(0L); + when(accountInfoMapper.insert(any(CcdiAccountInfo.class))).thenReturn(1); + + service.insertAccountInfo(dto); + + ArgumentCaptor captor = ArgumentCaptor.forClass(CcdiAccountInfo.class); + verify(accountInfoMapper).insert(captor.capture()); + BeanWrapperImpl wrapper = new BeanWrapperImpl(captor.getValue()); + assertNull(wrapper.getPropertyValue("isActualControl")); + assertNull(wrapper.getPropertyValue("avgMonthTxnCount")); + assertNull(wrapper.getPropertyValue("avgMonthTxnAmount")); + assertNull(wrapper.getPropertyValue("txnFrequencyLevel")); + assertNull(wrapper.getPropertyValue("debitSingleMaxAmount")); + assertNull(wrapper.getPropertyValue("creditSingleMaxAmount")); + assertNull(wrapper.getPropertyValue("debitDailyMaxAmount")); + assertNull(wrapper.getPropertyValue("creditDailyMaxAmount")); + assertNull(wrapper.getPropertyValue("txnRiskLevel")); + } + + private CcdiAccountInfoAddDTO buildBaseAddDto() { + CcdiAccountInfoAddDTO dto = new CcdiAccountInfoAddDTO(); + dto.setAccountNo("6222024000000001"); + dto.setAccountType("BANK"); + dto.setAccountName("测试账户"); + dto.setOpenBank("中国银行"); + dto.setBankCode("BOC"); + dto.setCurrency("CNY"); + dto.setStatus(1); + dto.setEffectiveDate(new Date()); + return dto; + } +} diff --git a/ccdi-info-collection/src/test/java/com/ruoyi/info/collection/service/CcdiBaseStaffAssetImportServiceImplTest.java b/ccdi-info-collection/src/test/java/com/ruoyi/info/collection/service/CcdiBaseStaffAssetImportServiceImplTest.java index 09349a9a..593d757a 100644 --- a/ccdi-info-collection/src/test/java/com/ruoyi/info/collection/service/CcdiBaseStaffAssetImportServiceImplTest.java +++ b/ccdi-info-collection/src/test/java/com/ruoyi/info/collection/service/CcdiBaseStaffAssetImportServiceImplTest.java @@ -93,10 +93,39 @@ class CcdiBaseStaffAssetImportServiceImplTest { ArgumentCaptor failureCaptor = ArgumentCaptor.forClass(Object.class); verify(valueOperations).set(eq("import:baseStaffAsset:task-2:failures"), failureCaptor.capture(), eq(7L), eq(TimeUnit.DAYS)); BaseStaffAssetImportFailureVO failure = (BaseStaffAssetImportFailureVO) ((List) failureCaptor.getValue()).get(0); + assertEquals("员工资产信息", failure.getSheetName()); + assertEquals(2, failure.getRowNum()); assertEquals("320101199201010022", failure.getPersonId()); assertTrue(failure.getErrorMessage().contains("员工资产导入仅支持员工本人证件号")); } + @Test + void importAssetInfoAsync_shouldFailWhenAssetAlreadyExists() { + CcdiBaseStaffAssetInfoExcel excel = buildExcel("320101199001010011", "房产"); + when(redisTemplate.opsForHash()).thenReturn(hashOperations); + when(redisTemplate.opsForValue()).thenReturn(valueOperations); + when(assetInfoMapper.selectOwnerCandidatesByBaseStaffIdCards(List.of("320101199001010011"))) + .thenReturn(List.of(owner("320101199001010011", "320101199001010011"))); + + CcdiAssetInfo existing = new CcdiAssetInfo(); + existing.setFamilyId("320101199001010011"); + existing.setPersonId("320101199001010011"); + existing.setAssetMainType("房产"); + existing.setAssetSubType("房产小类"); + existing.setAssetName("房产名称"); + when(assetInfoMapper.selectList(any())).thenReturn(List.of(existing)); + + service.importAssetInfoAsync(List.of(excel), "task-duplicate", "tester"); + + verify(assetInfoMapper, never()).insertBatch(any()); + ArgumentCaptor failureCaptor = ArgumentCaptor.forClass(Object.class); + verify(valueOperations).set(eq("import:baseStaffAsset:task-duplicate:failures"), failureCaptor.capture(), eq(7L), eq(TimeUnit.DAYS)); + BaseStaffAssetImportFailureVO failure = (BaseStaffAssetImportFailureVO) ((List) failureCaptor.getValue()).get(0); + assertEquals("员工资产信息", failure.getSheetName()); + assertEquals(2, failure.getRowNum()); + assertTrue(failure.getErrorMessage().contains("资产记录已存在")); + } + @Test void getImportStatusAndFailures_shouldUseBaseStaffAssetPrefixes() { when(redisTemplate.opsForHash()).thenReturn(hashOperations); diff --git a/ccdi-info-collection/src/test/java/com/ruoyi/info/collection/service/CcdiBaseStaffImportServiceImplTest.java b/ccdi-info-collection/src/test/java/com/ruoyi/info/collection/service/CcdiBaseStaffImportServiceImplTest.java index aa48a93d..6950d605 100644 --- a/ccdi-info-collection/src/test/java/com/ruoyi/info/collection/service/CcdiBaseStaffImportServiceImplTest.java +++ b/ccdi-info-collection/src/test/java/com/ruoyi/info/collection/service/CcdiBaseStaffImportServiceImplTest.java @@ -1,8 +1,14 @@ package com.ruoyi.info.collection.service; +import com.ruoyi.common.core.domain.entity.SysDept; import com.ruoyi.info.collection.domain.dto.CcdiBaseStaffAddDTO; import com.ruoyi.info.collection.service.impl.CcdiBaseStaffImportServiceImpl; +import com.ruoyi.system.mapper.SysDeptMapper; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; import java.math.BigDecimal; import java.util.Collections; @@ -11,38 +17,109 @@ import java.util.Set; import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.when; +@ExtendWith(MockitoExtension.class) class CcdiBaseStaffImportServiceImplTest { - private final CcdiBaseStaffImportServiceImpl service = new CcdiBaseStaffImportServiceImpl(); + @InjectMocks + private CcdiBaseStaffImportServiceImpl service; + + @Mock + private SysDeptMapper deptMapper; @Test void validateStaffData_shouldAllowEmptyAnnualIncome() { - assertDoesNotThrow(() -> service.validateStaffData(buildDto(null), false, Collections.emptySet(), Collections.emptySet())); + mockNormalDept(); + assertDoesNotThrow(() -> service.validateStaffData(buildDto(null), Collections.emptySet(), Collections.emptySet())); } @Test void validateStaffData_shouldAllowZeroAndTwoDecimalAnnualIncome() { - assertDoesNotThrow(() -> service.validateStaffData(buildDto(new BigDecimal("0.00")), false, Collections.emptySet(), Collections.emptySet())); - assertDoesNotThrow(() -> service.validateStaffData(buildDto(new BigDecimal("12345.67")), false, Collections.emptySet(), Collections.emptySet())); + mockNormalDept(); + assertDoesNotThrow(() -> service.validateStaffData(buildDto(new BigDecimal("0.00")), Collections.emptySet(), Collections.emptySet())); + assertDoesNotThrow(() -> service.validateStaffData(buildDto(new BigDecimal("12345.67")), Collections.emptySet(), Collections.emptySet())); + } + + @Test + void validateStaffData_shouldAllowPartyMemberValuesZeroAndOne() { + mockNormalDept(); + CcdiBaseStaffAddDTO nonPartyMember = buildDto(null); + nonPartyMember.setPartyMember(0); + CcdiBaseStaffAddDTO partyMember = buildDto(null); + partyMember.setPartyMember(1); + + assertDoesNotThrow(() -> service.validateStaffData(nonPartyMember, Collections.emptySet(), Collections.emptySet())); + assertDoesNotThrow(() -> service.validateStaffData(partyMember, Collections.emptySet(), Collections.emptySet())); + } + + @Test + void validateStaffData_shouldRejectInvalidPartyMemberValue() { + mockNormalDept(); + CcdiBaseStaffAddDTO dto = buildDto(null); + dto.setPartyMember(2); + + RuntimeException exception = assertThrows(RuntimeException.class, + () -> service.validateStaffData(dto, Set.of(), Set.of())); + + assertEquals("是否党员只能填写'0'或'1'", exception.getMessage()); } @Test void validateStaffData_shouldRejectNegativeAnnualIncome() { + mockNormalDept(); RuntimeException exception = assertThrows(RuntimeException.class, - () -> service.validateStaffData(buildDto(new BigDecimal("-1.00")), false, Set.of(), Set.of())); + () -> service.validateStaffData(buildDto(new BigDecimal("-1.00")), Set.of(), Set.of())); assertEquals("年收入不能为负数", exception.getMessage()); } @Test void validateStaffData_shouldRejectAnnualIncomeWithMoreThanTwoDecimals() { + mockNormalDept(); RuntimeException exception = assertThrows(RuntimeException.class, - () -> service.validateStaffData(buildDto(new BigDecimal("12.345")), false, Set.of(), Set.of())); + () -> service.validateStaffData(buildDto(new BigDecimal("12.345")), Set.of(), Set.of())); assertEquals("年收入最多保留2位小数", exception.getMessage()); } + @Test + void validateStaffData_shouldAllowWhenDeptIsNormalAndNotDeleted() { + mockNormalDept(); + assertDoesNotThrow(() -> service.validateStaffData(buildDto(null), Set.of(), Set.of())); + } + + @Test + void validateStaffData_shouldRejectWhenDeptDoesNotExist() { + when(deptMapper.selectDeptById(10L)).thenReturn(null); + + RuntimeException exception = assertThrows(RuntimeException.class, + () -> service.validateStaffData(buildDto(null), Set.of(), Set.of())); + + assertEquals("所属部门ID[10]不存在或已停用/删除,请检查机构号", exception.getMessage()); + } + + @Test + void validateStaffData_shouldRejectWhenDeptIsDisabled() { + when(deptMapper.selectDeptById(10L)).thenReturn(buildDept("1", "0")); + + RuntimeException exception = assertThrows(RuntimeException.class, + () -> service.validateStaffData(buildDto(null), Set.of(), Set.of())); + + assertEquals("所属部门ID[10]不存在或已停用/删除,请检查机构号", exception.getMessage()); + } + + @Test + void validateStaffData_shouldRejectWhenDeptIsDeleted() { + when(deptMapper.selectDeptById(10L)).thenReturn(buildDept("0", "2")); + + RuntimeException exception = assertThrows(RuntimeException.class, + () -> service.validateStaffData(buildDto(null), Set.of(), Set.of())); + + assertEquals("所属部门ID[10]不存在或已停用/删除,请检查机构号", exception.getMessage()); + } + private CcdiBaseStaffAddDTO buildDto(BigDecimal annualIncome) { CcdiBaseStaffAddDTO dto = new CcdiBaseStaffAddDTO(); dto.setName("张三"); @@ -51,7 +128,21 @@ class CcdiBaseStaffImportServiceImplTest { dto.setIdCard("320101199001010014"); dto.setPhone("13812345678"); dto.setStatus("0"); + dto.setPartyMember(1); dto.setAnnualIncome(annualIncome); return dto; } + + private SysDept buildDept(String status, String delFlag) { + SysDept dept = new SysDept(); + dept.setDeptId(10L); + dept.setDeptName("测试部门"); + dept.setStatus(status); + dept.setDelFlag(delFlag); + return dept; + } + + private void mockNormalDept() { + lenient().when(deptMapper.selectDeptById(10L)).thenReturn(buildDept("0", "0")); + } } diff --git a/ccdi-info-collection/src/test/java/com/ruoyi/info/collection/service/CcdiBaseStaffServiceImplTest.java b/ccdi-info-collection/src/test/java/com/ruoyi/info/collection/service/CcdiBaseStaffServiceImplTest.java index 2e8b99fd..5ec7edb5 100644 --- a/ccdi-info-collection/src/test/java/com/ruoyi/info/collection/service/CcdiBaseStaffServiceImplTest.java +++ b/ccdi-info-collection/src/test/java/com/ruoyi/info/collection/service/CcdiBaseStaffServiceImplTest.java @@ -55,6 +55,7 @@ class CcdiBaseStaffServiceImplTest { addDTO.setIdCard("320101199001010011"); addDTO.setPhone("13812345678"); addDTO.setStatus("0"); + addDTO.setPartyMember(1); addDTO.setAnnualIncome(new BigDecimal("12345.67")); addDTO.setAssetInfoList(List.of( buildAssetDto("房产"), @@ -70,6 +71,7 @@ class CcdiBaseStaffServiceImplTest { assertEquals(1, result); ArgumentCaptor staffCaptor = ArgumentCaptor.forClass(CcdiBaseStaff.class); verify(baseStaffMapper).insert(staffCaptor.capture()); + assertEquals(1, staffCaptor.getValue().getPartyMember()); assertEquals(new BigDecimal("12345.67"), staffCaptor.getValue().getAnnualIncome()); ArgumentCaptor> captor = ArgumentCaptor.forClass(List.class); verify(assetInfoService).replaceByFamilyId(eq("320101199001010011"), captor.capture()); @@ -92,6 +94,7 @@ class CcdiBaseStaffServiceImplTest { editDTO.setIdCard("320101199001010011"); editDTO.setPhone("13812345678"); editDTO.setStatus("0"); + editDTO.setPartyMember(0); editDTO.setAnnualIncome(new BigDecimal("45678.90")); editDTO.setAssetInfoList(List.of(buildAssetDto("车辆"))); @@ -104,6 +107,7 @@ class CcdiBaseStaffServiceImplTest { assertEquals(1, result); ArgumentCaptor staffCaptor = ArgumentCaptor.forClass(CcdiBaseStaff.class); verify(baseStaffMapper).updateById(staffCaptor.capture()); + assertEquals(0, staffCaptor.getValue().getPartyMember()); assertEquals(new BigDecimal("45678.90"), staffCaptor.getValue().getAnnualIncome()); verify(assetInfoService, never()).deleteByFamilyId("320101199001010011"); verify(assetInfoService).replaceByFamilyId("320101199001010011", editDTO.getAssetInfoList()); @@ -122,6 +126,7 @@ class CcdiBaseStaffServiceImplTest { editDTO.setIdCard("320101199001010011"); editDTO.setPhone("13812345678"); editDTO.setStatus("0"); + editDTO.setPartyMember(1); editDTO.setAssetInfoList(List.of(buildAssetDto("车辆"))); when(baseStaffMapper.selectById(1001L)).thenReturn(existing); @@ -135,17 +140,18 @@ class CcdiBaseStaffServiceImplTest { } @Test - void selectBaseStaffById_shouldReturnAssetInfoList() { + void selectBaseStaffById_shouldReturnSelfOwnedAssetInfoList() { CcdiBaseStaff staff = new CcdiBaseStaff(); staff.setStaffId(1001L); staff.setName("张三"); staff.setIdCard("320101199001010011"); staff.setStatus("0"); + staff.setPartyMember(1); staff.setAnnualIncome(new BigDecimal("88888.88")); CcdiAssetInfo assetInfo = new CcdiAssetInfo(); assetInfo.setFamilyId("320101199001010011"); - assetInfo.setPersonId("320101199201010022"); + assetInfo.setPersonId("320101199001010011"); assetInfo.setAssetMainType("车辆"); assetInfo.setAssetSubType("小汽车"); assetInfo.setAssetName("家庭车辆"); @@ -153,14 +159,16 @@ class CcdiBaseStaffServiceImplTest { assetInfo.setAssetStatus("正常"); when(baseStaffMapper.selectById(1001L)).thenReturn(staff); - when(assetInfoService.selectByFamilyId("320101199001010011")).thenReturn(List.of(assetInfo)); + when(assetInfoService.selectByFamilyIdAndPersonId("320101199001010011", "320101199001010011")) + .thenReturn(List.of(assetInfo)); CcdiBaseStaffVO result = service.selectBaseStaffById(1001L); assertNotNull(result.getAssetInfoList()); + assertEquals(1, result.getPartyMember()); assertEquals(new BigDecimal("88888.88"), result.getAnnualIncome()); assertEquals(1, result.getAssetInfoList().size()); - assertEquals("320101199201010022", result.getAssetInfoList().get(0).getPersonId()); + assertEquals("320101199001010011", result.getAssetInfoList().get(0).getPersonId()); assertEquals("车辆", result.getAssetInfoList().get(0).getAssetMainType()); } diff --git a/ccdi-info-collection/src/test/java/com/ruoyi/info/collection/service/CcdiEnterpriseBaseInfoImportServiceImplTest.java b/ccdi-info-collection/src/test/java/com/ruoyi/info/collection/service/CcdiEnterpriseBaseInfoImportServiceImplTest.java new file mode 100644 index 00000000..1791ae31 --- /dev/null +++ b/ccdi-info-collection/src/test/java/com/ruoyi/info/collection/service/CcdiEnterpriseBaseInfoImportServiceImplTest.java @@ -0,0 +1,77 @@ +package com.ruoyi.info.collection.service; + +import com.ruoyi.info.collection.domain.CcdiEnterpriseBaseInfo; +import com.ruoyi.info.collection.domain.excel.CcdiEnterpriseBaseInfoExcel; +import com.ruoyi.info.collection.service.impl.CcdiEnterpriseBaseInfoImportServiceImpl; +import org.junit.jupiter.api.Test; + +import java.util.HashSet; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class CcdiEnterpriseBaseInfoImportServiceImplTest { + + private final CcdiEnterpriseBaseInfoImportServiceImpl service = new CcdiEnterpriseBaseInfoImportServiceImpl(); + + @Test + void validateAndBuildEntity_shouldRejectWhenDatabaseAlreadyContainsCreditCode() { + CcdiEnterpriseBaseInfoExcel excel = buildExcel(); + + RuntimeException exception = assertThrows(RuntimeException.class, + () -> service.validateAndBuildEntity(excel, Set.of("91310000123456789A"), new HashSet<>(), "admin")); + + assertEquals("统一社会信用代码[91310000123456789A]已存在,请勿重复导入", exception.getMessage()); + } + + @Test + void validateAndBuildEntity_shouldRejectWhenExcelContainsDuplicateCreditCode() { + CcdiEnterpriseBaseInfoExcel excel = buildExcel(); + + RuntimeException exception = assertThrows(RuntimeException.class, + () -> service.validateAndBuildEntity(excel, Set.of(), new HashSet<>(Set.of("91310000123456789A")), "admin")); + + assertEquals("统一社会信用代码[91310000123456789A]在导入文件中重复,已跳过此条记录", exception.getMessage()); + } + + @Test + void validateAndBuildEntity_shouldNormalizeEnumTextToCode() { + CcdiEnterpriseBaseInfoExcel excel = buildExcel(); + excel.setRiskLevel("高风险"); + excel.setEntSource("一般企业"); + + CcdiEnterpriseBaseInfo entity = service.validateAndBuildEntity(excel, Set.of(), new HashSet<>(), "admin"); + + assertEquals("1", entity.getRiskLevel()); + assertEquals("GENERAL", entity.getEntSource()); + assertEquals("IMPORT", entity.getDataSource()); + assertEquals("admin", entity.getCreatedBy()); + } + + @Test + void validateAndBuildEntity_shouldAllowBlankStatus() { + CcdiEnterpriseBaseInfoExcel excel = buildExcel(); + excel.setStatus(null); + + CcdiEnterpriseBaseInfo entity = service.validateAndBuildEntity(excel, Set.of(), new HashSet<>(), "admin"); + + assertNull(entity.getStatus()); + assertEquals("IMPORT", entity.getDataSource()); + } + + private CcdiEnterpriseBaseInfoExcel buildExcel() { + CcdiEnterpriseBaseInfoExcel excel = new CcdiEnterpriseBaseInfoExcel(); + excel.setSocialCreditCode("91310000123456789A"); + excel.setEnterpriseName("测试企业"); + excel.setEnterpriseType("有限责任公司"); + excel.setEnterpriseNature("民营企业"); + excel.setIndustryClass("制造业"); + excel.setIndustryName("电子设备"); + excel.setStatus("存续"); + excel.setRiskLevel("1"); + excel.setEntSource("GENERAL"); + return excel; + } +} diff --git a/ccdi-info-collection/src/test/java/com/ruoyi/info/collection/service/CcdiEnterpriseBaseInfoServiceImplTest.java b/ccdi-info-collection/src/test/java/com/ruoyi/info/collection/service/CcdiEnterpriseBaseInfoServiceImplTest.java new file mode 100644 index 00000000..6021203c --- /dev/null +++ b/ccdi-info-collection/src/test/java/com/ruoyi/info/collection/service/CcdiEnterpriseBaseInfoServiceImplTest.java @@ -0,0 +1,204 @@ +package com.ruoyi.info.collection.service; + +import com.ruoyi.info.collection.domain.CcdiEnterpriseBaseInfo; +import com.ruoyi.info.collection.domain.dto.CcdiEnterpriseBaseInfoAddDTO; +import com.ruoyi.info.collection.domain.dto.CcdiEnterpriseBaseInfoEditDTO; +import com.ruoyi.info.collection.domain.vo.CcdiEnterpriseBaseInfoVO; +import com.ruoyi.info.collection.mapper.CcdiCustEnterpriseRelationMapper; +import com.ruoyi.info.collection.mapper.CcdiEnterpriseBaseInfoMapper; +import com.ruoyi.info.collection.mapper.CcdiIntermediaryEnterpriseRelationMapper; +import com.ruoyi.info.collection.mapper.CcdiStaffEnterpriseRelationMapper; +import com.ruoyi.info.collection.service.impl.CcdiEnterpriseBaseInfoServiceImpl; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.redis.core.RedisTemplate; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class CcdiEnterpriseBaseInfoServiceImplTest { + + @InjectMocks + private CcdiEnterpriseBaseInfoServiceImpl service; + + @Mock + private CcdiEnterpriseBaseInfoMapper enterpriseBaseInfoMapper; + + @Mock + private CcdiStaffEnterpriseRelationMapper staffEnterpriseRelationMapper; + + @Mock + private CcdiCustEnterpriseRelationMapper custEnterpriseRelationMapper; + + @Mock + private CcdiIntermediaryEnterpriseRelationMapper intermediaryEnterpriseRelationMapper; + + @Mock + private ICcdiEnterpriseBaseInfoImportService enterpriseBaseInfoImportService; + + @Mock + private RedisTemplate redisTemplate; + + @Test + void insertEnterpriseBaseInfo_shouldPersistWhenSocialCreditCodeIsUnique() { + CcdiEnterpriseBaseInfoAddDTO addDTO = buildAddDto(); + when(enterpriseBaseInfoMapper.selectById(addDTO.getSocialCreditCode())).thenReturn(null); + when(enterpriseBaseInfoMapper.insert(any(CcdiEnterpriseBaseInfo.class))).thenReturn(1); + + int result = service.insertEnterpriseBaseInfo(addDTO); + + assertEquals(1, result); + ArgumentCaptor captor = ArgumentCaptor.forClass(CcdiEnterpriseBaseInfo.class); + verify(enterpriseBaseInfoMapper).insert(captor.capture()); + assertEquals("测试企业", captor.getValue().getEnterpriseName()); + assertEquals("GENERAL", captor.getValue().getEntSource()); + } + + @Test + void insertEnterpriseBaseInfo_shouldSetManualDataSourceAndAllowBlankStatus() { + CcdiEnterpriseBaseInfoAddDTO addDTO = buildAddDto(); + addDTO.setStatus(null); + addDTO.setDataSource("API"); + when(enterpriseBaseInfoMapper.selectById(addDTO.getSocialCreditCode())).thenReturn(null); + when(enterpriseBaseInfoMapper.insert(any(CcdiEnterpriseBaseInfo.class))).thenReturn(1); + + int result = service.insertEnterpriseBaseInfo(addDTO); + + assertEquals(1, result); + ArgumentCaptor captor = ArgumentCaptor.forClass(CcdiEnterpriseBaseInfo.class); + verify(enterpriseBaseInfoMapper).insert(captor.capture()); + assertEquals("MANUAL", captor.getValue().getDataSource()); + assertNull(captor.getValue().getStatus()); + } + + @Test + void insertEnterpriseBaseInfo_shouldRejectInvalidRiskLevel() { + CcdiEnterpriseBaseInfoAddDTO addDTO = buildAddDto(); + addDTO.setRiskLevel("9"); + when(enterpriseBaseInfoMapper.selectById(addDTO.getSocialCreditCode())).thenReturn(null); + + RuntimeException exception = assertThrows(RuntimeException.class, + () -> service.insertEnterpriseBaseInfo(addDTO)); + + assertEquals("风险等级不在允许范围内", exception.getMessage()); + } + + @Test + void updateEnterpriseBaseInfo_shouldRejectWhenRecordMissing() { + CcdiEnterpriseBaseInfoEditDTO editDTO = buildEditDto(); + when(enterpriseBaseInfoMapper.selectById(editDTO.getSocialCreditCode())).thenReturn(null); + + RuntimeException exception = assertThrows(RuntimeException.class, + () -> service.updateEnterpriseBaseInfo(editDTO)); + + assertEquals("实体库记录不存在", exception.getMessage()); + } + + @Test + void updateEnterpriseBaseInfo_shouldKeepExistingDataSource() { + CcdiEnterpriseBaseInfoEditDTO editDTO = buildEditDto(); + editDTO.setDataSource("API"); + CcdiEnterpriseBaseInfo existing = new CcdiEnterpriseBaseInfo(); + existing.setSocialCreditCode(editDTO.getSocialCreditCode()); + existing.setDataSource("MANUAL"); + when(enterpriseBaseInfoMapper.selectById(editDTO.getSocialCreditCode())).thenReturn(existing); + when(enterpriseBaseInfoMapper.updateById(any(CcdiEnterpriseBaseInfo.class))).thenReturn(1); + + int result = service.updateEnterpriseBaseInfo(editDTO); + + assertEquals(1, result); + ArgumentCaptor captor = ArgumentCaptor.forClass(CcdiEnterpriseBaseInfo.class); + verify(enterpriseBaseInfoMapper).updateById(captor.capture()); + assertEquals("MANUAL", captor.getValue().getDataSource()); + } + + @Test + void selectEnterpriseBaseInfoById_shouldConvertEntityToVo() { + CcdiEnterpriseBaseInfo entity = new CcdiEnterpriseBaseInfo(); + entity.setSocialCreditCode("91310000123456789A"); + entity.setEnterpriseName("测试企业"); + entity.setRiskLevel("1"); + entity.setEntSource("GENERAL"); + when(enterpriseBaseInfoMapper.selectById("91310000123456789A")).thenReturn(entity); + + CcdiEnterpriseBaseInfoVO vo = service.selectEnterpriseBaseInfoById("91310000123456789A"); + + assertNotNull(vo); + assertEquals("测试企业", vo.getEnterpriseName()); + assertEquals("1", vo.getRiskLevel()); + } + + @Test + void deleteEnterpriseBaseInfoByIds_shouldDeleteInBatch() { + when(staffEnterpriseRelationMapper.selectCount(any())).thenReturn(0L); + when(custEnterpriseRelationMapper.selectCount(any())).thenReturn(0L); + when(intermediaryEnterpriseRelationMapper.selectCount(any())).thenReturn(0L); + when(enterpriseBaseInfoMapper.deleteBatchIds(java.util.List.of("91310000123456789A", "91310000123456789B"))) + .thenReturn(2); + + int result = service.deleteEnterpriseBaseInfoByIds(new String[]{"91310000123456789A", "91310000123456789B"}); + + assertEquals(2, result); + verify(enterpriseBaseInfoMapper).deleteBatchIds(java.util.List.of("91310000123456789A", "91310000123456789B")); + } + + @Test + void deleteEnterpriseBaseInfoByIds_shouldRejectWhenStaffRelationExists() { + when(staffEnterpriseRelationMapper.selectCount(any())).thenReturn(1L); + when(custEnterpriseRelationMapper.selectCount(any())).thenReturn(0L); + when(intermediaryEnterpriseRelationMapper.selectCount(any())).thenReturn(0L); + + RuntimeException exception = assertThrows(RuntimeException.class, + () -> service.deleteEnterpriseBaseInfoByIds(new String[]{"91310000123456789A"})); + + assertEquals("统一社会信用代码[91310000123456789A]已关联员工,删除失败", exception.getMessage()); + } + + @Test + void deleteEnterpriseBaseInfoByIds_shouldRejectWhenMultipleRelationsExist() { + when(staffEnterpriseRelationMapper.selectCount(any())).thenReturn(1L); + when(custEnterpriseRelationMapper.selectCount(any())).thenReturn(1L); + when(intermediaryEnterpriseRelationMapper.selectCount(any())).thenReturn(1L); + + RuntimeException exception = assertThrows(RuntimeException.class, + () -> service.deleteEnterpriseBaseInfoByIds(new String[]{"91310000123456789A"})); + + assertEquals("统一社会信用代码[91310000123456789A]已关联员工、信贷客户、中介,删除失败", exception.getMessage()); + } + + private CcdiEnterpriseBaseInfoAddDTO buildAddDto() { + CcdiEnterpriseBaseInfoAddDTO dto = new CcdiEnterpriseBaseInfoAddDTO(); + dto.setSocialCreditCode("91310000123456789A"); + dto.setEnterpriseName("测试企业"); + dto.setEnterpriseType("有限责任公司"); + dto.setEnterpriseNature("民营企业"); + dto.setIndustryClass("制造业"); + dto.setIndustryName("电子设备"); + dto.setStatus("存续"); + dto.setRiskLevel("1"); + dto.setEntSource("GENERAL"); + dto.setDataSource("MANUAL"); + return dto; + } + + private CcdiEnterpriseBaseInfoEditDTO buildEditDto() { + CcdiEnterpriseBaseInfoEditDTO dto = new CcdiEnterpriseBaseInfoEditDTO(); + dto.setSocialCreditCode("91310000123456789A"); + dto.setEnterpriseName("测试企业"); + dto.setStatus("存续"); + dto.setRiskLevel("1"); + dto.setEntSource("GENERAL"); + dto.setDataSource("MANUAL"); + return dto; + } +} diff --git a/ccdi-info-collection/src/test/java/com/ruoyi/info/collection/service/CcdiIntermediaryEnterpriseRelationImportServiceImplTest.java b/ccdi-info-collection/src/test/java/com/ruoyi/info/collection/service/CcdiIntermediaryEnterpriseRelationImportServiceImplTest.java new file mode 100644 index 00000000..d4eff2f9 --- /dev/null +++ b/ccdi-info-collection/src/test/java/com/ruoyi/info/collection/service/CcdiIntermediaryEnterpriseRelationImportServiceImplTest.java @@ -0,0 +1,172 @@ +package com.ruoyi.info.collection.service; + +import com.ruoyi.info.collection.domain.CcdiBizIntermediary; +import com.ruoyi.info.collection.domain.CcdiEnterpriseBaseInfo; +import com.ruoyi.info.collection.domain.CcdiIntermediaryEnterpriseRelation; +import com.ruoyi.info.collection.domain.excel.CcdiIntermediaryEnterpriseRelationExcel; +import com.ruoyi.info.collection.domain.vo.IntermediaryEnterpriseRelationImportFailureVO; +import com.ruoyi.info.collection.mapper.CcdiBizIntermediaryMapper; +import com.ruoyi.info.collection.mapper.CcdiEnterpriseBaseInfoMapper; +import com.ruoyi.info.collection.mapper.CcdiIntermediaryEnterpriseRelationMapper; +import com.ruoyi.info.collection.service.impl.CcdiIntermediaryEnterpriseRelationImportServiceImpl; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.redis.core.HashOperations; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.ValueOperations; + +import java.util.List; +import java.util.concurrent.TimeUnit; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class CcdiIntermediaryEnterpriseRelationImportServiceImplTest { + + @InjectMocks + private CcdiIntermediaryEnterpriseRelationImportServiceImpl service; + + @Mock + private CcdiIntermediaryEnterpriseRelationMapper relationMapper; + + @Mock + private CcdiBizIntermediaryMapper intermediaryMapper; + + @Mock + private CcdiEnterpriseBaseInfoMapper enterpriseBaseInfoMapper; + + @Mock + private RedisTemplate redisTemplate; + + @Mock + private HashOperations hashOperations; + + @Mock + private ValueOperations valueOperations; + + @Test + void importEnterpriseRelationAsync_shouldFailWhenOwnerPersonIdDoesNotExist() { + CcdiIntermediaryEnterpriseRelationExcel excel = buildExcel("320101199001010014", "91330100MA27X12345"); + prepareFailureRedisMocks(); + when(intermediaryMapper.selectList(any())).thenReturn(List.of()); + when(enterpriseBaseInfoMapper.selectList(any())).thenReturn(List.of(enterprise("91330100MA27X12345"))); + + service.importAsync(List.of(excel), "task-owner-miss", "tester"); + + verify(relationMapper, never()).insertBatch(any()); + IntermediaryEnterpriseRelationImportFailureVO failure = + firstFailure("import:intermediary-enterprise-relation:task-owner-miss:failures"); + assertEquals("320101199001010014", failure.getOwnerPersonId()); + assertTrue(failure.getErrorMessage().contains("中介本人不存在")); + } + + @Test + void importEnterpriseRelationAsync_shouldFailWhenEnterpriseDoesNotExist() { + CcdiIntermediaryEnterpriseRelationExcel excel = buildExcel("320101199001010014", "91330100MA27X12345"); + prepareFailureRedisMocks(); + when(intermediaryMapper.selectList(any())).thenReturn(List.of(owner("owner-biz", "320101199001010014"))); + when(enterpriseBaseInfoMapper.selectList(any())).thenReturn(List.of()); + when(relationMapper.batchExistsByCombinations(any())).thenReturn(List.of()); + + service.importAsync(List.of(excel), "task-ent-miss", "tester"); + + verify(relationMapper, never()).insertBatch(any()); + IntermediaryEnterpriseRelationImportFailureVO failure = + firstFailure("import:intermediary-enterprise-relation:task-ent-miss:failures"); + assertEquals("91330100MA27X12345", failure.getSocialCreditCode()); + assertTrue(failure.getErrorMessage().contains("机构表")); + } + + @Test + void importEnterpriseRelationAsync_shouldRejectFileDuplicateAndDbDuplicate() { + CcdiIntermediaryEnterpriseRelationExcel duplicateInDb = buildExcel("320101199001010014", "91330100MA27X12345"); + CcdiIntermediaryEnterpriseRelationExcel duplicateInFile1 = buildExcel("320101199003030035", "91330100MA27X12346"); + CcdiIntermediaryEnterpriseRelationExcel duplicateInFile2 = buildExcel("320101199003030035", "91330100MA27X12346"); + prepareFailureRedisMocks(); + when(intermediaryMapper.selectList(any())).thenReturn(List.of( + owner("owner-biz-1", "320101199001010014"), + owner("owner-biz-2", "320101199003030035") + )); + when(enterpriseBaseInfoMapper.selectList(any())).thenReturn(List.of( + enterprise("91330100MA27X12345"), + enterprise("91330100MA27X12346") + )); + when(relationMapper.batchExistsByCombinations(any())).thenReturn(List.of("owner-biz-1|91330100MA27X12345")); + + service.importAsync(List.of(duplicateInDb, duplicateInFile1, duplicateInFile2), "task-duplicate", "tester"); + + ArgumentCaptor> captor = ArgumentCaptor.forClass(List.class); + verify(relationMapper).insertBatch(captor.capture()); + assertEquals(1, captor.getValue().size()); + assertEquals("owner-biz-2", captor.getValue().get(0).getIntermediaryBizId()); + IntermediaryEnterpriseRelationImportFailureVO failure = + firstFailure("import:intermediary-enterprise-relation:task-duplicate:failures"); + assertTrue(failure.getErrorMessage().contains("重复") || failure.getErrorMessage().contains("已存在")); + } + + @Test + void importEnterpriseRelationAsync_shouldImportSuccessWhenOwnerAndEnterpriseExist() { + CcdiIntermediaryEnterpriseRelationExcel excel = buildExcel("320101199001010014", "91330100MA27X12345"); + prepareStatusRedisMock(); + when(intermediaryMapper.selectList(any())).thenReturn(List.of(owner("owner-biz", "320101199001010014"))); + when(enterpriseBaseInfoMapper.selectList(any())).thenReturn(List.of(enterprise("91330100MA27X12345"))); + when(relationMapper.batchExistsByCombinations(any())).thenReturn(List.of()); + + service.importAsync(List.of(excel), "task-success", "tester"); + + ArgumentCaptor> captor = ArgumentCaptor.forClass(List.class); + verify(relationMapper).insertBatch(captor.capture()); + assertEquals(1, captor.getValue().size()); + assertEquals("owner-biz", captor.getValue().get(0).getIntermediaryBizId()); + verify(valueOperations, never()).set(any(), any(), any(Long.class), any(TimeUnit.class)); + } + + private void prepareStatusRedisMock() { + when(redisTemplate.opsForHash()).thenReturn(hashOperations); + } + + private void prepareFailureRedisMocks() { + prepareStatusRedisMock(); + when(redisTemplate.opsForValue()).thenReturn(valueOperations); + } + + private IntermediaryEnterpriseRelationImportFailureVO firstFailure(String key) { + ArgumentCaptor failureCaptor = ArgumentCaptor.forClass(Object.class); + verify(valueOperations).set(org.mockito.ArgumentMatchers.eq(key), failureCaptor.capture(), + org.mockito.ArgumentMatchers.eq(7L), org.mockito.ArgumentMatchers.eq(TimeUnit.DAYS)); + return (IntermediaryEnterpriseRelationImportFailureVO) ((List) failureCaptor.getValue()).get(0); + } + + private CcdiIntermediaryEnterpriseRelationExcel buildExcel(String ownerPersonId, String socialCreditCode) { + CcdiIntermediaryEnterpriseRelationExcel excel = new CcdiIntermediaryEnterpriseRelationExcel(); + excel.setOwnerPersonId(ownerPersonId); + excel.setSocialCreditCode(socialCreditCode); + excel.setRelationPersonPost("董事"); + excel.setRemark("备注"); + return excel; + } + + private CcdiBizIntermediary owner(String bizId, String personId) { + CcdiBizIntermediary owner = new CcdiBizIntermediary(); + owner.setBizId(bizId); + owner.setPersonId(personId); + owner.setPersonSubType("本人"); + return owner; + } + + private CcdiEnterpriseBaseInfo enterprise(String socialCreditCode) { + CcdiEnterpriseBaseInfo enterprise = new CcdiEnterpriseBaseInfo(); + enterprise.setSocialCreditCode(socialCreditCode); + enterprise.setEnterpriseName("机构" + socialCreditCode.substring(socialCreditCode.length() - 2)); + return enterprise; + } +} diff --git a/ccdi-info-collection/src/test/java/com/ruoyi/info/collection/service/CcdiIntermediaryPersonImportServiceImplTest.java b/ccdi-info-collection/src/test/java/com/ruoyi/info/collection/service/CcdiIntermediaryPersonImportServiceImplTest.java new file mode 100644 index 00000000..31680827 --- /dev/null +++ b/ccdi-info-collection/src/test/java/com/ruoyi/info/collection/service/CcdiIntermediaryPersonImportServiceImplTest.java @@ -0,0 +1,176 @@ +package com.ruoyi.info.collection.service; + +import com.ruoyi.common.annotation.DictDropdown; +import com.ruoyi.info.collection.domain.CcdiBizIntermediary; +import com.ruoyi.info.collection.domain.excel.CcdiIntermediaryPersonExcel; +import com.ruoyi.info.collection.domain.vo.IntermediaryPersonImportFailureVO; +import com.ruoyi.info.collection.mapper.CcdiBizIntermediaryMapper; +import com.ruoyi.info.collection.service.impl.CcdiIntermediaryPersonImportServiceImpl; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.redis.core.HashOperations; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.ValueOperations; + +import java.lang.reflect.Field; +import java.util.List; +import java.util.concurrent.TimeUnit; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class CcdiIntermediaryPersonImportServiceImplTest { + + @InjectMocks + private CcdiIntermediaryPersonImportServiceImpl service; + + @Mock + private CcdiBizIntermediaryMapper intermediaryMapper; + + @Mock + private RedisTemplate redisTemplate; + + @Mock + private HashOperations hashOperations; + + @Mock + private ValueOperations valueOperations; + + @Test + void intermediaryPersonExcel_shouldUsePersonSubTypeDropdownAndDropRelationType() throws Exception { + Field personSubTypeField = CcdiIntermediaryPersonExcel.class.getDeclaredField("personSubType"); + DictDropdown dictDropdown = personSubTypeField.getAnnotation(DictDropdown.class); + + assertNotNull(dictDropdown); + assertEquals("ccdi_person_sub_type", dictDropdown.dictType()); + assertThrows(NoSuchFieldException.class, () -> CcdiIntermediaryPersonExcel.class.getDeclaredField("relationType")); + assertThrows(NoSuchFieldException.class, () -> IntermediaryPersonImportFailureVO.class.getDeclaredField("relationType")); + } + + @Test + void importPersonAsync_shouldFailWhenOwnerRowContainsOwnerPersonIdReference() { + CcdiIntermediaryPersonExcel owner = buildOwnerExcel("320101199001010014"); + owner.setRelatedNumId("320101199105050053"); + prepareFailureRedisMocks(); + + service.importPersonAsync(List.of(owner), "task-owner", "tester"); + + verify(intermediaryMapper, never()).insertBatch(any()); + IntermediaryPersonImportFailureVO failure = firstFailure("import:intermediary:task-owner:failures"); + assertEquals("320101199105050053", failure.getRelatedNumId()); + assertTrue(failure.getErrorMessage().contains("本人")); + } + + @Test + void importPersonAsync_shouldFailWhenRelativeRowMissesOwnerPersonId() { + CcdiIntermediaryPersonExcel relative = buildRelativeExcel("320101199201010027", "配偶", null); + prepareFailureRedisMocks(); + + service.importPersonAsync(List.of(relative), "task-relative", "tester"); + + verify(intermediaryMapper, never()).insertBatch(any()); + IntermediaryPersonImportFailureVO failure = firstFailure("import:intermediary:task-relative:failures"); + assertEquals("320101199201010027", failure.getPersonId()); + assertTrue(failure.getErrorMessage().contains("关联中介本人证件号码")); + } + + @Test + void importPersonAsync_shouldAllowRelativeReferencingSuccessfulOwnerInSameFile() { + CcdiIntermediaryPersonExcel owner = buildOwnerExcel("320101199001010014"); + CcdiIntermediaryPersonExcel relative = buildRelativeExcel("320101199201010027", "配偶", "320101199001010014"); + prepareStatusRedisMock(); + when(intermediaryMapper.selectList(any())).thenReturn(List.of()); + + service.importPersonAsync(List.of(owner, relative), "task-mixed", "tester"); + + ArgumentCaptor> captor = ArgumentCaptor.forClass(List.class); + verify(intermediaryMapper).insertBatch(captor.capture()); + assertEquals(2, captor.getValue().size()); + assertEquals("本人", captor.getValue().get(0).getPersonSubType()); + assertEquals("320101199001010014", captor.getValue().get(1).getRelatedNumId()); + verify(valueOperations, never()).set(any(), any(), any(Long.class), any(TimeUnit.class)); + } + + @Test + void importPersonAsync_shouldAllowSameRelativePersonIdUnderDifferentOwners() { + CcdiIntermediaryPersonExcel owner1 = buildOwnerExcel("320101199001010014"); + CcdiIntermediaryPersonExcel owner2 = buildOwnerExcel("320101199003030035"); + CcdiIntermediaryPersonExcel relative1 = buildRelativeExcel("320101199201010027", "配偶", "320101199001010014"); + CcdiIntermediaryPersonExcel relative2 = buildRelativeExcel("320101199201010027", "配偶", "320101199003030035"); + prepareStatusRedisMock(); + when(intermediaryMapper.selectList(any())).thenReturn(List.of()); + + service.importPersonAsync(List.of(owner1, owner2, relative1, relative2), "task-owner-scope", "tester"); + + ArgumentCaptor> captor = ArgumentCaptor.forClass(List.class); + verify(intermediaryMapper).insertBatch(captor.capture()); + assertEquals(4, captor.getValue().size()); + verify(valueOperations, never()).set(any(), any(), any(Long.class), any(TimeUnit.class)); + } + + @Test + void importPersonAsync_shouldRejectDuplicateRelativeUnderSameOwner() { + CcdiIntermediaryPersonExcel owner = buildOwnerExcel("320101199001010014"); + CcdiIntermediaryPersonExcel relative1 = buildRelativeExcel("320101199201010027", "配偶", "320101199001010014"); + CcdiIntermediaryPersonExcel relative2 = buildRelativeExcel("320101199201010027", "配偶", "320101199001010014"); + prepareFailureRedisMocks(); + when(intermediaryMapper.selectList(any())).thenReturn(List.of()); + + service.importPersonAsync(List.of(owner, relative1, relative2), "task-duplicate", "tester"); + + ArgumentCaptor> captor = ArgumentCaptor.forClass(List.class); + verify(intermediaryMapper).insertBatch(captor.capture()); + assertEquals(2, captor.getValue().size()); + IntermediaryPersonImportFailureVO failure = firstFailure("import:intermediary:task-duplicate:failures"); + assertEquals("320101199201010027", failure.getPersonId()); + assertTrue(failure.getErrorMessage().contains("重复")); + } + + private void prepareStatusRedisMock() { + when(redisTemplate.opsForHash()).thenReturn(hashOperations); + } + + private void prepareFailureRedisMocks() { + prepareStatusRedisMock(); + when(redisTemplate.opsForValue()).thenReturn(valueOperations); + } + + private IntermediaryPersonImportFailureVO firstFailure(String key) { + ArgumentCaptor failureCaptor = ArgumentCaptor.forClass(Object.class); + verify(valueOperations).set(org.mockito.ArgumentMatchers.eq(key), failureCaptor.capture(), + org.mockito.ArgumentMatchers.eq(7L), org.mockito.ArgumentMatchers.eq(TimeUnit.DAYS)); + return (IntermediaryPersonImportFailureVO) ((List) failureCaptor.getValue()).get(0); + } + + private CcdiIntermediaryPersonExcel buildOwnerExcel(String personId) { + CcdiIntermediaryPersonExcel excel = new CcdiIntermediaryPersonExcel(); + excel.setName("中介本人" + personId.substring(personId.length() - 2)); + excel.setPersonType("中介"); + excel.setPersonSubType("本人"); + excel.setIdType("身份证"); + excel.setPersonId(personId); + return excel; + } + + private CcdiIntermediaryPersonExcel buildRelativeExcel(String personId, String personSubType, String ownerPersonId) { + CcdiIntermediaryPersonExcel excel = new CcdiIntermediaryPersonExcel(); + excel.setName("中介亲属" + personId.substring(personId.length() - 2)); + excel.setPersonType("中介"); + excel.setPersonSubType(personSubType); + excel.setIdType("身份证"); + excel.setPersonId(personId); + excel.setRelatedNumId(ownerPersonId); + return excel; + } +} diff --git a/ccdi-info-collection/src/test/java/com/ruoyi/info/collection/service/CcdiStaffEnterpriseRelationImportServiceImplTest.java b/ccdi-info-collection/src/test/java/com/ruoyi/info/collection/service/CcdiStaffEnterpriseRelationImportServiceImplTest.java new file mode 100644 index 00000000..2660ea69 --- /dev/null +++ b/ccdi-info-collection/src/test/java/com/ruoyi/info/collection/service/CcdiStaffEnterpriseRelationImportServiceImplTest.java @@ -0,0 +1,138 @@ +package com.ruoyi.info.collection.service; + +import com.ruoyi.info.collection.domain.CcdiStaffEnterpriseRelation; +import com.ruoyi.info.collection.domain.CcdiStaffFmyRelation; +import com.ruoyi.info.collection.domain.excel.CcdiStaffEnterpriseRelationExcel; +import com.ruoyi.info.collection.service.impl.CcdiStaffEnterpriseRelationImportServiceImpl; +import org.junit.jupiter.api.Test; + +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.util.HashSet; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class CcdiStaffEnterpriseRelationImportServiceImplTest { + + private final CcdiStaffEnterpriseRelationImportServiceImpl service = new CcdiStaffEnterpriseRelationImportServiceImpl(); + + @Test + void validateAndBuildEntity_shouldBuildImportEntityForValidFamily() throws Exception { + CcdiStaffEnterpriseRelationExcel excel = buildExcel(); + CcdiStaffFmyRelation familyRelation = buildFamily(); + + CcdiStaffEnterpriseRelation entity = invokeValidateAndBuildEntity( + excel, + familyRelation, + Set.of(excel.getPersonId()), + Set.of(), + new HashSet<>(), + "admin" + ); + + assertEquals("IMPORT", entity.getDataSource()); + assertEquals(1, entity.getStatus()); + assertEquals(1, entity.getIsEmpFamily()); + assertEquals("admin", entity.getCreatedBy()); + } + + @Test + void validateAndBuildEntity_shouldRejectWhenFamilyDoesNotExist() throws Exception { + CcdiStaffEnterpriseRelationExcel excel = buildExcel(); + + RuntimeException exception = assertThrows(RuntimeException.class, + () -> invokeValidateAndBuildEntity(excel, null, Set.of(), Set.of(), new HashSet<>(), "admin")); + + assertEquals("亲属身份证号[" + excel.getPersonId() + "]不存在,请先维护员工亲属关系", exception.getMessage()); + } + + @Test + void validateAndBuildEntity_shouldRejectWhenFamilyIsInvalid() throws Exception { + CcdiStaffEnterpriseRelationExcel excel = buildExcel(); + + RuntimeException exception = assertThrows(RuntimeException.class, + () -> invokeValidateAndBuildEntity(excel, null, Set.of(excel.getPersonId()), Set.of(), new HashSet<>(), "admin")); + + assertEquals("亲属身份证号[" + excel.getPersonId() + "]不是有效员工亲属,请先维护有效的员工亲属关系", exception.getMessage()); + } + + @Test + void validateAndBuildEntity_shouldRejectWhenCombinationAlreadyExistsInDatabase() throws Exception { + CcdiStaffEnterpriseRelationExcel excel = buildExcel(); + CcdiStaffFmyRelation familyRelation = buildFamily(); + String combination = excel.getPersonId() + "|" + excel.getSocialCreditCode(); + + RuntimeException exception = assertThrows(RuntimeException.class, + () -> invokeValidateAndBuildEntity(excel, familyRelation, Set.of(excel.getPersonId()), Set.of(combination), new HashSet<>(), "admin")); + + assertEquals("亲属身份证号[" + excel.getPersonId() + "]和统一社会信用代码[" + excel.getSocialCreditCode() + "]的组合已存在,请勿重复导入", exception.getMessage()); + } + + @Test + void validateAndBuildEntity_shouldRejectWhenCombinationAlreadyExistsInExcel() throws Exception { + CcdiStaffEnterpriseRelationExcel excel = buildExcel(); + CcdiStaffFmyRelation familyRelation = buildFamily(); + String combination = excel.getPersonId() + "|" + excel.getSocialCreditCode(); + + RuntimeException exception = assertThrows(RuntimeException.class, + () -> invokeValidateAndBuildEntity(excel, familyRelation, Set.of(excel.getPersonId()), Set.of(), new HashSet<>(Set.of(combination)), "admin")); + + assertEquals("亲属身份证号[" + excel.getPersonId() + "]和统一社会信用代码[" + excel.getSocialCreditCode() + "]的组合在导入文件中重复,已跳过此条记录", exception.getMessage()); + } + + private CcdiStaffEnterpriseRelation invokeValidateAndBuildEntity(CcdiStaffEnterpriseRelationExcel excel, + CcdiStaffFmyRelation familyRelation, + Set knownFamilyCertNos, + Set existingCombinations, + Set processedCombinations, + String userName) throws Exception { + Method method = CcdiStaffEnterpriseRelationImportServiceImpl.class.getDeclaredMethod( + "validateAndBuildEntity", + CcdiStaffEnterpriseRelationExcel.class, + CcdiStaffFmyRelation.class, + Set.class, + Set.class, + Set.class, + String.class + ); + method.setAccessible(true); + try { + return (CcdiStaffEnterpriseRelation) method.invoke( + service, + excel, + familyRelation, + knownFamilyCertNos, + existingCombinations, + processedCombinations, + userName + ); + } catch (InvocationTargetException ex) { + Throwable targetException = ex.getTargetException(); + if (targetException instanceof RuntimeException runtimeException) { + throw runtimeException; + } + throw ex; + } + } + + private CcdiStaffEnterpriseRelationExcel buildExcel() { + CcdiStaffEnterpriseRelationExcel excel = new CcdiStaffEnterpriseRelationExcel(); + excel.setPersonId("320101199001010022"); + excel.setSocialCreditCode("91310000123456789A"); + excel.setEnterpriseName("测试企业"); + excel.setRelationPersonPost("董事"); + return excel; + } + + private CcdiStaffFmyRelation buildFamily() { + CcdiStaffFmyRelation familyRelation = new CcdiStaffFmyRelation(); + familyRelation.setPersonId("320101199001010011"); + familyRelation.setRelationCertNo("320101199001010022"); + familyRelation.setRelationName("李四"); + familyRelation.setStatus(1); + familyRelation.setIsEmpFamily(true); + return familyRelation; + } +} diff --git a/ccdi-info-collection/src/test/java/com/ruoyi/info/collection/service/CcdiStaffEnterpriseRelationServiceImplTest.java b/ccdi-info-collection/src/test/java/com/ruoyi/info/collection/service/CcdiStaffEnterpriseRelationServiceImplTest.java new file mode 100644 index 00000000..0876ac80 --- /dev/null +++ b/ccdi-info-collection/src/test/java/com/ruoyi/info/collection/service/CcdiStaffEnterpriseRelationServiceImplTest.java @@ -0,0 +1,156 @@ +package com.ruoyi.info.collection.service; + +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; +import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; +import com.ruoyi.info.collection.domain.CcdiStaffEnterpriseRelation; +import com.ruoyi.info.collection.domain.CcdiStaffFmyRelation; +import com.ruoyi.info.collection.domain.dto.CcdiStaffEnterpriseRelationAddDTO; +import com.ruoyi.info.collection.domain.dto.CcdiStaffEnterpriseRelationEditDTO; +import com.ruoyi.info.collection.domain.vo.CcdiStaffEnterpriseRelationOptionVO; +import com.ruoyi.info.collection.mapper.CcdiStaffEnterpriseRelationMapper; +import com.ruoyi.info.collection.mapper.CcdiStaffFmyRelationMapper; +import com.ruoyi.info.collection.service.impl.CcdiStaffEnterpriseRelationServiceImpl; +import org.apache.ibatis.builder.MapperBuilderAssistant; +import org.apache.ibatis.session.Configuration; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.redis.core.RedisTemplate; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class CcdiStaffEnterpriseRelationServiceImplTest { + + @BeforeAll + static void initTableInfo() { + registerTableInfo(CcdiStaffEnterpriseRelation.class, CcdiStaffEnterpriseRelationMapper.class.getName()); + registerTableInfo(CcdiStaffFmyRelation.class, CcdiStaffFmyRelationMapper.class.getName()); + } + + @InjectMocks + private CcdiStaffEnterpriseRelationServiceImpl service; + + @Mock + private CcdiStaffEnterpriseRelationMapper relationMapper; + + @Mock + private CcdiStaffFmyRelationMapper familyRelationMapper; + + @Mock + private ICcdiStaffEnterpriseRelationImportService relationImportService; + + @Mock + private RedisTemplate redisTemplate; + + @Test + void insertRelation_shouldAllowValidFamily() { + CcdiStaffEnterpriseRelationAddDTO addDTO = buildAddDto(); + CcdiStaffFmyRelation familyRelation = new CcdiStaffFmyRelation(); + familyRelation.setRelationCertNo(addDTO.getPersonId()); + familyRelation.setRelationName("李四"); + familyRelation.setPersonId("320101199001010011"); + + when(familyRelationMapper.selectOne(any())).thenReturn(familyRelation); + when(relationMapper.existsByPersonIdAndSocialCreditCode(addDTO.getPersonId(), addDTO.getSocialCreditCode())).thenReturn(false); + when(relationMapper.insert(any(CcdiStaffEnterpriseRelation.class))).thenReturn(1); + + int result = service.insertRelation(addDTO); + + assertEquals(1, result); + ArgumentCaptor captor = ArgumentCaptor.forClass(CcdiStaffEnterpriseRelation.class); + verify(relationMapper).insert(captor.capture()); + assertEquals(1, captor.getValue().getStatus()); + assertEquals("MANUAL", captor.getValue().getDataSource()); + assertEquals(1, captor.getValue().getIsEmpFamily()); + } + + @Test + void insertRelation_shouldRejectInvalidFamily() { + CcdiStaffEnterpriseRelationAddDTO addDTO = buildAddDto(); + + when(familyRelationMapper.selectOne(any())) + .thenReturn(null) + .thenReturn(new CcdiStaffFmyRelation()); + + RuntimeException exception = assertThrows(RuntimeException.class, () -> service.insertRelation(addDTO)); + + assertEquals("亲属身份证号[" + addDTO.getPersonId() + "]不是有效员工亲属,请先维护有效的员工亲属关系", exception.getMessage()); + } + + @Test + void insertRelation_shouldRejectMissingFamily() { + CcdiStaffEnterpriseRelationAddDTO addDTO = buildAddDto(); + + when(familyRelationMapper.selectOne(any())).thenReturn(null); + + RuntimeException exception = assertThrows(RuntimeException.class, () -> service.insertRelation(addDTO)); + + assertEquals("亲属身份证号[" + addDTO.getPersonId() + "]不存在,请先维护员工亲属关系", exception.getMessage()); + } + + @Test + void updateRelation_shouldExecuteEditFlow() { + CcdiStaffEnterpriseRelationEditDTO editDTO = new CcdiStaffEnterpriseRelationEditDTO(); + editDTO.setId(1L); + editDTO.setPersonId("320101199001010022"); + editDTO.setSocialCreditCode("91310000123456789A"); + editDTO.setEnterpriseName("测试企业"); + editDTO.setRelationPersonPost("董事"); + editDTO.setStatus(0); + editDTO.setRemark("测试备注"); + + when(relationMapper.update(isNull(), any(LambdaUpdateWrapper.class))).thenReturn(1); + + int result = service.updateRelation(editDTO); + + assertEquals(1, result); + verify(relationMapper).update(isNull(), any(LambdaUpdateWrapper.class)); + } + + @Test + void selectFamilyOptions_shouldDelegateToMapper() { + CcdiStaffEnterpriseRelationOptionVO option = new CcdiStaffEnterpriseRelationOptionVO(); + option.setRelationCertNo("320101199001010022"); + option.setRelationName("李四"); + option.setStaffPersonId("320101199001010011"); + option.setStaffPersonName("张三"); + List expected = List.of(option); + + when(relationMapper.selectFamilyOptions("320101")).thenReturn(expected); + + List result = service.selectFamilyOptions("320101"); + + assertSame(expected, result); + } + + private CcdiStaffEnterpriseRelationAddDTO buildAddDto() { + CcdiStaffEnterpriseRelationAddDTO addDTO = new CcdiStaffEnterpriseRelationAddDTO(); + addDTO.setPersonId("320101199001010022"); + addDTO.setSocialCreditCode("91310000123456789A"); + addDTO.setEnterpriseName("测试企业"); + addDTO.setRelationPersonPost("董事"); + return addDTO; + } + + private static void registerTableInfo(Class entityClass, String namespace) { + if (TableInfoHelper.getTableInfo(entityClass) != null) { + return; + } + MapperBuilderAssistant assistant = new MapperBuilderAssistant(new Configuration(), ""); + assistant.setCurrentNamespace(namespace); + TableInfoHelper.initTableInfo(assistant, entityClass); + } +} diff --git a/ccdi-info-collection/src/test/java/com/ruoyi/info/collection/service/CcdiStaffFmyRelationServiceImplTest.java b/ccdi-info-collection/src/test/java/com/ruoyi/info/collection/service/CcdiStaffFmyRelationServiceImplTest.java index c4ac1a26..7a35dcc9 100644 --- a/ccdi-info-collection/src/test/java/com/ruoyi/info/collection/service/CcdiStaffFmyRelationServiceImplTest.java +++ b/ccdi-info-collection/src/test/java/com/ruoyi/info/collection/service/CcdiStaffFmyRelationServiceImplTest.java @@ -6,6 +6,7 @@ import com.ruoyi.info.collection.domain.dto.CcdiAssetInfoDTO; import com.ruoyi.info.collection.domain.dto.CcdiStaffFmyRelationAddDTO; import com.ruoyi.info.collection.domain.dto.CcdiStaffFmyRelationEditDTO; import com.ruoyi.info.collection.domain.vo.CcdiStaffFmyRelationVO; +import com.ruoyi.info.collection.mapper.CcdiStaffEnterpriseRelationMapper; import com.ruoyi.info.collection.mapper.CcdiStaffFmyRelationMapper; import com.ruoyi.info.collection.service.impl.CcdiStaffFmyRelationServiceImpl; import org.junit.jupiter.api.Test; @@ -46,6 +47,9 @@ class CcdiStaffFmyRelationServiceImplTest { @Mock private ICcdiAssetInfoService assetInfoService; + @Mock + private CcdiStaffEnterpriseRelationMapper staffEnterpriseRelationMapper; + @Test void selectRelationById_shouldAggregateAssetInfoList() { CcdiStaffFmyRelationVO relationVO = new CcdiStaffFmyRelationVO(); @@ -171,6 +175,36 @@ class CcdiStaffFmyRelationServiceImplTest { verify(assetInfoService).replaceByFamilyIdAndPersonId("320101199001010011", "A123456789", editDTO.getAssetInfoList()); } + @Test + void updateRelation_shouldInvalidateEnterpriseRelationsWhenFamilyBecomesInvalid() { + CcdiStaffFmyRelation existing = new CcdiStaffFmyRelation(); + existing.setId(10L); + existing.setRelationCertType("护照"); + existing.setRelationCertNo("A123456789"); + existing.setStatus(1); + + CcdiStaffFmyRelationEditDTO editDTO = new CcdiStaffFmyRelationEditDTO(); + editDTO.setId(10L); + editDTO.setPersonId("320101199001010011"); + editDTO.setRelationType("配偶"); + editDTO.setRelationName("李四"); + editDTO.setRelationCertType("护照"); + editDTO.setRelationCertNo("A123456789"); + editDTO.setStatus(0); + editDTO.setAssetInfoList(List.of(buildAssetDto("车辆"))); + + when(relationMapper.selectById(10L)).thenReturn(existing); + when(relationMapper.updateById(any(CcdiStaffFmyRelation.class))).thenReturn(1); + + int result = service.updateRelation(editDTO); + + assertEquals(1, result); + var order = inOrder(relationMapper, staffEnterpriseRelationMapper, assetInfoService); + order.verify(relationMapper).updateById(any(CcdiStaffFmyRelation.class)); + order.verify(staffEnterpriseRelationMapper).invalidateByFamilyCertNo("A123456789"); + order.verify(assetInfoService).replaceByFamilyIdAndPersonId("320101199001010011", "A123456789", editDTO.getAssetInfoList()); + } + @Test void deleteRelationByIds_shouldDeleteRelativeAssetsBeforeDeletingRelations() { CcdiStaffFmyRelation relation1 = new CcdiStaffFmyRelation(); diff --git a/ccdi-info-collection/src/test/java/com/ruoyi/info/collection/service/CcdiStaffRecruitmentDualImportContractTest.java b/ccdi-info-collection/src/test/java/com/ruoyi/info/collection/service/CcdiStaffRecruitmentDualImportContractTest.java new file mode 100644 index 00000000..924e2207 --- /dev/null +++ b/ccdi-info-collection/src/test/java/com/ruoyi/info/collection/service/CcdiStaffRecruitmentDualImportContractTest.java @@ -0,0 +1,64 @@ +package com.ruoyi.info.collection.service; + +import java.lang.reflect.Field; +import java.nio.file.Files; +import java.nio.file.Path; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class CcdiStaffRecruitmentDualImportContractTest { + + @Test + void shouldExposeSingleDualSheetImportEntry() throws Exception { + String controller = Files.readString( + Path.of("src/main/java/com/ruoyi/info/collection/controller/CcdiStaffRecruitmentController.java") + ); + assertTrue(controller.contains("\"招聘信息\"")); + assertTrue(controller.contains("\"历史工作经历\"")); + assertFalse(controller.contains("workImportTemplate")); + assertFalse(controller.contains("importWorkData")); + + String service = Files.readString( + Path.of("src/main/java/com/ruoyi/info/collection/service/ICcdiStaffRecruitmentService.java") + ); + assertTrue(service.contains("String importRecruitment(")); + assertTrue(service.contains("List recruitmentList")); + assertTrue(service.contains("List workList")); + assertFalse(service.contains("importRecruitmentWork(")); + + String importService = Files.readString( + Path.of("src/main/java/com/ruoyi/info/collection/service/ICcdiStaffRecruitmentImportService.java") + ); + assertTrue(importService.contains("void importRecruitmentAsync(")); + assertTrue(importService.contains("List recruitmentList")); + assertTrue(importService.contains("List workList")); + assertFalse(importService.contains("importRecruitmentWorkAsync(")); + } + + @Test + void shouldExposeFailureSheetFieldsAndSingleTaskInit() throws Exception { + assertHasField( + "com.ruoyi.info.collection.domain.vo.RecruitmentImportFailureVO", + "sheetName" + ); + assertHasField( + "com.ruoyi.info.collection.domain.vo.RecruitmentImportFailureVO", + "sheetRowNum" + ); + + String serviceImpl = Files.readString( + Path.of("src/main/java/com/ruoyi/info/collection/service/impl/CcdiStaffRecruitmentServiceImpl.java") + ); + assertTrue(serviceImpl.contains("recruitmentList.size() + workList.size()")); + assertFalse(serviceImpl.contains("importRecruitmentWork(")); + } + + private void assertHasField(String className, String fieldName) throws Exception { + Class clazz = Class.forName(className); + Field field = clazz.getDeclaredField(fieldName); + assertNotNull(field); + } +} diff --git a/ccdi-info-collection/src/test/java/com/ruoyi/info/collection/service/CcdiStaffRecruitmentImportServiceImplTest.java b/ccdi-info-collection/src/test/java/com/ruoyi/info/collection/service/CcdiStaffRecruitmentImportServiceImplTest.java new file mode 100644 index 00000000..2f3561f4 --- /dev/null +++ b/ccdi-info-collection/src/test/java/com/ruoyi/info/collection/service/CcdiStaffRecruitmentImportServiceImplTest.java @@ -0,0 +1,98 @@ +package com.ruoyi.info.collection.service; + +import com.ruoyi.info.collection.domain.CcdiStaffRecruitment; +import com.ruoyi.info.collection.domain.excel.CcdiStaffRecruitmentWorkExcel; +import com.ruoyi.info.collection.domain.vo.RecruitmentImportFailureVO; +import com.ruoyi.info.collection.mapper.CcdiStaffRecruitmentMapper; +import com.ruoyi.info.collection.mapper.CcdiStaffRecruitmentWorkMapper; +import com.ruoyi.info.collection.service.impl.CcdiStaffRecruitmentImportServiceImpl; +import java.util.Collections; +import java.util.List; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.redis.core.HashOperations; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.ValueOperations; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class CcdiStaffRecruitmentImportServiceImplTest { + + @InjectMocks + private CcdiStaffRecruitmentImportServiceImpl service; + + @Mock + private CcdiStaffRecruitmentMapper recruitmentMapper; + + @Mock + private CcdiStaffRecruitmentWorkMapper recruitmentWorkMapper; + + @Mock + private RedisTemplate redisTemplate; + + @Mock + private ValueOperations valueOperations; + + @Mock + private HashOperations hashOperations; + + @Test + void shouldFailWholeWorkGroupWhenExistingHistoryExists() { + when(redisTemplate.opsForValue()).thenReturn(valueOperations); + when(redisTemplate.opsForHash()).thenReturn(hashOperations); + when(recruitmentMapper.selectBatchIds(any())).thenReturn(List.of(buildRecruitment("RC001"))); + when(recruitmentWorkMapper.selectCount(any())).thenReturn(1L); + + CcdiStaffRecruitmentWorkExcel workRow = new CcdiStaffRecruitmentWorkExcel(); + workRow.setRecruitId("RC001"); + workRow.setCandName("张三"); + workRow.setRecruitName("社会招聘项目"); + workRow.setPosName("Java工程师"); + workRow.setSortOrder(1); + workRow.setCompanyName("测试科技"); + workRow.setPositionName("开发工程师"); + workRow.setJobStartMonth("2022-01"); + + service.importRecruitmentAsync(Collections.emptyList(), List.of(workRow), "task-1", "admin"); + + verify(recruitmentWorkMapper, never()).delete(any()); + verify(recruitmentWorkMapper, never()).insert(any(com.ruoyi.info.collection.domain.CcdiStaffRecruitmentWork.class)); + + ArgumentCaptor failureCaptor = ArgumentCaptor.forClass(Object.class); + verify(valueOperations).set(eq("import:recruitment:task-1:failures"), failureCaptor.capture(), anyLong(), any()); + Object rawFailures = failureCaptor.getValue(); + assertNotNull(rawFailures); + assertInstanceOf(List.class, rawFailures); + List failures = (List) rawFailures; + assertFalse(failures.isEmpty()); + RecruitmentImportFailureVO failure = (RecruitmentImportFailureVO) failures.get(0); + assertEquals("历史工作经历", failure.getSheetName()); + assertEquals("2", failure.getSheetRowNum()); + assertEquals("招聘记录编号[RC001]已存在历史工作经历,不允许重复导入", failure.getErrorMessage()); + } + + private CcdiStaffRecruitment buildRecruitment(String recruitId) { + CcdiStaffRecruitment recruitment = new CcdiStaffRecruitment(); + recruitment.setRecruitId(recruitId); + recruitment.setRecruitType("SOCIAL"); + recruitment.setCandName("张三"); + recruitment.setRecruitName("社会招聘项目"); + recruitment.setPosName("Java工程师"); + return recruitment; + } +} diff --git a/ccdi-info-collection/src/test/java/com/ruoyi/info/collection/utils/EasyExcelUtilTemplateTest.java b/ccdi-info-collection/src/test/java/com/ruoyi/info/collection/utils/EasyExcelUtilTemplateTest.java index 70c28458..44a5ec81 100644 --- a/ccdi-info-collection/src/test/java/com/ruoyi/info/collection/utils/EasyExcelUtilTemplateTest.java +++ b/ccdi-info-collection/src/test/java/com/ruoyi/info/collection/utils/EasyExcelUtilTemplateTest.java @@ -2,10 +2,15 @@ package com.ruoyi.info.collection.utils; import com.ruoyi.common.core.domain.entity.SysDictData; import com.ruoyi.common.utils.DictUtils; +import com.ruoyi.info.collection.domain.excel.CcdiBaseStaffExcel; import com.ruoyi.info.collection.domain.excel.CcdiAssetInfoExcel; +import com.ruoyi.info.collection.domain.excel.CcdiEnterpriseBaseInfoExcel; +import com.ruoyi.info.collection.domain.excel.CcdiStaffRecruitmentExcel; +import com.ruoyi.info.collection.domain.excel.CcdiStaffRecruitmentWorkExcel; import com.ruoyi.info.collection.domain.excel.CcdiStaffFmyRelationExcel; import org.apache.poi.ss.usermodel.CellStyle; import org.apache.poi.ss.usermodel.DataValidation; +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.util.CellRangeAddress; @@ -72,6 +77,84 @@ class EasyExcelUtilTemplateTest { } } + @Test + void importTemplateWithDictDropdown_shouldAddPartyMemberDropdownToBaseStaffTemplate() throws Exception { + MockHttpServletResponse response = new MockHttpServletResponse(); + + try (MockedStatic mocked = mockStatic(DictUtils.class)) { + mocked.when(() -> DictUtils.getDictCache("ccdi_employee_status")) + .thenReturn(List.of( + buildDictData("在职", "0"), + buildDictData("离职", "1") + )); + mocked.when(() -> DictUtils.getDictCache("ccdi_yes_no_flag")) + .thenReturn(List.of( + buildDictData("是", "1"), + buildDictData("否", "0") + )); + + EasyExcelUtil.importTemplateWithDictDropdown(response, CcdiBaseStaffExcel.class, "员工信息"); + } + + try (Workbook workbook = WorkbookFactory.create(new ByteArrayInputStream(response.getContentAsByteArray()))) { + Sheet sheet = workbook.getSheetAt(0); + assertTrue(hasValidationOnColumn(sheet, 7), "是否党员列应包含下拉校验"); + } + } + + @Test + void importTemplateWithDictDropdown_shouldCreateRecruitmentDualSheetTemplate() throws Exception { + MockHttpServletResponse response = new MockHttpServletResponse(); + + try (MockedStatic mocked = mockStatic(DictUtils.class)) { + mocked.when(() -> DictUtils.getDictCache("ccdi_admit_status")) + .thenReturn(List.of( + buildDictData("录用"), + buildDictData("未录用"), + buildDictData("放弃") + )); + + EasyExcelUtil.importTemplateWithDictDropdown( + response, + CcdiStaffRecruitmentExcel.class, + "招聘信息", + CcdiStaffRecruitmentWorkExcel.class, + "历史工作经历", + "招聘信息管理导入模板" + ); + } + + try (Workbook workbook = WorkbookFactory.create(new ByteArrayInputStream(response.getContentAsByteArray()))) { + assertEquals(2, workbook.getNumberOfSheets(), "招聘导入模板应输出双Sheet"); + assertEquals("招聘信息", workbook.getSheetAt(0).getSheetName()); + assertEquals("历史工作经历", workbook.getSheetAt(1).getSheetName()); + } + } + + @Test + void importTemplateWithDictDropdown_shouldUseUpdatedEnterpriseHeaders() throws Exception { + MockHttpServletResponse response = new MockHttpServletResponse(); + + try (MockedStatic mocked = mockStatic(DictUtils.class)) { + mocked.when(() -> DictUtils.getDictCache("ccdi_entity_type")) + .thenReturn(List.of(buildDictData("有限责任公司"))); + mocked.when(() -> DictUtils.getDictCache("ccdi_enterprise_nature")) + .thenReturn(List.of(buildDictData("民营企业"))); + mocked.when(() -> DictUtils.getDictCache("ccdi_certificate_type")) + .thenReturn(List.of(buildDictData("居民身份证"))); + + EasyExcelUtil.importTemplateWithDictDropdown(response, CcdiEnterpriseBaseInfoExcel.class, "实体库管理"); + } + + try (Workbook workbook = WorkbookFactory.create(new ByteArrayInputStream(response.getContentAsByteArray()))) { + Row headerRow = workbook.getSheetAt(0).getRow(0); + assertEquals("经营状态", headerRow.getCell(16).getStringCellValue()); + assertEquals("风险等级*", headerRow.getCell(17).getStringCellValue()); + assertEquals("企业来源*", headerRow.getCell(18).getStringCellValue()); + assertEquals(19, headerRow.getLastCellNum()); + } + } + private void assertTextColumn(Sheet sheet, int columnIndex) { CellStyle style = sheet.getColumnStyle(columnIndex); assertNotNull(style, "文本列应设置默认样式"); @@ -90,9 +173,13 @@ class EasyExcelUtilTemplateTest { } private SysDictData buildDictData(String label) { + return buildDictData(label, label); + } + + private SysDictData buildDictData(String label, String value) { SysDictData dictData = new SysDictData(); dictData.setDictLabel(label); - dictData.setDictValue(label); + dictData.setDictValue(value); return dictData; } } diff --git a/ccdi-lsfx/src/test/java/com/ruoyi/lsfx/client/CreditParseClientTest.java b/ccdi-lsfx/src/test/java/com/ruoyi/lsfx/client/CreditParseClientTest.java deleted file mode 100644 index 99da5353..00000000 --- a/ccdi-lsfx/src/test/java/com/ruoyi/lsfx/client/CreditParseClientTest.java +++ /dev/null @@ -1,90 +0,0 @@ -package com.ruoyi.lsfx.client; - -import com.fasterxml.jackson.databind.ObjectMapper; -import com.ruoyi.lsfx.domain.response.CreditParseResponse; -import com.ruoyi.lsfx.exception.LsfxApiException; -import com.ruoyi.lsfx.util.HttpUtil; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.InjectMocks; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; -import org.springframework.test.util.ReflectionTestUtils; - -import java.io.File; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.mockito.ArgumentMatchers.anyMap; -import static org.mockito.ArgumentMatchers.anyString; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.ArgumentMatchers.isNull; -import static org.mockito.Mockito.argThat; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - -@ExtendWith(MockitoExtension.class) -class CreditParseClientTest { - - private final ObjectMapper objectMapper = new ObjectMapper(); - - @Mock - private HttpUtil httpUtil; - - @InjectMocks - private CreditParseClient client; - - @BeforeEach - void setUp() { - ReflectionTestUtils.setField(client, "creditParseUrl", "http://credit-host/xfeature-mngs/conversation/htmlEval"); - } - - @Test - void shouldDeserializeCreditParseResponse() throws Exception { - String json = """ - { - "message": "成功", - "status_code": "0", - "payload": { - "lx_header": {"query_cert_no": "3301"}, - "lx_debt": {"uncle_bank_house_bal": "12.00"}, - "lx_publictype": {"civil_cnt": 1} - } - } - """; - - CreditParseResponse response = objectMapper.readValue(json, CreditParseResponse.class); - - assertEquals("0", response.getStatusCode()); - assertEquals("3301", response.getPayload().getLxHeader().get("query_cert_no")); - } - - @Test - void shouldCallConfiguredUrlWithMultipartParams() { - File file = new File("sample.html"); - CreditParseResponse response = new CreditParseResponse(); - response.setStatusCode("0"); - - when(httpUtil.uploadFile(eq("http://credit-host/xfeature-mngs/conversation/htmlEval"), anyMap(), isNull(), eq(CreditParseResponse.class))) - .thenReturn(response); - - CreditParseResponse actual = client.parse("LXCUSTALL", "PERSON", file); - - assertEquals("0", actual.getStatusCode()); - verify(httpUtil).uploadFile(eq("http://credit-host/xfeature-mngs/conversation/htmlEval"), argThat(params -> - "LXCUSTALL".equals(params.get("model")) - && "PERSON".equals(params.get("hType")) - && file.equals(params.get("file")) - ), isNull(), eq(CreditParseResponse.class)); - } - - @Test - void shouldWrapHttpErrorsAsLsfxApiException() { - when(httpUtil.uploadFile(anyString(), anyMap(), isNull(), eq(CreditParseResponse.class))) - .thenThrow(new LsfxApiException("网络失败")); - - assertThrows(LsfxApiException.class, - () -> client.parse("LXCUSTALL", "PERSON", new File("sample.html"))); - } -} diff --git a/ccdi-project/pom.xml b/ccdi-project/pom.xml index d9b29636..77febc35 100644 --- a/ccdi-project/pom.xml +++ b/ccdi-project/pom.xml @@ -43,6 +43,12 @@ springdoc-openapi-starter-webmvc-ui + + + com.alibaba + easyexcel + + org.springframework.boot diff --git a/ccdi-project/src/main/java/com/ruoyi/ccdi/project/controller/CcdiProjectOverviewController.java b/ccdi-project/src/main/java/com/ruoyi/ccdi/project/controller/CcdiProjectOverviewController.java index d31d11c7..605fe824 100644 --- a/ccdi-project/src/main/java/com/ruoyi/ccdi/project/controller/CcdiProjectOverviewController.java +++ b/ccdi-project/src/main/java/com/ruoyi/ccdi/project/controller/CcdiProjectOverviewController.java @@ -1,5 +1,6 @@ package com.ruoyi.ccdi.project.controller; +import com.ruoyi.ccdi.project.domain.dto.CcdiProjectAbnormalAccountQueryDTO; import com.ruoyi.ccdi.project.domain.dto.CcdiProjectEmployeeCreditNegativeQueryDTO; import com.ruoyi.ccdi.project.domain.dto.CcdiProjectPersonAnalysisDetailQueryDTO; import com.ruoyi.ccdi.project.domain.dto.CcdiProjectRiskModelPeopleQueryDTO; @@ -7,6 +8,7 @@ import com.ruoyi.ccdi.project.domain.dto.CcdiProjectRiskPeopleQueryDTO; import com.ruoyi.ccdi.project.domain.dto.CcdiProjectSuspiciousTransactionQueryDTO; import com.ruoyi.ccdi.project.domain.excel.CcdiProjectRiskPeopleOverviewExcel; import com.ruoyi.ccdi.project.domain.excel.CcdiProjectSuspiciousTransactionExcel; +import com.ruoyi.ccdi.project.domain.vo.CcdiProjectAbnormalAccountPageVO; import com.ruoyi.ccdi.project.domain.vo.CcdiProjectEmployeeCreditNegativePageVO; import com.ruoyi.ccdi.project.domain.vo.CcdiProjectPersonAnalysisDetailVO; import com.ruoyi.ccdi.project.domain.vo.CcdiProjectOverviewDashboardVO; @@ -130,6 +132,17 @@ public class CcdiProjectOverviewController extends BaseController { return AjaxResult.success(pageVO); } + /** + * 查询异常账户人员信息 + */ + @GetMapping("/abnormal-account-people") + @Operation(summary = "查询异常账户人员信息") + @PreAuthorize("@ss.hasPermi('ccdi:project:query')") + public AjaxResult getAbnormalAccountPeople(CcdiProjectAbnormalAccountQueryDTO queryDTO) { + CcdiProjectAbnormalAccountPageVO pageVO = overviewService.getAbnormalAccountPeople(queryDTO); + return AjaxResult.success(pageVO); + } + /** * 导出涉疑交易明细 */ diff --git a/ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/dto/CcdiProjectAbnormalAccountQueryDTO.java b/ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/dto/CcdiProjectAbnormalAccountQueryDTO.java new file mode 100644 index 00000000..02544c72 --- /dev/null +++ b/ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/dto/CcdiProjectAbnormalAccountQueryDTO.java @@ -0,0 +1,19 @@ +package com.ruoyi.ccdi.project.domain.dto; + +import lombok.Data; + +/** + * 异常账户人员信息查询 DTO + */ +@Data +public class CcdiProjectAbnormalAccountQueryDTO { + + /** 项目ID */ + private Long projectId; + + /** 页码 */ + private Integer pageNum; + + /** 每页数量 */ + private Integer pageSize; +} diff --git a/ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/excel/CcdiProjectAbnormalAccountExcel.java b/ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/excel/CcdiProjectAbnormalAccountExcel.java new file mode 100644 index 00000000..2d66b217 --- /dev/null +++ b/ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/excel/CcdiProjectAbnormalAccountExcel.java @@ -0,0 +1,29 @@ +package com.ruoyi.ccdi.project.domain.excel; + +import com.ruoyi.common.annotation.Excel; +import lombok.Data; + +/** + * 异常账户人员信息导出对象 + */ +@Data +public class CcdiProjectAbnormalAccountExcel { + + @Excel(name = "账号") + private String accountNo; + + @Excel(name = "开户人") + private String accountName; + + @Excel(name = "银行") + private String bankName; + + @Excel(name = "异常类型") + private String abnormalType; + + @Excel(name = "异常发生时间") + private String abnormalTime; + + @Excel(name = "状态") + private String status; +} diff --git a/ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/vo/CcdiProjectAbnormalAccountItemVO.java b/ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/vo/CcdiProjectAbnormalAccountItemVO.java new file mode 100644 index 00000000..e4ae1f6c --- /dev/null +++ b/ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/vo/CcdiProjectAbnormalAccountItemVO.java @@ -0,0 +1,22 @@ +package com.ruoyi.ccdi.project.domain.vo; + +import lombok.Data; + +/** + * 异常账户人员信息行对象 + */ +@Data +public class CcdiProjectAbnormalAccountItemVO { + + private String accountNo; + + private String accountName; + + private String bankName; + + private String abnormalType; + + private String abnormalTime; + + private String status; +} diff --git a/ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/vo/CcdiProjectAbnormalAccountPageVO.java b/ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/vo/CcdiProjectAbnormalAccountPageVO.java new file mode 100644 index 00000000..9ad9720a --- /dev/null +++ b/ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/vo/CcdiProjectAbnormalAccountPageVO.java @@ -0,0 +1,16 @@ +package com.ruoyi.ccdi.project.domain.vo; + +import java.util.ArrayList; +import java.util.List; +import lombok.Data; + +/** + * 异常账户人员信息分页结果 + */ +@Data +public class CcdiProjectAbnormalAccountPageVO { + + private List rows = new ArrayList<>(); + + private Long total = 0L; +} diff --git a/ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/vo/CcdiProjectExtendedPurchaseDetailVO.java b/ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/vo/CcdiProjectExtendedPurchaseDetailVO.java index 7e8dd911..ae378f1a 100644 --- a/ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/vo/CcdiProjectExtendedPurchaseDetailVO.java +++ b/ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/vo/CcdiProjectExtendedPurchaseDetailVO.java @@ -1,6 +1,7 @@ package com.ruoyi.ccdi.project.domain.vo; import java.math.BigDecimal; +import java.util.List; import lombok.Data; /** @@ -80,4 +81,6 @@ public class CcdiProjectExtendedPurchaseDetailVO { private String updatedBy; private String updateTime; + + private List supplierList; } diff --git a/ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/vo/CcdiProjectExtendedPurchaseSupplierVO.java b/ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/vo/CcdiProjectExtendedPurchaseSupplierVO.java new file mode 100644 index 00000000..f694af1b --- /dev/null +++ b/ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/vo/CcdiProjectExtendedPurchaseSupplierVO.java @@ -0,0 +1,28 @@ +package com.ruoyi.ccdi.project.domain.vo; + +import lombok.Data; + +/** + * 专项核查采购供应商明细 + */ +@Data +public class CcdiProjectExtendedPurchaseSupplierVO { + + private Long id; + + private String purchaseId; + + private String supplierName; + + private String supplierUscc; + + private String contactPerson; + + private String contactPhone; + + private String supplierBankAccount; + + private Integer isBidWinner; + + private Integer sortOrder; +} diff --git a/ccdi-project/src/main/java/com/ruoyi/ccdi/project/mapper/CcdiBankTagAnalysisMapper.java b/ccdi-project/src/main/java/com/ruoyi/ccdi/project/mapper/CcdiBankTagAnalysisMapper.java index 7985cd43..6559dd54 100644 --- a/ccdi-project/src/main/java/com/ruoyi/ccdi/project/mapper/CcdiBankTagAnalysisMapper.java +++ b/ccdi-project/src/main/java/com/ruoyi/ccdi/project/mapper/CcdiBankTagAnalysisMapper.java @@ -292,6 +292,22 @@ public interface CcdiBankTagAnalysisMapper { */ List selectSalaryUnusedObjects(@Param("projectId") Long projectId); + /** + * 突然销户 + * + * @param projectId 项目ID + * @return 对象命中结果 + */ + List selectSuddenAccountClosureObjects(@Param("projectId") Long projectId); + + /** + * 休眠账户大额启用 + * + * @param projectId 项目ID + * @return 对象命中结果 + */ + List selectDormantAccountLargeActivationObjects(@Param("projectId") Long projectId); + /** * 大额炒股 * diff --git a/ccdi-project/src/main/java/com/ruoyi/ccdi/project/mapper/CcdiProjectOverviewMapper.java b/ccdi-project/src/main/java/com/ruoyi/ccdi/project/mapper/CcdiProjectOverviewMapper.java index aeac8d66..496d145c 100644 --- a/ccdi-project/src/main/java/com/ruoyi/ccdi/project/mapper/CcdiProjectOverviewMapper.java +++ b/ccdi-project/src/main/java/com/ruoyi/ccdi/project/mapper/CcdiProjectOverviewMapper.java @@ -2,10 +2,12 @@ package com.ruoyi.ccdi.project.mapper; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.ruoyi.ccdi.project.domain.CcdiProject; +import com.ruoyi.ccdi.project.domain.dto.CcdiProjectAbnormalAccountQueryDTO; import com.ruoyi.ccdi.project.domain.dto.CcdiProjectEmployeeCreditNegativeQueryDTO; import com.ruoyi.ccdi.project.domain.dto.CcdiProjectRiskModelPeopleQueryDTO; import com.ruoyi.ccdi.project.domain.dto.CcdiProjectRiskPeopleQueryDTO; import com.ruoyi.ccdi.project.domain.dto.CcdiProjectSuspiciousTransactionQueryDTO; +import com.ruoyi.ccdi.project.domain.vo.CcdiProjectAbnormalAccountItemVO; import com.ruoyi.ccdi.project.domain.vo.CcdiBankStatementListVO; import com.ruoyi.ccdi.project.domain.vo.CcdiProjectEmployeeCreditNegativeItemVO; import com.ruoyi.ccdi.project.domain.vo.CcdiProjectEmployeeRiskAggregateVO; @@ -106,6 +108,26 @@ public interface CcdiProjectOverviewMapper { @Param("query") CcdiProjectEmployeeCreditNegativeQueryDTO query ); + /** + * 分页查询异常账户人员信息 + * + * @param page 分页参数 + * @param query 查询条件 + * @return 分页结果 + */ + Page selectAbnormalAccountPage( + Page page, + @Param("query") CcdiProjectAbnormalAccountQueryDTO query + ); + + /** + * 查询异常账户人员信息导出列表 + * + * @param projectId 项目ID + * @return 导出列表 + */ + List selectAbnormalAccountList(@Param("projectId") Long projectId); + /** * 查询项目员工负面征信导出列表 * diff --git a/ccdi-project/src/main/java/com/ruoyi/ccdi/project/mapper/CcdiProjectSpecialCheckMapper.java b/ccdi-project/src/main/java/com/ruoyi/ccdi/project/mapper/CcdiProjectSpecialCheckMapper.java index 9bdc28b6..6dc3e8ab 100644 --- a/ccdi-project/src/main/java/com/ruoyi/ccdi/project/mapper/CcdiProjectSpecialCheckMapper.java +++ b/ccdi-project/src/main/java/com/ruoyi/ccdi/project/mapper/CcdiProjectSpecialCheckMapper.java @@ -6,6 +6,7 @@ import com.ruoyi.ccdi.project.domain.dto.CcdiProjectExtendedRecruitmentQueryDTO; import com.ruoyi.ccdi.project.domain.dto.CcdiProjectExtendedTransferQueryDTO; import com.ruoyi.ccdi.project.domain.vo.CcdiProjectExtendedPurchaseDetailVO; import com.ruoyi.ccdi.project.domain.vo.CcdiProjectExtendedPurchaseListItemVO; +import com.ruoyi.ccdi.project.domain.vo.CcdiProjectExtendedPurchaseSupplierVO; import com.ruoyi.ccdi.project.domain.vo.CcdiProjectExtendedRecruitmentDetailVO; import com.ruoyi.ccdi.project.domain.vo.CcdiProjectExtendedRecruitmentListItemVO; import com.ruoyi.ccdi.project.domain.vo.CcdiProjectExtendedTransferDetailVO; @@ -96,6 +97,18 @@ public interface CcdiProjectSpecialCheckMapper { @Param("purchaseId") String purchaseId ); + /** + * 查询专项核查采购供应商明细 + * + * @param projectId 项目ID + * @param purchaseId 采购事项ID + * @return 供应商明细 + */ + List selectExtendedPurchaseSuppliers( + @Param("projectId") Long projectId, + @Param("purchaseId") String purchaseId + ); + /** * 查询专项核查招聘拓展列表 * diff --git a/ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/ICcdiProjectOverviewService.java b/ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/ICcdiProjectOverviewService.java index 18fc1ba5..854906b4 100644 --- a/ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/ICcdiProjectOverviewService.java +++ b/ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/ICcdiProjectOverviewService.java @@ -1,13 +1,16 @@ package com.ruoyi.ccdi.project.service; +import com.ruoyi.ccdi.project.domain.dto.CcdiProjectAbnormalAccountQueryDTO; import com.ruoyi.ccdi.project.domain.dto.CcdiProjectEmployeeCreditNegativeQueryDTO; import com.ruoyi.ccdi.project.domain.dto.CcdiProjectPersonAnalysisDetailQueryDTO; import com.ruoyi.ccdi.project.domain.dto.CcdiProjectRiskModelPeopleQueryDTO; import com.ruoyi.ccdi.project.domain.dto.CcdiProjectRiskPeopleQueryDTO; import com.ruoyi.ccdi.project.domain.dto.CcdiProjectSuspiciousTransactionQueryDTO; +import com.ruoyi.ccdi.project.domain.excel.CcdiProjectAbnormalAccountExcel; import com.ruoyi.ccdi.project.domain.excel.CcdiProjectEmployeeCreditNegativeExcel; import com.ruoyi.ccdi.project.domain.excel.CcdiProjectRiskPeopleOverviewExcel; import com.ruoyi.ccdi.project.domain.excel.CcdiProjectSuspiciousTransactionExcel; +import com.ruoyi.ccdi.project.domain.vo.CcdiProjectAbnormalAccountPageVO; import com.ruoyi.ccdi.project.domain.vo.CcdiProjectEmployeeCreditNegativePageVO; import com.ruoyi.ccdi.project.domain.vo.CcdiProjectPersonAnalysisDetailVO; import com.ruoyi.ccdi.project.domain.vo.CcdiProjectOverviewDashboardVO; @@ -144,6 +147,28 @@ public interface ICcdiProjectOverviewService { return new CcdiProjectEmployeeCreditNegativePageVO(); } + /** + * 查询异常账户人员信息 + * + * @param queryDTO 查询条件 + * @return 分页结果 + */ + default CcdiProjectAbnormalAccountPageVO getAbnormalAccountPeople( + CcdiProjectAbnormalAccountQueryDTO queryDTO + ) { + return new CcdiProjectAbnormalAccountPageVO(); + } + + /** + * 导出异常账户人员信息 + * + * @param projectId 项目ID + * @return 导出列表 + */ + default List exportAbnormalAccountPeople(Long projectId) { + return List.of(); + } + /** * 重算结果总览员工结果并同步项目风险人数 * diff --git a/ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/impl/CcdiBankTagServiceImpl.java b/ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/impl/CcdiBankTagServiceImpl.java index 030e826c..1665f81f 100644 --- a/ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/impl/CcdiBankTagServiceImpl.java +++ b/ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/impl/CcdiBankTagServiceImpl.java @@ -39,6 +39,7 @@ public class CcdiBankTagServiceImpl implements ICcdiBankTagService { private static final String STATUS_RUNNING = "RUNNING"; private static final String STATUS_SUCCESS = "SUCCESS"; private static final String STATUS_FAILED = "FAILED"; + private static final String TASK_ERROR_MESSAGE_FALLBACK = "任务失败,详细异常请查看后端日志"; private static final String RESULT_TYPE_STATEMENT = "STATEMENT"; private static final String OBJECT_TYPE_STAFF_ID_CARD = "STAFF_ID_CARD"; @@ -147,12 +148,11 @@ public class CcdiBankTagServiceImpl implements ICcdiBankTagService { return task.getId(); } catch (Exception ex) { task.setStatus(STATUS_FAILED); - task.setErrorMessage(ex.getMessage()); task.setEndTime(new Date()); task.setNeedRerun(null); task.setUpdateBy(operator); task.setUpdateTime(new Date()); - taskMapper.updateTask(task); + updateFailedTaskSafely(task, ex); projectService.updateProjectStatus(projectId, CcdiProjectStatusConstants.PROCESSING, operator); log.error("【流水标签】任务执行失败: taskId={}, projectId={}, modelCode={}, triggerType={}, error={}", task.getId(), projectId, modelCode, triggerType, ex.getMessage(), ex); @@ -288,6 +288,8 @@ public class CcdiBankTagServiceImpl implements ICcdiBankTagService { case "WITHDRAW_AMT" -> analysisMapper.selectWithdrawAmtObjects(projectId); case "SALARY_QUICK_TRANSFER" -> analysisMapper.selectSalaryQuickTransferObjects(projectId); case "SALARY_UNUSED" -> analysisMapper.selectSalaryUnusedObjects(projectId); + case "SUDDEN_ACCOUNT_CLOSURE" -> analysisMapper.selectSuddenAccountClosureObjects(projectId); + case "DORMANT_ACCOUNT_LARGE_ACTIVATION" -> analysisMapper.selectDormantAccountLargeActivationObjects(projectId); case "PROXY_ACCOUNT_OPERATION" -> analysisMapper.selectProxyAccountOperationObjects(projectId); default -> List.of(); }; @@ -357,4 +359,44 @@ public class CcdiBankTagServiceImpl implements ICcdiBankTagService { } return Integer.parseInt(value); } + + private void updateFailedTaskSafely(CcdiBankTagTask task, Exception ex) { + task.setErrorMessage(buildSafeTaskErrorMessage(ex)); + try { + taskMapper.updateTask(task); + } catch (Exception updateEx) { + log.error("【流水标签】写入任务失败状态异常: taskId={}, error={}", + task.getId(), updateEx.getMessage(), updateEx); + task.setErrorMessage(TASK_ERROR_MESSAGE_FALLBACK); + taskMapper.updateTask(task); + } + } + + private static String buildSafeTaskErrorMessage(Throwable throwable) { + if (throwable == null) { + return TASK_ERROR_MESSAGE_FALLBACK; + } + + StringBuilder builder = new StringBuilder(); + if (throwable.getMessage() != null && !throwable.getMessage().isBlank()) { + builder.append(throwable.getMessage().trim()); + } + + Throwable rootCause = throwable; + while (rootCause.getCause() != null && rootCause.getCause() != rootCause) { + rootCause = rootCause.getCause(); + } + + if (rootCause != throwable && rootCause.getMessage() != null && !rootCause.getMessage().isBlank()) { + String rootMessage = rootCause.getMessage().trim(); + if (!builder.toString().contains(rootMessage)) { + if (!builder.isEmpty()) { + builder.append(" | rootCause="); + } + builder.append(rootMessage); + } + } + + return builder.isEmpty() ? TASK_ERROR_MESSAGE_FALLBACK : builder.toString(); + } } diff --git a/ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/impl/CcdiProjectOverviewServiceImpl.java b/ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/impl/CcdiProjectOverviewServiceImpl.java index 9e9e10b4..ab6570f8 100644 --- a/ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/impl/CcdiProjectOverviewServiceImpl.java +++ b/ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/impl/CcdiProjectOverviewServiceImpl.java @@ -2,15 +2,19 @@ package com.ruoyi.ccdi.project.service.impl; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.ruoyi.ccdi.project.domain.CcdiProject; +import com.ruoyi.ccdi.project.domain.dto.CcdiProjectAbnormalAccountQueryDTO; import com.ruoyi.ccdi.project.domain.dto.CcdiProjectEmployeeCreditNegativeQueryDTO; import com.ruoyi.ccdi.project.domain.dto.CcdiProjectPersonAnalysisDetailQueryDTO; import com.ruoyi.ccdi.project.domain.dto.CcdiProjectRiskModelPeopleQueryDTO; import com.ruoyi.ccdi.project.domain.dto.CcdiProjectRiskPeopleQueryDTO; import com.ruoyi.ccdi.project.domain.dto.CcdiProjectSuspiciousTransactionQueryDTO; +import com.ruoyi.ccdi.project.domain.excel.CcdiProjectAbnormalAccountExcel; import com.ruoyi.ccdi.project.domain.excel.CcdiProjectEmployeeCreditNegativeExcel; import com.ruoyi.ccdi.project.domain.excel.CcdiProjectRiskPeopleOverviewExcel; import com.ruoyi.ccdi.project.domain.excel.CcdiProjectSuspiciousTransactionExcel; import com.ruoyi.ccdi.project.domain.entity.CcdiProjectOverviewEmployeeResult; +import com.ruoyi.ccdi.project.domain.vo.CcdiProjectAbnormalAccountItemVO; +import com.ruoyi.ccdi.project.domain.vo.CcdiProjectAbnormalAccountPageVO; import com.ruoyi.ccdi.project.domain.vo.CcdiBankStatementListVO; import com.ruoyi.ccdi.project.domain.vo.CcdiBankStatementHitTagVO; import com.ruoyi.ccdi.project.domain.vo.CcdiProjectEmployeeCreditNegativeItemVO; @@ -258,6 +262,31 @@ public class CcdiProjectOverviewServiceImpl implements ICcdiProjectOverviewServi return result; } + @Override + public CcdiProjectAbnormalAccountPageVO getAbnormalAccountPeople(CcdiProjectAbnormalAccountQueryDTO queryDTO) { + ensureProjectExists(queryDTO.getProjectId()); + + Page page = new Page<>( + defaultAbnormalAccountPageNum(queryDTO.getPageNum()), + defaultAbnormalAccountPageSize(queryDTO.getPageSize()) + ); + Page resultPage = overviewMapper.selectAbnormalAccountPage(page, queryDTO); + + CcdiProjectAbnormalAccountPageVO result = new CcdiProjectAbnormalAccountPageVO(); + result.setRows(defaultList(resultPage == null ? null : resultPage.getRecords())); + result.setTotal(resultPage == null ? 0L : resultPage.getTotal()); + return result; + } + + @Override + public List exportAbnormalAccountPeople(Long projectId) { + ensureProjectExists(projectId); + + return defaultList(overviewMapper.selectAbnormalAccountList(projectId)).stream() + .map(this::buildAbnormalAccountExcelRow) + .toList(); + } + @Override public void exportRiskDetails(HttpServletResponse response, Long projectId) { CcdiProjectSuspiciousTransactionQueryDTO queryDTO = new CcdiProjectSuspiciousTransactionQueryDTO(); @@ -266,8 +295,9 @@ public class CcdiProjectOverviewServiceImpl implements ICcdiProjectOverviewServi List suspiciousRows = exportSuspiciousTransactions(queryDTO); List creditRows = exportEmployeeCreditNegative(projectId); + List abnormalRows = exportAbnormalAccountPeople(projectId); try { - workbookExporter.export(response, projectId, suspiciousRows, creditRows); + workbookExporter.export(response, projectId, suspiciousRows, creditRows, abnormalRows); } catch (IOException e) { throw new ServiceException("导出风险明细失败"); } @@ -420,6 +450,14 @@ public class CcdiProjectOverviewServiceImpl implements ICcdiProjectOverviewServi return pageSize == null || pageSize <= 0 ? 5L : pageSize.longValue(); } + private long defaultAbnormalAccountPageNum(Integer pageNum) { + return pageNum == null || pageNum <= 0 ? 1L : pageNum.longValue(); + } + + private long defaultAbnormalAccountPageSize(Integer pageSize) { + return pageSize == null || pageSize <= 0 ? 5L : pageSize.longValue(); + } + private long defaultPageNum(Integer pageNum) { return pageNum == null || pageNum < 1 ? 1L : pageNum.longValue(); } @@ -462,6 +500,17 @@ public class CcdiProjectOverviewServiceImpl implements ICcdiProjectOverviewServi return row; } + private CcdiProjectAbnormalAccountExcel buildAbnormalAccountExcelRow(CcdiProjectAbnormalAccountItemVO item) { + CcdiProjectAbnormalAccountExcel row = new CcdiProjectAbnormalAccountExcel(); + row.setAccountNo(item.getAccountNo()); + row.setAccountName(item.getAccountName()); + row.setBankName(item.getBankName()); + row.setAbnormalType(item.getAbnormalType()); + row.setAbnormalTime(item.getAbnormalTime()); + row.setStatus(item.getStatus()); + return row; + } + private String formatRelatedStaff(String relatedStaffName, String relatedStaffCode) { if (relatedStaffName == null || relatedStaffName.isBlank()) { return null; diff --git a/ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/impl/CcdiProjectRiskDetailWorkbookExporter.java b/ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/impl/CcdiProjectRiskDetailWorkbookExporter.java index 215c722e..4cc08acb 100644 --- a/ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/impl/CcdiProjectRiskDetailWorkbookExporter.java +++ b/ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/impl/CcdiProjectRiskDetailWorkbookExporter.java @@ -1,5 +1,6 @@ package com.ruoyi.ccdi.project.service.impl; +import com.ruoyi.ccdi.project.domain.excel.CcdiProjectAbnormalAccountExcel; import com.ruoyi.ccdi.project.domain.excel.CcdiProjectEmployeeCreditNegativeExcel; import com.ruoyi.ccdi.project.domain.excel.CcdiProjectSuspiciousTransactionExcel; import com.ruoyi.common.utils.file.FileUtils; @@ -27,7 +28,8 @@ public class CcdiProjectRiskDetailWorkbookExporter { HttpServletResponse response, Long projectId, List suspiciousRows, - List creditRows + List creditRows, + List abnormalRows ) throws IOException { response.setContentType(CONTENT_TYPE); FileUtils.setAttachmentResponseHeader(response, "风险明细_" + projectId + ".xlsx"); @@ -35,7 +37,7 @@ public class CcdiProjectRiskDetailWorkbookExporter { try (Workbook workbook = new XSSFWorkbook()) { writeSuspiciousSheet(workbook.createSheet("涉疑交易明细"), suspiciousRows); writeCreditSheet(workbook.createSheet("员工负面征信信息"), creditRows); - writeAbnormalAccountSheet(workbook.createSheet("异常账户人员信息")); + writeAbnormalAccountSheet(workbook.createSheet("异常账户人员信息"), abnormalRows); workbook.write(response.getOutputStream()); } } @@ -88,10 +90,21 @@ public class CcdiProjectRiskDetailWorkbookExporter { } } - private void writeAbnormalAccountSheet(Sheet sheet) { + private void writeAbnormalAccountSheet(Sheet sheet, List rows) { Row header = sheet.createRow(0); String[] headers = { "账号", "开户人", "银行", "异常类型", "异常发生时间", "状态" }; writeHeader(header, headers); + + for (int i = 0; i < rows.size(); i++) { + CcdiProjectAbnormalAccountExcel item = rows.get(i); + Row row = sheet.createRow(i + 1); + row.createCell(0).setCellValue(safeText(item.getAccountNo())); + row.createCell(1).setCellValue(safeText(item.getAccountName())); + row.createCell(2).setCellValue(safeText(item.getBankName())); + row.createCell(3).setCellValue(safeText(item.getAbnormalType())); + row.createCell(4).setCellValue(safeText(item.getAbnormalTime())); + row.createCell(5).setCellValue(safeText(item.getStatus())); + } } private void writeHeader(Row row, String[] headers) { diff --git a/ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/impl/CcdiProjectSpecialCheckServiceImpl.java b/ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/impl/CcdiProjectSpecialCheckServiceImpl.java index e8ea1b28..17d2844e 100644 --- a/ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/impl/CcdiProjectSpecialCheckServiceImpl.java +++ b/ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/impl/CcdiProjectSpecialCheckServiceImpl.java @@ -103,6 +103,9 @@ public class CcdiProjectSpecialCheckServiceImpl implements ICcdiProjectSpecialCh if (detail == null) { throw new ServiceException("当前记录不属于该项目专项核查范围"); } + detail.setSupplierList(defaultList( + specialCheckMapper.selectExtendedPurchaseSuppliers(queryDTO.getProjectId(), queryDTO.getPurchaseId()) + )); return detail; } diff --git a/ccdi-project/src/main/resources/mapper/ccdi/project/CcdiBankTagAnalysisMapper.xml b/ccdi-project/src/main/resources/mapper/ccdi/project/CcdiBankTagAnalysisMapper.xml index 1fd2f56b..4c0d8af8 100644 --- a/ccdi-project/src/main/resources/mapper/ccdi/project/CcdiBankTagAnalysisMapper.xml +++ b/ccdi-project/src/main/resources/mapper/ccdi/project/CcdiBankTagAnalysisMapper.xml @@ -924,7 +924,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" from ccdi_purchase_transaction pt inner join ( - ) project_staff on project_staff.staffId = pt.applicant_id + ) project_staff on project_staff.staffId COLLATE utf8mb4_general_ci = pt.applicant_id COLLATE utf8mb4_general_ci where IFNULL(pt.actual_amount, 0) > 100000 union select distinct @@ -935,7 +935,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" from ccdi_purchase_transaction pt inner join ( - ) project_staff on project_staff.staffId = pt.purchase_leader_id + ) project_staff on project_staff.staffId COLLATE utf8mb4_general_ci = pt.purchase_leader_id COLLATE utf8mb4_general_ci where pt.purchase_leader_id is not null and IFNULL(pt.actual_amount, 0) > 100000 ) t @@ -975,7 +975,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" from ccdi_purchase_transaction pt inner join ( - ) project_staff on project_staff.staffId = pt.applicant_id + ) project_staff on project_staff.staffId COLLATE utf8mb4_general_ci = pt.applicant_id COLLATE utf8mb4_general_ci where IFNULL(pt.actual_amount, 0) > 0 and IFNULL(pt.supplier_name, '') <> '' @@ -989,7 +989,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" from ccdi_purchase_transaction pt inner join ( - ) project_staff on project_staff.staffId = pt.purchase_leader_id + ) project_staff on project_staff.staffId COLLATE utf8mb4_general_ci = pt.purchase_leader_id COLLATE utf8mb4_general_ci where pt.purchase_leader_id is not null and IFNULL(pt.actual_amount, 0) > 0 and IFNULL(pt.supplier_name, '') <> '' @@ -1006,7 +1006,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" from ccdi_purchase_transaction pt inner join ( - ) project_staff on project_staff.staffId = pt.applicant_id + ) project_staff on project_staff.staffId COLLATE utf8mb4_general_ci = pt.applicant_id COLLATE utf8mb4_general_ci where IFNULL(pt.actual_amount, 0) > 0 union @@ -1018,7 +1018,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" from ccdi_purchase_transaction pt inner join ( - ) project_staff on project_staff.staffId = pt.purchase_leader_id + ) project_staff on project_staff.staffId COLLATE utf8mb4_general_ci = pt.purchase_leader_id COLLATE utf8mb4_general_ci where pt.purchase_leader_id is not null and IFNULL(pt.actual_amount, 0) > 0 ) source_total @@ -1211,6 +1211,101 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" ) t + + + + + + select + account.account_no as accountNo, + account.account_no as account_no, + coalesce(nullif(account.account_name, ''), staff.name) as accountName, + account.bank as bankName, + tr.rule_name as abnormalType, + tr.rule_code as rule_code, + case + when tr.rule_code = 'SUDDEN_ACCOUNT_CLOSURE' then date_format(account.invalid_date, '%Y-%m-%d') + when tr.rule_code = 'DORMANT_ACCOUNT_LARGE_ACTIVATION' then substring( + substring_index( + substring_index(tr.reason_detail, ',', 2), + '首次交易日期', + -1 + ), + 1, + 10 + ) + else null + end as abnormal_time, + case + when account.status = 1 then '正常' + when account.status = 2 then '已销户' + else cast(account.status as char) + end as status + from ccdi_bank_statement_tag_result tr + inner join ccdi_account_info account + on account.owner_type = 'EMPLOYEE' + and account.owner_id = tr.object_key + and instr(tr.reason_detail, account.account_no) > 0 + left join ccdi_base_staff staff + on staff.id_card = tr.object_key + + + + + + + + results.stream().anyMatch(item -> + "ABNORMAL_ACCOUNT".equals(item.getModelCode()) + && "SUDDEN_ACCOUNT_CLOSURE".equals(item.getRuleCode()) + && "OBJECT".equals(item.getResultType()) + && "STAFF_ID_CARD".equals(item.getObjectType()) + ))); + } + + @Test + void rebuildProject_shouldInsertDormantAccountLargeActivationObjectResults() { + ReflectionTestUtils.setField(service, "tagRuleExecutor", (Executor) Runnable::run); + + CcdiBankTagRule rule = buildRule("ABNORMAL_ACCOUNT", "异常账户", + "DORMANT_ACCOUNT_LARGE_ACTIVATION", "休眠账户大额启用", "OBJECT"); + BankTagRuleExecutionConfig config = buildConfig(40L, rule); + + BankTagObjectHitVO hit = new BankTagObjectHitVO(); + hit.setObjectType("STAFF_ID_CARD"); + hit.setObjectKey("330101199001011235"); + hit.setReasonDetail("账户62220002开户于2025-01-01,首次交易日期2025-08-01,沉睡时长7个月,启用后累计交易金额500000元,单笔最大金额120000元"); + + when(ruleMapper.selectEnabledRules("ABNORMAL_ACCOUNT")).thenReturn(List.of(rule)); + when(configResolver.resolve(40L, rule)).thenReturn(config); + when(analysisMapper.selectDormantAccountLargeActivationObjects(40L)).thenReturn(List.of(hit)); + + service.rebuildProject(40L, "ABNORMAL_ACCOUNT", "admin", TriggerType.MANUAL); + + verify(resultMapper).insertBatch(argThat(results -> results.stream().anyMatch(item -> + "ABNORMAL_ACCOUNT".equals(item.getModelCode()) + && "DORMANT_ACCOUNT_LARGE_ACTIVATION".equals(item.getRuleCode()) + && "OBJECT".equals(item.getResultType()) + && "STAFF_ID_CARD".equals(item.getObjectType()) + ))); + } + + @Test + void buildSafeTaskErrorMessage_shouldKeepLongMessageForLongTextColumn() throws Exception { + Method method = CcdiBankTagServiceImpl.class.getDeclaredMethod( + "buildSafeTaskErrorMessage", Throwable.class + ); + method.setAccessible(true); + + String longMessage = "X".repeat(5000); + RuntimeException throwable = new RuntimeException("root-cause:" + longMessage); + + String result = (String) method.invoke(null, throwable); + + assertNotNull(result); + assertTrue(result.length() > 2000, "LONGTEXT 方案下不应继续把错误信息截断到 2000"); + assertTrue(result.contains("root-cause"), "错误信息应保留根因关键字"); + } + + @Test + void abnormalAccountMapperXml_shouldDeclareObjectSelects() throws Exception { + String xml = Files.readString(Path.of("src/main/resources/mapper/ccdi/project/CcdiBankTagAnalysisMapper.xml")); + + assertTrue(xml.contains("select id=\"selectSuddenAccountClosureObjects\"")); + assertTrue(xml.contains("select id=\"selectDormantAccountLargeActivationObjects\"")); + } + + @Test + void dormantAccountLargeActivationMapperXml_shouldContainDormantAccountConditions() throws Exception { + String xml = Files.readString(Path.of("src/main/resources/mapper/ccdi/project/CcdiBankTagAnalysisMapper.xml")); + + assertTrue(xml.contains("select id=\"selectDormantAccountLargeActivationObjects\"")); + assertTrue(xml.contains("ai.owner_type = 'EMPLOYEE'")); + assertTrue(xml.contains("ai.status = 1")); + assertTrue(xml.contains("ai.effective_date is not null")); + assertTrue(xml.contains("DATE_ADD(ai.effective_date, INTERVAL 6 MONTH)")); + assertTrue(xml.contains("windowTotalAmount >= 500000") || xml.contains("windowMaxSingleAmount >= 100000")); + } + private CcdiBankTagRule buildRule(String modelCode, String modelName, String ruleCode, String ruleName, String resultType) { CcdiBankTagRule rule = new CcdiBankTagRule(); rule.setModelCode(modelCode); diff --git a/ccdi-project/src/test/java/com/ruoyi/ccdi/project/service/impl/CcdiProjectOverviewEmployeeResultBuilderTest.java b/ccdi-project/src/test/java/com/ruoyi/ccdi/project/service/impl/CcdiProjectOverviewEmployeeResultBuilderTest.java index 7ea53c87..0c68f6d1 100644 --- a/ccdi-project/src/test/java/com/ruoyi/ccdi/project/service/impl/CcdiProjectOverviewEmployeeResultBuilderTest.java +++ b/ccdi-project/src/test/java/com/ruoyi/ccdi/project/service/impl/CcdiProjectOverviewEmployeeResultBuilderTest.java @@ -12,6 +12,7 @@ import java.util.Map; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; class CcdiProjectOverviewEmployeeResultBuilderTest { @@ -38,7 +39,11 @@ class CcdiProjectOverviewEmployeeResultBuilderTest { buildHitRow(rowClass, "330000000000000001", "李四", "E1001", 12L, "信息二部", "SUSPICIOUS_PART_TIME", "可疑兼职", "MONTHLY_FIXED_INCOME", "疑似兼职", "MEDIUM"), buildHitRow(rowClass, "330000000000000001", "李四", "E1001", 12L, "信息二部", - "SUSPICIOUS_PROPERTY", "可疑财产", "HOUSE_REGISTRATION_MISMATCH", "房产登记不匹配", "LOW") + "SUSPICIOUS_PROPERTY", "可疑财产", "HOUSE_REGISTRATION_MISMATCH", "房产登记不匹配", "LOW"), + buildHitRow(rowClass, "330000000000000001", "李四", "E1001", 12L, "信息二部", + "ABNORMAL_ACCOUNT", "异常账户", "SUDDEN_ACCOUNT_CLOSURE", "突然销户", "HIGH"), + buildHitRow(rowClass, "330000000000000001", "李四", "E1001", 12L, "信息二部", + "ABNORMAL_ACCOUNT", "异常账户", "DORMANT_ACCOUNT_LARGE_ACTIVATION", "休眠账户大额启用", "HIGH") ); List results = @@ -52,20 +57,22 @@ class CcdiProjectOverviewEmployeeResultBuilderTest { assertEquals("E1001", result.getStaffCode()); assertEquals(12L, result.getDeptId()); assertEquals("信息二部", result.getDeptName()); - assertEquals(5, result.getRuleCount()); - assertEquals(4, result.getModelCount()); - assertEquals(7, result.getHitCount()); + assertEquals(7, result.getRuleCount()); + assertEquals(5, result.getModelCount()); + assertEquals(9, result.getHitCount()); assertEquals("HIGH", result.getRiskLevelCode()); - assertEquals("ABNORMAL_TRANSACTION,LARGE_TRANSACTION,SUSPICIOUS_PART_TIME,SUSPICIOUS_PROPERTY", + assertEquals("ABNORMAL_ACCOUNT,ABNORMAL_TRANSACTION,LARGE_TRANSACTION,SUSPICIOUS_PART_TIME,SUSPICIOUS_PROPERTY", result.getModelCodesCsv()); assertNotNull(result.getRiskPoint()); JSONArray modelNames = JSON.parseArray(result.getModelNamesJson()); - assertEquals(List.of("异常交易", "大额交易", "可疑兼职", "可疑财产"), + assertEquals(List.of("异常账户", "异常交易", "大额交易", "可疑兼职", "可疑财产"), modelNames.toList(String.class)); JSONArray hitRules = JSON.parseArray(result.getHitRulesJson()); - assertEquals(5, hitRules.size()); + assertEquals(7, hitRules.size()); + assertTrue(result.getHitRulesJson().contains("SUDDEN_ACCOUNT_CLOSURE")); + assertTrue(result.getHitRulesJson().contains("DORMANT_ACCOUNT_LARGE_ACTIVATION")); JSONObject firstRule = hitRules.getJSONObject(0); assertEquals("ABNORMAL_CUSTOMER_TRANSACTION", firstRule.getString("ruleCode")); assertEquals("异常客户交易", firstRule.getString("ruleName")); @@ -78,6 +85,7 @@ class CcdiProjectOverviewEmployeeResultBuilderTest { item -> item.getString("modelCode"), item -> item.getIntValue("warningCount") )); + assertEquals(2, warningCountByModel.get("ABNORMAL_ACCOUNT")); assertEquals(2, warningCountByModel.get("ABNORMAL_TRANSACTION")); assertEquals(3, warningCountByModel.get("LARGE_TRANSACTION")); assertEquals(1, warningCountByModel.get("SUSPICIOUS_PROPERTY")); diff --git a/ccdi-project/src/test/java/com/ruoyi/ccdi/project/service/impl/CcdiProjectOverviewServiceAbnormalAccountTest.java b/ccdi-project/src/test/java/com/ruoyi/ccdi/project/service/impl/CcdiProjectOverviewServiceAbnormalAccountTest.java new file mode 100644 index 00000000..b06fc1c2 --- /dev/null +++ b/ccdi-project/src/test/java/com/ruoyi/ccdi/project/service/impl/CcdiProjectOverviewServiceAbnormalAccountTest.java @@ -0,0 +1,148 @@ +package com.ruoyi.ccdi.project.service.impl; + +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.ruoyi.ccdi.project.domain.CcdiProject; +import com.ruoyi.ccdi.project.domain.dto.CcdiProjectAbnormalAccountQueryDTO; +import com.ruoyi.ccdi.project.domain.excel.CcdiProjectAbnormalAccountExcel; +import com.ruoyi.ccdi.project.domain.vo.CcdiProjectAbnormalAccountItemVO; +import com.ruoyi.ccdi.project.domain.vo.CcdiProjectAbnormalAccountPageVO; +import com.ruoyi.ccdi.project.mapper.CcdiBankTagResultMapper; +import com.ruoyi.ccdi.project.mapper.CcdiProjectMapper; +import com.ruoyi.ccdi.project.mapper.CcdiProjectOverviewEmployeeResultMapper; +import com.ruoyi.ccdi.project.mapper.CcdiProjectOverviewMapper; +import com.ruoyi.common.exception.ServiceException; +import java.util.List; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class CcdiProjectOverviewServiceAbnormalAccountTest { + + @InjectMocks + private CcdiProjectOverviewServiceImpl service; + + @Mock + private CcdiProjectOverviewMapper overviewMapper; + + @Mock + private CcdiProjectMapper projectMapper; + + @Mock + private CcdiProjectOverviewEmployeeResultMapper overviewEmployeeResultMapper; + + @Mock + private CcdiBankTagResultMapper bankTagResultMapper; + + @Mock + private CcdiProjectOverviewEmployeeResultBuilder overviewEmployeeResultBuilder; + + @Mock + private CcdiProjectRiskDetailWorkbookExporter workbookExporter; + + @Test + void shouldMapAbnormalAccountPageRowsAndTotal() { + mockProjectExists(40L); + + CcdiProjectAbnormalAccountQueryDTO queryDTO = new CcdiProjectAbnormalAccountQueryDTO(); + queryDTO.setProjectId(40L); + queryDTO.setPageNum(1); + queryDTO.setPageSize(5); + + CcdiProjectAbnormalAccountItemVO item = new CcdiProjectAbnormalAccountItemVO(); + item.setAccountNo("6222000000000001"); + item.setAccountName("李四"); + item.setBankName("中国农业银行"); + item.setAbnormalType("突然销户"); + item.setAbnormalTime("2026-03-20"); + item.setStatus("已销户"); + + Page resultPage = new Page<>(1, 5); + resultPage.setRecords(List.of(item)); + resultPage.setTotal(1L); + when(overviewMapper.selectAbnormalAccountPage(any(Page.class), any(CcdiProjectAbnormalAccountQueryDTO.class))) + .thenReturn(resultPage); + + CcdiProjectAbnormalAccountPageVO result = service.getAbnormalAccountPeople(queryDTO); + + assertEquals(1, result.getRows().size()); + assertEquals(1L, result.getTotal()); + assertEquals("6222000000000001", result.getRows().getFirst().getAccountNo()); + assertEquals("突然销户", result.getRows().getFirst().getAbnormalType()); + verify(overviewMapper).selectAbnormalAccountPage( + argThat(page -> page.getCurrent() == 1L && page.getSize() == 5L), + argThat(query -> query.getProjectId().equals(40L)) + ); + } + + @Test + void shouldDefaultAbnormalAccountPageNumAndPageSizeToOneAndFive() { + mockProjectExists(40L); + + Page emptyPage = new Page<>(1, 5); + emptyPage.setRecords(List.of()); + emptyPage.setTotal(0L); + when(overviewMapper.selectAbnormalAccountPage(any(Page.class), any(CcdiProjectAbnormalAccountQueryDTO.class))) + .thenReturn(emptyPage); + + CcdiProjectAbnormalAccountQueryDTO queryDTO = new CcdiProjectAbnormalAccountQueryDTO(); + queryDTO.setProjectId(40L); + service.getAbnormalAccountPeople(queryDTO); + + verify(overviewMapper).selectAbnormalAccountPage( + argThat(page -> page.getCurrent() == 1L && page.getSize() == 5L), + any(CcdiProjectAbnormalAccountQueryDTO.class) + ); + } + + @Test + void shouldExportAbnormalAccountPeopleRows() { + mockProjectExists(40L); + + CcdiProjectAbnormalAccountItemVO item = new CcdiProjectAbnormalAccountItemVO(); + item.setAccountNo("6222000000000002"); + item.setAccountName("王五"); + item.setBankName("中国银行"); + item.setAbnormalType("休眠账户大额启用"); + item.setAbnormalTime("2025-08-01"); + item.setStatus("正常"); + when(overviewMapper.selectAbnormalAccountList(40L)).thenReturn(List.of(item)); + + List rows = service.exportAbnormalAccountPeople(40L); + + assertEquals(1, rows.size()); + assertEquals("6222000000000002", rows.getFirst().getAccountNo()); + assertEquals("王五", rows.getFirst().getAccountName()); + assertEquals("中国银行", rows.getFirst().getBankName()); + assertEquals("休眠账户大额启用", rows.getFirst().getAbnormalType()); + assertEquals("2025-08-01", rows.getFirst().getAbnormalTime()); + assertEquals("正常", rows.getFirst().getStatus()); + verify(overviewMapper).selectAbnormalAccountList(40L); + } + + @Test + void shouldThrowWhenProjectDoesNotExistForAbnormalAccountQueries() { + when(projectMapper.selectById(99L)).thenReturn(null); + + CcdiProjectAbnormalAccountQueryDTO queryDTO = new CcdiProjectAbnormalAccountQueryDTO(); + queryDTO.setProjectId(99L); + + assertThrows(ServiceException.class, () -> service.getAbnormalAccountPeople(queryDTO)); + assertThrows(ServiceException.class, () -> service.exportAbnormalAccountPeople(99L)); + } + + private void mockProjectExists(Long projectId) { + CcdiProject project = new CcdiProject(); + project.setProjectId(projectId); + when(projectMapper.selectById(projectId)).thenReturn(project); + } +} diff --git a/ccdi-project/src/test/java/com/ruoyi/ccdi/project/service/impl/CcdiProjectOverviewServiceImplTest.java b/ccdi-project/src/test/java/com/ruoyi/ccdi/project/service/impl/CcdiProjectOverviewServiceImplTest.java index 774ae742..c1e5d2a7 100644 --- a/ccdi-project/src/test/java/com/ruoyi/ccdi/project/service/impl/CcdiProjectOverviewServiceImplTest.java +++ b/ccdi-project/src/test/java/com/ruoyi/ccdi/project/service/impl/CcdiProjectOverviewServiceImplTest.java @@ -5,10 +5,12 @@ import com.ruoyi.ccdi.project.domain.CcdiProject; import com.ruoyi.ccdi.project.domain.dto.CcdiProjectPersonAnalysisDetailQueryDTO; import com.ruoyi.ccdi.project.domain.dto.CcdiProjectRiskPeopleQueryDTO; import com.ruoyi.ccdi.project.domain.dto.CcdiProjectRiskModelPeopleQueryDTO; +import com.ruoyi.ccdi.project.domain.excel.CcdiProjectAbnormalAccountExcel; import com.ruoyi.ccdi.project.domain.excel.CcdiProjectEmployeeCreditNegativeExcel; import com.ruoyi.ccdi.project.domain.excel.CcdiProjectRiskPeopleOverviewExcel; import com.ruoyi.ccdi.project.domain.excel.CcdiProjectSuspiciousTransactionExcel; import com.ruoyi.ccdi.project.domain.entity.CcdiProjectOverviewEmployeeResult; +import com.ruoyi.ccdi.project.domain.vo.CcdiProjectAbnormalAccountItemVO; import com.ruoyi.ccdi.project.domain.vo.CcdiProjectEmployeeCreditNegativeItemVO; import com.ruoyi.ccdi.project.domain.vo.CcdiBankStatementListVO; import com.ruoyi.ccdi.project.domain.vo.CcdiProjectEmployeeRiskAggregateVO; @@ -268,6 +270,15 @@ class CcdiProjectOverviewServiceImplTest { creditItem.setCivilLmt(new BigDecimal("20000.00")); when(overviewMapper.selectEmployeeCreditNegativeList(40L)).thenReturn(List.of(creditItem)); + CcdiProjectAbnormalAccountItemVO abnormalItem = new CcdiProjectAbnormalAccountItemVO(); + abnormalItem.setAccountNo("6222000000000001"); + abnormalItem.setAccountName("李四"); + abnormalItem.setBankName("中国农业银行"); + abnormalItem.setAbnormalType("突然销户"); + abnormalItem.setAbnormalTime("2026-03-20"); + abnormalItem.setStatus("已销户"); + when(overviewMapper.selectAbnormalAccountList(40L)).thenReturn(List.of(abnormalItem)); + MockHttpServletResponse response = new MockHttpServletResponse(); service.exportRiskDetails(response, 40L); @@ -282,6 +293,9 @@ class CcdiProjectOverviewServiceImplTest { ), argThat((List rows) -> rows.size() == 1 && "李四".equals(rows.getFirst().getPersonName()) + ), + argThat((List rows) -> + rows.size() == 1 && "6222000000000001".equals(rows.getFirst().getAccountNo()) ) ); } diff --git a/ccdi-project/src/test/java/com/ruoyi/ccdi/project/service/impl/CcdiProjectRiskDetailWorkbookExporterTest.java b/ccdi-project/src/test/java/com/ruoyi/ccdi/project/service/impl/CcdiProjectRiskDetailWorkbookExporterTest.java index 7d2da1ef..c65e061b 100644 --- a/ccdi-project/src/test/java/com/ruoyi/ccdi/project/service/impl/CcdiProjectRiskDetailWorkbookExporterTest.java +++ b/ccdi-project/src/test/java/com/ruoyi/ccdi/project/service/impl/CcdiProjectRiskDetailWorkbookExporterTest.java @@ -1,5 +1,6 @@ package com.ruoyi.ccdi.project.service.impl; +import com.ruoyi.ccdi.project.domain.excel.CcdiProjectAbnormalAccountExcel; import com.ruoyi.ccdi.project.domain.excel.CcdiProjectEmployeeCreditNegativeExcel; import com.ruoyi.ccdi.project.domain.excel.CcdiProjectSuspiciousTransactionExcel; import org.apache.poi.ss.usermodel.WorkbookFactory; @@ -36,7 +37,15 @@ class CcdiProjectRiskDetailWorkbookExporterTest { creditRow.setCivilCnt(1); creditRow.setCivilLmt(new BigDecimal("20000.00")); - exporter.export(response, 40L, List.of(suspiciousRow), List.of(creditRow)); + CcdiProjectAbnormalAccountExcel abnormalRow = new CcdiProjectAbnormalAccountExcel(); + abnormalRow.setAccountNo("6222000000000001"); + abnormalRow.setAccountName("李四"); + abnormalRow.setBankName("中国农业银行"); + abnormalRow.setAbnormalType("突然销户"); + abnormalRow.setAbnormalTime("2026-03-20"); + abnormalRow.setStatus("已销户"); + + exporter.export(response, 40L, List.of(suspiciousRow), List.of(creditRow), List.of(abnormalRow)); assertTrue(response.getContentType().startsWith( "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" @@ -48,8 +57,18 @@ class CcdiProjectRiskDetailWorkbookExporterTest { assertEquals("员工负面征信信息", workbook.getSheetAt(1).getSheetName()); assertEquals("异常账户人员信息", workbook.getSheetAt(2).getSheetName()); assertEquals("账号", workbook.getSheetAt(2).getRow(0).getCell(0).getStringCellValue()); + assertEquals("开户人", workbook.getSheetAt(2).getRow(0).getCell(1).getStringCellValue()); + assertEquals("银行", workbook.getSheetAt(2).getRow(0).getCell(2).getStringCellValue()); + assertEquals("异常类型", workbook.getSheetAt(2).getRow(0).getCell(3).getStringCellValue()); + assertEquals("异常发生时间", workbook.getSheetAt(2).getRow(0).getCell(4).getStringCellValue()); assertEquals("状态", workbook.getSheetAt(2).getRow(0).getCell(5).getStringCellValue()); - assertEquals(1, workbook.getSheetAt(2).getPhysicalNumberOfRows()); + assertEquals("6222000000000001", workbook.getSheetAt(2).getRow(1).getCell(0).getStringCellValue()); + assertEquals("李四", workbook.getSheetAt(2).getRow(1).getCell(1).getStringCellValue()); + assertEquals("中国农业银行", workbook.getSheetAt(2).getRow(1).getCell(2).getStringCellValue()); + assertEquals("突然销户", workbook.getSheetAt(2).getRow(1).getCell(3).getStringCellValue()); + assertEquals("2026-03-20", workbook.getSheetAt(2).getRow(1).getCell(4).getStringCellValue()); + assertEquals("已销户", workbook.getSheetAt(2).getRow(1).getCell(5).getStringCellValue()); + assertEquals(2, workbook.getSheetAt(2).getPhysicalNumberOfRows()); } } } diff --git a/ccdi-project/src/test/java/com/ruoyi/ccdi/project/sql/CcdiAbnormalAccountRuleSqlMetadataTest.java b/ccdi-project/src/test/java/com/ruoyi/ccdi/project/sql/CcdiAbnormalAccountRuleSqlMetadataTest.java new file mode 100644 index 00000000..5743cd83 --- /dev/null +++ b/ccdi-project/src/test/java/com/ruoyi/ccdi/project/sql/CcdiAbnormalAccountRuleSqlMetadataTest.java @@ -0,0 +1,47 @@ +package com.ruoyi.ccdi.project.sql; + +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class CcdiAbnormalAccountRuleSqlMetadataTest { + + @Test + void abnormalAccountMetadataSql_shouldContainModelAndRuleDefinitions() throws IOException { + Path path = Path.of("..", "sql", "migration", + "2026-03-31-create-ccdi-account-info-and-abnormal-account-rules.sql"); + + assertTrue(Files.exists(path), "异常账户模型迁移脚本应存在"); + + String sql = Files.readString(path, StandardCharsets.UTF_8); + assertAll( + () -> assertTrue(sql.contains("ABNORMAL_ACCOUNT")), + () -> assertTrue(sql.contains("SUDDEN_ACCOUNT_CLOSURE")), + () -> assertTrue(sql.contains("DORMANT_ACCOUNT_LARGE_ACTIVATION")), + () -> assertTrue(sql.contains("'OBJECT'")) + ); + } + + @Test + void abnormalAccountMetadataSql_shouldContainAccountInfoTableDefinition() throws IOException { + Path path = Path.of("..", "sql", "migration", + "2026-03-31-create-ccdi-account-info-and-abnormal-account-rules.sql"); + + assertTrue(Files.exists(path), "异常账户模型迁移脚本应存在"); + + String sql = Files.readString(path, StandardCharsets.UTF_8).toLowerCase(); + assertAll( + () -> assertTrue(sql.contains("create table if not exists `ccdi_account_info`")), + () -> assertTrue(sql.contains("`account_no`")), + () -> assertTrue(sql.contains("`owner_type`")), + () -> assertTrue(sql.contains("`effective_date`")), + () -> assertTrue(sql.contains("`invalid_date`")) + ); + } +} diff --git a/ccdi-project/src/test/java/com/ruoyi/ccdi/project/sql/CcdiAccountInfoMergeSqlTest.java b/ccdi-project/src/test/java/com/ruoyi/ccdi/project/sql/CcdiAccountInfoMergeSqlTest.java new file mode 100644 index 00000000..d0059199 --- /dev/null +++ b/ccdi-project/src/test/java/com/ruoyi/ccdi/project/sql/CcdiAccountInfoMergeSqlTest.java @@ -0,0 +1,33 @@ +package com.ruoyi.ccdi.project.sql; + +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class CcdiAccountInfoMergeSqlTest { + + @Test + void accountInfoMergeSql_shouldAddColumnsMigrateDataAndDropLegacyTable() throws IOException { + Path path = Path.of("..", "sql", "migration", + "2026-04-16-merge-ccdi-account-result-into-info.sql"); + + assertTrue(Files.exists(path), "账户库合表迁移脚本应存在"); + + String sql = Files.readString(path, StandardCharsets.UTF_8).toLowerCase(); + assertAll( + () -> assertTrue(sql.contains("bin/mysql_utf8_exec.sh")), + () -> assertTrue(sql.contains("ccdi_account_info")), + () -> assertTrue(sql.contains("add column `is_self_account`")), + () -> assertTrue(sql.contains("monthly_avg_trans_count")), + () -> assertTrue(sql.contains("update `ccdi_account_info` ai")), + () -> assertTrue(sql.contains("join `ccdi_account_result` ar")), + () -> assertTrue(sql.contains("drop table `ccdi_account_result`")) + ); + } +} diff --git a/ccdi-project/src/test/java/com/ruoyi/ccdi/project/sql/CcdiBankTagRuleSqlMetadataTest.java b/ccdi-project/src/test/java/com/ruoyi/ccdi/project/sql/CcdiBankTagRuleSqlMetadataTest.java index 22239681..66300f3c 100644 --- a/ccdi-project/src/test/java/com/ruoyi/ccdi/project/sql/CcdiBankTagRuleSqlMetadataTest.java +++ b/ccdi-project/src/test/java/com/ruoyi/ccdi/project/sql/CcdiBankTagRuleSqlMetadataTest.java @@ -30,6 +30,23 @@ class CcdiBankTagRuleSqlMetadataTest { assertPhase2Metadata(migrationSql); } + @Test + void abnormalAccountMetadataSql_shouldContainBusinessCaliberAndRuleRemark() throws IOException { + String migrationSql = readProjectFile("sql", "migration", + "2026-03-31-create-ccdi-account-info-and-abnormal-account-rules.sql"); + + assertAll( + () -> assertTrue(migrationSql.contains("员工本人账户已销户,且销户日前30天内仍存在交易记录。"), + "SUDDEN_ACCOUNT_CLOSURE 应使用设计文档中的业务口径"), + () -> assertTrue(migrationSql.contains("员工本人账户开户后长期未使用,首次启用后出现大额资金流动。"), + "DORMANT_ACCOUNT_LARGE_ACTIVATION 应使用设计文档中的业务口径"), + () -> assertTrue(migrationSql.contains("真实规则:识别员工本人账户销户前30天内仍有交易的员工对象"), + "SUDDEN_ACCOUNT_CLOSURE 应同步真实规则说明"), + () -> assertTrue(migrationSql.contains("真实规则:识别长期休眠后首次启用即出现大额资金流动的员工对象"), + "DORMANT_ACCOUNT_LARGE_ACTIVATION 应同步真实规则说明") + ); + } + private void assertPhase1Metadata(String sqlContent) { assertAll( () -> assertTrue(sqlContent.contains("'FOREX_BUY_AMT'") diff --git a/deploy/deploy-release-prod.sh b/deploy/deploy-release-prod.sh new file mode 100755 index 00000000..2b6a379c --- /dev/null +++ b/deploy/deploy-release-prod.sh @@ -0,0 +1,212 @@ +#!/bin/sh + +set -eu + +SCRIPT_DIR=$(CDPATH= cd -- "$(dirname "$0")" && pwd) +BACKEND_DIR="${SCRIPT_DIR}/backend" +FRONTEND_DIR="${SCRIPT_DIR}/frontend" +START_SCRIPT="${SCRIPT_DIR}/start-java-backend-prod.sh" +BACKUP_ROOT="${SCRIPT_DIR}/backups" +WORK_ROOT="${SCRIPT_DIR}/.deploy-work" +TIMESTAMP=$(date '+%Y%m%d%H%M%S') +BACKUP_DIR="${BACKUP_ROOT}/${TIMESTAMP}" +WORK_DIR="${WORK_ROOT}/release-${TIMESTAMP}" +RELEASE_ZIP="${1:-}" + +RELEASE_JAR="" +RELEASE_DIST_ZIP="" +FRONTEND_SOURCE_DIR="" + +timestamp() { + date '+%Y-%m-%d %H:%M:%S' +} + +log_info() { + printf '[%s] %s\n' "$(timestamp)" "$1" +} + +log_error() { + printf '[%s] %s\n' "$(timestamp)" "$1" >&2 +} + +usage() { + cat <<'EOF' +用法: ./deploy-release-prod.sh [上线压缩包路径] + +目录要求: + deploy-release-prod.sh + start-java-backend-prod.sh + backend/ + frontend/ + ccdi_YYYYMMDD.zip + +说明: + 未传上线压缩包路径时,脚本会自动使用当前脚本目录下唯一的 .zip 文件。 + 上线压缩包根层必须包含 ruoyi-admin.jar 和 dist.zip。 +EOF +} + +require_command() { + if ! command -v "$1" >/dev/null 2>&1; then + log_error "缺少命令: $1" + exit 1 + fi +} + +resolve_path() { + input_path="$1" + + case "${input_path}" in + /*) + printf '%s\n' "${input_path}" + ;; + *) + input_dir=$(dirname "${input_path}") + input_base=$(basename "${input_path}") + printf '%s/%s\n' "$(CDPATH= cd -- "${input_dir}" && pwd)" "${input_base}" + ;; + esac +} + +resolve_release_zip() { + if [ -n "${RELEASE_ZIP}" ]; then + RELEASE_ZIP=$(resolve_path "${RELEASE_ZIP}") + else + mkdir -p "${WORK_ROOT}" + candidate_file="${WORK_ROOT}/zip-candidates-${TIMESTAMP}.txt" + find "${SCRIPT_DIR}" -maxdepth 1 -type f -name '*.zip' ! -name 'dist.zip' | sort > "${candidate_file}" + candidate_count=$(wc -l < "${candidate_file}" | tr -d ' ') + + if [ "${candidate_count}" -eq 0 ]; then + log_error "未在脚本目录找到上线压缩包,请传入压缩包路径" + exit 1 + fi + + if [ "${candidate_count}" -gt 1 ]; then + log_error "脚本目录存在多个上线压缩包,请显式传入压缩包路径" + cat "${candidate_file}" >&2 + exit 1 + fi + + RELEASE_ZIP=$(sed -n '1p' "${candidate_file}") + rm -f "${candidate_file}" + fi + + if [ ! -f "${RELEASE_ZIP}" ]; then + log_error "上线压缩包不存在: ${RELEASE_ZIP}" + exit 1 + fi +} + +assert_layout() { + if [ ! -d "${BACKEND_DIR}" ]; then + log_error "未找到后端目录: ${BACKEND_DIR}" + exit 1 + fi + + if [ ! -d "${FRONTEND_DIR}" ]; then + log_error "未找到前端目录: ${FRONTEND_DIR}" + exit 1 + fi + + if [ ! -f "${START_SCRIPT}" ]; then + log_error "未找到后端启动脚本: ${START_SCRIPT}" + exit 1 + fi +} + +backup_dir() { + source_dir="$1" + target_dir="$2" + + mkdir -p "${target_dir}" + if [ -n "$(find "${source_dir}" -mindepth 1 -maxdepth 1 -print -quit)" ]; then + cp -a "${source_dir}/." "${target_dir}/" + log_info "已备份 ${source_dir} 到 ${target_dir}" + else + log_info "目录为空,已创建空备份目录: ${target_dir}" + fi +} + +backup_current_files() { + mkdir -p "${BACKUP_DIR}" + backup_dir "${BACKEND_DIR}" "${BACKUP_DIR}/backend" + backup_dir "${FRONTEND_DIR}" "${BACKUP_DIR}/frontend" +} + +extract_release_package() { + mkdir -p "${WORK_DIR}/release" "${WORK_DIR}/frontend" + log_info "开始解压上线压缩包: ${RELEASE_ZIP}" + unzip -q "${RELEASE_ZIP}" -d "${WORK_DIR}/release" + + RELEASE_JAR="${WORK_DIR}/release/ruoyi-admin.jar" + RELEASE_DIST_ZIP="${WORK_DIR}/release/dist.zip" + if [ ! -f "${RELEASE_JAR}" ]; then + log_error "上线压缩包根层缺少 ruoyi-admin.jar" + exit 1 + fi + if [ ! -f "${RELEASE_DIST_ZIP}" ]; then + log_error "上线压缩包根层缺少 dist.zip" + exit 1 + fi + + unzip -q "${RELEASE_DIST_ZIP}" -d "${WORK_DIR}/frontend" + FRONTEND_SOURCE_DIR="${WORK_DIR}/frontend/dist" + if [ ! -f "${FRONTEND_SOURCE_DIR}/index.html" ]; then + log_error "dist.zip 解压后未找到 dist/index.html" + exit 1 + fi +} + +deploy_backend() { + target_jar="${BACKEND_DIR}/ruoyi-admin.jar" + deploying_jar="${BACKEND_DIR}/.ruoyi-admin.jar.deploying" + + cp "${RELEASE_JAR}" "${deploying_jar}" + mv "${deploying_jar}" "${target_jar}" + log_info "后端 Jar 已部署: ${target_jar}" +} + +deploy_frontend() { + find "${FRONTEND_DIR}" -mindepth 1 -maxdepth 1 -exec rm -rf {} + + cp -a "${FRONTEND_SOURCE_DIR}/." "${FRONTEND_DIR}/" + log_info "前端文件已部署到: ${FRONTEND_DIR}" +} + +cleanup_work_dir() { + rm -rf "${WORK_DIR}" +} + +main() { + case "${1:-}" in + -h|--help|help) + usage + exit 0 + ;; + esac + + if [ "$#" -gt 1 ]; then + usage + exit 1 + fi + + require_command "unzip" + require_command "find" + + resolve_release_zip + assert_layout + backup_current_files + + trap cleanup_work_dir 0 + extract_release_package + deploy_backend + deploy_frontend + cleanup_work_dir + trap - 0 + + log_info "部署完成,备份目录: ${BACKUP_DIR}" + log_info "开始重启后端并输出日志" + bash "${START_SCRIPT}" restart +} + +main "$@" diff --git a/deploy/deploy-to-nas-tongweb.sh b/deploy/deploy-to-nas-tongweb.sh new file mode 100755 index 00000000..6f70371d --- /dev/null +++ b/deploy/deploy-to-nas-tongweb.sh @@ -0,0 +1,101 @@ +#!/bin/bash + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" + +SERVER_HOST="116.62.17.81" +SERVER_PORT="9444" +SERVER_USERNAME="wkc" +SERVER_PASSWORD="wkc@0825" +REMOTE_ROOT="/volume1/webapp/ccdi" +TONGWEB_HOME="${TONGWEB_HOME:-/opt/TongWeb}" +APP_NAME="${APP_NAME:-ruoyi-admin}" +DRY_RUN="false" + +ensure_command() { + local command_name="$1" + if ! command -v "${command_name}" >/dev/null 2>&1; then + echo "缺少命令: ${command_name}" >&2 + exit 1 + fi +} + +ensure_paramiko() { + if python3 - <<'PY' +import importlib.util +import sys + +sys.exit(0 if importlib.util.find_spec("paramiko") else 1) +PY + then + return + fi + + python3 -m pip install --user paramiko +} + +POSITION=0 +for arg in "$@"; do + if [[ "${arg}" == "--dry-run" ]]; then + DRY_RUN="true" + continue + fi + + POSITION=$((POSITION + 1)) + case "${POSITION}" in + 1) SERVER_HOST="${arg}" ;; + 2) SERVER_PORT="${arg}" ;; + 3) SERVER_USERNAME="${arg}" ;; + 4) SERVER_PASSWORD="${arg}" ;; + 5) REMOTE_ROOT="${arg}" ;; + 6) TONGWEB_HOME="${arg}" ;; + 7) APP_NAME="${arg}" ;; + *) + echo "仅支持 [host] [port] [username] [password] [remoteRoot] [tongwebHome] [appName] [--dry-run]" >&2 + exit 1 + ;; + esac +done + +if [[ "${DRY_RUN}" == "true" ]]; then + echo "[DryRun] TongWeb NAS 部署参数预览" + echo "Host: ${SERVER_HOST}" + echo "Port: ${SERVER_PORT}" + echo "Username: ${SERVER_USERNAME}" + echo "RemoteRoot: ${REMOTE_ROOT}" + echo "TongWebHome: ${TONGWEB_HOME}" + echo "AppName: ${APP_NAME}" + exit 0 +fi + +echo "[1/4] 检查本地环境" +ensure_command "mvn" +ensure_command "python3" + +echo "[2/4] 打包后端 war" +( + cd "${REPO_ROOT}" + mvn -pl ruoyi-admin -am package -DskipTests +) + +WAR_PATH="${REPO_ROOT}/ruoyi-admin/target/ruoyi-admin.war" +if [[ ! -f "${WAR_PATH}" ]]; then + echo "未找到后端 war 包: ${WAR_PATH}" >&2 + exit 1 +fi + +echo "[3/4] 检查远端执行依赖" +ensure_paramiko + +echo "[4/4] 上传 war 并重启 TongWeb" +python3 "${SCRIPT_DIR}/remote-deploy-tongweb.py" \ + --host "${SERVER_HOST}" \ + --port "${SERVER_PORT}" \ + --username "${SERVER_USERNAME}" \ + --password "${SERVER_PASSWORD}" \ + --local-war "${WAR_PATH}" \ + --remote-root "${REMOTE_ROOT}" \ + --tongweb-home "${TONGWEB_HOME}" \ + --app-name "${APP_NAME}" diff --git a/deploy/deploy-to-nas.sh b/deploy/deploy-to-nas.sh index 8ccb167d..c8c3a879 100755 --- a/deploy/deploy-to-nas.sh +++ b/deploy/deploy-to-nas.sh @@ -104,6 +104,9 @@ copy_path "${REPO_ROOT}/ruoyi-ui/dist" "${STAGE_ROOT}/frontend/dist" copy_path "${REPO_ROOT}/docker-compose.yml" "${STAGE_ROOT}/docker-compose.yml" copy_path "${REPO_ROOT}/.env.example" "${STAGE_ROOT}/.env.example" copy_path "${REPO_ROOT}/ruoyi-admin/target/ruoyi-admin.jar" "${STAGE_ROOT}/backend/ruoyi-admin.jar" +python3 "${SCRIPT_DIR}/render_nas_env.py" \ + --template "${REPO_ROOT}/.env.example" \ + --output "${STAGE_ROOT}/.env" echo "[5/5] 上传并远端部署" ensure_paramiko diff --git a/deploy/deploy.ps1 b/deploy/deploy.ps1 index 39177c6c..d089edcf 100644 --- a/deploy/deploy.ps1 +++ b/deploy/deploy.ps1 @@ -95,6 +95,12 @@ Copy-ItemSafe (Join-Path $repoRoot "ruoyi-ui\\dist") (Join-Path $stageRoot "fron Copy-ItemSafe (Join-Path $repoRoot "docker-compose.yml") (Join-Path $stageRoot "docker-compose.yml") Copy-ItemSafe (Join-Path $repoRoot ".env.example") (Join-Path $stageRoot ".env.example") Copy-ItemSafe (Join-Path $repoRoot "ruoyi-admin\\target\\ruoyi-admin.jar") (Join-Path $stageRoot "backend\\ruoyi-admin.jar") +python (Join-Path $scriptDir "render_nas_env.py") ` + --template (Join-Path $repoRoot ".env.example") ` + --output (Join-Path $stageRoot ".env") +if ($LASTEXITCODE -ne 0) { + throw "生成 NAS 部署 .env 失败" +} Write-Host "[5/5] 上传并远端部署" $paramikoCheck = @' diff --git a/deploy/remote-deploy-tongweb.py b/deploy/remote-deploy-tongweb.py new file mode 100644 index 00000000..5c467976 --- /dev/null +++ b/deploy/remote-deploy-tongweb.py @@ -0,0 +1,136 @@ +import argparse +import posixpath +import shlex +import sys +from pathlib import Path + +import paramiko + + +def parse_args(): + parser = argparse.ArgumentParser(description="Upload backend war to NAS and restart TongWeb.") + parser.add_argument("--host", required=True) + parser.add_argument("--port", type=int, required=True) + parser.add_argument("--username", required=True) + parser.add_argument("--password", required=True) + parser.add_argument("--local-war", required=True) + parser.add_argument("--remote-root", required=True) + parser.add_argument("--tongweb-home", required=True) + parser.add_argument("--app-name", required=True) + return parser.parse_args() + + +def run_command(ssh, command): + stdin, stdout, stderr = ssh.exec_command(command) + exit_code = stdout.channel.recv_exit_status() + output = stdout.read().decode("utf-8", errors="ignore") + error = stderr.read().decode("utf-8", errors="ignore") + return exit_code, output, error + + +def sudo_prefix(password): + return f"printf '%s\\n' {shlex.quote(password)} | sudo -S -p '' " + + +def detect_command_prefix(ssh, password, command): + plain_exit_code, _, _ = run_command(ssh, f"{command} >/dev/null 2>&1") + if plain_exit_code == 0: + return "" + + sudo_probe = f"{sudo_prefix(password)}{command} >/dev/null 2>&1" + sudo_exit_code, _, _ = run_command(ssh, sudo_probe) + if sudo_exit_code == 0: + return sudo_prefix(password) + + raise RuntimeError(f"Remote command is not accessible: {command}") + + +def ensure_remote_path(ssh, prefix, remote_path): + command = f"{prefix}mkdir -p {shlex.quote(remote_path)}" + exit_code, output, error = run_command(ssh, command) + if exit_code != 0: + raise RuntimeError(f"Failed to create remote directory {remote_path}:\n{output}\n{error}") + + +def upload_file(sftp, local_file, remote_file): + parent_dir = posixpath.dirname(remote_file) + try: + sftp.listdir(parent_dir) + except OSError: + raise RuntimeError(f"SFTP remote directory not found: {parent_dir}") + sftp.put(str(local_file), remote_file) + + +def build_deploy_command(args, prefix): + app_war_name = f"{args.app_name}.war" + remote_war_path = posixpath.join(args.remote_root.rstrip("/"), "backend", app_war_name) + autodeploy_dir = posixpath.join(args.tongweb_home.rstrip("/"), "autodeploy") + deployed_war_path = posixpath.join(autodeploy_dir, app_war_name) + deployed_dir_path = posixpath.join(autodeploy_dir, args.app_name) + stop_script = posixpath.join(args.tongweb_home.rstrip("/"), "bin", "stopserver.sh") + start_script = posixpath.join(args.tongweb_home.rstrip("/"), "bin", "startservernohup.sh") + + return ( + "set -e;" + f"test -d {shlex.quote(args.tongweb_home)};" + f"test -x {shlex.quote(stop_script)};" + f"test -x {shlex.quote(start_script)};" + f"{prefix}mkdir -p {shlex.quote(autodeploy_dir)};" + f"{prefix}sh {shlex.quote(stop_script)} >/dev/null 2>&1 || true;" + f"{prefix}rm -rf {shlex.quote(deployed_dir_path)};" + f"{prefix}rm -f {shlex.quote(deployed_war_path)};" + f"{prefix}cp {shlex.quote(remote_war_path)} {shlex.quote(deployed_war_path)};" + f"{prefix}sh {shlex.quote(start_script)};" + "sleep 5;" + f"ls -l {shlex.quote(autodeploy_dir)};" + ) + + +def main(): + args = parse_args() + local_war = Path(args.local_war).resolve() + if not local_war.exists(): + raise FileNotFoundError(f"Local war does not exist: {local_war}") + + ssh = paramiko.SSHClient() + ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy()) + ssh.connect( + hostname=args.host, + port=args.port, + username=args.username, + password=args.password, + timeout=20, + ) + + sftp = ssh.open_sftp() + try: + remote_root = args.remote_root.rstrip("/") + remote_backend_dir = posixpath.join(remote_root, "backend") + remote_war_path = posixpath.join(remote_backend_dir, f"{args.app_name}.war") + + ensure_remote_path(ssh, "", remote_root) + ensure_remote_path(ssh, "", remote_backend_dir) + upload_file(sftp, local_war, remote_war_path) + + command_prefix = detect_command_prefix(ssh, args.password, f"test -d {shlex.quote(args.tongweb_home)}") + deploy_command = build_deploy_command(args, command_prefix) + exit_code, output, error = run_command(ssh, deploy_command) + if exit_code != 0: + raise RuntimeError(f"Remote TongWeb deploy failed:\n{output}\n{error}") + + print("=== DEPLOY OUTPUT ===") + print(output.strip()) + if error.strip(): + print("=== DEPLOY STDERR ===") + print(error.strip()) + finally: + sftp.close() + ssh.close() + + +if __name__ == "__main__": + try: + main() + except Exception as exc: + print(str(exc), file=sys.stderr) + sys.exit(1) diff --git a/deploy/render_nas_env.py b/deploy/render_nas_env.py new file mode 100644 index 00000000..f36256fc --- /dev/null +++ b/deploy/render_nas_env.py @@ -0,0 +1,47 @@ +import argparse +from pathlib import Path + + +NAS_ENV_OVERRIDES = { + "CCDI_DB_HOST": "192.168.0.111", + "CCDI_DB_PORT": "40628", +} + + +def parse_args(): + parser = argparse.ArgumentParser(description="Render NAS deployment .env for CCDI docker compose.") + parser.add_argument("--template", required=True) + parser.add_argument("--output", required=True) + return parser.parse_args() + + +def render_env_text(template_text: str) -> str: + rendered_lines = [] + replaced_keys = set() + + for line in template_text.splitlines(): + key, separator, value = line.partition("=") + if separator and key in NAS_ENV_OVERRIDES: + rendered_lines.append(f"{key}={NAS_ENV_OVERRIDES[key]}") + replaced_keys.add(key) + continue + rendered_lines.append(line) + + for key, value in NAS_ENV_OVERRIDES.items(): + if key not in replaced_keys: + rendered_lines.append(f"{key}={value}") + + return "\n".join(rendered_lines) + "\n" + + +def main(): + args = parse_args() + template_path = Path(args.template) + output_path = Path(args.output) + + template_text = template_path.read_text(encoding="utf-8") + output_path.write_text(render_env_text(template_text), encoding="utf-8") + + +if __name__ == "__main__": + main() diff --git a/deploy/start-java-backend-prod.sh b/deploy/start-java-backend-prod.sh new file mode 100755 index 00000000..d8f33276 --- /dev/null +++ b/deploy/start-java-backend-prod.sh @@ -0,0 +1,310 @@ +#!/bin/bash + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +# ==================== 生产配置区:按服务器实际路径修改 ==================== + +# JDK 安装目录。留空时使用服务器已有 JAVA_HOME;仍为空时使用 PATH 中的 java。 +BACKEND_JAVA_HOME="" + +# 后端 Jar 所在目录。生产目录结构为:启动脚本在外层,Jar 位于 backend/ruoyi-admin.jar。 +APP_HOME="${SCRIPT_DIR}/backend" + +# 后端 Jar 文件名。 +JAR_NAME="ruoyi-admin.jar" + +# Spring Profile。 +SPRING_PROFILES_ACTIVE="uat" + +# JVM 参数。 +JAVA_OPTS="-Duser.timezone=Asia/Shanghai -Xms512m -Xmx1024m -XX:MetaspaceSize=128m -XX:MaxMetaspaceSize=512m -XX:+HeapDumpOnOutOfMemoryError" + +# 额外应用启动参数,例如:--server.port=8080 +APP_ARGS="" + +# 停止进程等待秒数。 +STOP_WAIT_SECONDS=30 + +# ==================== 以下为脚本逻辑,一般不需要修改 ==================== + +if [[ "${APP_HOME}" != /* ]]; then + APP_HOME="${SCRIPT_DIR}/${APP_HOME}" +fi +JAR_PATH="${APP_HOME}/${JAR_NAME}" +RELATIVE_JAR_PATH="" +if [[ "${APP_HOME}" == "${SCRIPT_DIR}/"* ]]; then + RELATIVE_JAR_PATH="${APP_HOME#${SCRIPT_DIR}/}/${JAR_NAME}" +fi +LOG_DIR="${APP_HOME}/logs" +CONSOLE_LOG="${LOG_DIR}/backend-console.log" +PID_FILE="${LOG_DIR}/backend-java.pid" +APP_MARKER="-Dccdi.backend.prod.home=${APP_HOME}" +JAVA_CMD="java" + +timestamp() { + date "+%Y-%m-%d %H:%M:%S" +} + +log_info() { + printf '[%s] %s\n' "$(timestamp)" "$1" +} + +log_error() { + printf '[%s] %s\n' "$(timestamp)" "$1" >&2 +} + +usage() { + cat <<'EOF' +用法: ./start-java-backend-prod.sh [start|stop|restart|status|logs] + +默认动作: + start 先关闭旧后端进程,再启动生产后端 Jar,启动成功后持续输出控制台日志 + +常用配置: + 配置统一写在脚本顶部“生产配置区”,包括 BACKEND_JAVA_HOME、APP_HOME、SPRING_PROFILES_ACTIVE、JAVA_OPTS。 + +示例: + ./start-java-backend-prod.sh restart +EOF +} + +ensure_command() { + local command_name="$1" + if ! command -v "${command_name}" >/dev/null 2>&1; then + log_error "缺少命令: ${command_name}" + exit 1 + fi +} + +resolve_java_cmd() { + local configured_java_home="${BACKEND_JAVA_HOME}" + if [[ -z "${configured_java_home}" ]]; then + configured_java_home="${JAVA_HOME:-}" + fi + + if [[ -n "${configured_java_home}" ]]; then + configured_java_home="${configured_java_home%/}" + if [[ ! -x "${configured_java_home}/bin/java" ]]; then + log_error "配置的 JAVA_HOME 无效,未找到可执行文件: ${configured_java_home}/bin/java" + exit 1 + fi + + export JAVA_HOME="${configured_java_home}" + JAVA_CMD="${JAVA_HOME}/bin/java" + else + ensure_command "java" + JAVA_CMD="java" + fi + + log_info "使用 Java 命令: ${JAVA_CMD}" +} + +get_process_table() { + local process_table + if ! process_table="$(ps -ef 2>/dev/null)"; then + log_error "执行 ps -ef 失败,无法扫描旧进程" + return 1 + fi + + printf '%s\n' "${process_table}" +} + +is_managed_pid() { + local pid="$1" + if [[ -z "${pid}" ]] || ! kill -0 "${pid}" 2>/dev/null; then + return 1 + fi + + local process_table + if ! process_table="$(get_process_table)"; then + return 1 + fi + + local line + while IFS= read -r line; do + set -- ${line} + if [[ "${2:-}" == "${pid}" ]] && is_backend_process_line "${line}"; then + return 0 + fi + done <<<"${process_table}" + + return 1 +} + +is_backend_process_line() { + local line="$1" + [[ "${line}" != *""* ]] || return 1 + [[ "${line}" == *" -jar ${JAR_PATH}"* ]] && return 0 + [[ -n "${RELATIVE_JAR_PATH}" && "${line}" == *" -jar ${RELATIVE_JAR_PATH}"* ]] +} + +collect_pids() { + local all_pids="" + local pid + local process_table + if ! process_table="$(get_process_table)"; then + return 1 + fi + + if [[ -f "${PID_FILE}" ]]; then + pid="$(cat "${PID_FILE}" 2>/dev/null || true)" + if is_managed_pid "${pid}"; then + all_pids="${all_pids} ${pid}" + fi + fi + + local line + while IFS= read -r line; do + set -- ${line} + pid="${2:-}" + if [[ "${pid}" =~ ^[0-9]+$ ]] && is_backend_process_line "${line}"; then + all_pids="${all_pids} ${pid}" + fi + done <<<"${process_table}" + + local unique_pids="" + for pid in ${all_pids}; do + case " ${unique_pids} " in + *" ${pid} "*) ;; + *) unique_pids="${unique_pids} ${pid}" ;; + esac + done + + xargs <<<"${unique_pids}" 2>/dev/null || true +} + +start_backend() { + resolve_java_cmd + + if [[ ! -f "${JAR_PATH}" ]]; then + log_error "未找到后端 Jar: ${JAR_PATH}" + exit 1 + fi + + local running_pids + running_pids="$(collect_pids)" + if [[ -n "${running_pids}" ]]; then + log_error "检测到后端已在运行: ${running_pids}" + exit 1 + fi + + mkdir -p "${LOG_DIR}" + printf '\n===== %s start =====\n' "$(timestamp)" >>"${CONSOLE_LOG}" + + local profile_arg="" + if [[ -n "${SPRING_PROFILES_ACTIVE}" ]]; then + profile_arg="--spring.profiles.active=${SPRING_PROFILES_ACTIVE}" + fi + + log_info "开始启动后端 Jar: ${JAR_PATH}" + nohup "${JAVA_CMD}" "${APP_MARKER}" ${JAVA_OPTS} -jar "${JAR_PATH}" ${profile_arg} ${APP_ARGS} >>"${CONSOLE_LOG}" 2>&1 & + echo $! >"${PID_FILE}" + + sleep 3 + + local starter_pid + starter_pid="$(cat "${PID_FILE}" 2>/dev/null || true)" + if [[ -z "${starter_pid}" ]] || ! kill -0 "${starter_pid}" 2>/dev/null; then + log_error "启动命令未保持运行,请检查日志: ${CONSOLE_LOG}" + exit 1 + fi + + log_info "后端启动完成,PID: ${starter_pid}" +} + +stop_backend() { + local pids + pids="$(collect_pids)" + + if [[ -z "${pids}" ]]; then + log_info "未发现运行中的后端进程" + rm -f "${PID_FILE}" + return 0 + fi + + log_info "准备停止后端进程: ${pids}" + local pid + for pid in ${pids}; do + kill -TERM "${pid}" 2>/dev/null || true + done + + local elapsed=0 + local remaining_pids="${pids}" + while [[ -n "${remaining_pids}" && "${elapsed}" -lt "${STOP_WAIT_SECONDS}" ]]; do + sleep 1 + elapsed=$((elapsed + 1)) + remaining_pids="" + for pid in ${pids}; do + if kill -0 "${pid}" 2>/dev/null; then + remaining_pids="${remaining_pids} ${pid}" + fi + done + remaining_pids="$(xargs <<<"${remaining_pids}" 2>/dev/null || true)" + done + + if [[ -n "${remaining_pids}" ]]; then + log_info "仍有进程未退出,执行强制停止: ${remaining_pids}" + for pid in ${remaining_pids}; do + kill -KILL "${pid}" 2>/dev/null || true + done + fi + + rm -f "${PID_FILE}" + log_info "后端停止完成" +} + +status_backend() { + local pids + pids="$(collect_pids)" + if [[ -n "${pids}" ]]; then + log_info "后端正在运行,进程: ${pids}" + return 0 + fi + + log_info "后端未运行" +} + +follow_logs() { + mkdir -p "${LOG_DIR}" + touch "${CONSOLE_LOG}" + log_info "持续输出日志中,按 Ctrl+C 仅退出日志查看,不会停止后端进程" + tail -n 200 -F "${CONSOLE_LOG}" +} + +start_action() { + stop_backend + start_backend + follow_logs +} + +main() { + local action="${1:-start}" + case "${action}" in + start) + start_action + ;; + stop) + stop_backend + ;; + restart) + start_action + ;; + status) + status_backend + ;; + logs) + follow_logs + ;; + -h|--help|help) + usage + ;; + *) + usage + exit 1 + ;; + esac +} + +main "$@" diff --git a/docker-compose.yml b/docker-compose.yml index b5ea0ec7..f24f342a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -21,6 +21,7 @@ services: context: . dockerfile: docker/mock/Dockerfile container_name: ccdi-lsfx-mock + command: ["python", "main.py", "--rule-hit-mode", "subset"] restart: unless-stopped depends_on: - backend diff --git a/docs/design/2026-03-31-abnormal-account-bank-tag-design.md b/docs/design/2026-03-31-abnormal-account-bank-tag-design.md new file mode 100644 index 00000000..07b84cc9 --- /dev/null +++ b/docs/design/2026-03-31-abnormal-account-bank-tag-design.md @@ -0,0 +1,382 @@ +# 异常账户模型接入银行流水打标设计文档 + +**模块**: 银行流水打标 +**日期**: 2026-03-31 + +## 一、背景 + +当前银行流水打标主链路已经具备以下基础能力: + +- 规则元数据管理与启用控制 +- `CcdiBankTagServiceImpl` 统一执行入口 +- `CcdiBankTagAnalysisMapper.xml` 承载真实规则 SQL +- `ccdi_bank_statement_tag_result` 统一承载 `STATEMENT / OBJECT` 命中结果 +- 项目风险总览按对象型结果聚合员工风险情况 + +根据 [异常账户.xlsx](/Users/wkc/Desktop/ccdi/ccdi/assets/异常账户.xlsx) 与 [员工账户.xlsx](/Users/wkc/Desktop/ccdi/ccdi/assets/员工账户.xlsx),本次需要新增独立模型“异常账户”,并正式接入以下两条规则: + +- `SUDDEN_ACCOUNT_CLOSURE`:突然销户 +- `DORMANT_ACCOUNT_LARGE_ACTIVATION`:休眠账户大额启用 + +这两条规则均依赖新增账户信息表 `ccdi_account_info`,且风险筛查对象明确为“员工本人”。本次目标是在不改造现有打标架构的前提下,将两条规则纳入现有项目打标主链路,并补充能够稳定命中的测试数据与验证手段。 + +## 二、目标 + +本次设计目标如下: + +1. 新增账户信息表 `ccdi_account_info`,支撑异常账户规则计算。 +2. 新增独立模型 `ABNORMAL_ACCOUNT`,并接入 2 条对象型规则。 +3. 将两条规则接入现有 `executeObjectRule(...)` 打标链路,不新增平行处理模块。 +4. 补充最小可命中的测试数据 SQL,并覆盖正样本与反样本。 +5. 保留 Java 自动化测试,同时在验证阶段使用 MySQL MCP 执行真实 SQL,确认命中结果符合业务口径。 +6. 在设计确认后,分别产出后端与前端实施计划文档。 + +## 三、范围 + +### 3.1 本次范围 + +- 新增 `ccdi_account_info` 建表 SQL +- 新增模型 `ABNORMAL_ACCOUNT` +- 新增规则元数据 `SUDDEN_ACCOUNT_CLOSURE`、`DORMANT_ACCOUNT_LARGE_ACTIVATION` +- `CcdiBankTagServiceImpl` 新增对象型规则分发 +- `CcdiBankTagAnalysisMapper.java/.xml` 新增 2 条对象型查询 +- 新增测试数据 SQL +- 新增 Java 自动化测试 +- 新增基于 MySQL MCP 的真实 SQL 验证步骤 +- 新增设计文档、后端实施计划、前端实施计划 + +### 3.2 不在本次范围 + +- 不开发“异常账户人员信息”独立查询、分页、详情、导出真实数据链路 +- 不改前端页面展示逻辑 +- 不扩展到关系人或外部账户 +- 不新增动态规则引擎、DSL 或兼容性补丁方案 +- 不改造 `lsfx-mock-server` +- 不将固定阈值改造成项目可配置参数 + +## 四、现状分析 + +### 4.1 当前主链路 + +当前项目级银行流水打标流程为: + +1. `CcdiBankTagServiceImpl.rebuildProject(...)` 加载启用规则。 +2. 规则按 `rule_code` 分发到 `executeStatementRule(...)` 或 `executeObjectRule(...)`。 +3. `CcdiBankTagAnalysisMapper.xml` 执行真实 SQL,返回流水型或对象型命中结果。 +4. Service 将命中结果组装为 `CcdiBankTagResult` 并写入 `ccdi_bank_statement_tag_result`。 +5. 项目结果总览再按对象维度聚合风险人数和命中规则快照。 + +### 4.2 当前缺口 + +当前仓库中“异常账户人员信息”仍为占位展示,且主打标规则中尚无“异常账户”模型与对应规则编码。也就是说,本次缺口主要是: + +- 缺少账户信息基础表 +- 缺少异常账户模型与规则元数据 +- 缺少两条规则的对象型 SQL +- 缺少最小可命中的测试样本与真实 SQL 验证 + +## 五、方案对比 + +### 5.1 方案一:最小闭环接入现有对象型打标链路 + +做法: + +- 新增独立模型 `ABNORMAL_ACCOUNT` +- 两条规则均按 `OBJECT` 结果类型落到员工维度 +- 通过 `CcdiBankTagAnalysisMapper.xml` 计算命中结果 +- 结果继续写入 `ccdi_bank_statement_tag_result` + +优点: + +- 改动最小 +- 完全复用现有打标主链路 +- 能直接进入现有员工风险总览聚合 + +缺点: + +- 本轮不打通“异常账户人员信息”独立详情链路 + +### 5.2 方案二:在方案一基础上同时打通异常账户独立结果链路 + +优点: + +- 风险详情中的“异常账户人员信息”可展示真实数据 + +缺点: + +- 改动范围明显扩大 +- 超出本次需求 +- 不符合最短路径实现要求 + +### 5.3 方案三:仅补 SQL 验证,不接入主系统打标链路 + +优点: + +- 开发最省 + +缺点: + +- 无法满足“正式接入主系统打标链路”的需求 + +### 5.4 结论 + +采用方案一: + +- 新增独立模型 `ABNORMAL_ACCOUNT` +- 两条规则均按对象型规则接入现有打标链路 +- 结果沉淀到现有结果表 +- 后续如需开发异常账户独立查询能力,再以此为基础扩展 + +## 六、总体设计 + +### 6.1 模型与规则设计 + +本次新增如下模型与规则: + +- 模型编码:`ABNORMAL_ACCOUNT` +- 模型名称:`异常账户` +- 规则一:`SUDDEN_ACCOUNT_CLOSURE` / `突然销户` +- 规则二:`DORMANT_ACCOUNT_LARGE_ACTIVATION` / `休眠账户大额启用` + +两条规则统一定义为: + +- `result_type = OBJECT` +- `object_type = STAFF_ID_CARD` +- `object_key = 员工身份证号` + +### 6.2 结果落库 + +两条规则命中后继续写入现有结果表 `ccdi_bank_statement_tag_result`,不新增单独结果表。 + +结果字段约束如下: + +- `model_code = ABNORMAL_ACCOUNT` +- `rule_code` 使用全大写风格 +- `result_type = OBJECT` +- `bank_statement_id = null` +- `object_type = STAFF_ID_CARD` +- `object_key = 员工身份证号` +- `reason_detail` 存储账户号、异常日期与统计快照 + +### 6.3 数据流 + +数据流保持为: + +1. 项目级打标入口加载启用规则。 +2. 当规则编码为 `SUDDEN_ACCOUNT_CLOSURE` 或 `DORMANT_ACCOUNT_LARGE_ACTIVATION` 时,进入 `executeObjectRule(...)`。 +3. Mapper SQL 在项目范围内将 `ccdi_bank_statement` 与 `ccdi_account_info`、`ccdi_base_staff` 关联。 +4. SQL 返回员工身份证号维度的对象型命中结果。 +5. Service 将命中结果写入 `ccdi_bank_statement_tag_result`。 +6. 员工风险聚合继续从该结果表汇总,无需新建平行链路。 + +## 七、表结构设计 + +### 7.1 新增表 `ccdi_account_info` + +以 [员工账户.xlsx](/Users/wkc/Desktop/ccdi/ccdi/assets/员工账户.xlsx) 为准,新增表 `ccdi_account_info`,核心字段如下: + +- `account_id` +- `account_no` +- `account_type` +- `account_name` +- `owner_type` +- `owner_id` +- `bank` +- `bank_code` +- `currency` +- `is_self_account` +- `monthly_avg_trans_count` +- `monthly_avg_trans_amount` +- `trans_freq_type` +- `dr_max_single_amount` +- `cr_max_single_amount` +- `dr_max_daily_amount` +- `cr_max_daily_amount` +- `trans_risk_level` +- `status` +- `effective_date` +- `invalid_date` +- `created_by` +- `updated_by` +- `create_time` +- `update_time` + +### 7.2 关联约束 + +本次规则只识别员工本人账户,关联口径固定为: + +- `ccdi_account_info.owner_type = 'EMPLOYEE'` +- `ccdi_account_info.owner_id = ccdi_base_staff.id_card` +- `ccdi_account_info.account_no = ccdi_bank_statement.LE_ACCOUNT_NO` + +说明: + +- 仓库中当前未见单独的账号加解密或标准化链路,因此本次设计要求建表脚本、测试数据与流水数据直接使用一致账号值 +- 本次不将关系人账户纳入规则范围 + +## 八、规则 SQL 口径 + +### 8.1 `SUDDEN_ACCOUNT_CLOSURE` + +业务口径: + +- 员工本人账户已销户 +- 销户日前 30 天内仍存在交易记录 + +SQL 设计约束: + +- 仅统计项目内流水 +- 统计窗口限定为 `[invalid_date - 30天, invalid_date)` +- 按“员工身份证号 + 账号”粒度聚合,再映射回员工对象 + +命中条件: + +- `status = 2` +- `invalid_date is not null` +- 窗口内存在至少 1 笔交易 + +返回结果: + +- `objectType = STAFF_ID_CARD` +- `objectKey = 员工身份证号` + +`reasonDetail` 结构: + +- `账户{account_no}于{invalid_date}销户,销户前30天内最后交易日{last_tx_date},累计交易金额{window_total_amount}元,单笔最大金额{window_max_single_amount}元` + +### 8.2 `DORMANT_ACCOUNT_LARGE_ACTIVATION` + +业务口径: + +- 员工本人账户状态正常 +- 开户后长期未使用 +- 首次启用后出现大额资金流动 + +SQL 设计约束: + +- 仅统计项目内流水 +- 以该账户在项目内的首次流水日期作为“启用时间” +- “沉睡时长”按开户日期到首次交易日期计算 + +命中条件: + +- `status = 1` +- `effective_date is not null` +- `first_tx_date >= effective_date + 6个月` +- 且满足以下任一: + - 启用后累计交易总额 `>= 500000` + - 启用后单笔最大交易金额 `>= 100000` + +返回结果: + +- `objectType = STAFF_ID_CARD` +- `objectKey = 员工身份证号` + +`reasonDetail` 结构: + +- `账户{account_no}开户于{effective_date},首次交易日期{first_tx_date},沉睡时长{dormant_months}个月,启用后累计交易金额{total_amount}元,单笔最大金额{max_single_amount}元` + +### 8.3 公共规则约束 + +- 仅识别员工本人账户,不识别关系人和外部账户 +- 仅按项目内流水计算,不跨项目拼接历史流水 +- 累计金额使用 `amount_dr + amount_cr` +- 单笔最大金额使用 `greatest(amount_dr, amount_cr)` +- 同一员工多个账户分别判断,允许同一规则写入多条结果,避免强行合并后丢失账户级快照 + +## 九、测试数据设计 + +### 9.1 测试数据组织原则 + +新增一份独立增量 SQL,放在 `sql/migration/`,仅构造本次规则所需最小样本。 + +### 9.2 样本设计 + +建议最少包含以下样本: + +- 员工 A:命中 `SUDDEN_ACCOUNT_CLOSURE` + - 账户已销户 + - 销户前 30 天内有 2 到 3 笔项目流水 +- 员工 B:命中 `DORMANT_ACCOUNT_LARGE_ACTIVATION` + - 开户日期早于首次交易至少 6 个月 + - 启用后累计金额超过 50 万 +- 员工 C:休眠不足 6 个月,不命中 +- 员工 D:已销户,但销户前 30 天无流水,不命中 + +### 9.3 数据一致性要求 + +- `ccdi_account_info.account_no` 与 `ccdi_bank_statement.LE_ACCOUNT_NO` 必须一致 +- `owner_id` 与员工身份证号一致 +- 正样本与反样本必须处于同一项目验证口径下,避免跨项目误差 + +## 十、测试与验证设计 + +### 10.1 Java 自动化测试 + +保留两层自动化测试: + +1. Service 分发测试 + - 新规则能进入 `executeObjectRule(...)` +2. Mapper / SQL 结构测试 + - 新 Mapper 方法存在 + - XML 中存在对应 ` + +``` + +- [ ] **Step 5: 运行编译验证新链路能被 Spring 扫描** + +Run: `mvn -pl ccdi-info-collection -am -DskipTests compile` +Expected: `BUILD SUCCESS` + +- [ ] **Step 6: 提交关系表 Java 链路** + +```bash +git add ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/CcdiIntermediaryEnterpriseRelation.java ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/dto/CcdiIntermediaryEnterpriseRelationAddDTO.java ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/dto/CcdiIntermediaryEnterpriseRelationEditDTO.java ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/vo/CcdiIntermediaryEnterpriseRelationVO.java ccdi-info-collection/src/main/java/com/ruoyi/info/collection/mapper/CcdiIntermediaryEnterpriseRelationMapper.java ccdi-info-collection/src/main/resources/mapper/info/collection/CcdiIntermediaryEnterpriseRelationMapper.xml +git commit -m "feat: 新增中介机构关系后端链路" +``` + +### Task 4: 重写首页三类记录联合查询 + +**Files:** + +- Modify: `ccdi-info-collection/src/main/java/com/ruoyi/info/collection/mapper/CcdiIntermediaryMapper.java` +- Modify: `ccdi-info-collection/src/main/resources/mapper/info/collection/CcdiIntermediaryMapper.xml` +- Modify: `ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/vo/CcdiIntermediaryVO.java` + +- [ ] **Step 1: 先写 Mapper 测试,锁定三类记录口径** + +在 `CcdiIntermediaryMapperTest` 中至少覆盖: + +```java +assertThat(records).extracting("recordType") + .contains("INTERMEDIARY", "RELATIVE", "ENTERPRISE_RELATION"); +``` + +- [ ] **Step 2: 运行测试确认旧 SQL 不满足新断言** + +Run: `mvn -pl ccdi-info-collection -am -Dtest=CcdiIntermediaryMapperTest test` +Expected: FAIL,原因是旧 SQL 只返回个人 / 机构并列记录。 + +- [ ] **Step 3: 改写 `CcdiIntermediaryMapper.xml`** + +三段 SQL 分别返回统一字段: + +```sql +'INTERMEDIARY' AS record_type +'RELATIVE' AS record_type +'ENTERPRISE_RELATION' AS record_type +``` + +并统一别名为: + +```sql +record_id, name, certificate_no, related_intermediary_name, relation_text, create_time +``` + +- [ ] **Step 4: 加入首页搜索条件** + +`relatedIntermediaryKeyword` 同时匹配: + +```sql +related_name LIKE ... +OR related_person_id LIKE ... +``` + +- [ ] **Step 5: 运行测试确认联合查询通过** + +Run: `mvn -pl ccdi-info-collection -am -Dtest=CcdiIntermediaryMapperTest test` +Expected: PASS + +- [ ] **Step 6: 提交首页联合查询改造** + +```bash +git add ccdi-info-collection/src/main/java/com/ruoyi/info/collection/mapper/CcdiIntermediaryMapper.java ccdi-info-collection/src/main/resources/mapper/info/collection/CcdiIntermediaryMapper.xml ccdi-info-collection/src/test/java/com/ruoyi/info/collection/mapper/CcdiIntermediaryMapperTest.java +git commit -m "feat: 改造中介首页联合查询口径" +``` + +### Task 5: 实现中介本人、亲属、机构关系服务逻辑 + +**Files:** + +- Modify: `ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/ICcdiIntermediaryService.java` +- Modify: `ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/impl/CcdiIntermediaryServiceImpl.java` +- Modify: `ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/CcdiBizIntermediary.java` +- Reference: `ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/impl/CcdiCustEnterpriseRelationServiceImpl.java` + +- [ ] **Step 1: 先写服务测试,锁定关键规则** + +测试覆盖: + +```java +// 新增中介本人固定 personSubType=本人 +// 新增亲属时禁止 personSubType=本人 +// 删除中介本人会同时删除亲属和机构关系 +``` + +- [ ] **Step 2: 运行服务测试确认旧实现失败** + +Run: `mvn -pl ccdi-info-collection -am -Dtest=CcdiIntermediaryServiceImplTest test` +Expected: FAIL,原因是旧服务不支持亲属和关系表链路。 + +- [ ] **Step 3: 扩展服务接口** + +新增方法: + +```java +List selectRelativeList(String bizId); +int insertRelative(String bizId, CcdiIntermediaryRelativeAddDTO addDTO); +int updateRelative(CcdiIntermediaryRelativeEditDTO editDTO); +int deleteRelativeById(String relativeBizId); +List selectEnterpriseRelationList(String bizId); +int insertEnterpriseRelation(String bizId, CcdiIntermediaryEnterpriseRelationAddDTO addDTO); +int updateEnterpriseRelation(CcdiIntermediaryEnterpriseRelationEditDTO editDTO); +int deleteEnterpriseRelationById(Long id); +``` + +- [ ] **Step 4: 实现中介本人固定口径** + +```java +person.setPersonSubType("本人"); +person.setRelatedNumId(null); +``` + +- [ ] **Step 5: 实现亲属校验** + +亲属新增 / 编辑时: + +```java +if ("本人".equals(addDTO.getPersonSubType())) { + throw new RuntimeException("亲属关系不能为本人"); +} +``` + +- [ ] **Step 6: 实现机构关系校验** + +校验: + +```java +enterpriseBaseInfoMapper.selectById(socialCreditCode) != null +``` + +并拦截同一中介重复关系。 + +- [ ] **Step 7: 实现中介本人级联删除** + +```java +bizIntermediaryMapper.deleteById(bizId); +bizIntermediaryMapper.delete(new LambdaQueryWrapper() + .eq(CcdiBizIntermediary::getRelatedNumId, bizId)); +enterpriseRelationMapper.delete(new LambdaQueryWrapper() + .eq(CcdiIntermediaryEnterpriseRelation::getIntermediaryBizId, bizId)); +``` + +- [ ] **Step 8: 运行服务测试确认通过** + +Run: `mvn -pl ccdi-info-collection -am -Dtest=CcdiIntermediaryServiceImplTest test` +Expected: PASS + +- [ ] **Step 9: 提交服务层改造** + +```bash +git add ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/ICcdiIntermediaryService.java ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/impl/CcdiIntermediaryServiceImpl.java ccdi-info-collection/src/test/java/com/ruoyi/info/collection/service/CcdiIntermediaryServiceImplTest.java +git commit -m "feat: 实现中介主从结构服务逻辑" +``` + +### Task 6: 暴露控制器子资源接口 + +**Files:** + +- Modify: `ccdi-info-collection/src/main/java/com/ruoyi/info/collection/controller/CcdiIntermediaryController.java` +- Create: `ccdi-info-collection/src/test/java/com/ruoyi/info/collection/controller/CcdiIntermediaryControllerTest.java` + +- [ ] **Step 1: 先写控制器测试** + +至少覆盖: + +```java +GET /ccdi/intermediary/{bizId}/relatives +POST /ccdi/intermediary/{bizId}/relative +GET /ccdi/intermediary/{bizId}/enterprise-relations +POST /ccdi/intermediary/{bizId}/enterprise-relation +``` + +- [ ] **Step 2: 运行测试确认接口未实现时失败** + +Run: `mvn -pl ccdi-info-collection -am -Dtest=CcdiIntermediaryControllerTest test` +Expected: FAIL + +- [ ] **Step 3: 补亲属子资源接口** + +```java +@GetMapping("/{bizId}/relatives") +@PostMapping("/{bizId}/relative") +@GetMapping("/relative/{relativeBizId}") +@PutMapping("/relative") +@DeleteMapping("/relative/{relativeBizId}") +``` + +- [ ] **Step 4: 补机构关系子资源接口** + +```java +@GetMapping("/{bizId}/enterprise-relations") +@PostMapping("/{bizId}/enterprise-relation") +@GetMapping("/enterprise-relation/{id}") +@PutMapping("/enterprise-relation") +@DeleteMapping("/enterprise-relation/{id}") +``` + +- [ ] **Step 5: 调整原有中介本人接口注释与语义** + +Expected: Swagger 注释不再出现“机构中介新增 / 编辑”旧语义。 + +- [ ] **Step 6: 运行控制器测试确认通过** + +Run: `mvn -pl ccdi-info-collection -am -Dtest=CcdiIntermediaryControllerTest test` +Expected: PASS + +- [ ] **Step 7: 提交控制器改造** + +```bash +git add ccdi-info-collection/src/main/java/com/ruoyi/info/collection/controller/CcdiIntermediaryController.java ccdi-info-collection/src/test/java/com/ruoyi/info/collection/controller/CcdiIntermediaryControllerTest.java +git commit -m "feat: 新增中介亲属和机构关系接口" +``` + +### Task 7: 完成整体回归验证 + +**Files:** + +- Modify: `docs/plans/backend/2026-04-17-intermediary-library-refactor-backend-implementation.md` + 执行阶段补充真实验证结果。 + +- [ ] **Step 1: 运行中介模块定向测试** + +Run: `mvn -pl ccdi-info-collection -am -Dtest=CcdiIntermediaryServiceImplTest,CcdiIntermediaryMapperTest,CcdiIntermediaryControllerTest test` +Expected: `BUILD SUCCESS` + +- [ ] **Step 2: 运行模块编译** + +Run: `mvn -pl ccdi-info-collection -am -DskipTests compile` +Expected: `BUILD SUCCESS` + +- [ ] **Step 3: 如需验证 SQL,使用 UTF-8 脚本执行** + +Run: `bin/mysql_utf8_exec.sh sql/migration/2026-04-17-create-intermediary-enterprise-relation.sql` +Expected: 关系表创建成功,无乱码。 + +- [ ] **Step 4: 记录真实执行结果** + +Expected: 文档中补充实际命令和结果,不写假数据。 + +- [ ] **Step 5: 汇总并提交** + +```bash +git add docs/plans/backend/2026-04-17-intermediary-library-refactor-backend-implementation.md +git commit -m "docs: 更新中介库后端实施计划执行结果" +``` + +## 验证命令 + +```bash +mvn -pl ccdi-info-collection -am -Dtest=CcdiIntermediaryServiceImplTest,CcdiIntermediaryMapperTest,CcdiIntermediaryControllerTest test +mvn -pl ccdi-info-collection -am -DskipTests compile +bin/mysql_utf8_exec.sh sql/migration/2026-04-17-create-intermediary-enterprise-relation.sql +``` + +## 执行结果 + +- 实际测试命令:`mvn -pl ccdi-info-collection -am -Dsurefire.failIfNoSpecifiedTests=false -Dtest=CcdiIntermediaryServiceImplTest,CcdiIntermediaryMapperTest,CcdiIntermediaryControllerTest test` +- 测试结果:`BUILD SUCCESS`,共执行 8 个测试,`Failures: 0, Errors: 0, Skipped: 0` +- 实际编译命令:`mvn -pl ccdi-info-collection -am -DskipTests compile` +- 编译结果:`BUILD SUCCESS` +- 实际数据库变更命令:`bin/mysql_utf8_exec.sh sql/migration/2026-04-17-create-intermediary-enterprise-relation.sql` +- 数据库变更结果:`ccdi_intermediary_enterprise_relation` 已创建成功 +- 实际全库排序规则修复命令:`bin/mysql_utf8_exec.sh sql/migration/2026-04-17-unify-all-table-collation-to-utf8mb4-general-ci.sql` +- 排序规则修复结果:业务表、系统表的表级与字符字段级排序规则已统一为 `utf8mb4_general_ci` +- 实际运行验证: + - 重新打包命令:`mvn -pl ruoyi-admin -am -DskipTests package` + - 接口验证:`POST /login/test` 获取 token 后,`GET /ccdi/intermediary/list?pageNum=1&pageSize=10` 返回 `code=200`,联合查询不再出现 `Illegal mix of collations` + +## 完成标准 + +- `ccdi_intermediary_enterprise_relation` 表与唯一约束创建完成 +- 中介本人固定使用 `person_sub_type=本人` +- 亲属使用 `person_sub_type` 表达关系,并通过 `related_num_id` 关联所属中介 +- 首页列表统一返回“中介本人 / 亲属 / 机构关系”三类记录 +- 中介本人、亲属、机构关系接口链路完整 +- 删除中介本人时会删除亲属和机构关系,但不会删除机构主档 +- 后端定向测试、编译与 SQL 验证命令已执行并记录真实结果 diff --git a/docs/plans/backend/2026-04-17-tongweb-integration-implementation.md b/docs/plans/backend/2026-04-17-tongweb-integration-implementation.md new file mode 100644 index 00000000..38ec26c3 --- /dev/null +++ b/docs/plans/backend/2026-04-17-tongweb-integration-implementation.md @@ -0,0 +1,79 @@ +# 2026-04-17 TongWeb 接入实施记录 + +## 1. 改动目标 + +- 按 TongWeb 接入指南将后端默认内嵌容器从 Tomcat 切换为 TongWeb +- 保持当前 Spring Boot 3.5.8 工程结构不变,按 Jakarta 体系接入 TongWeb 3.x Starter +- 将 TongWeb license 随 `ruoyi-admin` 产物一起打包,并补齐可回归测试 + +## 2. 实施内容 + +### 2.1 Maven 依赖与仓库配置 + +涉及文件: + +- `pom.xml` +- `ruoyi-admin/pom.xml` + +实施内容: + +- 在根 `pom.xml` 增加 `tongweb.version=7.0.E.7` +- 在根 `pom.xml` 增加 TongWeb Maven 仓库 `https://mvn.elitescloud.com/nexus/repository/maven-releases/` +- 在根 `pom.xml` 的 `dependencyManagement` 中声明 `com.tongweb.springboot:tongweb-spring-boot-starter-3.x` +- 在 `ruoyi-admin/pom.xml` 中对 `ruoyi-framework` 传递进来的 `spring-boot-starter-tomcat` 做排除 +- 在 `ruoyi-admin/pom.xml` 中引入 `tongweb-spring-boot-starter-3.x` +- 在 `ruoyi-admin/pom.xml` 中显式保留 `src/main/resources` 资源打包规则,确保 `.dat` 文件进入产物 + +说明: + +- 指南中的 `tongweb-spring-boot-starter-2.x` 仅适用于 Spring Boot 2.x +- 当前仓库为 Spring Boot 3.5.8,因此本次按 TongWeb 3.x Starter 接入,避免 Jakarta/Servlet 版本不兼容 + +### 2.2 TongWeb 基础配置与 license 资源 + +涉及文件: + +- `ruoyi-admin/src/main/resources/application.yml` +- `ruoyi-admin/src/main/resources/Tongweb_license.dat` + +实施内容: + +- 在基础配置 `application.yml` 中新增 `server.tongweb.license.path=classpath:Tongweb_license.dat` +- 将 TongWeb license 以 `Tongweb_license.dat` 固定名称放入 `ruoyi-admin/src/main/resources/` +- 保留现有环境文件中的 `server.tomcat.*` 配置,先不做额外收缩,后续仅在 TongWeb 启动日志明确报冲突时再按最小范围调整 + +### 2.3 回归测试补齐 + +涉及文件: + +- `ruoyi-admin/src/test/java/com/ruoyi/config/TongWebIntegrationConfigurationTest.java` + +实施内容: + +- 新增 classpath 资源测试,校验 `Tongweb_license.dat` 能从测试类路径读取 +- 新增基础配置测试,校验 `application.yml` 中存在 `server.tongweb.license.path` +- 测试目标是防止后续重构时误删 TongWeb 配置或 license 资源 + +## 3. 验证项 + +建议执行以下命令: + +```bash +mvn -pl ruoyi-admin -am test -Dtest=TongWebIntegrationConfigurationTest,LogbackConfigurationTest +mvn -pl ruoyi-admin -am package -DskipTests +mvn -pl ruoyi-admin -am dependency:tree "-Dincludes=com.tongweb.springboot:*,com.tongweb:*,org.apache.tomcat.embed:*" +jar tf ruoyi-admin/target/ruoyi-admin.jar | rg 'Tongweb_license.dat|tongweb' +``` + +验证重点: + +- TongWeb 依赖能够正常解析 +- `spring-boot-starter-tomcat` 不再作为 `ruoyi-admin` 主依赖链出现 +- `Tongweb_license.dat` 已进入打包产物 +- TongWeb 相关 jar 已进入 `BOOT-INF/lib/` + +## 4. 结论 + +- 本次接入保持现有工程结构与启动入口不变,仅替换内嵌 Servlet 容器实现 +- 接入链路已经覆盖依赖、配置、资源与自动化校验四个层面 +- 后续若要做运行态验证,可继续基于本次改造执行 `spring-boot:run` 或打包后启动,并在验证完成后关闭测试进程 diff --git a/docs/plans/backend/2026-04-20-docker-bank-tag-stuck-backend-implementation.md b/docs/plans/backend/2026-04-20-docker-bank-tag-stuck-backend-implementation.md new file mode 100644 index 00000000..2032d22d --- /dev/null +++ b/docs/plans/backend/2026-04-20-docker-bank-tag-stuck-backend-implementation.md @@ -0,0 +1,65 @@ +# Docker 环境项目打标卡住后端实施记录 + +## 背景 + +- 现象:Docker 部署后的后端中,项目打标任务会长时间停留在“打标中”。 +- 样本项目:`90337` +- 排查目标:确认任务卡住的真实根因,并按最短路径修复。 + +## 已定位问题 + +### 1. 打标规则 SQL 在 MySQL 8 / Docker 环境触发排序规则冲突 + +- 规则:`LARGE_PURCHASE_TRANSACTION` +- 异常:`Illegal mix of collations (utf8mb4_0900_ai_ci) and (utf8mb4_general_ci)` +- 影响:任务在规则执行阶段直接失败。 + +### 2. 失败落库时错误信息过长,导致任务状态无法更新 + +- 表:`ccdi_bank_tag_task` +- 字段:`error_message` +- 异常:`Data too long for column 'error_message'` +- 影响: + - 任务本应更新为 `FAILED`,但更新再次失败 + - 项目状态没有从 `打标中` 回退 + - 前端看到的就是“打标一直卡住” + +## 本次改动范围 + +- `ccdi-project/src/main/resources/mapper/ccdi/project/CcdiBankTagAnalysisMapper.xml` + - 为 `LARGE_PURCHASE_TRANSACTION` 相关 join 显式补齐统一排序规则。 +- `sql/migration/2026-04-20-fix-bank-tag-task-error-message-longtext.sql` + - 将 `ccdi_bank_tag_task` 表统一到 `utf8mb4_general_ci`,并把 `error_message` 调整为 `LONGTEXT`。 +- `ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/impl/CcdiBankTagServiceImpl.java` + - 调整任务失败错误信息拼装逻辑,保留完整根因,不再按 2000 截断。 +- `ccdi-project/src/test/java/com/ruoyi/ccdi/project/service/impl/CcdiBankTagServiceImplTest.java` + - 新增错误信息裁剪测试。 +- `ccdi-project/src/test/java/com/ruoyi/ccdi/project/mapper/CcdiBankTagAnalysisMapperXmlTest.java` + - 新增 SQL 排序规则约束测试。 + +## 验证 + +- 运行新增测试,先确认失败,再完成修复后确认通过。 +- 修复后检查项目 `90337` 的任务状态是否能正确进入失败态或完成态,不再停留在 `RUNNING`。 + +## 实际执行结果 + +- 已执行数据库脚本: + - `sql/migration/2026-04-20-fix-bank-tag-task-error-message-longtext.sql` +- 已重新打包并重启 Docker 后端容器。 +- 已手工复位历史卡死任务: + - `ccdi_bank_tag_task.id = 76` +- 已重新触发项目 `90337` 打标。 +- 最新验证结果: + - `ccdi_bank_tag_task.id = 79` + - `status = SUCCESS` + - `success_rule_count = 35` + - `hit_count = 132` + - 项目 `90337` 状态已回到 `1 = 已完成` + +## 结论 + +- “打标卡住”并非任务一直运行,而是: + 1. 采购相关规则 SQL 因排序规则冲突失败 + 2. 失败异常写入 `ccdi_bank_tag_task.error_message` 时又因字段长度不足再次失败 +- 本次已按数据库方案改为 `LONGTEXT`,并将采购链路所有相关 join 显式统一为 `utf8mb4_general_ci`。 diff --git a/docs/plans/backend/2026-04-20-intermediary-import-backend-implementation.md b/docs/plans/backend/2026-04-20-intermediary-import-backend-implementation.md new file mode 100644 index 00000000..f389f058 --- /dev/null +++ b/docs/plans/backend/2026-04-20-intermediary-import-backend-implementation.md @@ -0,0 +1,461 @@ +# 中介库导入改造后端实施计划 + +> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. +> **执行约束:** 仓库约定不开 subagent,执行时使用 `superpowers:executing-plans`。 + +**Goal:** 完成中介库后端导入改造,统一 `related_num_id` 为“关联中介本人证件号码”,新增“导入中介信息”和“导入中介实体关联关系”两条异步导入链路,并保证手工维护、统一查询和级联删除与新语义一致。 + +**Architecture:** 后端保持 `CcdiIntermediaryController + CcdiIntermediaryServiceImpl + ccdi_biz_intermediary/ccdi_intermediary_enterprise_relation` 的现有主线,不新增平行模块。外部 API 继续保持 `bizId` 契约,服务层内部把亲属链路统一转换为“本人证件号码”处理;导入链路对齐员工数据导入,拆分为“中介信息导入”和“中介实体关联关系导入”两套独立任务与失败记录。 + +**Tech Stack:** Java 21, Spring Boot 3, MyBatis Plus, MySQL, Redis, EasyExcel, JUnit 5, Maven, Markdown + +--- + +## 文件结构与职责 + +**设计与计划基线** + +- `docs/design/2026-04-20-intermediary-import-refactor-design.md` + 当前需求和边界的唯一设计基线,实施不得偏离其中已确认口径。 + +**SQL 与迁移** + +- Create: `sql/migration/2026-04-20-migrate-intermediary-related-num-id-to-person-id.sql` + 负责把历史 `related_num_id = 本人 biz_id` 迁移为 `related_num_id = 本人 person_id`。 +- Create: `sql/migration/2026-04-20-fix-ccdi-person-sub-type-dict.sql` + 负责补齐或校正 `ccdi_person_sub_type` 字典项,供导入模板下拉使用。 + +**中介主链路** + +- Modify: `ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/CcdiBizIntermediary.java` + 调整 `relatedNumId` 字段注释语义。 +- Modify: `ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/ICcdiIntermediaryService.java` + 暴露新的校验与导入能力方法。 +- Modify: `ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/impl/CcdiIntermediaryServiceImpl.java` + 完成亲属主从语义切换、手工维护逻辑修正、关系唯一性判断和级联删除。 +- Modify: `ccdi-info-collection/src/main/resources/mapper/info/collection/CcdiIntermediaryMapper.xml` + 把统一列表中的亲属关联条件从 `parent.biz_id` 改为 `parent.person_id`。 + +**导入链路** + +- Modify: `ccdi-info-collection/src/main/java/com/ruoyi/info/collection/controller/CcdiIntermediaryController.java` + 重组导入接口,保留中介信息导入,新增实体关联关系导入。 +- Modify: `ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/excel/CcdiIntermediaryPersonExcel.java` + 让 `personSubType` 使用字典下拉,移除废弃的 `relationType` 列,重命名 `relatedNumId` 模板文案。 +- Modify: `ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/vo/IntermediaryPersonImportFailureVO.java` + 对齐新模板字段,移除废弃字段。 +- Modify: `ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/ICcdiIntermediaryPersonImportService.java` + 调整中介信息导入接口定义。 +- Modify: `ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/impl/CcdiIntermediaryPersonImportServiceImpl.java` + 重写为“本人阶段 + 亲属阶段”的混合导入实现。 +- Create: `ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/excel/CcdiIntermediaryEnterpriseRelationExcel.java` + 中介实体关联关系导入模板实体。 +- Create: `ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/vo/IntermediaryEnterpriseRelationImportFailureVO.java` + 实体关联关系失败记录 VO。 +- Create: `ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/ICcdiIntermediaryEnterpriseRelationImportService.java` + 实体关联关系导入服务接口。 +- Create: `ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/impl/CcdiIntermediaryEnterpriseRelationImportServiceImpl.java` + 实体关联关系异步导入实现。 + +**既有 DTO / VO / Mapper 补充** + +- Modify: `ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/dto/CcdiIntermediaryPersonAddDTO.java` +- Modify: `ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/dto/CcdiIntermediaryPersonEditDTO.java` +- Modify: `ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/dto/CcdiIntermediaryRelativeAddDTO.java` +- Modify: `ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/dto/CcdiIntermediaryRelativeEditDTO.java` +- Modify: `ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/vo/CcdiIntermediaryPersonDetailVO.java` +- Modify: `ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/vo/CcdiIntermediaryRelativeVO.java` +- Modify: `ccdi-info-collection/src/main/java/com/ruoyi/info/collection/mapper/CcdiIntermediaryEnterpriseRelationMapper.java` +- Modify: `ccdi-info-collection/src/main/resources/mapper/info/collection/CcdiIntermediaryEnterpriseRelationMapper.xml` + +**测试** + +- Modify: `ccdi-info-collection/src/test/java/com/ruoyi/info/collection/service/CcdiIntermediaryServiceImplTest.java` +- Modify: `ccdi-info-collection/src/test/java/com/ruoyi/info/collection/mapper/CcdiIntermediaryMapperTest.java` +- Modify: `ccdi-info-collection/src/test/java/com/ruoyi/info/collection/controller/CcdiIntermediaryControllerTest.java` +- Create: `ccdi-info-collection/src/test/java/com/ruoyi/info/collection/service/CcdiIntermediaryPersonImportServiceImplTest.java` +- Create: `ccdi-info-collection/src/test/java/com/ruoyi/info/collection/service/CcdiIntermediaryEnterpriseRelationImportServiceImplTest.java` + +## 实施任务 + +### Task 1: 编写迁移脚本并锁定实施窗口 + +**Files:** + +- Create: `sql/migration/2026-04-20-migrate-intermediary-related-num-id-to-person-id.sql` +- Create: `sql/migration/2026-04-20-fix-ccdi-person-sub-type-dict.sql` +- Reference: `sql/migration/2026-04-17-fix-intermediary-person-sub-type-dict.sql` +- Reference: `sql/dpc_intermediary_dict_data_20260129.sql` + +- [ ] **Step 1: 写迁移前校验 SQL** + +在迁移脚本头部先写校验查询,明确输出: + +```sql +-- 1. 找不到对应本人 biz_id 的亲属 +-- 2. 本人 person_id 为空的记录 +-- 3. 迁移后同一中介本人下 related_num_id + person_id 冲突的记录 +``` + +- [ ] **Step 2: 写正式迁移 SQL** + +将旧语义统一改为新语义: + +```sql +UPDATE ccdi_biz_intermediary child +JOIN ccdi_biz_intermediary parent + ON child.related_num_id = parent.biz_id +SET child.related_num_id = parent.person_id +WHERE child.person_sub_type <> '本人'; +``` + +- [ ] **Step 3: 写 `ccdi_person_sub_type` 字典修正脚本** + +至少补齐: + +```sql +本人 / 配偶 / 子女 / 父母 / 兄弟姐妹 / 其他 +``` + +Expected: 模板下拉和后端校验使用同一字典源。 + +- [ ] **Step 4: 用 UTF-8 脚本执行 SQL 验证** + +Run: + +```bash +bin/mysql_utf8_exec.sh sql/migration/2026-04-20-fix-ccdi-person-sub-type-dict.sql +bin/mysql_utf8_exec.sh sql/migration/2026-04-20-migrate-intermediary-related-num-id-to-person-id.sql +``` + +Expected: SQL 正常执行,无乱码,无唯一性冲突。 + +- [ ] **Step 5: 提交迁移脚本** + +```bash +git add sql/migration/2026-04-20-migrate-intermediary-related-num-id-to-person-id.sql sql/migration/2026-04-20-fix-ccdi-person-sub-type-dict.sql +git commit -m "新增中介导入迁移脚本" +``` + +### Task 2: 切换中介模块内部主从语义 + +**Files:** + +- Modify: `ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/CcdiBizIntermediary.java` +- Modify: `ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/ICcdiIntermediaryService.java` +- Modify: `ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/impl/CcdiIntermediaryServiceImpl.java` +- Modify: `ccdi-info-collection/src/main/resources/mapper/info/collection/CcdiIntermediaryMapper.xml` +- Modify: `ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/dto/CcdiIntermediaryRelativeAddDTO.java` +- Modify: `ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/dto/CcdiIntermediaryRelativeEditDTO.java` +- Test: `ccdi-info-collection/src/test/java/com/ruoyi/info/collection/service/CcdiIntermediaryServiceImplTest.java` +- Test: `ccdi-info-collection/src/test/java/com/ruoyi/info/collection/mapper/CcdiIntermediaryMapperTest.java` + +- [ ] **Step 1: 先写失败测试,覆盖亲属新语义** + +补测试场景: + +```java +// 1. 手工新增亲属时 related_num_id 写本人 person_id +// 2. 查询亲属列表按 parent.person_id 关联 +// 3. 删除本人时按本人 person_id 级联删除亲属 +// 4. 相同亲属 person_id 挂到不同本人时允许成功 +``` + +- [ ] **Step 2: 调整服务层内部转换逻辑** + +关键改造点: + +```java +CcdiBizIntermediary owner = requireIntermediaryPerson(bizId); +relative.setRelatedNumId(owner.getPersonId()); +``` + +以及: + +```java +wrapper.eq(CcdiBizIntermediary::getRelatedNumId, owner.getPersonId()) +``` + +- [ ] **Step 3: 收敛唯一性校验** + +新增两个明确规则: + +```java +// 本人:personId 全表唯一 +// 亲属:同一 relatedNumId + personId 唯一 +``` + +Expected: 不再把亲属 `personId` 误当成全表唯一。 + +- [ ] **Step 4: 修改联合查询 SQL** + +把亲属联表条件改成: + +```xml +ON child.related_num_id = parent.person_id +``` + +Expected: 统一列表在迁移后仍能查出亲属记录。 + +- [ ] **Step 5: 运行服务层与 Mapper 测试** + +Run: + +```bash +mvn -pl ccdi-info-collection -am -Dtest=CcdiIntermediaryServiceImplTest,CcdiIntermediaryMapperTest test +``` + +Expected: 目标测试通过,新的主从语义断言成立。 + +- [ ] **Step 6: 提交语义切换代码** + +```bash +git add ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/CcdiBizIntermediary.java ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/ICcdiIntermediaryService.java ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/impl/CcdiIntermediaryServiceImpl.java ccdi-info-collection/src/main/resources/mapper/info/collection/CcdiIntermediaryMapper.xml ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/dto/CcdiIntermediaryRelativeAddDTO.java ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/dto/CcdiIntermediaryRelativeEditDTO.java ccdi-info-collection/src/test/java/com/ruoyi/info/collection/service/CcdiIntermediaryServiceImplTest.java ccdi-info-collection/src/test/java/com/ruoyi/info/collection/mapper/CcdiIntermediaryMapperTest.java +git commit -m "调整中介亲属关联语义" +``` + +### Task 3: 重构中介信息导入为“本人 + 亲属”混合导入 + +**Files:** + +- Modify: `ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/excel/CcdiIntermediaryPersonExcel.java` +- Modify: `ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/vo/IntermediaryPersonImportFailureVO.java` +- Modify: `ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/ICcdiIntermediaryPersonImportService.java` +- Modify: `ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/impl/CcdiIntermediaryPersonImportServiceImpl.java` +- Modify: `ccdi-info-collection/src/main/java/com/ruoyi/info/collection/controller/CcdiIntermediaryController.java` +- Test: `ccdi-info-collection/src/test/java/com/ruoyi/info/collection/service/CcdiIntermediaryPersonImportServiceImplTest.java` +- Test: `ccdi-info-collection/src/test/java/com/ruoyi/info/collection/controller/CcdiIntermediaryControllerTest.java` + +- [ ] **Step 1: 先写导入失败测试** + +覆盖: + +```java +// 1. personSubType 使用字典下拉字段 +// 2. personSubType=本人 且 relatedNumId 非空 -> 失败 +// 3. personSubType!=本人 且 relatedNumId 为空 -> 失败 +// 4. 亲属引用本次导入前面成功的本人 -> 成功 +// 5. 同一亲属挂不同本人 -> 成功 +// 6. 同一本人名下重复亲属 -> 失败 +``` + +- [ ] **Step 2: 调整 Excel 模板实体** + +最小改动: + +```java +@DictDropdown(dictType = "ccdi_person_sub_type") +private String personSubType; + +@ExcelProperty("关联中介本人证件号码") +private String relatedNumId; +``` + +同时删除: + +```java +private String relationType; +``` + +- [ ] **Step 3: 按“两阶段”重写导入实现** + +核心流程: + +```java +List owners = ... +List relatives = ... + +// owners: personId 全表唯一 +// relatives: relatedNumId + personId 唯一 +``` + +Expected: 中介本人和亲属在一个文件中可混合导入。 + +- [ ] **Step 4: 统一失败记录字段** + +`IntermediaryPersonImportFailureVO` 只保留当前模板字段,不再暴露废弃的 `relationType`。 + +- [ ] **Step 5: 调整 Controller 返回文案与模板下载** + +Expected: 下载模板时 `personSubType` 下拉来源于 `ccdi_person_sub_type`,返回的失败记录字段与模板一致。 + +- [ ] **Step 6: 运行导入链路测试** + +Run: + +```bash +mvn -pl ccdi-info-collection -am -Dtest=CcdiIntermediaryPersonImportServiceImplTest,CcdiIntermediaryControllerTest test +``` + +Expected: 混合导入与失败口径通过。 + +- [ ] **Step 7: 提交中介信息导入改造** + +```bash +git add ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/excel/CcdiIntermediaryPersonExcel.java ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/vo/IntermediaryPersonImportFailureVO.java ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/ICcdiIntermediaryPersonImportService.java ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/impl/CcdiIntermediaryPersonImportServiceImpl.java ccdi-info-collection/src/main/java/com/ruoyi/info/collection/controller/CcdiIntermediaryController.java ccdi-info-collection/src/test/java/com/ruoyi/info/collection/service/CcdiIntermediaryPersonImportServiceImplTest.java ccdi-info-collection/src/test/java/com/ruoyi/info/collection/controller/CcdiIntermediaryControllerTest.java +git commit -m "改造中介信息导入链路" +``` + +### Task 4: 新增中介实体关联关系导入链路 + +**Files:** + +- Create: `ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/excel/CcdiIntermediaryEnterpriseRelationExcel.java` +- Create: `ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/vo/IntermediaryEnterpriseRelationImportFailureVO.java` +- Create: `ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/ICcdiIntermediaryEnterpriseRelationImportService.java` +- Create: `ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/impl/CcdiIntermediaryEnterpriseRelationImportServiceImpl.java` +- Modify: `ccdi-info-collection/src/main/java/com/ruoyi/info/collection/controller/CcdiIntermediaryController.java` +- Modify: `ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/impl/CcdiIntermediaryServiceImpl.java` +- Modify: `ccdi-info-collection/src/main/resources/mapper/info/collection/CcdiIntermediaryEnterpriseRelationMapper.xml` +- Test: `ccdi-info-collection/src/test/java/com/ruoyi/info/collection/service/CcdiIntermediaryEnterpriseRelationImportServiceImplTest.java` +- Test: `ccdi-info-collection/src/test/java/com/ruoyi/info/collection/controller/CcdiIntermediaryControllerTest.java` + +- [ ] **Step 1: 先写关系导入测试** + +覆盖: + +```java +// 1. 中介本人证件号码不存在 -> 失败 +// 2. socialCreditCode 不存在于机构表 -> 失败 +// 3. 同一文件内相同 personId + socialCreditCode -> 失败 +// 4. 数据库内已存在相同 intermediary_biz_id + socialCredit_code -> 失败 +// 5. 正常导入成功并写入关系表 +``` + +- [ ] **Step 2: 新建 Excel 与失败记录对象** + +字段固定为: + +```java +ownerPersonId +socialCreditCode +relationPersonPost +remark +``` + +- [ ] **Step 3: 新建异步导入服务** + +服务逻辑: + +```java +// 证件号码 -> 查本人 bizId +// 社会信用代码 -> 校验机构存在 +// intermediaryBizId + socialCreditCode 唯一 +``` + +- [ ] **Step 4: 补 Controller 接口** + +新增: + +```java +/importEnterpriseRelationTemplate +/importEnterpriseRelationData +/importEnterpriseRelationStatus/{taskId} +/importEnterpriseRelationFailures/{taskId} +``` + +- [ ] **Step 5: 运行关系导入测试** + +Run: + +```bash +mvn -pl ccdi-info-collection -am -Dtest=CcdiIntermediaryEnterpriseRelationImportServiceImplTest,CcdiIntermediaryControllerTest test +``` + +Expected: 实体关联关系导入链路完整通过。 + +- [ ] **Step 6: 提交关系导入代码** + +```bash +git add ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/excel/CcdiIntermediaryEnterpriseRelationExcel.java ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/vo/IntermediaryEnterpriseRelationImportFailureVO.java ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/ICcdiIntermediaryEnterpriseRelationImportService.java ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/impl/CcdiIntermediaryEnterpriseRelationImportServiceImpl.java ccdi-info-collection/src/main/java/com/ruoyi/info/collection/controller/CcdiIntermediaryController.java ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/impl/CcdiIntermediaryServiceImpl.java ccdi-info-collection/src/main/resources/mapper/info/collection/CcdiIntermediaryEnterpriseRelationMapper.xml ccdi-info-collection/src/test/java/com/ruoyi/info/collection/service/CcdiIntermediaryEnterpriseRelationImportServiceImplTest.java ccdi-info-collection/src/test/java/com/ruoyi/info/collection/controller/CcdiIntermediaryControllerTest.java +git commit -m "新增中介实体关联关系导入" +``` + +### Task 5: 做完整回归验证并更新文档 + +**Files:** + +- Modify: `docs/design/2026-04-20-intermediary-import-refactor-design.md` +- Modify: `docs/plans/backend/2026-04-20-intermediary-import-backend-implementation.md` +- Reference: `bin/mysql_utf8_exec.sh` + +- [ ] **Step 1: 运行后端完整相关测试集合** + +Run: + +```bash +mvn -pl ccdi-info-collection -am -Dtest=CcdiIntermediaryServiceImplTest,CcdiIntermediaryMapperTest,CcdiIntermediaryControllerTest,CcdiIntermediaryPersonImportServiceImplTest,CcdiIntermediaryEnterpriseRelationImportServiceImplTest test +``` + +Expected: 全部 PASS。 + +- [ ] **Step 2: 编译信息采集模块,确认没有遗漏编译错误** + +Run: + +```bash +mvn -pl ccdi-info-collection -am clean compile +``` + +Expected: `BUILD SUCCESS`。 + +- [ ] **Step 3: 记录 SQL 执行与测试结果** + +在计划文档底部补: + +```markdown +- 实际执行的 SQL 脚本 +- 实际执行的 Maven 命令 +- PASS / FAIL 结果 +``` + +- [ ] **Step 4: 提交文档回写** + +```bash +git add docs/design/2026-04-20-intermediary-import-refactor-design.md docs/plans/backend/2026-04-20-intermediary-import-backend-implementation.md +git commit -m "补充中介导入后端实施记录" +``` + +## 验证命令 + +```bash +bin/mysql_utf8_exec.sh sql/migration/2026-04-20-fix-ccdi-person-sub-type-dict.sql +bin/mysql_utf8_exec.sh sql/migration/2026-04-20-migrate-intermediary-related-num-id-to-person-id.sql +mvn -pl ccdi-info-collection -am -Dtest=CcdiIntermediaryServiceImplTest,CcdiIntermediaryMapperTest,CcdiIntermediaryControllerTest,CcdiIntermediaryPersonImportServiceImplTest,CcdiIntermediaryEnterpriseRelationImportServiceImplTest test +mvn -pl ccdi-info-collection -am clean compile +``` + +## 完成标准 + +- `related_num_id` 已按“关联中介本人证件号码”完成迁移和代码切换 +- 手工新增亲属、查询亲属、删除本人级联删除全部与新语义一致 +- 中介信息导入支持本人和亲属混合导入 +- 同一亲属证件号允许关联多个不同中介本人 +- 中介实体关联关系导入独立可用,且只写关系表 +- `relationType` 已从导入链路中移除 +- `personSubType` 模板下拉已切换为 `ccdi_person_sub_type` +- 后端测试与编译验证通过 + +## 执行结果 + +- SQL 脚本: + `sql/migration/2026-04-20-fix-ccdi-person-sub-type-dict.sql` + `sql/migration/2026-04-20-migrate-intermediary-related-num-id-to-person-id.sql` + 结果:已执行 `bin/mysql_utf8_exec.sh`。 +- SQL 核查: + `post_migration_missing_parent = 1025` + `legacy_biz_id_reference = 0` + `owner_person_id_empty_after_migration = 754` + 结果:可迁移数据已切换完成,旧 `biz_id` 语义残留清零,但历史脏数据仍需后续清洗。 +- Maven 命令: + `mvn -pl ccdi-info-collection -am -Dsurefire.failIfNoSpecifiedTests=false -Dtest=CcdiIntermediaryServiceImplTest,CcdiIntermediaryMapperTest test` + 结果:PASS +- Maven 命令: + `mvn -pl ccdi-info-collection -am -Dsurefire.failIfNoSpecifiedTests=false -Dtest=CcdiIntermediaryPersonImportServiceImplTest,CcdiIntermediaryEnterpriseRelationImportServiceImplTest,CcdiIntermediaryControllerTest test` + 结果:PASS +- Maven 命令: + `mvn -pl ccdi-info-collection -am -Dsurefire.failIfNoSpecifiedTests=false -Dtest=CcdiIntermediaryServiceImplTest,CcdiIntermediaryMapperTest,CcdiIntermediaryControllerTest,CcdiIntermediaryPersonImportServiceImplTest,CcdiIntermediaryEnterpriseRelationImportServiceImplTest test` + 结果:PASS +- Maven 命令: + `mvn -pl ccdi-info-collection -am clean compile` + 结果:PASS(BUILD SUCCESS) diff --git a/docs/plans/backend/2026-04-21-enterprise-delete-relation-check-backend-implementation.md b/docs/plans/backend/2026-04-21-enterprise-delete-relation-check-backend-implementation.md new file mode 100644 index 00000000..ed66b26e --- /dev/null +++ b/docs/plans/backend/2026-04-21-enterprise-delete-relation-check-backend-implementation.md @@ -0,0 +1,34 @@ +# 实体库删除关联校验后端实施计划 + +## 目标 + +在实体库管理删除接口中增加删除前校验,确保待删除实体与中介、员工、信贷客户不存在关联关系;一旦存在任一关联,当前删除操作直接失败并返回明确提示。 + +## 涉及文件 + +- `ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/impl/CcdiEnterpriseBaseInfoServiceImpl.java` +- `ccdi-info-collection/src/test/java/com/ruoyi/info/collection/service/CcdiEnterpriseBaseInfoServiceImplTest.java` + +## 实施步骤 + +1. 在 `CcdiEnterpriseBaseInfoServiceImpl` 的批量删除入口增加逐条删除前校验。 +2. 使用现有三张关系表进行计数判断: + `ccdi_staff_enterprise_relation` + `ccdi_cust_enterprise_relation` + `ccdi_intermediary_enterprise_relation` +3. 若任一关系存在,拼装“员工/信贷客户/中介”中文提示并抛出运行时异常,阻断整批删除。 +4. 保持所有待删实体均通过校验后,再执行原有批量删除。 +5. 补充服务层单元测试,覆盖无关联可删除、单一关联拦截、多关联拦截三类场景。 + +## 验证命令 + +```bash +mvn -pl ccdi-info-collection -am -Dsurefire.failIfNoSpecifiedTests=false -Dtest=CcdiEnterpriseBaseInfoServiceImplTest test +``` + +## 完成标准 + +- 实体库删除前会校验员工、信贷客户、中介三类关联 +- 存在关联时接口返回明确失败原因,不执行删除 +- 无关联时保留原有批量删除行为 +- 定向单元测试通过 diff --git a/docs/plans/backend/2026-04-21-redis断连自动重连修复实施计划.md b/docs/plans/backend/2026-04-21-redis断连自动重连修复实施计划.md new file mode 100644 index 00000000..ca06f504 --- /dev/null +++ b/docs/plans/backend/2026-04-21-redis断连自动重连修复实施计划.md @@ -0,0 +1,53 @@ +# Redis 断连自动重连修复实施计划 + +## 1. 背景 + +后端当前通过 Spring Boot 自动配置的 Lettuce `RedisConnectionFactory` 提供 Redis 连接。现场反馈 Redis 短暂断连或重启后,后端不会恢复可用连接,后续 Redis 访问持续失败。 + +## 2. 问题定位 + +- 项目未自定义 `LettuceConnectionFactory`,使用的是 Spring Boot 默认装配。 +- 默认共享连接在未开启连接校验时,存在继续复用失效连接的风险。 +- 当前 `ruoyi-framework` 仅配置了 `RedisTemplate` 序列化,未对 Lettuce 连接工厂做任何重连相关定制。 + +## 3. 实施方案 + +### 3.1 最短路径修复 + +在 `ruoyi-framework` 的 Redis 配置中增加一个 `BeanPostProcessor`,对 Spring Boot 自动创建的 `LettuceConnectionFactory` 统一开启 `validateConnection`。 + +### 3.2 预期效果 + +- Redis 恢复可用后,连接工厂在获取共享连接时会先校验连接有效性。 +- 避免继续复用已经失效的 Lettuce 共享连接。 +- 不调整现有 `RedisTemplate`、业务缓存调用方式和 YAML 配置结构。 + +## 4. 代码改动点 + +- `ruoyi-framework/src/main/java/com/ruoyi/framework/config/RedisConfig.java` + - 增加 Lettuce 连接工厂后处理器。 + - 在 Bean 初始化阶段统一开启 `validateConnection`。 +- `ruoyi-framework/src/test/java/com/ruoyi/framework/config/RedisConfigTest.java` + - 新增回归测试,校验 Lettuce 连接工厂会被强制打开连接校验。 +- `ruoyi-framework/pom.xml` + - 增加 `spring-boot-starter-test` 测试依赖。 + +## 5. 验证计划 + +执行以下命令验证: + +```bash +mvn -pl ruoyi-framework -am test -Dtest=RedisConfigTest -Dsurefire.failIfNoSpecifiedTests=false +``` + +验证通过标准: + +- `RedisConfigTest` 通过。 +- `ruoyi-framework` 模块构建成功。 +- 不影响 `ruoyi-common`、`ruoyi-system` 作为依赖模块的测试阶段执行。 + +## 6. 影响范围 + +- 影响模块:`ruoyi-framework` +- 影响对象:所有通过 Spring 容器注入并复用 `RedisConnectionFactory` / `RedisTemplate` 的后端 Redis 调用 +- 不涉及数据库结构、前端页面、接口入参与返回结构变更 diff --git a/docs/plans/backend/2026-04-22-base-staff-dual-sheet-import-backend-implementation.md b/docs/plans/backend/2026-04-22-base-staff-dual-sheet-import-backend-implementation.md new file mode 100644 index 00000000..019df245 --- /dev/null +++ b/docs/plans/backend/2026-04-22-base-staff-dual-sheet-import-backend-implementation.md @@ -0,0 +1,37 @@ +# 员工信息维护双 Sheet 导入后端实施计划 + +## 目标 +- 将员工信息维护导入模板改为 `员工信息` + `员工资产信息` 双 Sheet。 +- 统一由 `/ccdi/baseStaff/importData` 接收单文件上传,并按有数据的 Sheet 分别调用现有员工导入与员工资产导入方法。 +- 员工信息导入取消“更新已存在员工”能力,命中现有员工 ID 或身份证号时直接记失败。 +- 两类失败记录统一补充 `sheetName`、`rowNum`、`errorMessage`,便于直接定位 Excel 中的失败位置。 + +## 实施内容 +- 控制器改造 + - 修改 `CcdiBaseStaffController#importTemplate`,下载双 Sheet 模板,文件名统一为“员工信息维护导入模板”。 + - 修改 `CcdiBaseStaffController#importData`,按 Sheet 名分别读取 `CcdiBaseStaffExcel` 与 `CcdiBaseStaffAssetInfoExcel`。 + - 两个 Sheet 均为空时返回错误;任一 Sheet 有数据时,仅提交对应导入任务。 + - 返回新的双任务提交结果对象,包含 `staffTaskId`、`assetTaskId`、`message`。 +- 服务改造 + - 修改 `ICcdiBaseStaffService`、`CcdiBaseStaffServiceImpl`,移除 `updateSupport` 参数。 + - 修改 `ICcdiBaseStaffImportService`、`CcdiBaseStaffImportServiceImpl`,移除更新分支与 `insertOrUpdateBatch` 调用。 + - 员工导入校验统一为: + - 员工 ID 已存在:失败 + - 身份证号已存在:失败 + - Excel 内重复:失败 + - 员工资产导入补充重复校验: + - 数据库中存在同一 `personId + assetMainType + assetSubType + assetName`:失败 + - 导入文件中存在同一组合重复:失败 +- VO 修正 + - 新增员工双 Sheet 提交结果 VO。 + - 修正员工导入失败记录 VO 字段名为 `staffId`,与前端表格字段保持一致。 + - 员工与员工资产失败记录 VO 均增加 `sheetName`、`rowNum`。 + +## 验证 +- `mvn -pl ccdi-info-collection,ruoyi-admin -am -DskipTests compile` +- 补充控制器与服务层回归测试,覆盖双 Sheet 分发与“已存在即失败”规则。 + +## 影响范围 +- `ccdi-info-collection/src/main/java/com/ruoyi/info/collection/controller/` +- `ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/` +- `ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/vo/` diff --git a/docs/plans/backend/2026-04-22-bidding-import-failure-display-backend-implementation.md b/docs/plans/backend/2026-04-22-bidding-import-failure-display-backend-implementation.md new file mode 100644 index 00000000..c46ed7e1 --- /dev/null +++ b/docs/plans/backend/2026-04-22-bidding-import-failure-display-backend-implementation.md @@ -0,0 +1,26 @@ +# 2026-04-22 招投标导入失败展示增强后端实施计划 + +## 1. 目标 + +- 为招投标导入失败记录补充失败来源 `Sheet` +- 为失败记录补充 Excel 失败行号 +- 保持现有导入校验逻辑不变,仅增强失败记录元数据 + +## 2. 涉及范围 + +- `ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/vo/PurchaseTransactionImportFailureVO.java` +- `ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/impl/CcdiPurchaseTransactionImportServiceImpl.java` + +## 3. 实施步骤 + +1. 在失败记录 VO 中新增 `sheetName`、`sheetRowNum` 字段,供前端弹窗直接读取 +2. 在导入服务中为主信息 Sheet 和供应商明细 Sheet 建立“Excel 数据行号”上下文 +3. 在主信息校验、供应商校验、主从关系校验、空采购事项 ID 供应商校验等失败分支中,统一写入对应的 `Sheet` 与行号 +4. 对跨多行触发的失败场景,行号以合并字符串形式返回,便于页面直接展示 +5. 保留原有失败原因与业务字段,避免影响已有失败记录查询接口 + +## 4. 验证方式 + +- 执行后端编译,确认新增字段和异常封装无编译错误 +- 通过真实页面上传失败样本,核对失败记录接口返回 `sheetName / sheetRowNum / errorMessage` +- 覆盖至少一个主信息失败样本和一个供应商明细失败样本 diff --git a/docs/plans/backend/2026-04-22-bidding-info-maintenance-backend-implementation.md b/docs/plans/backend/2026-04-22-bidding-info-maintenance-backend-implementation.md new file mode 100644 index 00000000..46ce91f1 --- /dev/null +++ b/docs/plans/backend/2026-04-22-bidding-info-maintenance-backend-implementation.md @@ -0,0 +1,35 @@ +# 招投标信息维护后端实施计划 + +## 目标 +- 将现有 `purchaseTransaction` 后端链路改造为“招投标主信息 + 供应商明细子表”结构。 +- 保留原有 URL、权限前缀和内部类名,统一用户可见文案为“招投标信息维护”。 +- 支持详情查询返回全部供应商明细,列表返回中标供应商摘要和参与供应商数。 +- 支持双 Sheet 导入模板与按 `purchaseId` 聚合校验的异步导入。 + +## 实施内容 +- 数据层 + - 新增 `ccdi_purchase_transaction_supplier` 明细表初始化 SQL 与增量迁移脚本。 + - 迁移脚本回填历史中标供应商数据,并将菜单名称更新为“招投标信息维护”。 +- 领域模型 + - 新增供应商 entity、DTO、VO、Excel 模型。 + - 主 DTO/VO 增加 `supplierList`,主 VO 增加 `supplierCount`。 + - 主 Excel 模板改为仅承载招投标主信息,供应商明细独立建模。 +- 接口与服务 + - 列表 SQL 增加供应商数聚合。 + - 详情查询补充供应商明细列表。 + - 新增/修改时由 `supplierList` 自动回填主表中标供应商摘要字段。 + - 删除主记录时级联删除供应商明细。 + - 导入链路改为“双 Sheet 读取 + 按事项聚合校验 + 主从同落库”。 +- 项目专项核查 + - 项目采购详情 VO、Mapper、Service 增加供应商明细查询能力,保持项目详情与信息维护详情口径一致。 + +## 验证 +- `mvn -pl ccdi-info-collection,ccdi-project -am -DskipTests compile` +- `./bin/restart_java_backend.sh restart` +- 浏览器验证列表接口、详情接口与项目详情供应商明细展示。 + +## 产出文件 +- `sql/ccdi_purchase_transaction.sql` +- `sql/ccdi_purchase_transaction_menu.sql` +- `sql/migration/2026-04-22-bidding-info-maintenance-supplier-detail.sql` +- `ccdi-info-collection` 与 `ccdi-project` 相关后端代码 diff --git a/docs/plans/backend/2026-04-22-ccdi-database-default-collation-backend-implementation.md b/docs/plans/backend/2026-04-22-ccdi-database-default-collation-backend-implementation.md new file mode 100644 index 00000000..ea8a46f2 --- /dev/null +++ b/docs/plans/backend/2026-04-22-ccdi-database-default-collation-backend-implementation.md @@ -0,0 +1,39 @@ +# CCDI 数据库默认排序规则修复实施计划 + +## 保存路径确认 + +- 路径:`docs/plans/backend/2026-04-22-ccdi-database-default-collation-backend-implementation.md` +- 归类:后端实施计划 + +## 目标 + +- 将 `ccdi` 数据库默认字符集统一为 `utf8mb4` +- 将 `ccdi` 数据库默认排序规则统一为 `utf8mb4_general_ci` + +## 背景 + +- 当前 `ccdi` 数据库默认排序规则为 `utf8mb4_unicode_ci`。 +- 仓库数据库规范要求业务库默认排序规则统一为 `utf8mb4_general_ci`,避免新建表或新增字符字段继续继承错误默认值。 + +## 实施步骤 + +1. 新增数据库增量脚本,执行 `ALTER DATABASE ccdi CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci`。 +2. 使用 `bin/mysql_utf8_exec.sh` 在当前开发库执行脚本。 +3. 查询 `information_schema.SCHEMATA` 回查默认字符集与默认排序规则是否生效。 +4. 补充实施记录,说明变更范围与验证结果。 + +## 验证命令 + +```bash +bin/mysql_utf8_exec.sh sql/migration/2026-04-22-fix-ccdi-database-default-collation.sql +``` + +```bash +mysql ... -e "SELECT SCHEMA_NAME, DEFAULT_CHARACTER_SET_NAME, DEFAULT_COLLATION_NAME FROM information_schema.SCHEMATA WHERE SCHEMA_NAME='ccdi';" +``` + +## 完成标准 + +- `ccdi` 的 `DEFAULT_CHARACTER_SET_NAME` 为 `utf8mb4` +- `ccdi` 的 `DEFAULT_COLLATION_NAME` 为 `utf8mb4_general_ci` +- 本次变更已形成实施记录 diff --git a/docs/plans/backend/2026-04-22-info-maintenance-remove-export-and-menu-sort-backend-implementation.md b/docs/plans/backend/2026-04-22-info-maintenance-remove-export-and-menu-sort-backend-implementation.md new file mode 100644 index 00000000..88a2cd26 --- /dev/null +++ b/docs/plans/backend/2026-04-22-info-maintenance-remove-export-and-menu-sort-backend-implementation.md @@ -0,0 +1,47 @@ +# 信息维护移除导出与菜单排序后端实施计划 + +**Goal:** 移除信息维护相关模块的后端导出接口与导出权限,并通过增量 SQL 统一“信息维护”目录下的菜单顺序。 + +**Architecture:** 后端仅收口 `ccdi-info-collection` 控制器层的 `/export` 接口,不调整列表、详情、导入与删除链路;菜单治理通过 `sql/migration` 新增一份可重复执行脚本完成,脚本同时删除导出权限按钮并更新 `order_num`。 + +**Tech Stack:** Java 21, Spring Boot 3, MyBatis Plus, MySQL, Markdown + +--- + +## 文件结构与职责 + +- `ccdi-info-collection/src/main/java/com/ruoyi/info/collection/controller/` + 移除信息维护模块各控制器的 `/export` 接口。 +- `sql/migration/2026-04-22-remove-info-maintenance-export-and-sort-menus.sql` + 删除导出权限菜单并统一“信息维护”子菜单排序。 +- `sql/*.sql` + 修正仓库内已有菜单脚本,避免新库初始化时继续带出导出权限或错误顺序。 + +## 实施步骤 + +- [x] 盘点信息维护模块现存 `/export` 接口与导出权限点 +- [x] 移除员工、关系、招聘、调动、采购、账户等模块的控制器导出接口 +- [x] 新增菜单增量脚本,删除导出权限并统一菜单排序 +- [x] 同步修正仓库内已有菜单 SQL,避免新环境重新带回导出权限 +- [x] 运行检索校验,确认控制器层不再暴露信息维护导出接口 + +## 验证 + +```bash +rg -n "@PostMapping\\(\"/export\"\\)|hasPermi\\('ccdi:.*:export'\\)" \ + ccdi-info-collection/src/main/java/com/ruoyi/info/collection/controller/CcdiAccountInfoController.java \ + ccdi-info-collection/src/main/java/com/ruoyi/info/collection/controller/CcdiBaseStaffController.java \ + ccdi-info-collection/src/main/java/com/ruoyi/info/collection/controller/CcdiCustEnterpriseRelationController.java \ + ccdi-info-collection/src/main/java/com/ruoyi/info/collection/controller/CcdiCustFmyRelationController.java \ + ccdi-info-collection/src/main/java/com/ruoyi/info/collection/controller/CcdiPurchaseTransactionController.java \ + ccdi-info-collection/src/main/java/com/ruoyi/info/collection/controller/CcdiStaffEnterpriseRelationController.java \ + ccdi-info-collection/src/main/java/com/ruoyi/info/collection/controller/CcdiStaffFmyRelationController.java \ + ccdi-info-collection/src/main/java/com/ruoyi/info/collection/controller/CcdiStaffRecruitmentController.java \ + ccdi-info-collection/src/main/java/com/ruoyi/info/collection/controller/CcdiStaffTransferController.java +``` + +## 完成标准 + +- 信息维护相关控制器不再提供 `/export` 接口 +- “信息维护”菜单下相关导出权限按钮已可通过增量 SQL 清理 +- 菜单排序调整为统一且可重复执行的固定顺序 diff --git a/docs/plans/backend/2026-04-22-staff-family-dual-sheet-import-backend-implementation.md b/docs/plans/backend/2026-04-22-staff-family-dual-sheet-import-backend-implementation.md new file mode 100644 index 00000000..7e10bc56 --- /dev/null +++ b/docs/plans/backend/2026-04-22-staff-family-dual-sheet-import-backend-implementation.md @@ -0,0 +1,39 @@ +# 员工亲属关系维护双 Sheet 导入后端实施计划 + +## 目标 +- 将员工亲属关系维护导入模板改为双 Sheet: + - `员工亲属关系信息` + - `亲属资产信息` +- 将导入提交入口统一到 `/ccdi/staffFmyRelation/importData`。 +- 统一补充失败记录定位字段,支持前端展示 `Sheet / Excel行号 / 失败原因`。 + +## 实施内容 +- Controller 调整 + - `CcdiStaffFmyRelationController#importTemplate` 改为输出双 Sheet 模板,模板文件名统一为“员工亲属关系维护导入模板”。 + - `CcdiStaffFmyRelationController#importData` 一次读取两个 Sheet。 + - 按有数据的 Sheet 分别提交亲属关系导入任务和亲属资产导入任务。 + - 返回新的提交结果 VO,包含 `relationTaskId`、`assetTaskId` 和提示文案。 +- VO 调整 + - `StaffFmyRelationImportFailureVO` 增加 `sheetName`、`rowNum`。 + - `AssetImportFailureVO` 增加 `sheetName`、`rowNum`。 + - 新增 `StaffFmyRelationImportSubmitResultVO`。 +- 导入服务调整 + - `CcdiStaffFmyRelationImportServiceImpl` 失败记录写入固定 `sheetName=员工亲属关系信息`,并记录 Excel 数据行号。 + - `CcdiAssetInfoImportServiceImpl` 失败记录写入固定 `sheetName=亲属资产信息`,并记录 Excel 数据行号。 +- 兼容策略 + - 保留原 `CcdiAssetInfoController` 的状态查询与失败记录查询接口,前端继续复用原有资产任务轮询与失败记录查看能力。 + +## 验证 +- 后端优先验证: + - `CcdiStaffFmyRelationControllerTest` + - `CcdiAssetInfoControllerTest` +- 编译验证: + - `mvn -pl ccdi-info-collection -am -Dmaven.test.skip=true compile` + +## 影响范围 +- `ccdi-info-collection/src/main/java/com/ruoyi/info/collection/controller/CcdiStaffFmyRelationController.java` +- `ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/impl/CcdiStaffFmyRelationImportServiceImpl.java` +- `ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/impl/CcdiAssetInfoImportServiceImpl.java` +- `ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/vo/StaffFmyRelationImportFailureVO.java` +- `ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/vo/AssetImportFailureVO.java` +- `ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/vo/StaffFmyRelationImportSubmitResultVO.java` diff --git a/docs/plans/backend/2026-04-22-staff-recruitment-collation-fix-backend-implementation.md b/docs/plans/backend/2026-04-22-staff-recruitment-collation-fix-backend-implementation.md new file mode 100644 index 00000000..d6045964 --- /dev/null +++ b/docs/plans/backend/2026-04-22-staff-recruitment-collation-fix-backend-implementation.md @@ -0,0 +1,31 @@ +# 员工招聘列表排序规则冲突修复实施计划 + +## 保存路径确认 + +- 路径:`docs/plans/backend/2026-04-22-staff-recruitment-collation-fix-backend-implementation.md` +- 归类:后端实施计划 + +## 背景 + +- 员工招聘列表查询执行 `ccdi_staff_recruitment` 与 `ccdi_staff_recruitment_work` 的 `recruit_id` 关联时,报错 `Illegal mix of collations (utf8mb4_0900_ai_ci) and (utf8mb4_general_ci)`。 +- 现有招聘主表已被纳入全库统一排序规则脚本,但历史工作经历子表在建表时未显式声明 `utf8mb4_general_ci`,且未被纳入统一修复脚本,导致在 MySQL 8 环境中可能沿用默认 `utf8mb4_0900_ai_ci`。 + +## 实施范围 + +- 后端 MyBatis 查询 XML +- 招聘相关 SQL 建表脚本 +- 数据库增量迁移脚本 + +## 实施步骤 + +1. 修改招聘列表查询 SQL,在 `ccdi_staff_recruitment_work.recruit_id` 聚合与关联时显式使用 `utf8mb4_general_ci`,先恢复查询可用性。 +2. 修正 `2026-04-15-add-staff-recruitment-social-work-summary.sql`,为 `ccdi_staff_recruitment_work` 建表语句补齐 `COLLATE=utf8mb4_general_ci`。 +3. 补充增量脚本,将现有库中的 `ccdi_staff_recruitment_work` 转换为 `utf8mb4_general_ci`。 +4. 更新全库统一排序规则脚本,将该表纳入统一修复范围,避免后续漏执行。 +5. 编译受影响模块,确认 Mapper XML 与资源装配正常。 + +## 验证要点 + +- `selectRecruitmentPage` 查询不再因 `recruit_id` 关联报排序规则冲突。 +- `ccdi_staff_recruitment_work` 表级与字符字段排序规则统一为 `utf8mb4_general_ci`。 +- `mvn -pl ccdi-info-collection -am compile` 通过。 diff --git a/docs/plans/backend/2026-04-22-staff-recruitment-work-experience-manual-edit-plan.md b/docs/plans/backend/2026-04-22-staff-recruitment-work-experience-manual-edit-plan.md new file mode 100644 index 00000000..039d1ce0 --- /dev/null +++ b/docs/plans/backend/2026-04-22-staff-recruitment-work-experience-manual-edit-plan.md @@ -0,0 +1,36 @@ +# 招聘信息历史工作经历手动编辑后端实施计划 + +## 文档信息 + +- 保存路径:`docs/plans/backend/2026-04-22-staff-recruitment-work-experience-manual-edit-plan.md` +- 适用范围:招聘信息管理编辑接口 +- 需求目标:在招聘信息编辑页支持手动维护历史工作经历,并保证保存后落到 `ccdi_staff_recruitment_work` 子表 + +## 实施范围 + +1. 扩展招聘信息编辑 DTO,允许接收历史工作经历列表。 +2. 增加历史工作经历子项 DTO,并对字符长度、年月格式进行基础校验。 +3. 调整招聘信息编辑服务: + - 主表 `ccdi_staff_recruitment` 继续按原逻辑更新; + - 当招聘类型为 `SOCIAL` 且前端传入工作经历列表时,按招聘记录编号先删后插覆盖子表; + - 当招聘类型改为 `CAMPUS` 时,删除该记录已存在的历史工作经历。 + +## 实施步骤 + +1. 新增历史工作经历编辑 DTO,约束 `companyName`、`departmentName`、`positionName`、年月等字段长度与格式。 +2. 在 `CcdiStaffRecruitmentEditDTO` 中增加 `workExperienceList` 字段,并启用嵌套校验。 +3. 在 `CcdiStaffRecruitmentServiceImpl.updateRecruitment` 中增加子表覆盖保存逻辑。 +4. 保持详情查询逻辑不变,继续通过已有 `selectWorkExperienceList` 返回子表明细。 + +## 影响评估 + +- 仅影响招聘信息编辑接口,不影响招聘信息导入、详情查询、列表分页逻辑。 +- 不新增数据库结构变更,不新增菜单或权限。 +- 旧前端若未传 `workExperienceList`,社招编辑仍保留已有工作经历数据,不会被误删。 + +## 验证要点 + +1. 编辑社招记录时可提交多条历史工作经历并成功保存。 +2. 编辑社招记录时删除全部历史工作经历后提交,子表数据应被清空。 +3. 将社招记录改为校招后提交,历史工作经历应自动删除。 +4. 非法年月格式或超长字段应被后端校验拒绝。 diff --git a/docs/plans/backend/2026-04-23-base-staff-import-dept-validation-backend-implementation.md b/docs/plans/backend/2026-04-23-base-staff-import-dept-validation-backend-implementation.md new file mode 100644 index 00000000..8ee5ebca --- /dev/null +++ b/docs/plans/backend/2026-04-23-base-staff-import-dept-validation-backend-implementation.md @@ -0,0 +1,32 @@ +# 员工信息导入机构号校验后端实施计划 + +## 目标 +- 在员工信息 Excel 导入链路中校验 `所属部门ID(deptId)` 是否对应有效机构号。 +- 有效口径统一为 `sys_dept` 中“正常且未删除”的部门,即 `status = '0'` 且 `del_flag = '0'`。 +- 命中不存在、已停用或已删除的部门时,不入库,直接进入员工导入失败记录。 + +## 实施内容 +- 导入服务改造 + - 修改 `ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/impl/CcdiBaseStaffImportServiceImpl.java`。 + - 在 `validateStaffData` 中于必填校验后增加 `deptId` 有效性校验。 + - 新增私有方法按 `deptId` 查询部门并校验 `status` 与 `delFlag`。 + - 校验失败时抛出统一错误文案:`所属部门ID[xxx]不存在或已停用/删除,请检查机构号`。 +- 部门 Mapper 对齐 + - 修改 `ruoyi-system/src/main/resources/mapper/system/SysDeptMapper.xml`。 + - 为 `selectDeptById` 查询补齐 `d.del_flag` 字段,保证导入服务可同时判断停用与逻辑删除状态。 +- 单元测试补充 + - 修改 `ccdi-info-collection/src/test/java/com/ruoyi/info/collection/service/CcdiBaseStaffImportServiceImplTest.java`。 + - 增加部门存在、停用、删除三类校验测试。 + - 修改 `ccdi-info-collection/src/test/java/com/ruoyi/info/collection/service/CcdiBaseStaffDualImportServiceTest.java`。 + - 增加混合导入场景测试,验证合法员工成功入库、非法 `deptId` 写入失败记录且任务状态为 `PARTIAL_SUCCESS`。 + +## 验证 +- 定向单测: + - `mvn -pl ccdi-info-collection -am -Dsurefire.failIfNoSpecifiedTests=false -Dtest=CcdiBaseStaffImportServiceImplTest,CcdiBaseStaffDualImportServiceTest test` +- 编译校验: + - `mvn -pl ccdi-info-collection,ruoyi-admin -am -DskipTests compile` + +## 影响范围 +- 员工信息导入后端异步校验逻辑 +- 系统部门主键查询字段映射 +- 员工导入相关单元测试 diff --git a/docs/plans/backend/2026-04-23-bidding-supplier-validation-backend-implementation.md b/docs/plans/backend/2026-04-23-bidding-supplier-validation-backend-implementation.md new file mode 100644 index 00000000..6164d867 --- /dev/null +++ b/docs/plans/backend/2026-04-23-bidding-supplier-validation-backend-implementation.md @@ -0,0 +1,23 @@ +# 招投标供应商校验后端实施计划 + +## 目标 +- 让招投标信息维护页面的新增、编辑接口仅保留供应商名称和统一信用代码必填校验。 +- 移除供应商联系人、联系电话、银行账户,以及供应商名称/统一信用代码的内容格式校验,避免页面保存被接口层拦截。 + +## 实施内容 +- 调整 `CcdiPurchaseTransactionSupplierDTO` + - 保留 `supplierName` 的 `@NotBlank`。 + - 为 `supplierUscc` 增加 `@NotBlank` 必填校验。 + - 移除 `supplierName` 的长度校验。 + - 移除 `supplierUscc` 的格式校验。 + - 移除 `contactPerson`、`contactPhone`、`supplierBankAccount` 的内容校验注解。 + +## 验证 +- `mvn -pl ccdi-info-collection -am -DskipTests compile` +- `sh bin/restart_java_backend.sh` +- 结合真实页面验证: + - 新增弹窗提交 `supplierUscc=ABC`、`contactPhone=123` 成功 + - 编辑弹窗提交 `supplierUscc=XYZ`、`contactPhone=abc123` 成功 + +## 产出文件 +- `ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/dto/CcdiPurchaseTransactionSupplierDTO.java` diff --git a/docs/plans/backend/2026-04-23-enterprise-base-info-add-dialog-backend-implementation.md b/docs/plans/backend/2026-04-23-enterprise-base-info-add-dialog-backend-implementation.md new file mode 100644 index 00000000..a48f171f --- /dev/null +++ b/docs/plans/backend/2026-04-23-enterprise-base-info-add-dialog-backend-implementation.md @@ -0,0 +1,50 @@ +# 实体库管理新增弹窗后端实施计划 + +## 文档路径确认 + +- 后端实施计划保存路径:`docs/plans/backend/` +- 本文档文件名:`2026-04-23-enterprise-base-info-add-dialog-backend-implementation.md` + +## 需求目标 + +- 实体库管理新增场景不再要求前端传入数据来源。 +- 新增时由后端自动写入数据来源,人工新增统一落为 `MANUAL`。 +- 编辑时后端保留原有数据来源,不允许通过请求修改。 +- 经营状态调整为非必填项。 +- 导入模板同步去除数据来源输入列,并将经营状态改为非必填展示。 +- 导入时由后端自动写入 `IMPORT`,不再依赖模板传值。 + +## 实施范围 + +- `CcdiEnterpriseBaseInfoAddDTO` +- `CcdiEnterpriseBaseInfoEditDTO` +- `CcdiEnterpriseBaseInfoServiceImpl` +- `CcdiEnterpriseBaseInfoImportServiceImpl` +- `CcdiEnterpriseBaseInfoExcel` + +## 实施步骤 + +1. 去除新增 DTO 中 `status`、`dataSource` 的必填约束,编辑 DTO 中去除 `status` 的必填约束。 +2. 调整新增服务逻辑,仅校验风险等级和企业来源,新增数据时后端固定写入 `MANUAL`。 +3. 调整编辑服务逻辑,更新时始终沿用数据库中的原始数据来源。 +4. 调整状态字段赋值逻辑,将空白字符串统一收敛为 `null`,避免出现空串脏数据。 +5. 调整导入服务逻辑,去掉对模板数据来源的解析和校验,导入时固定写入 `IMPORT`。 +6. 修改导入模板对象,移除数据来源列,并把“经营状态*”调整为“经营状态”。 +7. 补充/更新单元测试,覆盖新增自动写入 `MANUAL`、编辑保留原数据来源、导入自动写入 `IMPORT`、经营状态非必填和模板表头变更。 + +## 验证方案 + +- 执行定向测试: + `mvn -pl ccdi-info-collection -am -Dsurefire.failIfNoSpecifiedTests=false -Dtest=CcdiEnterpriseBaseInfoServiceImplTest,CcdiEnterpriseBaseInfoImportServiceImplTest,EasyExcelUtilTemplateTest test` +- 通过真实后端接口验证: + - 新增不传 `dataSource`、不传 `status` 仍可成功 + - 查询结果中 `dataSource=MANUAL` + - 删除测试数据成功 +- 下载导入模板并核对首行表头: + - 包含 `经营状态` + - 不包含 `数据来源` + +## 风险与注意事项 + +- 当前工作区存在其他未提交改动,本次仅处理实体库管理相关文件,不回退无关内容。 +- 导入模板对象同时承担导入解析职责,本次变更后模板和导入字段保持一致。 diff --git a/docs/plans/backend/2026-04-23-staff-family-enterprise-relation-backend-implementation.md b/docs/plans/backend/2026-04-23-staff-family-enterprise-relation-backend-implementation.md new file mode 100644 index 00000000..cfe70962 --- /dev/null +++ b/docs/plans/backend/2026-04-23-staff-family-enterprise-relation-backend-implementation.md @@ -0,0 +1,174 @@ +# 员工亲属实体关联维护后端实施计划 + +> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 将现有员工实体关联后端链路切换为员工亲属实体关联,支持亲属维度查询展示、有效亲属校验、亲属下拉搜索、异步导入,以及亲属关系失效时自动把对应实体关联置为无效。 + +**Architecture:** 保留现有 `CcdiStaffEnterpriseRelation` 模块和 `ccdi_staff_enterprise_relation` 表,不新增平行模块或新表。查询链路改为 `ccdi_staff_enterprise_relation -> ccdi_staff_fmy_relation -> ccdi_base_staff`,新增和导入统一以有效员工亲属为准入条件,失效联动放在员工亲属关系保存事务内处理。 + +**Tech Stack:** Java 21, Spring Boot 3, MyBatis Plus, Redis, EasyExcel, JUnit 5, MySQL, Markdown + +--- + +## 文件结构与职责 + +**员工亲属实体关联主链路** + +- `ccdi-info-collection/src/main/java/com/ruoyi/info/collection/controller/CcdiStaffEnterpriseRelationController.java` + 继续作为实体关联对外入口,补亲属下拉搜索接口,调整列表/详情/导入文案为亲属语义。 +- `ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/ICcdiStaffEnterpriseRelationService.java` + 补亲属下拉查询方法定义。 +- `ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/impl/CcdiStaffEnterpriseRelationServiceImpl.java` + 负责列表、详情、新增、编辑、删除、导入任务提交,核心改动是把合法性校验从员工基础信息切到员工亲属关系。 +- `ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/ICcdiStaffEnterpriseRelationImportService.java` + 保持异步导入接口不变,只切换导入校验语义。 +- `ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/impl/CcdiStaffEnterpriseRelationImportServiceImpl.java` + 调整导入校验、失败原因和导入日志文案,改为按有效亲属校验并回填亲属名称。 +- `ccdi-info-collection/src/main/java/com/ruoyi/info/collection/mapper/CcdiStaffEnterpriseRelationMapper.java` + 补亲属下拉查询、按亲属身份证批量置无效的方法声明。 +- `ccdi-info-collection/src/main/resources/mapper/info/collection/CcdiStaffEnterpriseRelationMapper.xml` + 调整列表/详情 SQL,增加亲属名称、关联员工字段映射与亲属下拉查询 SQL。 + +**实体关联契约对象** + +- `ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/dto/CcdiStaffEnterpriseRelationQueryDTO.java` + 查询条件切到亲属语义,承接亲属身份证、亲属名称、关联员工、统一社会信用代码、企业名称、状态。 +- `ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/dto/CcdiStaffEnterpriseRelationAddDTO.java` + 保持 `personId + socialCreditCode` 主键语义,但 `personId` 改为亲属身份证号。 +- `ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/dto/CcdiStaffEnterpriseRelationEditDTO.java` + 保持编辑主键不可变。 +- `ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/vo/CcdiStaffEnterpriseRelationVO.java` + 补亲属名称、关联员工身份证、关联员工姓名字段,移除旧的员工本人姓名语义。 +- `ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/vo/StaffEnterpriseRelationImportFailureVO.java` + 补亲属名称字段,失败记录切到亲属语义。 +- `ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/vo/CcdiStaffEnterpriseRelationOptionVO.java` + 新增亲属下拉返回对象,承接亲属身份证、亲属名称、关联员工身份证、关联员工姓名。 +- `ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/excel/CcdiStaffEnterpriseRelationExcel.java` + 模板标题与字段注释切换为亲属语义。 + +**员工亲属关系联动链路** + +- `ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/impl/CcdiStaffFmyRelationServiceImpl.java` + 在亲属状态从有效变为无效时,联动调用实体关联 Mapper 把该亲属名下关联记录批量置无效。 + +**测试** + +- `ccdi-info-collection/src/test/java/com/ruoyi/info/collection/service/CcdiStaffEnterpriseRelationServiceImplTest.java` + 覆盖新增、编辑、亲属下拉查询和无效亲属拦截。 +- `ccdi-info-collection/src/test/java/com/ruoyi/info/collection/service/CcdiStaffEnterpriseRelationImportServiceImplTest.java` + 覆盖导入成功、亲属不存在失败、亲属无效失败、库内重复失败、Excel 内重复失败。 +- `ccdi-info-collection/src/test/java/com/ruoyi/info/collection/service/CcdiStaffFmyRelationServiceImplTest.java` + 增补亲属状态改无效后,实体关联自动改无效的事务联动测试。 +- `ccdi-info-collection/src/test/java/com/ruoyi/info/collection/mapper/CcdiStaffEnterpriseRelationMapperTest.java` + 覆盖列表 SQL 结果映射、亲属下拉查询与批量置无效 SQL。 + +## 实施任务 + +### Task 1: 调整实体关联 DTO / VO / Excel 契约为亲属语义 + +**Files:** + +- Modify: `ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/dto/CcdiStaffEnterpriseRelationQueryDTO.java` +- Modify: `ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/dto/CcdiStaffEnterpriseRelationAddDTO.java` +- Modify: `ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/dto/CcdiStaffEnterpriseRelationEditDTO.java` +- Modify: `ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/vo/CcdiStaffEnterpriseRelationVO.java` +- Modify: `ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/vo/StaffEnterpriseRelationImportFailureVO.java` +- Create: `ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/vo/CcdiStaffEnterpriseRelationOptionVO.java` +- Modify: `ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/excel/CcdiStaffEnterpriseRelationExcel.java` +- Reference: `docs/superpowers/specs/2026-04-23-staff-family-enterprise-relation-design.md` + +- [ ] 更新 QueryDTO 字段,新增 `relationName`、`staffPersonName`,保留 `personId` 但注释明确为亲属身份证号。 +- [ ] 更新 AddDTO / EditDTO 注释和校验提示,把所有“身份证号”语义改为“亲属身份证号”。 +- [ ] 在 VO 中增加 `relationName`、`staffPersonId`、`staffPersonName`,移除或停用旧 `personName` 员工本人语义。 +- [ ] 新建下拉 VO,字段至少包含 `relationCertNo`、`relationName`、`staffPersonId`、`staffPersonName`。 +- [ ] 调整 Excel 导入对象标题、注释和列头说明,保证“身份证号”明确指亲属身份证号。 + +### Task 2: 改造 Mapper 查询链路与亲属下拉 SQL + +**Files:** + +- Modify: `ccdi-info-collection/src/main/java/com/ruoyi/info/collection/mapper/CcdiStaffEnterpriseRelationMapper.java` +- Modify: `ccdi-info-collection/src/main/resources/mapper/info/collection/CcdiStaffEnterpriseRelationMapper.xml` +- Reference: `ccdi-info-collection/src/main/resources/mapper/info/collection/CcdiStaffFmyRelationMapper.xml` + +- [ ] 在 Mapper 接口中新增 `selectFamilyOptions(query)` 方法,返回 `CcdiStaffEnterpriseRelationOptionVO` 列表。 +- [ ] 在 Mapper 接口中新增 `invalidateByFamilyCertNo(personId)` 批量置无效方法。 +- [ ] 重写列表 SQL,按 `ser.person_id = sfr.relation_cert_no` 回补 `relation_name`、`staff_person_id`、`staff_person_name`。 +- [ ] 重写详情 SQL,确保详情回显与列表字段口径完全一致。 +- [ ] 给查询区补动态条件:亲属身份证号、亲属名称、关联员工、统一社会信用代码、企业名称、状态。 +- [ ] 实现亲属下拉 SQL,仅返回 `is_emp_family = 1 and status = 1` 的有效亲属,并支持 `relation_cert_no` 模糊匹配。 + +### Task 3: 改造 Service / Controller 的查询、新增、编辑与下拉搜索 + +**Files:** + +- Modify: `ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/ICcdiStaffEnterpriseRelationService.java` +- Modify: `ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/impl/CcdiStaffEnterpriseRelationServiceImpl.java` +- Modify: `ccdi-info-collection/src/main/java/com/ruoyi/info/collection/controller/CcdiStaffEnterpriseRelationController.java` +- Reference: `ccdi-info-collection/src/main/java/com/ruoyi/info/collection/controller/CcdiBaseStaffController.java` + +- [ ] 在 Service 接口中新增亲属下拉查询方法定义。 +- [ ] 在 ServiceImpl 中新增“按亲属身份证查询有效亲属关系”的私有校验方法,统一给新增和导入复用。 +- [ ] 将新增逻辑从“校验员工身份证存在”改为“校验有效亲属存在”,失败时返回亲属语义错误提示。 +- [ ] 保持编辑逻辑中 `personId` 和 `socialCreditCode` 不可修改,只切换错误文案为亲属语义。 +- [ ] 在 Controller 中新增亲属下拉搜索接口,建议路径为 `/familyOptions`。 +- [ ] 调整导入模板接口标题、列表/详情 Swagger 注释和日志标题,全部切换为“员工亲属实体关联”口径。 + +### Task 4: 改造异步导入校验与失败记录 + +**Files:** + +- Modify: `ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/impl/CcdiStaffEnterpriseRelationImportServiceImpl.java` +- Modify: `ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/ICcdiStaffEnterpriseRelationImportService.java` +- Modify: `ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/vo/StaffEnterpriseRelationImportFailureVO.java` +- Reference: `ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/impl/CcdiStaffFmyRelationImportServiceImpl.java` + +- [ ] 去掉当前导入里对 `ccdi_base_staff` 的存在性校验,改为批量查询有效员工亲属。 +- [ ] 批量缓存有效亲属集合,避免导入时逐行查库。 +- [ ] 对亲属不存在、亲属无效、库内重复、Excel 内重复分别生成明确失败原因。 +- [ ] 在失败记录 VO 中回填亲属名称,便于前端失败弹窗展示。 +- [ ] 更新导入日志标题和 Redis 提示文案,统一为亲属实体关联口径。 + +### Task 5: 在员工亲属关系保存链路中加入失效联动 + +**Files:** + +- Modify: `ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/impl/CcdiStaffFmyRelationServiceImpl.java` +- Modify: `ccdi-info-collection/src/main/java/com/ruoyi/info/collection/mapper/CcdiStaffEnterpriseRelationMapper.java` +- Modify: `ccdi-info-collection/src/main/resources/mapper/info/collection/CcdiStaffEnterpriseRelationMapper.xml` + +- [ ] 在 `CcdiStaffFmyRelationServiceImpl.updateRelation` 中保留旧记录查询,识别“有效 -> 无效”状态变更。 +- [ ] 注入 `CcdiStaffEnterpriseRelationMapper`,在状态收缩时按 `relation_cert_no` 批量更新实体关联 `status = 0`。 +- [ ] 保证该联动与亲属关系更新处于同一事务内,避免亲属已失效但实体关联仍有效。 +- [ ] 不实现“无效 -> 有效”反向恢复,严格遵守设计范围。 + +### Task 6: 补后端测试与验证命令 + +**Files:** + +- Modify: `ccdi-info-collection/src/test/java/com/ruoyi/info/collection/service/CcdiStaffEnterpriseRelationServiceImplTest.java` +- Modify: `ccdi-info-collection/src/test/java/com/ruoyi/info/collection/service/CcdiStaffEnterpriseRelationImportServiceImplTest.java` +- Modify: `ccdi-info-collection/src/test/java/com/ruoyi/info/collection/service/CcdiStaffFmyRelationServiceImplTest.java` +- Modify or Create: `ccdi-info-collection/src/test/java/com/ruoyi/info/collection/mapper/CcdiStaffEnterpriseRelationMapperTest.java` + +- [ ] 为新增接口补“有效亲属可新增、无效亲属不可新增、亲属不存在不可新增”三组测试。 +- [ ] 为列表查询补“能回填亲属名称和关联员工”的 SQL 映射测试。 +- [ ] 为亲属下拉补“只返回有效员工亲属且支持身份证模糊搜索”的测试。 +- [ ] 为导入补“成功、亲属不存在失败、亲属无效失败、库内重复、文件内重复”五组测试。 +- [ ] 为员工亲属关系状态变更补“有效改无效后自动将实体关联置无效”的事务测试。 + +## 验证命令 + +```bash +mvn -pl ccdi-info-collection -am -Dtest=CcdiStaffEnterpriseRelationServiceImplTest,CcdiStaffEnterpriseRelationImportServiceImplTest,CcdiStaffFmyRelationServiceImplTest,CcdiStaffEnterpriseRelationMapperTest test +mvn -pl ccdi-info-collection -am -DskipTests compile +``` + +## 完成标准 + +- 列表和详情返回亲属身份证、亲属名称、关联员工字段 +- 新增和导入只允许使用有效员工亲属 +- 亲属下拉搜索接口可按身份证模糊查询有效亲属 +- 导入失败记录可展示亲属名称和亲属语义错误原因 +- 亲属关系从有效改为无效后,对应实体关联自动改为无效 +- 后端定向测试和编译命令可通过 diff --git a/docs/plans/backend/2026-04-23-staff-recruitment-dual-sheet-import-backend-implementation.md b/docs/plans/backend/2026-04-23-staff-recruitment-dual-sheet-import-backend-implementation.md new file mode 100644 index 00000000..817104de --- /dev/null +++ b/docs/plans/backend/2026-04-23-staff-recruitment-dual-sheet-import-backend-implementation.md @@ -0,0 +1,320 @@ +# Staff Recruitment Dual-Sheet Import Backend Implementation Plan + +> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 将招聘信息管理后端导入链路收口为“招聘信息 + 历史工作经历”双 Sheet 单任务模式,并补齐失败 Sheet、失败行号、失败原因。 + +**Architecture:** 保留现有 `ccdi:staffRecruitment:*` 权限、`/ccdi/staffRecruitment/*` 路径和现有主从表结构,只收口控制器、服务接口和异步导入编排。导入任务先处理主信息 Sheet,再按 `recruitId` 分组处理工作经历 Sheet,工作经历匹配“本次主 Sheet 成功数据 + 数据库已有主信息”,若数据库中已存在旧工作经历则直接整组失败,不做覆盖。 + +**Tech Stack:** Java 21, Spring Boot 3, MyBatis-Plus, EasyExcel, Redis, JUnit 5, Mockito + +--- + +## File Map + +- Modify: `ccdi-info-collection/src/main/java/com/ruoyi/info/collection/controller/CcdiStaffRecruitmentController.java` + - 收口双 Sheet 模板下载与统一导入入口,移除独立工作经历导入接口 +- Modify: `ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/ICcdiStaffRecruitmentService.java` + - 暴露统一的双 Sheet 提交入口 +- Modify: `ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/ICcdiStaffRecruitmentImportService.java` + - 暴露统一的双 Sheet 异步导入入口 +- Modify: `ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/impl/CcdiStaffRecruitmentServiceImpl.java` + - 初始化统一 Redis 任务状态并提交统一异步任务 +- Modify: `ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/impl/CcdiStaffRecruitmentImportServiceImpl.java` + - 实现主 Sheet 与工作经历 Sheet 两阶段编排、行号上下文、已有工作经历报错规则 +- Modify: `ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/vo/RecruitmentImportFailureVO.java` + - 新增 `sheetName`、`sheetRowNum` +- Create: `ccdi-info-collection/src/test/java/com/ruoyi/info/collection/service/CcdiStaffRecruitmentDualImportContractTest.java` + - 锁定控制器与接口的双 Sheet 契约 +- Create: `ccdi-info-collection/src/test/java/com/ruoyi/info/collection/service/CcdiStaffRecruitmentImportServiceImplTest.java` + - 锁定异步导入编排、已有工作经历报错和失败定位行为 +- Modify: `ccdi-info-collection/src/test/java/com/ruoyi/info/collection/utils/EasyExcelUtilTemplateTest.java` + - 补模板双 Sheet 约束回归 +- Create: `docs/reports/implementation/2026-04-23-staff-recruitment-dual-sheet-import-implementation.md` + - 记录最终实施内容、验证结果与浏览器实测结论 + +### Task 1: 锁定双 Sheet 导入接口契约 + +**Files:** +- Create: `ccdi-info-collection/src/test/java/com/ruoyi/info/collection/service/CcdiStaffRecruitmentDualImportContractTest.java` +- Modify: `ccdi-info-collection/src/main/java/com/ruoyi/info/collection/controller/CcdiStaffRecruitmentController.java` +- Modify: `ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/ICcdiStaffRecruitmentService.java` +- Modify: `ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/ICcdiStaffRecruitmentImportService.java` + +- [ ] **Step 1: 写控制器与接口契约失败测试** + +```java +@Test +void shouldExposeSingleDualSheetImportEntry() throws Exception { + String controller = Files.readString( + Path.of("src/main/java/com/ruoyi/info/collection/controller/CcdiStaffRecruitmentController.java") + ); + assertTrue(controller.contains("\"招聘信息\"")); + assertTrue(controller.contains("\"历史工作经历\"")); + assertFalse(controller.contains("workImportTemplate")); + assertFalse(controller.contains("importWorkData")); +} +``` + +- [ ] **Step 2: 运行测试确认失败** + +Run: `mvn -pl ccdi-info-collection -Dtest=CcdiStaffRecruitmentDualImportContractTest test` + +Expected: FAIL,提示控制器仍存在 `workImportTemplate` / `importWorkData` 或接口签名仍为双入口 + +- [ ] **Step 3: 最小化修改控制器与服务接口** + +```java +String importRecruitment( + List recruitmentList, + List workList +); + +void importRecruitmentAsync( + List recruitmentList, + List workList, + String taskId, + String userName +); +``` + +- [ ] **Step 4: 重跑契约测试** + +Run: `mvn -pl ccdi-info-collection -Dtest=CcdiStaffRecruitmentDualImportContractTest test` + +Expected: PASS,控制器仅保留双 Sheet 模板与单导入入口 + +- [ ] **Step 5: 提交这一小步** + +```bash +git add \ + ccdi-info-collection/src/main/java/com/ruoyi/info/collection/controller/CcdiStaffRecruitmentController.java \ + ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/ICcdiStaffRecruitmentService.java \ + ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/ICcdiStaffRecruitmentImportService.java \ + ccdi-info-collection/src/test/java/com/ruoyi/info/collection/service/CcdiStaffRecruitmentDualImportContractTest.java +git commit -m "收口招聘双Sheet导入接口" +``` + +### Task 2: 接入双 Sheet 模板与失败 VO 字段 + +**Files:** +- Modify: `ccdi-info-collection/src/main/java/com/ruoyi/info/collection/controller/CcdiStaffRecruitmentController.java` +- Modify: `ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/vo/RecruitmentImportFailureVO.java` +- Modify: `ccdi-info-collection/src/test/java/com/ruoyi/info/collection/service/CcdiStaffRecruitmentDualImportContractTest.java` +- Modify: `ccdi-info-collection/src/test/java/com/ruoyi/info/collection/utils/EasyExcelUtilTemplateTest.java` + +- [ ] **Step 1: 先补失败 VO 与模板契约测试** + +```java +assertHasField( + "com.ruoyi.info.collection.domain.vo.RecruitmentImportFailureVO", + "sheetName" +); +assertHasField( + "com.ruoyi.info.collection.domain.vo.RecruitmentImportFailureVO", + "sheetRowNum" +); +``` + +- [ ] **Step 2: 运行测试确认失败** + +Run: `mvn -pl ccdi-info-collection -Dtest=CcdiStaffRecruitmentDualImportContractTest,EasyExcelUtilTemplateTest test` + +Expected: FAIL,提示 `RecruitmentImportFailureVO` 缺字段或模板断言未通过 + +- [ ] **Step 3: 最小化实现字段与模板导出** + +```java +EasyExcelUtil.importTemplateWithDictDropdown( + response, + CcdiStaffRecruitmentExcel.class, + "招聘信息", + CcdiStaffRecruitmentWorkExcel.class, + "历史工作经历", + "招聘信息管理导入模板" +); +``` + +- [ ] **Step 4: 重跑测试** + +Run: `mvn -pl ccdi-info-collection -Dtest=CcdiStaffRecruitmentDualImportContractTest,EasyExcelUtilTemplateTest test` + +Expected: PASS,模板输出双 Sheet,失败 VO 具备新字段 + +- [ ] **Step 5: 提交这一小步** + +```bash +git add \ + ccdi-info-collection/src/main/java/com/ruoyi/info/collection/controller/CcdiStaffRecruitmentController.java \ + ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/vo/RecruitmentImportFailureVO.java \ + ccdi-info-collection/src/test/java/com/ruoyi/info/collection/service/CcdiStaffRecruitmentDualImportContractTest.java \ + ccdi-info-collection/src/test/java/com/ruoyi/info/collection/utils/EasyExcelUtilTemplateTest.java +git commit -m "补齐招聘双Sheet模板与失败字段" +``` + +### Task 3: 收口服务层任务初始化与统一状态统计 + +**Files:** +- Modify: `ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/impl/CcdiStaffRecruitmentServiceImpl.java` +- Modify: `ccdi-info-collection/src/test/java/com/ruoyi/info/collection/service/CcdiStaffRecruitmentDualImportContractTest.java` + +- [ ] **Step 1: 先写服务层统一任务初始化失败测试** + +```java +@Test +void shouldInitializeSingleRedisTaskForTwoSheets() throws Exception { + String service = Files.readString( + Path.of("src/main/java/com/ruoyi/info/collection/service/impl/CcdiStaffRecruitmentServiceImpl.java") + ); + assertTrue(service.contains("recruitmentList.size() + workList.size()")); + assertFalse(service.contains("importRecruitmentWork(")); +} +``` + +- [ ] **Step 2: 运行测试确认失败** + +Run: `mvn -pl ccdi-info-collection -Dtest=CcdiStaffRecruitmentDualImportContractTest test` + +Expected: FAIL,提示服务层仍保留独立工作经历任务初始化 + +- [ ] **Step 3: 实现统一提交入口** + +```java +public String importRecruitment( + List recruitmentList, + List workList +) { + int totalCount = recruitmentList.size() + workList.size(); + recruitmentImportService.importRecruitmentAsync(recruitmentList, workList, taskId, userName); + return taskId; +} +``` + +- [ ] **Step 4: 重跑契约测试** + +Run: `mvn -pl ccdi-info-collection -Dtest=CcdiStaffRecruitmentDualImportContractTest test` + +Expected: PASS,任务总数按双 Sheet 合并统计,接口只剩统一入口 + +- [ ] **Step 5: 提交这一小步** + +```bash +git add \ + ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/impl/CcdiStaffRecruitmentServiceImpl.java \ + ccdi-info-collection/src/test/java/com/ruoyi/info/collection/service/CcdiStaffRecruitmentDualImportContractTest.java +git commit -m "统一招聘双Sheet任务初始化" +``` + +### Task 4: 实现异步导入两阶段编排与失败定位 + +**Files:** +- Create: `ccdi-info-collection/src/test/java/com/ruoyi/info/collection/service/CcdiStaffRecruitmentImportServiceImplTest.java` +- Modify: `ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/impl/CcdiStaffRecruitmentImportServiceImpl.java` + +- [ ] **Step 1: 先写异步导入失败测试** + +```java +@Test +void shouldFailWholeWorkGroupWhenExistingHistoryExists() { + // arrange: recruitmentWorkMapper.countByRecruitId("RC001") -> 1 + // act: importRecruitmentAsync(mainRows, workRows, taskId, "admin") + // assert: recruitmentWorkMapper.insert(...) not called + // assert: failure.sheetName == "历史工作经历" + // assert: failure.sheetRowNum == "2" +} +``` + +- [ ] **Step 2: 运行测试确认失败** + +Run: `mvn -pl ccdi-info-collection -Dtest=CcdiStaffRecruitmentImportServiceImplTest test` + +Expected: FAIL,提示当前实现仍会删除旧工作经历或未记录 `sheetName` / `sheetRowNum` + +- [ ] **Step 3: 以最小改动实现两阶段编排** + +```java +List indexedMainRows = buildMainImportRows(recruitmentList); +List indexedWorkRows = buildWorkImportRows(workList); +Map importedRecruitmentMap = importMainSheet(indexedMainRows, failures, userName); +importWorkSheet(indexedWorkRows, importedRecruitmentMap, failures, userName); +``` + +- [ ] **Step 4: 补充工作经历“已有旧记录即失败”与行号上下文** + +```java +if (hasExistingWorkHistory(recruitId)) { + throw buildValidationException( + "历史工作经历", + extractWorkRowNums(workRows), + String.format("招聘记录编号[%s]已存在历史工作经历,不允许重复导入", recruitId) + ); +} +``` + +- [ ] **Step 5: 重跑测试** + +Run: `mvn -pl ccdi-info-collection -Dtest=CcdiStaffRecruitmentImportServiceImplTest test` + +Expected: PASS,已有工作经历不覆盖,失败记录带 Sheet 与行号 + +- [ ] **Step 6: 提交这一小步** + +```bash +git add \ + ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/impl/CcdiStaffRecruitmentImportServiceImpl.java \ + ccdi-info-collection/src/test/java/com/ruoyi/info/collection/service/CcdiStaffRecruitmentImportServiceImplTest.java +git commit -m "实现招聘双Sheet异步导入编排" +``` + +### Task 5: 做后端回归、补实施记录并交付联调入口 + +**Files:** +- Modify: `ccdi-info-collection/src/main/java/com/ruoyi/info/collection/controller/CcdiStaffRecruitmentController.java` +- Modify: `ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/impl/CcdiStaffRecruitmentServiceImpl.java` +- Modify: `ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/impl/CcdiStaffRecruitmentImportServiceImpl.java` +- Modify: `ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/vo/RecruitmentImportFailureVO.java` +- Create: `docs/reports/implementation/2026-04-23-staff-recruitment-dual-sheet-import-implementation.md` + +- [ ] **Step 1: 运行后端定向测试** + +Run: `mvn -pl ccdi-info-collection -Dtest=CcdiStaffRecruitmentDualImportContractTest,CcdiStaffRecruitmentImportServiceImplTest,EasyExcelUtilTemplateTest test` + +Expected: PASS,双 Sheet 契约、异步编排、模板约束全部通过 + +- [ ] **Step 2: 运行模块编译** + +Run: `mvn -pl ccdi-info-collection,ruoyi-admin -am -DskipTests compile` + +Expected: BUILD SUCCESS + +- [ ] **Step 3: 启动后端供前端联调** + +Run: `sh bin/restart_java_backend.sh` + +Expected: 后端正常重启,`/ccdi/staffRecruitment/importTemplate` 与 `/importData` 可访问 + +- [ ] **Step 4: 补实施记录** + +```md +- 导入入口收口为双 Sheet 单任务 +- 工作经历导入改为“已有旧记录时报错” +- 失败记录补齐失败 Sheet、失败行号、失败原因 +- 已完成后端编译与定向测试 +``` + +- [ ] **Step 5: 提交后端收尾** + +```bash +git add \ + ccdi-info-collection/src/main/java/com/ruoyi/info/collection/controller/CcdiStaffRecruitmentController.java \ + ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/ICcdiStaffRecruitmentService.java \ + ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/ICcdiStaffRecruitmentImportService.java \ + ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/impl/CcdiStaffRecruitmentServiceImpl.java \ + ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/impl/CcdiStaffRecruitmentImportServiceImpl.java \ + ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/vo/RecruitmentImportFailureVO.java \ + ccdi-info-collection/src/test/java/com/ruoyi/info/collection/service/CcdiStaffRecruitmentDualImportContractTest.java \ + ccdi-info-collection/src/test/java/com/ruoyi/info/collection/service/CcdiStaffRecruitmentImportServiceImplTest.java \ + ccdi-info-collection/src/test/java/com/ruoyi/info/collection/utils/EasyExcelUtilTemplateTest.java \ + docs/reports/implementation/2026-04-23-staff-recruitment-dual-sheet-import-implementation.md +git commit -m "完成招聘双Sheet导入后端改造" +``` diff --git a/docs/plans/backend/2026-04-26-enterprise-auto-fill-backend-implementation.md b/docs/plans/backend/2026-04-26-enterprise-auto-fill-backend-implementation.md new file mode 100644 index 00000000..a80bdd48 --- /dev/null +++ b/docs/plans/backend/2026-04-26-enterprise-auto-fill-backend-implementation.md @@ -0,0 +1,493 @@ +# 关联业务自动补入实体库 Backend Implementation Plan + +> **执行约束:** 按当前项目 `AGENTS.md` 执行;未获得用户明确要求时不启用 subagent。Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 新建和导入员工亲属实体关联、中介实体关联、信贷客户实体关联、招投标供应商时,实体库缺失的企业自动写入 `ccdi_enterprise_base_info`。 + +**Architecture:** 新增一个后端内部实体库自动补全服务,统一处理“已存在不覆盖、缺失则最小插入、同批去重、来源和风险等级映射”。各业务 Service 在业务校验通过、业务数据落库前调用该能力;`EnterpriseSource` 枚举新增 `SUPPLIER` 并继续由现有 `/ccdi/enum/enterpriseSource` 接口驱动前端。 + +**Tech Stack:** Java 21, Spring Boot 3, MyBatis Plus, JUnit 5, Mockito, Maven. + +--- + +## File Structure + +- Modify: `ccdi-info-collection/src/main/java/com/ruoyi/info/collection/enums/EnterpriseSource.java` + - 新增 `SUPPLIER("SUPPLIER", "供应商")`。 +- Create: `ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/support/EnterpriseAutoFillService.java` + - 内部补全服务,封装单条和批量实体补入。 +- Modify: `ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/impl/CcdiStaffEnterpriseRelationServiceImpl.java` + - 新建员工亲属实体关联前补实体库。 +- Modify: `ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/impl/CcdiStaffEnterpriseRelationImportServiceImpl.java` + - 导入成功行批量补实体库。 +- Modify: `ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/impl/CcdiCustEnterpriseRelationServiceImpl.java` + - 新建信贷客户实体关联前补实体库。 +- Modify: `ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/impl/CcdiCustEnterpriseRelationImportServiceImpl.java` + - 导入成功行批量补实体库。 +- Modify: `ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/impl/CcdiIntermediaryServiceImpl.java` + - 中介实体关联新建时取消实体库必须已存在校验,改为补实体库。 + - 中介库管理新增实体时风险等级默认高风险。 +- Modify: `ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/impl/CcdiIntermediaryEnterpriseRelationImportServiceImpl.java` + - 取消“机构表不存在”失败条件,改为成功行批量补实体库。 +- Modify: `ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/impl/CcdiEnterpriseBaseInfoImportServiceImpl.java` + - 中介库管理导入实体风险等级默认高风险。 +- Modify: `ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/impl/CcdiPurchaseTransactionServiceImpl.java` + - 招投标新建时供应商补实体库。 +- Modify: `ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/impl/CcdiPurchaseTransactionImportServiceImpl.java` + - 招投标导入成功采购事项的供应商批量补实体库。 +- Test: existing unit tests under `ccdi-info-collection/src/test/java/com/ruoyi/info/collection/` + - 扩展或新增对应 Service/Import/Controller 测试。 +- Create: `docs/reports/implementation/2026-04-26-enterprise-auto-fill-implementation.md` + - 记录修改内容、影响范围、验证情况。 + +## Task 1: EnterpriseSource 枚举与接口契约 + +**Files:** +- Modify: `ccdi-info-collection/src/main/java/com/ruoyi/info/collection/enums/EnterpriseSource.java` +- Modify: `ccdi-info-collection/src/test/java/com/ruoyi/info/collection/controller/CcdiEnumControllerTest.java` + +- [ ] **Step 1: 写失败测试** + +在 `CcdiEnumControllerTest#getEnterpriseSourceOptions_shouldReturnConfiguredOptions` 中断言返回值包含 `SUPPLIER/供应商`。 + +```java +assertTrue(data.stream() + .map(EnumOptionVO.class::cast) + .anyMatch(option -> + "SUPPLIER".equals(option.getValue()) && "供应商".equals(option.getLabel()))); +``` + +- [ ] **Step 2: 运行测试确认失败** + +Run: + +```bash +mvn -pl ccdi-info-collection -Dtest=CcdiEnumControllerTest test +``` + +Expected: FAIL,提示未找到 `SUPPLIER`。 + +- [ ] **Step 3: 实现枚举** + +在 `EnterpriseSource` 中新增: + +```java +SUPPLIER("SUPPLIER", "供应商"), +``` + +保持 `contains`、`resolveCode`、`getDescByCode` 通过 `values()` 自动生效。 + +- [ ] **Step 4: 运行测试确认通过** + +Run: + +```bash +mvn -pl ccdi-info-collection -Dtest=CcdiEnumControllerTest test +``` + +Expected: PASS。 + +## Task 2: 实体库自动补全服务 + +**Files:** +- Create: `ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/support/EnterpriseAutoFillService.java` +- Test: `ccdi-info-collection/src/test/java/com/ruoyi/info/collection/service/support/EnterpriseAutoFillServiceTest.java` + +- [ ] **Step 1: 写服务测试** + +覆盖以下行为: + +- 已存在实体不插入、不覆盖。 +- 缺失实体插入最小记录。 +- 中介来源写 `riskLevel=1`。 +- 员工亲属、信贷客户、供应商来源写 `riskLevel=null`。 +- 批量同一信用代码只插一次,并使用首次有效名称。 +- 插入时遇到主键重复按已存在处理。 + +核心断言示例: + +```java +assertEquals("SUPPLIER", captured.getEntSource()); +assertNull(captured.getRiskLevel()); +assertEquals("IMPORT", captured.getDataSource()); +``` + +- [ ] **Step 2: 运行测试确认失败** + +Run: + +```bash +mvn -pl ccdi-info-collection -Dtest=EnterpriseAutoFillServiceTest test +``` + +Expected: FAIL,类不存在。 + +- [ ] **Step 3: 实现服务接口** + +创建内部记录类型和方法: + +```java +@Service +public class EnterpriseAutoFillService { + @Resource + private CcdiEnterpriseBaseInfoMapper enterpriseBaseInfoMapper; + + public record EnterpriseFillItem( + String socialCreditCode, + String enterpriseName, + String entSource, + String dataSource, + String userName + ) {} + + @Transactional + public void ensureExists(EnterpriseFillItem item) { + ensureExistsBatch(List.of(item)); + } + + @Transactional + public void ensureExistsBatch(List items) { + // trim、过滤空信用代码、按 socialCreditCode 首次出现去重 + // selectBatchIds 查询已存在记录 + // 组装 CcdiEnterpriseBaseInfo 最小实体 + // riskLevel: INTERMEDIARY -> "1",其他 -> null + // dataSource: MANUAL 或 IMPORT + // 分批调用 enterpriseBaseInfoMapper.insertBatch + // 捕获 DuplicateKeyException 后继续逐条 selectById/insert,重复则忽略 + } +} +``` + +实现注意: + +- 不调用 `CcdiEnterpriseBaseInfoServiceImpl#insertEnterpriseBaseInfo`,避免复用手工新增风险等级校验。 +- 对非中介来源显式 `setRiskLevel(null)`。 +- 不更新已存在实体。 +- `enterpriseName` 使用来源业务已通过校验的名称,不增加额外兜底。 + +- [ ] **Step 4: 运行服务测试** + +Run: + +```bash +mvn -pl ccdi-info-collection -Dtest=EnterpriseAutoFillServiceTest test +``` + +Expected: PASS。 + +## Task 3: 员工亲属实体关联接入 + +**Files:** +- Modify: `CcdiStaffEnterpriseRelationServiceImpl.java` +- Modify: `CcdiStaffEnterpriseRelationImportServiceImpl.java` +- Modify: `CcdiStaffEnterpriseRelationServiceImplTest.java` +- Modify: `CcdiStaffEnterpriseRelationImportServiceImplTest.java` + +- [ ] **Step 1: 写新建测试** + +在 `insertRelation_shouldAllowValidFamily` 中注入 `EnterpriseAutoFillService` mock,并验证: + +```java +verify(enterpriseAutoFillService).ensureExists(argThat(item -> + "91310000123456789A".equals(item.socialCreditCode()) + && "测试企业".equals(item.enterpriseName()) + && "EMP_RELATION".equals(item.entSource()) + && "MANUAL".equals(item.dataSource()))); +``` + +- [ ] **Step 2: 写导入测试** + +扩展导入测试,验证成功行调用批量补入,失败行不进入补入集合。 + +- [ ] **Step 3: 运行员工亲属测试确认失败** + +Run: + +```bash +mvn -pl ccdi-info-collection -Dtest=CcdiStaffEnterpriseRelationServiceImplTest,CcdiStaffEnterpriseRelationImportServiceImplTest test +``` + +Expected: FAIL,尚未调用自动补全服务。 + +- [ ] **Step 4: 实现新建接入** + +在 `insertRelation` 中,`existsByPersonIdAndSocialCreditCode` 通过后、`relationMapper.insert` 前调用: + +```java +enterpriseAutoFillService.ensureExists(new EnterpriseAutoFillService.EnterpriseFillItem( + addDTO.getSocialCreditCode(), + addDTO.getEnterpriseName(), + EnterpriseSource.EMP_RELATION.getCode(), + DataSource.MANUAL.getCode(), + SecurityUtils.getUsername() +)); +``` + +- [ ] **Step 5: 实现导入接入** + +在 `importRelationAsync` 成功构建 `newRecords` 后、`saveBatch(newRecords, 500)` 前,按成功记录组装补入集合: + +```java +enterpriseAutoFillService.ensureExistsBatch(newRecords.stream() + .map(item -> new EnterpriseFillItem(item.getSocialCreditCode(), item.getEnterpriseName(), + EnterpriseSource.EMP_RELATION.getCode(), DataSource.IMPORT.getCode(), userName)) + .toList()); +``` + +- [ ] **Step 6: 运行员工亲属测试** + +Run: + +```bash +mvn -pl ccdi-info-collection -Dtest=CcdiStaffEnterpriseRelationServiceImplTest,CcdiStaffEnterpriseRelationImportServiceImplTest test +``` + +Expected: PASS。 + +## Task 4: 信贷客户实体关联接入 + +**Files:** +- Modify: `CcdiCustEnterpriseRelationServiceImpl.java` +- Modify: `CcdiCustEnterpriseRelationImportServiceImpl.java` +- Test: add `CcdiCustEnterpriseRelationServiceImplTest.java` if missing +- Test: add or extend `CcdiCustEnterpriseRelationImportServiceImplTest.java` + +- [ ] **Step 1: 写新建测试** + +验证 `insertRelation` 成功时调用自动补全: + +```java +assertEquals("CREDIT_CUSTOMER", item.entSource()); +assertEquals("MANUAL", item.dataSource()); +``` + +- [ ] **Step 2: 写导入测试** + +准备一条成功、一条重复组合失败,验证只有成功行传入 `ensureExistsBatch`。 + +- [ ] **Step 3: 运行测试确认失败** + +Run: + +```bash +mvn -pl ccdi-info-collection -Dtest=CcdiCustEnterpriseRelationServiceImplTest,CcdiCustEnterpriseRelationImportServiceImplTest test +``` + +Expected: FAIL,尚未调用自动补全服务。 + +- [ ] **Step 4: 实现新建接入** + +在 `insertRelation` 唯一性校验后、插入前调用自动补全,来源 `CREDIT_CUSTOMER`,数据来源 `MANUAL`。 + +- [ ] **Step 5: 实现导入接入** + +在 `importRelationAsync` 成功记录批量插入前调用自动补全,来源 `CREDIT_CUSTOMER`,数据来源 `IMPORT`。 + +- [ ] **Step 6: 运行测试** + +Run: + +```bash +mvn -pl ccdi-info-collection -Dtest=CcdiCustEnterpriseRelationServiceImplTest,CcdiCustEnterpriseRelationImportServiceImplTest test +``` + +Expected: PASS。 + +## Task 5: 中介实体关联和中介实体管理规则 + +**Files:** +- Modify: `CcdiIntermediaryServiceImpl.java` +- Modify: `CcdiIntermediaryEnterpriseRelationImportServiceImpl.java` +- Modify: `CcdiEnterpriseBaseInfoImportServiceImpl.java` +- Modify: `CcdiIntermediaryServiceImplTest.java` +- Modify: `CcdiIntermediaryEnterpriseRelationImportServiceImplTest.java` +- Modify: `CcdiEnterpriseBaseInfoImportServiceImplTest.java` + +- [ ] **Step 1: 写中介实体关联新建测试** + +验证实体库缺失不再抛“关联机构不存在”,而是调用自动补全并插入关联: + +```java +when(enterpriseRelationMapper.existsByIntermediaryBizIdAndSocialCreditCode("owner-biz", uscc)).thenReturn(false); +verify(enterpriseAutoFillService).ensureExists(argThat(item -> + "INTERMEDIARY".equals(item.entSource()) && "MANUAL".equals(item.dataSource()))); +verify(enterpriseRelationMapper).insert(any(CcdiIntermediaryEnterpriseRelation.class)); +``` + +- [ ] **Step 2: 写中介实体关联导入测试** + +将现有 `importEnterpriseRelationAsync_shouldFailWhenEnterpriseDoesNotExist` 改成成功场景,断言: + +- 不再产生失败记录。 +- 调用 `ensureExistsBatch`。 +- 插入关联记录。 + +- [ ] **Step 3: 写中介库管理默认高风险测试** + +在 `CcdiEnterpriseBaseInfoImportServiceImplTest` 增加: + +```java +excel.setRiskLevel(null); +excel.setEntSource("中介"); +CcdiEnterpriseBaseInfo entity = service.validateAndBuildEntity(excel, Set.of(), new HashSet<>(), "admin"); +assertEquals("1", entity.getRiskLevel()); +assertEquals("INTERMEDIARY", entity.getEntSource()); +``` + +在 `CcdiIntermediaryServiceImplTest` 验证 `insertIntermediaryEntity` 未传风险等级时写入 `1`。 + +- [ ] **Step 4: 运行测试确认失败** + +Run: + +```bash +mvn -pl ccdi-info-collection -Dtest=CcdiIntermediaryServiceImplTest,CcdiIntermediaryEnterpriseRelationImportServiceImplTest,CcdiEnterpriseBaseInfoImportServiceImplTest test +``` + +Expected: FAIL。 + +- [ ] **Step 5: 实现新建接入** + +修改 `validateEnterpriseRelation`:保留中介本人和重复组合校验,删除 `enterpriseBaseInfoMapper.selectById(socialCreditCode) == null` 抛错。 + +在 `insertIntermediaryEnterpriseRelation` 插入前调用自动补全,来源 `INTERMEDIARY`,数据来源 `MANUAL`。 + +- [ ] **Step 6: 实现导入接入** + +在导入服务中删除 `getExistingEnterpriseCodes` 的失败判断。成功记录插入前按 Excel 行组装实体补入,来源 `INTERMEDIARY`,数据来源 `IMPORT`。 + +- [ ] **Step 7: 实现中介实体默认高风险** + +在 `insertIntermediaryEntity` 中,如果 `riskLevel` 为空,设置为 `"1"`。 + +在 `CcdiEnterpriseBaseInfoImportServiceImpl#validateAndBuildEntity` 中,当解析出的 `entSource` 为 `INTERMEDIARY` 且 `riskLevel` 为空时,设置 `"1"`。 + +- [ ] **Step 8: 运行测试** + +Run: + +```bash +mvn -pl ccdi-info-collection -Dtest=CcdiIntermediaryServiceImplTest,CcdiIntermediaryEnterpriseRelationImportServiceImplTest,CcdiEnterpriseBaseInfoImportServiceImplTest test +``` + +Expected: PASS。 + +## Task 6: 招投标供应商接入 + +**Files:** +- Modify: `CcdiPurchaseTransactionServiceImpl.java` +- Modify: `CcdiPurchaseTransactionImportServiceImpl.java` +- Test: add `CcdiPurchaseTransactionServiceImplTest.java` if missing +- Modify: `CcdiPurchaseTransactionFeatureContractTest.java` or add import service unit test + +- [ ] **Step 1: 写新建测试** + +验证 `insertTransaction` 成功时,仅对 `supplierUscc` 不为空的供应商调用自动补全: + +```java +assertEquals("SUPPLIER", item.entSource()); +assertEquals("MANUAL", item.dataSource()); +assertEquals("供应商A", item.enterpriseName()); +``` + +- [ ] **Step 2: 写导入测试** + +准备一个成功采购事项和一个失败采购事项,断言只有成功事项的供应商进入 `ensureExistsBatch`,且来源为 `SUPPLIER`、数据来源为 `IMPORT`。 + +- [ ] **Step 3: 运行测试确认失败** + +Run: + +```bash +mvn -pl ccdi-info-collection -Dtest=CcdiPurchaseTransactionServiceImplTest,CcdiPurchaseTransactionImportServiceImplTest,CcdiPurchaseTransactionFeatureContractTest test +``` + +Expected: FAIL。 + +- [ ] **Step 4: 实现新建接入** + +在 `insertTransaction` 中,`buildSupplierEntities` 和校验完成后、写主从表前,收集供应商: + +```java +enterpriseAutoFillService.ensureExistsBatch(supplierList.stream() + .filter(item -> StringUtils.isNotEmpty(item.getSupplierUscc())) + .map(item -> new EnterpriseFillItem(item.getSupplierUscc(), item.getSupplierName(), + EnterpriseSource.SUPPLIER.getCode(), DataSource.MANUAL.getCode(), SecurityUtils.getUsername())) + .toList()); +``` + +- [ ] **Step 5: 实现导入接入** + +在 `importTransactionAsync` 中,按成功构建的 `newSuppliers` 收集供应商实体,在 `saveBatch(newTransactions, 500)` 之前调用自动补全。失败事项的供应商不进入 `newSuppliers`,天然不补。 + +- [ ] **Step 6: 运行测试** + +Run: + +```bash +mvn -pl ccdi-info-collection -Dtest=CcdiPurchaseTransactionServiceImplTest,CcdiPurchaseTransactionImportServiceImplTest,CcdiPurchaseTransactionFeatureContractTest test +``` + +Expected: PASS。 + +## Task 7: 集成验证与实施记录 + +**Files:** +- Create: `docs/reports/implementation/2026-04-26-enterprise-auto-fill-implementation.md` + +- [ ] **Step 1: 运行后端相关测试集合** + +Run: + +```bash +mvn -pl ccdi-info-collection -Dtest=CcdiEnumControllerTest,EnterpriseAutoFillServiceTest,CcdiStaffEnterpriseRelationServiceImplTest,CcdiStaffEnterpriseRelationImportServiceImplTest,CcdiCustEnterpriseRelationServiceImplTest,CcdiCustEnterpriseRelationImportServiceImplTest,CcdiIntermediaryServiceImplTest,CcdiIntermediaryEnterpriseRelationImportServiceImplTest,CcdiEnterpriseBaseInfoImportServiceImplTest,CcdiPurchaseTransactionServiceImplTest,CcdiPurchaseTransactionImportServiceImplTest,CcdiPurchaseTransactionFeatureContractTest test +``` + +Expected: BUILD SUCCESS。 + +- [ ] **Step 2: 如涉及数据库实测,确认 `risk_level` 落库值** + +验证样本: + +- 员工亲属自动补入:`risk_level IS NULL` +- 信贷客户自动补入:`risk_level IS NULL` +- 招投标供应商自动补入:`risk_level IS NULL` +- 中介自动补入:`risk_level = '1'` + +- [ ] **Step 3: 写实施记录** + +实施记录至少包含: + +```markdown +# 关联业务自动补入实体库实施记录 + +## 修改内容 +- 新增实体库自动补全服务 +- 接入员工亲属、中介、信贷客户、招投标链路 +- 新增 SUPPLIER 企业来源 + +## 影响范围 +- ccdi-info-collection 后端服务 +- 实体库管理企业来源枚举接口 + +## 验证情况 +- 列出 Maven 测试命令与结果 +- 列出页面或数据库验证结果 +``` + +- [ ] **Step 4: 检查工作区** + +Run: + +```bash +git status --short +``` + +Expected: 仅包含本次功能相关源码、测试和实施记录,不包含 `.DS_Store` 或生成测试文件。 + +- [ ] **Step 5: 提交后端改动** + +```bash +git add ccdi-info-collection/src/main/java/com/ruoyi/info/collection ccdi-info-collection/src/test/java/com/ruoyi/info/collection docs/reports/implementation/2026-04-26-enterprise-auto-fill-implementation.md +git commit -m "实现关联业务自动补入实体库" +``` diff --git a/docs/plans/frontend/2026-03-31-abnormal-account-bank-tag-frontend-implementation-plan.md b/docs/plans/frontend/2026-03-31-abnormal-account-bank-tag-frontend-implementation-plan.md new file mode 100644 index 00000000..346da583 --- /dev/null +++ b/docs/plans/frontend/2026-03-31-abnormal-account-bank-tag-frontend-implementation-plan.md @@ -0,0 +1,167 @@ +# 异常账户模型接入银行流水打标前端 Implementation Plan + +> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. +> +> 仓库约束:当前仓库明确禁止开启 subagent,执行时统一使用 `superpowers:executing-plans`。 + +**Goal:** 基于后端新增异常账户模型完成前端影响面核查,确认本轮无需新增页面、接口或交互改动,并把验证结论沉淀为前端实施计划与实施记录。 + +**Architecture:** 前端保持零代码改动策略,继续消费现有项目结果总览对象聚合结果,不提前扩展“异常账户人员信息”占位区块。本计划聚焦影响面核查、联调验证和文档沉淀,确保执行时不会误把需求扩展成前端功能改造。 + +**Tech Stack:** Vue 2, Element UI, RuoYi 前端, 项目详情风险总览现有页面 + +--- + +## File Map + +**Create:** + +- `docs/reports/implementation/2026-03-31-abnormal-account-bank-tag-frontend-implementation.md` + - 记录前端零改动结论、联调范围和验证结果 + +**Modify:** + +- `docs/plans/frontend/2026-03-31-abnormal-account-bank-tag-frontend-implementation-plan.md` + - 当前实施计划文档本身 + +**No Change Expected:** + +- `ruoyi-ui/src/views/ccdiProject/components/detail/RiskModelSection.vue` + - 现有风险模型列表继续展示后端返回的模型与规则 +- `ruoyi-ui/src/views/ccdiProject/components/detail/ProjectAnalysisSidebar.vue` + - 继续展示后端聚合后的核心异常标签 +- `ruoyi-ui/src/views/ccdiProject/components/detail/RiskDetailSection.vue` + - 本轮不开发异常账户独立详情链路 +- `ruoyi-ui/src/api/ccdi/` + - 本轮不新增 API 封装 + +## Task 1: 先锁定“前端不改代码”的回归边界 + +**Files:** + +- Create: `docs/reports/implementation/2026-03-31-abnormal-account-bank-tag-frontend-implementation.md` + +- [ ] **Step 1: 记录现有页面承接点** + +在实施记录中先写明本轮只核查以下页面承接能力: + +1. 风险模型列表是否直接消费后端返回的模型/规则 +2. 结果总览员工聚合是否直接消费后端对象型结果 +3. 风险详情中的“异常账户人员信息”是否仍为占位区域 + +- [ ] **Step 2: 用代码检索确认前端当前承接方式** + +Run: + +```bash +rg -n "异常账户人员信息|异常标签|风险模型|hitRules|modelCode" ruoyi-ui/src/views/ccdiProject -S +``` + +Expected: + +- 能定位风险模型、总览和风险详情组件 +- 没有现成的异常账户独立查询 API + +- [ ] **Step 3: 把零改动边界写入实施记录** + +记录结论: + +- 前端当前通过既有后端聚合接口承接模型和规则展示 +- 本轮不需要新增字段、按钮、弹窗或路由 + +- [ ] **Step 4: 提交本任务** + +```bash +git add docs/reports/implementation/2026-03-31-abnormal-account-bank-tag-frontend-implementation.md +git commit -m "补充异常账户模型前端影响分析" +``` + +## Task 2: 联调确认现有页面可承接新增模型 + +**Files:** + +- Modify: `docs/reports/implementation/2026-03-31-abnormal-account-bank-tag-frontend-implementation.md` + +- [ ] **Step 1: 如需本地验证,启动前端** + +Run: + +```bash +cd ruoyi-ui +npm run dev +``` + +Expected: + +- 前端正常启动 + +- [ ] **Step 2: 联调核查 3 个页面点** + +至少验证以下内容: + +1. 风险模型区域可展示新增模型 `异常账户` +2. 员工结果总览可看到由后端聚合出的新增命中规则 +3. 风险详情“异常账户人员信息”区域仍保持原状,不因本轮后端接入报错 + +- [ ] **Step 3: 记录联调结论** + +在实施记录中写明: + +- 是否需要前端改代码 +- 若无需改动,说明原因是现有页面直接消费后端聚合结果 +- 若启动了 `npm run dev`,验证结束后已关闭进程 + +- [ ] **Step 4: 提交本任务** + +```bash +git add docs/reports/implementation/2026-03-31-abnormal-account-bank-tag-frontend-implementation.md +git commit -m "补充异常账户模型前端联调结论" +``` + +## Task 3: 做前端构建回归并收尾 + +**Files:** + +- Modify: `docs/reports/implementation/2026-03-31-abnormal-account-bank-tag-frontend-implementation.md` + +- [ ] **Step 1: 运行前端构建回归** + +Run: + +```bash +cd ruoyi-ui +npm run build:prod +``` + +Expected: + +- PASS + +- [ ] **Step 2: 完善实施记录** + +记录: + +- 本轮前端零代码改动 +- 构建结果 +- 联调承接点 +- 若启动过 `npm run dev`,已主动关闭进程 + +- [ ] **Step 3: 最终提交** + +```bash +git add docs/reports/implementation/2026-03-31-abnormal-account-bank-tag-frontend-implementation.md +git commit -m "完成异常账户模型前端实施记录" +``` + +## Final Verification + +- [ ] 运行: + +```bash +cd ruoyi-ui +npm run build:prod +``` + +- [ ] 确认本轮前端无源码改动需求 +- [ ] 确认风险模型、结果总览、风险详情三处承接点已核查 +- [ ] 如启动过 `npm run dev`,验证结束后主动关闭前端进程 diff --git a/docs/plans/frontend/2026-03-31-lsfx-mock-server-abnormal-account-baseline-sync-frontend-implementation.md b/docs/plans/frontend/2026-03-31-lsfx-mock-server-abnormal-account-baseline-sync-frontend-implementation.md new file mode 100644 index 00000000..00d1f55b --- /dev/null +++ b/docs/plans/frontend/2026-03-31-lsfx-mock-server-abnormal-account-baseline-sync-frontend-implementation.md @@ -0,0 +1,128 @@ +# LSFX Mock Server 异常账户基线同步前端 Implementation Plan + +> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. +> +> 仓库约束:当前仓库明确禁止开启 subagent,执行时统一使用 `superpowers:executing-plans`。 + +**Goal:** 在不修改 `ruoyi-ui` 源码的前提下,明确本次 `lsfx-mock-server` 异常账户基线同步对前端的影响边界,沉淀“零代码改动”实施计划与核验记录。 + +**Architecture:** 本次需求只增强 Mock 服务内部的异常账户事实落库能力,不修改对外银行流水接口协议,也不新增前端入参、页面或调试入口。前端计划采用“影响面检索 + 协议不变确认 + 文档留痕”的最短路径;若核查发现必须适配新字段或新交互,应停止执行并回到设计阶段,而不是在本计划中扩展 UI。 + +**Tech Stack:** Vue 2, rg, git diff, Markdown docs + +--- + +## File Structure + +- `ruoyi-ui/src/api/`: 只用于检索是否存在直接依赖 `lsfx-mock-server` 异常账户内部事实的新接口封装,不预期修改。 +- `ruoyi-ui/src/views/ccdiProject/`: 只用于确认现有页面是否直接依赖 Mock 内部账户事实,不预期修改。 +- `docs/reports/implementation/2026-03-31-lsfx-mock-server-abnormal-account-baseline-sync-frontend-implementation.md`: 记录前端零代码改动结论。 +- `docs/tests/records/2026-03-31-lsfx-mock-server-abnormal-account-baseline-sync-frontend-verification.md`: 记录检索命令、查验范围和判断依据。 + +## Task 1: 核验前端是否需要承接本次 Mock 基线同步 + +**Files:** +- Reference: `ruoyi-ui/src/api/` +- Reference: `ruoyi-ui/src/views/ccdiProject/` +- Reference: `docs/design/2026-03-31-lsfx-mock-server-abnormal-account-baseline-sync-design.md` + +- [ ] **Step 1: Check existing frontend touchpoints** + +先确认本次需求是否触达以下任一前端边界: + +- 前端是否直接调用 `lsfx-mock-server` 并依赖异常账户内部事实 +- 前端是否需要新增字段才能继续消费 `/watson/api/project/getBSByLogId` +- 前端是否存在专门围绕 Mock 联调的页面、按钮或测试入口需要跟进 + +若三项都不存在,则本轮前端默认保持零代码改动。 + +- [ ] **Step 2: Verify with search commands** + +Run: + +```bash +cd ruoyi-ui +rg -n "lsfx|mock|异常账户|getBSByLogId|bankStatement|account_info" src +``` + +Expected: + +- 不存在必须新增前端适配的直接依赖 +- 不应因为 Mock 内部写库增强而顺手增加演示页、调试页或临时开关 + +- [ ] **Step 3: Confirm contract stability** + +对照设计文档确认以下三点全部成立: + +- `/watson/api/project/getBSByLogId` 返回结构不变 +- 本次只新增 Mock 内部异常账户基线写库,不新增前端入参 +- 风险页面仍只消费后端聚合结果,不直接读取 `ccdi_account_info` + +若任一点不成立,停止执行并回到设计阶段。 + +- [ ] **Step 4: Record the no-op boundary** + +在后续实施记录中明确写明: + +- 本次需求不涉及 `ruoyi-ui` 源码修改 +- 前端不会为了“方便联调”新增占位页面、按钮或 mock 参数 + +- [ ] **Step 5: Commit** + +```bash +git add \ + docs/reports/implementation/2026-03-31-lsfx-mock-server-abnormal-account-baseline-sync-frontend-implementation.md \ + docs/tests/records/2026-03-31-lsfx-mock-server-abnormal-account-baseline-sync-frontend-verification.md +git commit -m "记录异常账户基线同步前端零改动结论" +``` + +## Task 2: 沉淀前端核验记录并确认源码未被误改 + +**Files:** +- Create: `docs/reports/implementation/2026-03-31-lsfx-mock-server-abnormal-account-baseline-sync-frontend-implementation.md` +- Create: `docs/tests/records/2026-03-31-lsfx-mock-server-abnormal-account-baseline-sync-frontend-verification.md` + +- [ ] **Step 1: Write implementation record** + +在 `docs/reports/implementation/2026-03-31-lsfx-mock-server-abnormal-account-baseline-sync-frontend-implementation.md` 中记录: + +- 需求主体是 `lsfx-mock-server` 后端基线同步 +- 前端不直接消费 Mock 新增的内部写库行为 +- 因此本轮不修改 `ruoyi-ui` 源码 + +- [ ] **Step 2: Write verification record** + +在 `docs/tests/records/2026-03-31-lsfx-mock-server-abnormal-account-baseline-sync-frontend-verification.md` 中记录: + +- 执行过的 `rg` / `git diff` 命令 +- 核验目录范围 +- “无需前端改动”的判断依据 + +- [ ] **Step 3: Verify frontend diff scope** + +Run: + +```bash +git diff --name-only -- ruoyi-ui +``` + +Expected: + +- 无与本次需求相关的新前端改动 +- 若存在既有无关改动,只记录“本计划未新增前端源码变更”,不顺手处理他人改动 + +- [ ] **Step 4: Confirm no frontend build is required** + +在验证记录中明确写明: + +- 因为 `ruoyi-ui` 无本次需求相关源码改动,本次不执行 `npm run build:prod` +- 若后续出现真实前端接入需求,再单独补构建与联调验证 + +- [ ] **Step 5: Commit** + +```bash +git add \ + docs/reports/implementation/2026-03-31-lsfx-mock-server-abnormal-account-baseline-sync-frontend-implementation.md \ + docs/tests/records/2026-03-31-lsfx-mock-server-abnormal-account-baseline-sync-frontend-verification.md +git commit -m "补充异常账户基线同步前端核验记录" +``` diff --git a/docs/plans/frontend/2026-03-31-lsfx-mock-server-abnormal-account-frontend-implementation-plan.md b/docs/plans/frontend/2026-03-31-lsfx-mock-server-abnormal-account-frontend-implementation-plan.md new file mode 100644 index 00000000..1725818b --- /dev/null +++ b/docs/plans/frontend/2026-03-31-lsfx-mock-server-abnormal-account-frontend-implementation-plan.md @@ -0,0 +1,124 @@ +# LSFX Mock Server 异常账户命中流水前端 Implementation Plan + +> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. +> +> 仓库约束:当前仓库明确禁止开启 subagent,执行时统一使用 `superpowers:executing-plans`。 + +**Goal:** 在不新增 `ruoyi-ui` 页面、接口或交互的前提下,明确本次 `lsfx-mock-server` 异常账户命中流水建设对前端的影响边界,并沉淀零代码改动结论与核验记录。 + +**Architecture:** 本次需求主体是 `lsfx-mock-server` 内部造数能力增强,不修改主系统前端消费协议。前端计划采用“零源码改动 + 承接边界核查 + 文档沉淀”的最短路径;若核查发现必须新增前端字段或联调适配,应停止执行并回到设计阶段,而不是在本计划中临时扩展 UI。 + +**Tech Stack:** Vue 2, rg, Markdown docs + +--- + +## File Structure + +- `ruoyi-ui/src/api/`: 只用于检索是否存在依赖 Mock 异常账户造数的新接口封装,不预期修改。 +- `ruoyi-ui/src/views/ccdiProject/`: 只用于确认现有风险页面是否直接消费后端聚合结果,不预期修改。 +- `docs/reports/implementation/2026-03-31-lsfx-mock-server-abnormal-account-frontend-implementation.md`: 记录本次前端零代码改动的结论。 +- `docs/tests/records/2026-03-31-lsfx-mock-server-abnormal-account-frontend-verification.md`: 记录核验命令、目录范围和判断依据。 + +## Task 1: 核验当前前端无需新增异常账户 Mock 适配代码 + +**Files:** +- Reference: `ruoyi-ui/src/api/` +- Reference: `ruoyi-ui/src/views/ccdiProject/` +- Reference: `docs/design/2026-03-31-lsfx-mock-server-abnormal-account-design.md` + +- [ ] **Step 1: Check the existing frontend touchpoints** + +确认当前前端是否存在以下任一需要同步改造的触点: + +- 直接调用 `lsfx-mock-server` 的页面或接口封装 +- 依赖异常账户 Mock 返回新字段的前端展示逻辑 +- 需要为 Mock 联调新增独立上传页、测试页或调试按钮的场景 + +若不存在,则本轮前端默认保持零代码改动。 + +- [ ] **Step 2: Verify with search commands** + +Run: + +```bash +cd ruoyi-ui +rg -n "lsfx|mock|异常账户|getBSByLogId|bankStatement" src +``` + +Expected: + +- 若仅命中既有业务页面或无直接 Mock 依赖,说明本轮无需新增前端代码 +- 不应因为 Mock 服务增强而顺手新增演示页或调试页 + +- [ ] **Step 3: Confirm no contract adaptation is needed** + +对照设计文档确认: + +- Mock 返回的银行流水接口结构没有变化 +- 本次仅增强内部造数,不新增前端必填参数 +- 风险页面继续消费后端聚合结果,不直接依赖 Mock 内部账户事实 + +若上述任一不成立,则停止执行并回到设计阶段。 + +- [ ] **Step 4: Record the no-op conclusion** + +在后续实施记录中明确写明: + +- 本次需求不涉及 `ruoyi-ui` 源码改动 +- 不为了“方便联调”临时增加前端占位页面或按钮 + +- [ ] **Step 5: Commit** + +```bash +git add docs/reports/implementation/2026-03-31-lsfx-mock-server-abnormal-account-frontend-implementation.md docs/tests/records/2026-03-31-lsfx-mock-server-abnormal-account-frontend-verification.md +git commit -m "记录异常账户Mock前端零改动结论" +``` + +## Task 2: 沉淀前端核验记录并确保源码无变更 + +**Files:** +- Create: `docs/reports/implementation/2026-03-31-lsfx-mock-server-abnormal-account-frontend-implementation.md` +- Create: `docs/tests/records/2026-03-31-lsfx-mock-server-abnormal-account-frontend-verification.md` + +- [ ] **Step 1: Write implementation record** + +在 `docs/reports/implementation/2026-03-31-lsfx-mock-server-abnormal-account-frontend-implementation.md` 中记录: + +- 需求主体是 `lsfx-mock-server` 后端造数 +- 前端当前不直接消费 Mock 内部新增的异常账户事实 +- 因此本轮不修改 `ruoyi-ui` 任何源码 + +- [ ] **Step 2: Write verification record** + +在 `docs/tests/records/2026-03-31-lsfx-mock-server-abnormal-account-frontend-verification.md` 中记录: + +- 执行过的 `rg` 命令 +- 查验目录范围 +- “无需前端改动”的判断依据 + +- [ ] **Step 3: Verify frontend diff stays empty** + +Run: + +```bash +git diff --name-only -- ruoyi-ui +``` + +Expected: + +- 无输出 +- 证明本次前端计划执行保持零源码改动 + +- [ ] **Step 4: Confirm no frontend build is required** + +在验证记录中明确写明: + +- 因为 `ruoyi-ui` 无源码改动,本次不执行 `npm run build:prod` +- 若后续新增真实前端接入点,再补构建与联调验证 + +- [ ] **Step 5: Commit** + +```bash +git add docs/reports/implementation/2026-03-31-lsfx-mock-server-abnormal-account-frontend-implementation.md docs/tests/records/2026-03-31-lsfx-mock-server-abnormal-account-frontend-verification.md +git commit -m "补充异常账户Mock前端核验记录" +``` diff --git a/docs/plans/frontend/2026-03-31-project-detail-risk-details-abnormal-account-frontend-implementation-plan.md b/docs/plans/frontend/2026-03-31-project-detail-risk-details-abnormal-account-frontend-implementation-plan.md new file mode 100644 index 00000000..64c408be --- /dev/null +++ b/docs/plans/frontend/2026-03-31-project-detail-risk-details-abnormal-account-frontend-implementation-plan.md @@ -0,0 +1,350 @@ +# 项目详情风险明细异常账户人员信息前端 Implementation Plan + +> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. +> +> 仓库约束:当前仓库明确禁止开启 subagent,执行时统一使用 `superpowers:executing-plans`。 + +**Goal:** 将 `RiskDetailSection.vue` 中的“异常账户人员信息”从占位表格改为真实接口驱动的列表,并与统一导出的 6 个字段保持一致。 + +**Architecture:** 前端保持最小改动,继续复用 `RiskDetailSection.vue` 内现有的独立分区加载模式,为异常账户区块补一套与员工负面征信相同风格的 `loading/pageNum/pageSize/total/list` 状态。数据请求通过 `@/api/ccdi/projectOverview` 新增一个轻量 GET 方法,不改 `PreliminaryCheck.vue` 的页面编排逻辑。 + +**Tech Stack:** Vue 2, Element UI, RuoYi request 封装, Node 静态单测, Vue CLI 构建 + +--- + +## File Map + +**Create:** + +- `ruoyi-ui/tests/unit/risk-detail-abnormal-account-layout.test.js` + - 覆盖异常账户区块的真实列结构和空态文案 +- `ruoyi-ui/tests/unit/risk-detail-abnormal-account-pagination.test.js` + - 覆盖异常账户独立分页状态与加载方法 + +**Modify:** + +- `ruoyi-ui/src/api/ccdi/projectOverview.js` + - 新增异常账户分页查询 API 封装 +- `ruoyi-ui/src/views/ccdiProject/components/detail/RiskDetailSection.vue` + - 接入真实异常账户列表、独立分页、独立 loading、移除旧占位操作列 +- `ruoyi-ui/src/views/ccdiProject/components/detail/preliminaryCheck.mock.js` + - 对齐 mock 中异常账户字段名,避免静态预览口径落后于真实页面 +- `ruoyi-ui/tests/unit/preliminary-check-model-and-detail.test.js` + - 更新旧断言,移除对“查看详情”的占位依赖 +- `docs/reports/implementation/2026-03-31-project-detail-risk-details-abnormal-account-frontend-implementation.md` + - 记录前端实施与验证结果 + +**No Change Expected:** + +- `ruoyi-ui/src/views/ccdiProject/components/detail/PreliminaryCheck.vue` + - 仍只负责把 `projectId` 传给 `RiskDetailSection` + +## Task 1: 锁定异常账户区块的静态结构与测试期望 + +**Files:** + +- Create: `ruoyi-ui/tests/unit/risk-detail-abnormal-account-layout.test.js` +- Modify: `ruoyi-ui/tests/unit/preliminary-check-model-and-detail.test.js` + +- [ ] **Step 1: 先写新的静态布局测试** + +创建 `risk-detail-abnormal-account-layout.test.js`,直接读取 `RiskDetailSection.vue` 源码并断言以下 token: + +```js +[ + "异常账户人员信息", + "账号", + "开户人", + "银行", + "异常类型", + "异常发生时间", + "状态", + "当前项目暂无异常账户人员信息", +] +``` + +- [ ] **Step 2: 更新旧测试,先让它失败** + +在 `preliminary-check-model-and-detail.test.js` 中把这一段: + +```js +["风险明细", "涉疑交易明细", "异常账户人员信息", "查看详情"] +``` + +改成断言新的异常账户列名,不再要求旧占位的“查看详情”。 + +- [ ] **Step 3: 运行静态单测确认失败** + +Run: + +```bash +cd ruoyi-ui +node tests/unit/risk-detail-abnormal-account-layout.test.js +node tests/unit/preliminary-check-model-and-detail.test.js +``` + +Expected: + +- FAIL,提示 `RiskDetailSection.vue` 仍包含旧列结构或缺少新字段 + +- [ ] **Step 4: 提交本任务** + +本任务先不提交,等待真实模板改完后与 Task 2 一起提交。 + +## Task 2: 接入异常账户真实 API 与独立分页状态 + +**Files:** + +- Modify: `ruoyi-ui/src/api/ccdi/projectOverview.js` +- Modify: `ruoyi-ui/src/views/ccdiProject/components/detail/RiskDetailSection.vue` +- Create: `ruoyi-ui/tests/unit/risk-detail-abnormal-account-pagination.test.js` + +- [ ] **Step 1: 先写分页/加载相关静态测试** + +创建 `risk-detail-abnormal-account-pagination.test.js`,断言源码已包含: + +- `getOverviewAbnormalAccountPeople` +- `abnormalAccountLoading` +- `abnormalAccountPageNum` +- `abnormalAccountPageSize` +- `abnormalAccountTotal` +- `abnormalAccountList` +- `handleAbnormalAccountPageChange` +- `loadAbnormalAccountPeople` + +- [ ] **Step 2: 跑静态测试确认失败** + +Run: + +```bash +cd ruoyi-ui +node tests/unit/risk-detail-abnormal-account-pagination.test.js +``` + +Expected: + +- FAIL,提示 API 方法或分页状态尚未接入 + +- [ ] **Step 3: 在 API 文件中增加轻量请求封装** + +在 `projectOverview.js` 中新增: + +```js +export function getOverviewAbnormalAccountPeople(params) { + return request({ + url: "/ccdi/project/overview/abnormal-account-people", + method: "get", + params: { + projectId: params.projectId, + pageNum: params.pageNum, + pageSize: params.pageSize + } + }) +} +``` + +- [ ] **Step 4: 在 `RiskDetailSection.vue` 中补齐独立状态与加载函数** + +按员工负面征信区块的模式补充: + +1. `data()` 中新增: + - `abnormalAccountLoading` + - `abnormalAccountPageNum` + - `abnormalAccountPageSize` + - `abnormalAccountTotal` + - `abnormalAccountList` +2. `watch.sectionData` 中初始化: + - `projectId` + - 默认页码 1 + - 页大小 5 + - 初始列表为空 +3. 新增: + - `loadAbnormalAccountPeople()` + - `handleAbnormalAccountPageChange(pageInfo)` + +加载规则: + +- 无 `projectId` 时直接清空列表并结束 +- 请求失败时仅清空异常账户区块并提示 `加载异常账户人员信息失败` +- 不影响涉疑交易和员工负面征信区块 + +- [ ] **Step 5: 重新运行静态测试** + +Run: + +```bash +cd ruoyi-ui +node tests/unit/risk-detail-abnormal-account-pagination.test.js +``` + +Expected: + +- PASS + +- [ ] **Step 6: 提交本任务** + +```bash +git add ruoyi-ui/src/api/ccdi/projectOverview.js \ + ruoyi-ui/src/views/ccdiProject/components/detail/RiskDetailSection.vue \ + ruoyi-ui/tests/unit/risk-detail-abnormal-account-pagination.test.js +git commit -m "补充异常账户人员前端查询状态" +``` + +## Task 3: 把异常账户表格从占位列改成真实 6 列 + +**Files:** + +- Modify: `ruoyi-ui/src/views/ccdiProject/components/detail/RiskDetailSection.vue` +- Modify: `ruoyi-ui/src/views/ccdiProject/components/detail/preliminaryCheck.mock.js` +- Create: `ruoyi-ui/tests/unit/risk-detail-abnormal-account-layout.test.js` +- Modify: `ruoyi-ui/tests/unit/preliminary-check-model-and-detail.test.js` + +- [ ] **Step 1: 修改异常账户表格模板** + +把旧占位表格: + +- `账户号` +- `账户人姓名` +- `开户银行` +- `异常发生时间` +- `状态` +- `操作` + +改为真实 6 列: + +- `账号` +- `开户人` +- `银行` +- `异常类型` +- `异常发生时间` +- `状态` + +并删除“操作 / 查看详情”列。 + +- [ ] **Step 2: 补独立空态与分页组件** + +将异常账户区块从纯 `el-table` 升级为与员工负面征信一致的结构: + +- `v-loading="abnormalAccountLoading"` +- `:data="abnormalAccountList"` +- `` +- 独立 `pagination` + +- [ ] **Step 3: 对齐 mock 字段命名** + +在 `preliminaryCheck.mock.js` 中把异常账户 mock 数据对齐成: + +- `accountNo` +- `accountName` +- `bankName` +- `abnormalType` +- `abnormalTime` +- `status` + +说明: + +- 这里不要求 mock 走真实接口 +- 只是避免静态数据结构继续停留在旧字段名 + +- [ ] **Step 4: 运行静态布局测试** + +Run: + +```bash +cd ruoyi-ui +node tests/unit/risk-detail-abnormal-account-layout.test.js +node tests/unit/preliminary-check-model-and-detail.test.js +``` + +Expected: + +- PASS + +- [ ] **Step 5: 提交本任务** + +```bash +git add ruoyi-ui/src/views/ccdiProject/components/detail/RiskDetailSection.vue \ + ruoyi-ui/src/views/ccdiProject/components/detail/preliminaryCheck.mock.js \ + ruoyi-ui/tests/unit/risk-detail-abnormal-account-layout.test.js \ + ruoyi-ui/tests/unit/preliminary-check-model-and-detail.test.js +git commit -m "调整异常账户人员信息前端展示列" +``` + +## Task 4: 做前端联调与构建回归 + +**Files:** + +- Modify: `ruoyi-ui/src/views/ccdiProject/components/detail/RiskDetailSection.vue` +- Modify: `docs/reports/implementation/2026-03-31-project-detail-risk-details-abnormal-account-frontend-implementation.md` + +- [ ] **Step 1: 本地执行静态单测** + +Run: + +```bash +cd ruoyi-ui +node tests/unit/risk-detail-abnormal-account-layout.test.js +node tests/unit/risk-detail-abnormal-account-pagination.test.js +node tests/unit/preliminary-check-model-and-detail.test.js +node tests/unit/risk-detail-employee-credit-negative-layout.test.js +``` + +Expected: + +- PASS + +- [ ] **Step 2: 跑前端生产构建** + +Run: + +```bash +cd ruoyi-ui +npm run build:prod +``` + +Expected: + +- PASS + +- [ ] **Step 3: 如需手工联调,启动前端并在验证后关闭** + +Run: + +```bash +cd ruoyi-ui +npm run dev +``` + +至少验证: + +1. `异常账户人员信息` 区块只显示 6 个目标字段 +2. 翻页只刷新异常账户区块 +3. 统一导出按钮仍请求 `ccdi/project/overview/risk-details/export` +4. 异常账户查询失败时只影响当前区块 + +验证结束后必须关闭 `npm run dev` 进程。 + +- [ ] **Step 4: 编写前端实施记录** + +在实施记录中写清: + +- 新增 API 方法 +- `RiskDetailSection.vue` 的状态与模板调整 +- mock 字段对齐 +- 静态单测与构建结果 +- 如有手工联调,记录启动与关闭前端进程情况 + +- [ ] **Step 5: 提交本任务** + +```bash +git add docs/reports/implementation/2026-03-31-project-detail-risk-details-abnormal-account-frontend-implementation.md +git commit -m "记录异常账户人员信息前端实施" +``` + +## Final Verification + +- [ ] 页面与导出统一使用 `账号 / 开户人 / 银行 / 异常类型 / 异常发生时间 / 状态` +- [ ] 页面已移除旧占位“查看详情”列 +- [ ] 异常账户分页状态与员工负面征信分页状态互不干扰 +- [ ] 构建命令 `npm run build:prod` 通过 +- [ ] 如启动了前端进程,验证结束后已手动关闭 diff --git a/docs/plans/frontend/2026-04-17-base-staff-asset-helper-remove-frontend-implementation.md b/docs/plans/frontend/2026-04-17-base-staff-asset-helper-remove-frontend-implementation.md new file mode 100644 index 00000000..e924668b --- /dev/null +++ b/docs/plans/frontend/2026-04-17-base-staff-asset-helper-remove-frontend-implementation.md @@ -0,0 +1,33 @@ +# 员工信息页资产提示文案移除前端实施计划 + +> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 移除员工信息页资产信息区域中“员工信息页仅维护员工本人资产”“资产持有人默认为当前员工本人,无需额外填写”的备注展示。 + +**Architecture:** 本次仅调整员工信息维护页模板展示,不改动资产表单字段、默认值、提交参数或后端接口。实现上直接删除备注 DOM 和对应样式,保持页面其余交互不变。 + +**Tech Stack:** Vue 2, Element UI, JavaScript, Markdown + +--- + +## 文件结构与职责 + +- `ruoyi-ui/src/views/ccdiBaseStaff/index.vue` + 删除资产信息区域的备注文案和无用样式。 + +## 实施步骤 + +- [ ] 删除资产信息区备注展示块 +- [ ] 删除 `assets-helper` 对应样式,避免残留无用 CSS +- [ ] 运行前端构建确认页面模板调整未引入语法错误 + +## 验证 + +```bash +source ~/.nvm/nvm.sh && nvm use 14.21.3 >/dev/null && npm run build:prod +``` + +## 完成标准 + +- 员工信息页不再展示上述两条资产备注 +- 资产新增、编辑、提交逻辑保持不变 diff --git a/docs/plans/frontend/2026-04-17-base-staff-party-member-frontend-implementation.md b/docs/plans/frontend/2026-04-17-base-staff-party-member-frontend-implementation.md new file mode 100644 index 00000000..6d04805c --- /dev/null +++ b/docs/plans/frontend/2026-04-17-base-staff-party-member-frontend-implementation.md @@ -0,0 +1,53 @@ +# 员工基础信息新增是否党员字段前端实施计划 + +> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 在员工信息维护页面新增“是否党员”基础字段,并打通列表展示、详情回显、编辑录入与导入失败记录展示。 + +**Architecture:** 保持 `ruoyi-ui/src/views/ccdiBaseStaff/index.vue` 的现有页面结构不变,仅在已有“基本信息”区域与员工列表中插入一个新字段。字段值与后端保持一致,前端统一使用 `0/1` 数值口径,并通过页面内格式化方法展示为“是/否”。 + +**Tech Stack:** Vue 2, Element UI, JavaScript, npm, nvm, Markdown + +--- + +## 文件结构与职责 + +**前端源码** + +- `ruoyi-ui/src/views/ccdiBaseStaff/index.vue` + 新增列表列、编辑表单、详情弹窗、失败记录弹窗和格式化方法。 + +**依赖接口** + +- `ruoyi-ui/src/api/ccdiBaseStaff.js` + 本次接口路径不变,继续复用现有新增/编辑/详情 API,只承接新增字段。 + +## 实施步骤 + +- [ ] 在员工列表中增加“是否党员”列,统一显示“是/否”。 +- [ ] 在新增/编辑弹窗的基本信息区域增加“是否党员”单选项,默认值设为“否”。 +- [ ] 在详情弹窗中增加“是否党员”展示,保证历史员工查看时能回显。 +- [ ] 在导入失败记录弹窗中增加“是否党员”列,便于排查模板数据问题。 +- [ ] 在页面 methods 中新增 `formatPartyMember`,统一处理 `0/1/null` 的展示。 +- [ ] 使用 `nvm` 选择当前机器可用的 Node 版本后执行前端构建验证。 + +## 验证记录 + +- 已尝试执行: + +```bash +source ~/.nvm/nvm.sh && nvm use +source ~/.nvm/nvm.sh && nvm use 14.21.3 && npm run build:prod +``` + +- 当前结果: + - 仓库内未提供 `.nvmrc`,直接执行 `nvm use` 无法自动切到项目版本。 + - 当前机器存在 `v14.21.3`、`v25.9.0` 与 `system(v24.14.0)`,后续前端验证建议优先使用 `v14.21.3`。 + - 已使用 `v14.21.3` 成功执行 `npm run build:prod`,构建通过,仅保留项目原有的包体积告警。 + +## 完成标准 + +- 员工列表、详情、编辑弹窗可见“是否党员” +- 提交新增/编辑时会带上 `partyMember` +- 导入失败记录能展示该字段 +- 已明确前端构建使用 `nvm` 的版本前提与当前环境限制 diff --git a/docs/plans/frontend/2026-04-17-employee-self-asset-only-frontend-implementation.md b/docs/plans/frontend/2026-04-17-employee-self-asset-only-frontend-implementation.md new file mode 100644 index 00000000..e1644808 --- /dev/null +++ b/docs/plans/frontend/2026-04-17-employee-self-asset-only-frontend-implementation.md @@ -0,0 +1,24 @@ +# 员工信息仅展示本人资产前端实施计划 + +## 变更目标 + +- 员工信息页资产区域统一按“本人资产”口径展示 +- 详情弹窗移除“资产实际持有人身份证号”和“归属类型” +- 员工资产编辑表单不再保留亲属资产相关提示与校验 + +## 变更范围 + +- `ruoyi-ui/src/views/ccdiBaseStaff/index.vue` + +## 实施步骤 + +1. 更新资产信息区提示文案,明确员工信息页仅维护员工本人资产 +2. 删除详情弹窗中的“资产实际持有人身份证号”“归属类型”列 +3. 清理表单中 `personId` 的必填与格式校验,新增资产时默认带入当前员工身份证号 +4. 更新员工资产导入弹窗提示文案,明确仅允许导入员工本人资产 + +## 验证要点 + +- 员工详情弹窗仅显示本人资产字段 +- 新增、编辑员工资产时不再出现亲属资产口径提示 +- 员工身份证号变更后,表单内资产仍跟随当前员工身份证号 diff --git a/docs/plans/frontend/2026-04-17-enterprise-base-info-management-frontend-implementation.md b/docs/plans/frontend/2026-04-17-enterprise-base-info-management-frontend-implementation.md new file mode 100644 index 00000000..023d7149 --- /dev/null +++ b/docs/plans/frontend/2026-04-17-enterprise-base-info-management-frontend-implementation.md @@ -0,0 +1,126 @@ +# 实体库管理前端实施计划 + +> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 新增独立的实体库管理前端页面,打通查询、新增、查看、编辑、删除、导入、导入状态轮询与失败记录查看。 + +**Architecture:** 继续沿用员工信息维护的单页大文件实现方式,新建 `ccdiEnterpriseBaseInfo` 页面和独立 API 文件,不改造现有中介页面。选项数据统一从 `ccdiEnum` 拉取,列表、弹窗、导入和失败记录交互整体对齐员工信息维护,避免页面内再维护一套业务语义。 + +**Tech Stack:** Vue 2, Element UI, JavaScript, npm, nvm, Markdown + +--- + +## 文件结构与职责 + +**前端源码** + +- `ruoyi-ui/src/views/ccdiEnterpriseBaseInfo/index.vue` + 实体库管理主页面,负责搜索、表格、编辑弹窗、详情弹窗、导入和失败记录。 +- `ruoyi-ui/src/api/ccdiEnterpriseBaseInfo.js` + 新增实体库管理接口封装。 +- `ruoyi-ui/src/api/ccdiEnum.js` + 补风险等级与企业来源选项请求方法。 + +**依赖参考** + +- `ruoyi-ui/src/views/ccdiBaseStaff/index.vue` + 作为页面结构、导入轮询、失败记录和工具栏交互模板。 +- `ruoyi-ui/src/views/ccdiIntermediary/index.vue` + 参考实体导入失败记录展示字段。 +- `ruoyi-ui/src/api/ccdiBaseStaff.js` + 参考单表管理 API 风格。 + +## 实施任务 + +### Task 1: 搭建实体库管理 API 与选项加载 + +**Files:** + +- Create: `ruoyi-ui/src/api/ccdiEnterpriseBaseInfo.js` +- Modify: `ruoyi-ui/src/api/ccdiEnum.js` + +- [x] 在新 API 文件中定义 `listEnterpriseBaseInfo`、`getEnterpriseBaseInfo`、`addEnterpriseBaseInfo`、`updateEnterpriseBaseInfo`、`delEnterpriseBaseInfo`、`importTemplate`、`importData`、`getImportStatus`、`getImportFailures`。 +- [x] 在 `ccdiEnum.js` 中新增 `getEnterpriseRiskLevelOptions` 与 `getEnterpriseSourceOptions`。 +- [x] 继续复用现有 `getCorpTypeOptions`、`getCorpNatureOptions`、`getCertTypeOptions`、`getDataSourceOptions`。 + +### Task 2: 搭建页面骨架与查询表单 + +**Files:** + +- Create: `ruoyi-ui/src/views/ccdiEnterpriseBaseInfo/index.vue` +- Reference: `ruoyi-ui/src/views/ccdiBaseStaff/index.vue` + +- [x] 初始化页面数据结构,至少包含 `loading`、`enterpriseBaseInfoList`、`queryParams`、`form`、`rules`、`open`、`detailOpen`、`upload`、`showFailureButton`。 +- [x] 查询区按设计文档落 `enterpriseName`、`socialCreditCode`、`enterpriseType`、`enterpriseNature`、`industryClass`、`status`、`riskLevel`、`entSource`。 +- [x] 在 `created` 或首屏初始化流程中并发拉取主体类型、企业性质、证件类型、风险等级、企业来源、数据来源选项。 +- [x] 搜索、重置、分页方法命名保持员工页一致,降低后续维护成本。 + +### Task 3: 完成列表、详情与编辑弹窗 + +**Files:** + +- Modify: `ruoyi-ui/src/views/ccdiEnterpriseBaseInfo/index.vue` + +- [x] 列表区展示企业名称、统一社会信用代码、企业类型、企业性质、行业分类、所属行业、法定代表人、经营状态、风险等级、企业来源、数据来源、创建时间。 +- [x] 通过格式化方法把 `riskLevel`、`entSource`、`dataSource` 展示成中文描述,不直接露出编码。 +- [x] 实现详情弹窗,只读展示与表单同口径字段。 +- [x] 实现新增/编辑弹窗,编辑态禁用统一社会信用代码输入框。 +- [x] 表单中补齐 `status`、`riskLevel`、`entSource`、`dataSource` 四个可维护字段。 +- [x] 提交时保持最短路径,不做额外参数转换层,只在需要时把空字符串标准化。 + +### Task 4: 完成删除、导入与失败记录流程 + +**Files:** + +- Modify: `ruoyi-ui/src/views/ccdiEnterpriseBaseInfo/index.vue` + +- [x] 删除逻辑对齐员工页,支持单条删除和批量删除。 +- [x] 新增导入弹窗配置,上传地址指向 `/ccdi/enterpriseBaseInfo/importData`。 +- [x] 导入完成后缓存最近一次任务号,建议 key 使用 `enterprise_base_info_import_last_task`。 +- [x] 增加导入状态轮询逻辑,状态结束后刷新列表并按结果决定是否显示“查看导入失败记录”按钮。 +- [x] 新增失败记录弹窗,至少展示企业名称、统一社会信用代码、企业类型、企业性质、风险等级、企业来源、失败原因。 +- [x] 提供“清除历史记录”能力,行为与员工页保持一致。 + +### Task 5: 补页面校验和展示细节 + +**Files:** + +- Modify: `ruoyi-ui/src/views/ccdiEnterpriseBaseInfo/index.vue` + +- [x] 为统一社会信用代码增加 18 位格式校验。 +- [x] 为企业名称、经营状态、风险等级、企业来源、数据来源增加必填校验。 +- [x] 新增 `formatRiskLevel`、`formatEnterpriseSource`、`formatDataSource`、`formatStatus` 等展示方法。 +- [x] 详情和列表统一复用格式化方法,避免页面出现双口径文本。 + +### Task 6: 执行前端构建验证 + +**Files:** + +- Modify: `docs/plans/frontend/2026-04-17-enterprise-base-info-management-frontend-implementation.md` + 在执行阶段补实际验证结果。 + +- [x] 使用 `nvm` 切换 Node 版本,优先使用当前仓库已验证可构建的版本。 +- [x] 执行生产构建,确认页面编译通过。 +- [x] 若有构建阻塞,记录真实错误,不引入额外兼容方案。 + +## 验证命令 + +```bash +cd /Users/wkc/Desktop/ccdi/ccdi/ruoyi-ui +source ~/.nvm/nvm.sh && nvm use 14.21.3 +npm run build:prod +``` + +## 执行结果 + +- 实际执行命令:`cd /Users/wkc/Desktop/ccdi/ccdi/ruoyi-ui && source ~/.nvm/nvm.sh && nvm use 14.21.3 && npm run build:prod` +- 构建结果:`BUILD SUCCESS` +- 备注:构建过程中仅有既有包体积告警(asset size / entrypoint size limit),未出现语法或模块解析错误 + +## 完成标准 + +- 前端新增独立“实体库管理”页面与 API 文件 +- 查询、分页、详情、新增、编辑、删除链路可用 +- 风险等级、企业来源、数据来源全部以统一选项口径展示和提交 +- 导入轮询、失败记录、历史任务缓存可用 +- 已使用 `nvm` 切换 Node 版本并完成前端构建验证 diff --git a/docs/plans/frontend/2026-04-17-intermediary-library-refactor-frontend-implementation.md b/docs/plans/frontend/2026-04-17-intermediary-library-refactor-frontend-implementation.md new file mode 100644 index 00000000..8dc8c221 --- /dev/null +++ b/docs/plans/frontend/2026-04-17-intermediary-library-refactor-frontend-implementation.md @@ -0,0 +1,479 @@ +# 中介库主从结构改造前端实施计划 + +> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 将中介库前端页面改造为“中介综合库”,支持三类记录统一列表展示、按记录类型分流编辑,并支持“先新增中介本人,再在详情中维护亲属和关联机构”的交互链路。 + +**Architecture:** 保留 `ruoyi-ui/src/views/ccdiIntermediary/` 作为唯一页面入口,不新增一级菜单和独立平行页面。首页搜索与表格改成统一口径,新增只新增中介本人;中介详情改成维护容器,内部承载中介本人信息、亲属列表、关联机构列表;亲属和机构关系通过独立编辑弹窗维护。 + +**Tech Stack:** Vue 2, Element UI, JavaScript, npm, nvm, Markdown + +--- + +## 文件结构与职责 + +**设计与参考** + +- `docs/design/2026-04-17-intermediary-library-refactor-design.md` + 本次前端实施的设计基线。 + +**前端 API** + +- `ruoyi-ui/src/api/ccdiIntermediary.js` + 继续作为中介综合库 API 文件,补亲属与机构关系子资源接口,并收敛旧机构中介接口。 + +**页面主文件** + +- `ruoyi-ui/src/views/ccdiIntermediary/index.vue` + 负责搜索、统一列表、三类记录分流、新增链路和详情联动。 + +**页面组件** + +- `ruoyi-ui/src/views/ccdiIntermediary/components/SearchForm.vue` + 改为新搜索字段与提示文案。 +- `ruoyi-ui/src/views/ccdiIntermediary/components/DataTable.vue` + 改为固定五列表头和按 `recordType` 分流操作按钮。 +- `ruoyi-ui/src/views/ccdiIntermediary/components/DetailDialog.vue` + 从只读详情改为“中介详情维护容器”。 +- `ruoyi-ui/src/views/ccdiIntermediary/components/EditDialog.vue` + 收敛为中介本人新增 / 编辑弹窗,去掉旧机构中介表单。 +- `ruoyi-ui/src/views/ccdiIntermediary/components/RelativeEditDialog.vue` + 新增亲属编辑弹窗。 +- `ruoyi-ui/src/views/ccdiIntermediary/components/EnterpriseRelationEditDialog.vue` + 新增关联机构关系编辑弹窗。 + +**依赖参考** + +- `ruoyi-ui/src/views/ccdiStaffFmyRelation/index.vue` + 参考亲属类表单字段与关系展示。 +- `ruoyi-ui/src/views/ccdiCustEnterpriseRelation/index.vue` + 参考关系类表单和列表交互。 +- `ruoyi-ui/src/views/ccdiBaseStaff/index.vue` + 参考统一页内搜索、分页与基础弹窗写法。 + +## 实施任务 + +### Task 1: 收敛 API 文件为三类记录统一链路 + +**Files:** + +- Modify: `ruoyi-ui/src/api/ccdiIntermediary.js` + +- [ ] **Step 1: 先梳理旧 API 的调用位置** + +Run: `rg -n "addEntityIntermediary|getEntityIntermediary|updateEntityIntermediary" ruoyi-ui/src/views/ccdiIntermediary -S` +Expected: 明确哪些旧机构中介方法会被移除或替换。 + +- [ ] **Step 2: 保留中介本人接口并调整命名语义** + +保留: + +```js +listIntermediary(query) +getPersonIntermediary(bizId) +addPersonIntermediary(data) +updatePersonIntermediary(data) +``` + +- [ ] **Step 3: 新增亲属接口** + +补充: + +```js +listIntermediaryRelatives(bizId) +getIntermediaryRelative(relativeBizId) +addIntermediaryRelative(bizId, data) +updateIntermediaryRelative(data) +delIntermediaryRelative(relativeBizId) +``` + +- [ ] **Step 4: 新增机构关系接口** + +补充: + +```js +listIntermediaryEnterpriseRelations(bizId) +getIntermediaryEnterpriseRelation(id) +addIntermediaryEnterpriseRelation(bizId, data) +updateIntermediaryEnterpriseRelation(data) +delIntermediaryEnterpriseRelation(id) +``` + +- [ ] **Step 5: 删除前端对旧机构中介 CRUD 的直接依赖** + +Expected: 不再调用旧 `/ccdi/intermediary/entity` 新增 / 修改接口。 + +- [ ] **Step 6: 提交 API 调整** + +```bash +git add ruoyi-ui/src/api/ccdiIntermediary.js +git commit -m "feat: 调整中介综合库前端接口" +``` + +### Task 2: 改造搜索表单和首页查询参数 + +**Files:** + +- Modify: `ruoyi-ui/src/views/ccdiIntermediary/index.vue` +- Modify: `ruoyi-ui/src/views/ccdiIntermediary/components/SearchForm.vue` + +- [ ] **Step 1: 先写页面数据结构变更** + +将 `queryParams` 收敛为: + +```js +{ + pageNum: 1, + pageSize: 10, + name: null, + certificateNo: null, + recordType: null, + relatedIntermediaryKeyword: null +} +``` + +- [ ] **Step 2: 修改搜索表单字段** + +搜索区固定为: + +```vue +名称 +证件号 +记录类型 +关联中介信息 +``` + +- [ ] **Step 3: 为“关联中介信息”补提示文案** + +文案示例: + +```vue +placeholder="请输入关联中介姓名或证件号" +``` + +- [ ] **Step 4: 移除旧“中介类型”搜索** + +Expected: 页面上不再出现“个人 / 机构”并列筛选语义。 + +- [ ] **Step 5: 运行本地 lint 前自查** + +Expected: 查询参数和表单字段命名完全一致,没有遗留旧字段。 + +- [ ] **Step 6: 提交搜索区改造** + +```bash +git add ruoyi-ui/src/views/ccdiIntermediary/index.vue ruoyi-ui/src/views/ccdiIntermediary/components/SearchForm.vue +git commit -m "feat: 调整中介综合库搜索条件" +``` + +### Task 3: 改造统一列表与三类记录操作分流 + +**Files:** + +- Modify: `ruoyi-ui/src/views/ccdiIntermediary/components/DataTable.vue` +- Modify: `ruoyi-ui/src/views/ccdiIntermediary/index.vue` + +- [ ] **Step 1: 先写出列表固定列结构** + +表格列固定为: + +```vue +名称 +证件号 +关联中介姓名 +关联关系 +创建时间 +操作 +``` + +- [ ] **Step 2: 让表格读取统一字段** + +统一使用: + +```js +row.name +row.certificateNo +row.relatedIntermediaryName +row.relationText +row.createTime +``` + +- [ ] **Step 3: 按 `recordType` 渲染操作按钮** + +逻辑示例: + +```js +if (row.recordType === 'INTERMEDIARY') { ... } +if (row.recordType === 'RELATIVE') { ... } +if (row.recordType === 'ENTERPRISE_RELATION') { ... } +``` + +- [ ] **Step 4: 在 `index.vue` 中改造 `handleDetail` / `handleUpdate` / `handleDelete`** + +Expected: 三类记录进入不同弹窗和不同删除接口。 + +- [ ] **Step 5: 删除对旧机构中介详情弹窗的依赖** + +Expected: 首页不再把机构主档当作中介主记录。 + +- [ ] **Step 6: 提交列表分流改造** + +```bash +git add ruoyi-ui/src/views/ccdiIntermediary/components/DataTable.vue ruoyi-ui/src/views/ccdiIntermediary/index.vue +git commit -m "feat: 改造中介综合库统一列表" +``` + +### Task 4: 收敛中介本人编辑弹窗 + +**Files:** + +- Modify: `ruoyi-ui/src/views/ccdiIntermediary/components/EditDialog.vue` +- Modify: `ruoyi-ui/src/views/ccdiIntermediary/index.vue` + +- [ ] **Step 1: 删除旧“个人 / 机构二选一”入口** + +Expected: 新增按钮点击后直接打开中介本人表单,不再先选类型。 + +- [ ] **Step 2: 删除旧机构中介表单片段** + +Expected: `EditDialog.vue` 仅保留中介本人字段。 + +- [ ] **Step 3: 在提交前固定前端口径** + +前端不让用户选择: + +```js +form.personSubType = '本人' +form.relatedNumId = null +``` + +- [ ] **Step 4: 保存成功后自动进入中介详情维护页** + +在 `index.vue` 中串起: + +```js +await addPersonIntermediary(data) +await openDetailByBizId(...) +``` + +- [ ] **Step 5: 自查编辑态** + +Expected: 修改中介本人时仍可正常加载详情和更新,但不允许切换为亲属语义。 + +- [ ] **Step 6: 提交中介本人弹窗改造** + +```bash +git add ruoyi-ui/src/views/ccdiIntermediary/components/EditDialog.vue ruoyi-ui/src/views/ccdiIntermediary/index.vue +git commit -m "feat: 收敛中介本人编辑弹窗" +``` + +### Task 5: 新增亲属编辑弹窗 + +**Files:** + +- Create: `ruoyi-ui/src/views/ccdiIntermediary/components/RelativeEditDialog.vue` +- Modify: `ruoyi-ui/src/views/ccdiIntermediary/index.vue` +- Reference: `ruoyi-ui/src/views/ccdiStaffFmyRelation/index.vue` + +- [ ] **Step 1: 新建亲属弹窗组件骨架** + +最小 props: + +```js +visible +title +form +relationTypeOptions +certTypeOptions +ownerName +``` + +- [ ] **Step 2: 布置亲属字段** + +至少包含: + +```vue +姓名 +证件号 +证件类型 +性别 +手机号码 +联系地址 +亲属关系(personSubType) +备注 +所属中介姓名(只读) +``` + +- [ ] **Step 3: 禁止选择“本人”** + +Expected: 亲属关系选项中过滤掉 `本人`。 + +- [ ] **Step 4: 在 `index.vue` 中接入新增 / 编辑 / 删除亲属流程** + +Expected: 首页点亲属记录可直接编辑;详情页也可新增亲属。 + +- [ ] **Step 5: 保存成功后刷新首页和详情内亲属列表** + +Expected: 无需手工刷新页面。 + +- [ ] **Step 6: 提交亲属弹窗链路** + +```bash +git add ruoyi-ui/src/views/ccdiIntermediary/components/RelativeEditDialog.vue ruoyi-ui/src/views/ccdiIntermediary/index.vue +git commit -m "feat: 新增中介亲属编辑弹窗" +``` + +### Task 6: 新增机构关系编辑弹窗 + +**Files:** + +- Create: `ruoyi-ui/src/views/ccdiIntermediary/components/EnterpriseRelationEditDialog.vue` +- Modify: `ruoyi-ui/src/views/ccdiIntermediary/index.vue` +- Reference: `ruoyi-ui/src/views/ccdiCustEnterpriseRelation/index.vue` + +- [ ] **Step 1: 新建机构关系弹窗组件骨架** + +最小 props: + +```js +visible +title +form +ownerName +``` + +- [ ] **Step 2: 布置机构关系字段** + +固定字段为: + +```vue +统一社会信用代码 +关联角色/职务(relationPersonPost) +备注 +所属中介姓名(只读) +``` + +- [ ] **Step 3: 只做关系维护,不出现机构主档字段** + +Expected: 不出现企业性质、法定代表人等机构主档表单项。 + +- [ ] **Step 4: 在 `index.vue` 中接入新增 / 编辑 / 删除机构关系流程** + +Expected: 首页点机构关系记录可直接编辑;详情页也可新增关系。 + +- [ ] **Step 5: 保存成功后刷新首页和详情内机构关系列表** + +Expected: 新增关系后首页能查到该条记录。 + +- [ ] **Step 6: 提交机构关系弹窗链路** + +```bash +git add ruoyi-ui/src/views/ccdiIntermediary/components/EnterpriseRelationEditDialog.vue ruoyi-ui/src/views/ccdiIntermediary/index.vue +git commit -m "feat: 新增中介机构关系编辑弹窗" +``` + +### Task 7: 将详情弹窗改为中介详情维护容器 + +**Files:** + +- Modify: `ruoyi-ui/src/views/ccdiIntermediary/components/DetailDialog.vue` +- Modify: `ruoyi-ui/src/views/ccdiIntermediary/index.vue` + +- [ ] **Step 1: 先定义详情容器需要的数据结构** + +至少包括: + +```js +personDetail +relativeList +enterpriseRelationList +``` + +- [ ] **Step 2: 将详情弹窗改为三个分区** + +分区固定为: + +```vue +中介基本信息 +亲属信息 +关联机构信息 +``` + +- [ ] **Step 3: 在亲属分区增加“新增亲属 / 编辑 / 删除”** + +Expected: 用户在中介详情中即可维护名下亲属。 + +- [ ] **Step 4: 在机构关系分区增加“新增关联机构 / 编辑 / 删除”** + +Expected: 用户在中介详情中即可维护名下机构关系。 + +- [ ] **Step 5: 让首页新增成功后自动打开该详情容器** + +Expected: 完整符合“先建中介,再在下面维护”的流程。 + +- [ ] **Step 6: 提交详情维护容器改造** + +```bash +git add ruoyi-ui/src/views/ccdiIntermediary/components/DetailDialog.vue ruoyi-ui/src/views/ccdiIntermediary/index.vue +git commit -m "feat: 改造中介详情维护容器" +``` + +### Task 8: 执行前端构建验证 + +**Files:** + +- Modify: `docs/plans/frontend/2026-04-17-intermediary-library-refactor-frontend-implementation.md` + 在执行阶段补充真实验证结果。 + +- [ ] **Step 1: 使用 nvm 切换前端 Node 版本** + +Run: `cd /Users/wkc/Desktop/ccdi/ccdi/ruoyi-ui && source ~/.nvm/nvm.sh && nvm use 14.21.3` +Expected: 输出 `Now using node v14.21.3` + +- [ ] **Step 2: 执行生产构建** + +Run: `cd /Users/wkc/Desktop/ccdi/ccdi/ruoyi-ui && source ~/.nvm/nvm.sh && nvm use 14.21.3 && npm run build:prod` +Expected: 构建成功,无语法错误和模块缺失错误。 + +- [ ] **Step 3: 如需联调,启动前端后手工验证关键链路** + +Run: `cd /Users/wkc/Desktop/ccdi/ccdi/ruoyi-ui && source ~/.nvm/nvm.sh && nvm use 14.21.3 && npm run dev` +Expected: 页面可打开,退出后主动结束进程。 + +- [ ] **Step 4: 记录真实执行结果** + +Expected: 文档中补充实际命令与结果,不写假数据。 + +- [ ] **Step 5: 提交验证结果** + +```bash +git add docs/plans/frontend/2026-04-17-intermediary-library-refactor-frontend-implementation.md +git commit -m "docs: 更新中介库前端实施计划执行结果" +``` + +## 验证命令 + +```bash +cd /Users/wkc/Desktop/ccdi/ccdi/ruoyi-ui +source ~/.nvm/nvm.sh && nvm use 14.21.3 +npm run build:prod +``` + +## 执行结果 + +- 实际执行命令:`cd /Users/wkc/Desktop/ccdi/ccdi/ruoyi-ui && source ~/.nvm/nvm.sh && nvm use 14.21.3 && npm run build:prod` +- Node 版本:`v14.21.3` +- 构建结果:`DONE Build complete. The dist directory is ready to be deployed.` +- 备注:仅存在既有包体积告警(asset size / entrypoint size limit),无语法错误和模块解析错误 +- 补充修复:中介库首页“查看中介亲属”弹窗已改为只读模式,查看态不再允许编辑或提交 +- 补充修复:中介库首页“查看关联机构”弹窗已改为只读模式,查看态不再允许编辑或提交 + +## 完成标准 + +- 首页搜索字段调整为名称、证件号、记录类型、关联中介信息 +- 首页列表固定展示名称、证件号、关联中介姓名、关联关系、创建时间 +- 新增按钮只新增中介本人,不再新增机构中介主记录 +- 首页能同时展示中介本人、亲属、机构关系三类记录 +- 首页点击亲属或机构关系记录可直接进入各自编辑弹窗 +- 中介详情支持维护中介本人、亲属列表、机构关系列表 +- 前端已使用 `nvm` 指定 Node 版本并完成构建验证 diff --git a/docs/plans/frontend/2026-04-20-intermediary-import-frontend-implementation.md b/docs/plans/frontend/2026-04-20-intermediary-import-frontend-implementation.md new file mode 100644 index 00000000..5a50ff08 --- /dev/null +++ b/docs/plans/frontend/2026-04-20-intermediary-import-frontend-implementation.md @@ -0,0 +1,413 @@ +# 中介库导入改造前端实施计划 + +> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. +> **执行约束:** 仓库约定不开 subagent,执行时使用 `superpowers:executing-plans`;前端 Node 版本必须通过 `nvm` 切换到仓库已验证版本。 + +**Goal:** 将中介库前端导入入口改造为“导入中介信息 + 导入中介实体关联关系”两按钮模式,去掉旧的“个人/机构”切换导入,打通异步轮询、失败记录查看、历史任务恢复,并保持现有手工维护链路继续使用 `bizId` 契约。 + +**Architecture:** 保留 `ruoyi-ui/src/views/ccdiIntermediary/` 作为唯一页面入口,不新增平行页面。导入弹窗继续复用现有 `ImportDialog.vue` 外壳,但改为由父页面通过 `scene`/`importType` 驱动,而不是弹窗内部单选切换;`index.vue` 负责管理两类导入任务状态、失败记录和按钮展示,交互整体对齐员工数据导入页面。 + +**Tech Stack:** Vue 2, Element UI, JavaScript, npm, nvm, Markdown + +--- + +## 文件结构与职责 + +**设计与计划基线** + +- `docs/design/2026-04-20-intermediary-import-refactor-design.md` + 已确认的导入边界、字段口径和前端交互基线。 + +**前端源码** + +- Modify: `ruoyi-ui/src/views/ccdiIntermediary/index.vue` + 负责顶部按钮、两类导入弹窗打开逻辑、异步任务状态恢复、失败记录弹窗和表格刷新。 +- Modify: `ruoyi-ui/src/views/ccdiIntermediary/components/ImportDialog.vue` + 从“内部切换 person/entity”改为“父组件传入导入场景”,支持中介信息导入和实体关联关系导入。 +- Modify: `ruoyi-ui/src/api/ccdiIntermediary.js` + 补两类导入的模板下载、上传、状态查询、失败记录 API。 + +**测试** + +- Create: `ruoyi-ui/tests/unit/intermediary-import-toolbar.test.js` +- Create: `ruoyi-ui/tests/unit/intermediary-import-dialog.test.js` +- Create: `ruoyi-ui/tests/unit/intermediary-import-state.test.js` +- Create: `ruoyi-ui/tests/unit/intermediary-import-api.test.js` +- Modify: `ruoyi-ui/tests/unit/intermediary-person-edit-ui.test.js` + +**依赖参考** + +- `ruoyi-ui/src/views/ccdiBaseStaff/index.vue` + 参考顶部导入按钮、轮询、失败记录、历史任务恢复。 +- `ruoyi-ui/src/views/ccdiIntermediary/components/ImportDialog.vue` + 继续作为导入弹窗壳子,不重复造一个全新组件。 +- `ruoyi-ui/tests/unit/employee-asset-import-ui.test.js` + 参考导入类单测结构和断言风格。 + +## 实施任务 + +### Task 1: 改造 API 文件为两类导入链路 + +**Files:** + +- Modify: `ruoyi-ui/src/api/ccdiIntermediary.js` +- Test: `ruoyi-ui/tests/unit/intermediary-import-api.test.js` + +- [ ] **Step 1: 先写失败测试,固定 API 方法名与路径** + +覆盖: + +```js +importPersonTemplate +importPersonData +getPersonImportStatus +getPersonImportFailures +importEnterpriseRelationTemplate +importEnterpriseRelationData +getEnterpriseRelationImportStatus +getEnterpriseRelationImportFailures +``` + +- [ ] **Step 2: 保留中介信息导入 API** + +中介信息导入继续使用: + +```js +/ccdi/intermediary/importPersonTemplate +/ccdi/intermediary/importPersonData +/ccdi/intermediary/importPersonStatus/{taskId} +/ccdi/intermediary/importPersonFailures/{taskId} +``` + +- [ ] **Step 3: 新增实体关联关系导入 API** + +新增: + +```js +/ccdi/intermediary/importEnterpriseRelationTemplate +/ccdi/intermediary/importEnterpriseRelationData +/ccdi/intermediary/importEnterpriseRelationStatus/{taskId} +/ccdi/intermediary/importEnterpriseRelationFailures/{taskId} +``` + +- [ ] **Step 4: 删除前端对旧 `importEntity*` 导入接口的依赖** + +Expected: 页面不再调用旧“机构中介导入”接口。 + +- [ ] **Step 5: 运行 API 测试** + +Run: + +```bash +cd /Users/wkc/Desktop/ccdi/ccdi/ruoyi-ui +node tests/unit/intermediary-import-api.test.js +``` + +Expected: PASS,方法名和 URL 与设计文档一致。 + +- [ ] **Step 6: 提交 API 调整** + +```bash +git add ruoyi-ui/src/api/ccdiIntermediary.js ruoyi-ui/tests/unit/intermediary-import-api.test.js +git commit -m "调整中介导入前端接口" +``` + +### Task 2: 将导入弹窗改为场景驱动 + +**Files:** + +- Modify: `ruoyi-ui/src/views/ccdiIntermediary/components/ImportDialog.vue` +- Test: `ruoyi-ui/tests/unit/intermediary-import-dialog.test.js` + +- [ ] **Step 1: 先写弹窗行为测试** + +覆盖: + +```js +// 1. 不再渲染“个人中介/机构中介”单选 +// 2. scene=person 时调用中介信息导入地址 +// 3. scene=enterpriseRelation 时调用实体关联关系导入地址 +// 4. 模板文案随场景切换 +``` + +- [ ] **Step 2: 把内部单选切换改成父组件传参** + +新增 props: + +```js +scene: { + type: String, + default: 'person' +} +``` + +Expected: 弹窗自身不再维护 `formData.importType`。 + +- [ ] **Step 3: 根据 `scene` 计算上传地址和模板地址** + +```js +scene === 'person' +scene === 'enterpriseRelation' +``` + +- [ ] **Step 4: 按场景返回不同提示文案** + +中介信息导入提示需要包含: + +```text +personSubType 为字典下拉; +本人行 relatedNumId 为空; +亲属行 relatedNumId 填关联中介本人证件号码。 +``` + +实体关联关系导入提示需要包含: + +```text +只导入中介与机构关系; +统一社会信用代码必须已存在于系统机构表。 +``` + +- [ ] **Step 5: 运行弹窗测试** + +Run: + +```bash +cd /Users/wkc/Desktop/ccdi/ccdi/ruoyi-ui +node tests/unit/intermediary-import-dialog.test.js +``` + +Expected: PASS,弹窗只根据外部场景工作。 + +- [ ] **Step 6: 提交弹窗改造** + +```bash +git add ruoyi-ui/src/views/ccdiIntermediary/components/ImportDialog.vue ruoyi-ui/tests/unit/intermediary-import-dialog.test.js +git commit -m "改造中介导入弹窗场景切换" +``` + +### Task 3: 改造页面顶部按钮与任务状态 + +**Files:** + +- Modify: `ruoyi-ui/src/views/ccdiIntermediary/index.vue` +- Test: `ruoyi-ui/tests/unit/intermediary-import-toolbar.test.js` +- Test: `ruoyi-ui/tests/unit/intermediary-import-state.test.js` + +- [ ] **Step 1: 先写按钮与状态测试** + +覆盖: + +```js +// 1. 顶部有两个导入按钮 +// 2. 按钮文案正确 +// 3. 两类失败记录按钮独立显示 +// 4. localStorage 使用两套独立 key +``` + +- [ ] **Step 2: 在工具栏增加两个导入按钮** + +按钮文案固定为: + +```vue +导入中介信息 +导入中介实体关联关系 +``` + +- [ ] **Step 3: 在页面中维护两套独立任务状态** + +建议状态结构: + +```js +personImportTask +enterpriseRelationImportTask +``` + +以及两套本地缓存 key: + +```js +ccdi_intermediary_person_import_task +ccdi_intermediary_enterprise_relation_import_task +``` + +- [ ] **Step 4: 保持页面原有手工维护链路不变** + +Expected: 打开详情、新增亲属、查询关系列表等既有逻辑继续走 `bizId`,不因导入改造改变页面其它交互。 + +- [ ] **Step 5: 运行按钮与状态测试** + +Run: + +```bash +cd /Users/wkc/Desktop/ccdi/ccdi/ruoyi-ui +node tests/unit/intermediary-import-toolbar.test.js +node tests/unit/intermediary-import-state.test.js +``` + +Expected: PASS,按钮与任务缓存隔离正确。 + +- [ ] **Step 6: 提交页面状态改造** + +```bash +git add ruoyi-ui/src/views/ccdiIntermediary/index.vue ruoyi-ui/tests/unit/intermediary-import-toolbar.test.js ruoyi-ui/tests/unit/intermediary-import-state.test.js +git commit -m "调整中介导入页面入口状态" +``` + +### Task 4: 接入失败记录弹窗与完成态刷新 + +**Files:** + +- Modify: `ruoyi-ui/src/views/ccdiIntermediary/index.vue` +- Modify: `ruoyi-ui/src/views/ccdiIntermediary/components/ImportDialog.vue` +- Modify: `ruoyi-ui/tests/unit/intermediary-person-edit-ui.test.js` + +- [ ] **Step 1: 先写失败记录行为断言** + +在现有中介页面测试里补充: + +```js +// 1. 导入完成且 failureCount > 0 时显示对应失败记录按钮 +// 2. 查看失败记录时调用对应接口 +// 3. 清除历史记录后按钮消失 +``` + +- [ ] **Step 2: 在 `index.vue` 中落两套失败记录弹窗状态** + +建议状态: + +```js +personFailureDialogVisible +enterpriseRelationFailureDialogVisible +personFailureList +enterpriseRelationFailureList +``` + +- [ ] **Step 3: 对齐员工导入的完成态逻辑** + +Expected: + +```js +导入完成 -> 刷新列表 -> 刷新当前详情(如有) -> 更新失败按钮状态 +``` + +- [ ] **Step 4: 对齐员工导入的历史记录恢复逻辑** + +页面刷新后如果任务仍在缓存中: + +```js +恢复失败记录按钮 +恢复上次导入摘要 +必要时继续轮询 +``` + +- [ ] **Step 5: 运行页面行为测试** + +Run: + +```bash +cd /Users/wkc/Desktop/ccdi/ccdi/ruoyi-ui +node tests/unit/intermediary-person-edit-ui.test.js +``` + +Expected: PASS,没有把导入状态逻辑打坏现有详情维护链路。 + +- [ ] **Step 6: 提交失败记录与刷新逻辑** + +```bash +git add ruoyi-ui/src/views/ccdiIntermediary/index.vue ruoyi-ui/src/views/ccdiIntermediary/components/ImportDialog.vue ruoyi-ui/tests/unit/intermediary-person-edit-ui.test.js +git commit -m "补齐中介导入失败记录交互" +``` + +### Task 5: 做前端验证并回写计划结果 + +**Files:** + +- Modify: `docs/plans/frontend/2026-04-20-intermediary-import-frontend-implementation.md` + +- [ ] **Step 1: 使用 nvm 切换到仓库已验证版本** + +Run: + +```bash +cd /Users/wkc/Desktop/ccdi/ccdi/ruoyi-ui +source ~/.nvm/nvm.sh && nvm use 14.21.3 +``` + +Expected: 输出 `Now using node v14.21.3`。 + +- [ ] **Step 2: 运行前端相关单测脚本** + +Run: + +```bash +cd /Users/wkc/Desktop/ccdi/ccdi/ruoyi-ui +node tests/unit/intermediary-import-api.test.js +node tests/unit/intermediary-import-dialog.test.js +node tests/unit/intermediary-import-toolbar.test.js +node tests/unit/intermediary-import-state.test.js +node tests/unit/intermediary-person-edit-ui.test.js +``` + +Expected: 全部 PASS。 + +- [ ] **Step 3: 执行生产构建验证** + +Run: + +```bash +cd /Users/wkc/Desktop/ccdi/ccdi/ruoyi-ui +source ~/.nvm/nvm.sh && nvm use 14.21.3 && npm run build:prod +``` + +Expected: `BUILD SUCCESS`,没有新增语法或模块解析错误。 + +- [ ] **Step 4: 回写执行结果** + +在计划文档末尾补: + +```markdown +- 实际执行的 node / build 命令 +- 各测试脚本 PASS / FAIL 结果 +- 构建结果 +``` + +- [ ] **Step 5: 提交验证结果** + +```bash +git add docs/plans/frontend/2026-04-20-intermediary-import-frontend-implementation.md +git commit -m "补充中介导入前端实施记录" +``` + +## 验证命令 + +```bash +cd /Users/wkc/Desktop/ccdi/ccdi/ruoyi-ui +source ~/.nvm/nvm.sh && nvm use 14.21.3 +node tests/unit/intermediary-import-api.test.js +node tests/unit/intermediary-import-dialog.test.js +node tests/unit/intermediary-import-toolbar.test.js +node tests/unit/intermediary-import-state.test.js +node tests/unit/intermediary-person-edit-ui.test.js +source ~/.nvm/nvm.sh && nvm use 14.21.3 && npm run build:prod +``` + +## 完成标准 + +- 顶部只有“导入中介信息”和“导入中介实体关联关系”两个按钮 +- 导入弹窗不再内部切换“个人/机构”,而是由页面场景驱动 +- 两类导入的模板下载、上传、轮询、失败记录全部独立 +- 现有手工维护链路继续保持 `bizId` 契约 +- 页面支持恢复两类最近一次导入任务状态 +- 导入提示文案已经明确 `personSubType` 字典下拉、`relationType` 废弃、`relatedNumId` 新语义 +- 已使用 `nvm use 14.21.3` 完成前端测试脚本和生产构建验证 + +## 执行结果 + +- Node 命令: + `source ~/.nvm/nvm.sh && cd ruoyi-ui && nvm use 14.21.3` + 结果:PASS(Now using node v14.21.3) +- Node 命令: + `source ~/.nvm/nvm.sh && cd ruoyi-ui && nvm use 14.21.3 >/dev/null && node tests/unit/intermediary-import-api.test.js && node tests/unit/intermediary-import-dialog.test.js && node tests/unit/intermediary-import-toolbar.test.js && node tests/unit/intermediary-import-state.test.js && node tests/unit/intermediary-person-edit-ui.test.js` + 结果:PASS +- Node 命令: + `source ~/.nvm/nvm.sh && cd ruoyi-ui && nvm use 14.21.3 >/dev/null && npm run build:prod` + 结果:PASS(构建成功,仅保留原有 bundle size warning) diff --git a/docs/plans/frontend/2026-04-22-base-staff-asset-single-horizontal-scroll-frontend-implementation.md b/docs/plans/frontend/2026-04-22-base-staff-asset-single-horizontal-scroll-frontend-implementation.md new file mode 100644 index 00000000..f0e00465 --- /dev/null +++ b/docs/plans/frontend/2026-04-22-base-staff-asset-single-horizontal-scroll-frontend-implementation.md @@ -0,0 +1,36 @@ +# 员工信息维护资产表单单横向滚动实施计划 + +> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 修复员工信息维护页“新增员工”弹窗中资产信息表单出现两条横向滚动条的问题,仅保留一条可用横向滚动条。 + +**Architecture:** 本次仅调整 `ccdiBaseStaff` 页面资产编辑表格的容器样式,不改动资产字段、提交参数、接口结构和详情展示逻辑。实现路径为移除外层包裹容器的横向滚动能力,并取消表格根节点的强制最小宽度,让横向滚动统一由 `el-table` 内部负责。 + +**Tech Stack:** Vue 2, Element UI, JavaScript, Markdown + +--- + +## 文件结构与职责 + +- `ruoyi-ui/src/views/ccdiBaseStaff/index.vue` + 调整资产编辑表格容器样式,消除双横向滚动条。 + +## 实施步骤 + +- [x] 定位资产编辑表格双横向滚动条的成因 +- [x] 删除外层滚动容器的横向滚动能力 +- [x] 取消表格根节点的强制最小宽度,保留 `el-table` 内部横向滚动 +- [x] 运行前端构建校验模板与样式改动未引入语法问题 +- [x] 使用浏览器实际打开员工信息维护弹窗,确认资产表单只剩一条横向滚动条 + +## 验证 + +```bash +source ~/.nvm/nvm.sh && nvm use 14.21.3 >/dev/null && cd ruoyi-ui && npm run build:prod +``` + +## 完成标准 + +- 员工信息维护页资产信息编辑表格仅保留一条横向滚动条 +- 资产字段录入、删除和提交交互保持不变 +- 页面未新增额外横向滚动副作用 diff --git a/docs/plans/frontend/2026-04-22-base-staff-dual-sheet-import-frontend-implementation.md b/docs/plans/frontend/2026-04-22-base-staff-dual-sheet-import-frontend-implementation.md new file mode 100644 index 00000000..a66e4fc7 --- /dev/null +++ b/docs/plans/frontend/2026-04-22-base-staff-dual-sheet-import-frontend-implementation.md @@ -0,0 +1,38 @@ +# 员工信息维护双 Sheet 导入前端实施计划 + +## 目标 +- 将员工信息维护页面的两个导入按钮合并为一个。 +- 统一使用双 Sheet 模板上传,并根据后端返回的 `staffTaskId`、`assetTaskId` 分别沿用原有轮询与失败记录能力。 +- 保留员工失败记录与员工资产失败记录两个独立查看入口。 +- 两个失败记录弹窗都需要展示失败来源 Sheet、Excel 行号和失败原因。 + +## 实施内容 +- 页面入口调整 + - 删除“导入资产信息”按钮,仅保留一个“导入”按钮。 + - 删除独立员工资产上传弹窗,保留一个统一上传弹窗。 +- 上传交互调整 + - 去掉“是否更新已经存在的员工数据”复选框。 + - 模板提示调整为双 Sheet 说明,明确支持只填一个或同时填写两个 Sheet。 + - 下载模板文件名统一为“员工信息维护导入模板”。 +- 任务处理调整 + - 上传成功后解析 `staffTaskId`、`assetTaskId`。 + - 有员工任务 ID 时,启动原员工导入轮询。 + - 有资产任务 ID 时,启动原资产导入轮询。 + - 未返回的任务类型不清空对应历史失败记录状态。 +- 失败记录展示调整 + - 员工失败记录弹窗增加 `Sheet`、`Excel行号` 列。 + - 员工资产失败记录弹窗增加 `Sheet`、`Excel行号` 列。 +- API 调整 + - `ruoyi-ui/src/api/ccdiBaseStaff.js` 去掉 `updateSupport` 参数,保持单文件上传定义。 + +## 验证 +- `source ~/.nvm/nvm.sh && nvm use 14.21.3 >/dev/null && cd ruoyi-ui && npm run build:prod` +- 页面联调覆盖: + - 只导员工 Sheet + - 只导资产 Sheet + - 双 Sheet 同时导入 + - 两类失败记录入口分别展示 + +## 影响范围 +- `ruoyi-ui/src/views/ccdiBaseStaff/index.vue` +- `ruoyi-ui/src/api/ccdiBaseStaff.js` diff --git a/docs/plans/frontend/2026-04-22-bidding-dialog-width-and-supplier-sequence-frontend-implementation.md b/docs/plans/frontend/2026-04-22-bidding-dialog-width-and-supplier-sequence-frontend-implementation.md new file mode 100644 index 00000000..b7f454bc --- /dev/null +++ b/docs/plans/frontend/2026-04-22-bidding-dialog-width-and-supplier-sequence-frontend-implementation.md @@ -0,0 +1,24 @@ +# 招投标信息维护弹窗宽度与供应商序号前端实施计划 + +## 保存路径确认 +- 前端实施计划保存到 `docs/plans/frontend/` + +## 目标 +- 新增、编辑弹窗宽度调整为页面宽度的 `80%` +- 供应商明细不再录入排序,改为页面按当前行顺序自动生成序号与提交顺序 + +## 实施范围 +- 文件:`ruoyi-ui/src/views/ccdiPurchaseTransaction/index.vue` + +## 实施步骤 +1. 将新增/编辑共用弹窗宽度从固定像素改为 `80%` +2. 将供应商明细表中的“排序”输入列改为只读“序号”展示列 +3. 调整供应商新增与提交逻辑,提交时按当前行顺序自动补齐 `sortOrder` +4. 清理空白供应商行判断,避免隐藏排序字段后把空白行误判为有效数据 +5. 使用真实页面验证新增/编辑弹窗显示与供应商录入行为 + +## 验证点 +- 新增/编辑弹窗宽度明显放大至页面宽度的 `80%` +- 供应商明细不再出现排序输入框 +- 新增供应商后可正常录入供应商名称、统一信用代码、联系人、联系电话、银行账户 +- 编辑已有数据时供应商顺序按当前表格行顺序提交 diff --git a/docs/plans/frontend/2026-04-22-bidding-import-failure-display-frontend-implementation.md b/docs/plans/frontend/2026-04-22-bidding-import-failure-display-frontend-implementation.md new file mode 100644 index 00000000..0551a2b2 --- /dev/null +++ b/docs/plans/frontend/2026-04-22-bidding-import-failure-display-frontend-implementation.md @@ -0,0 +1,23 @@ +# 2026-04-22 招投标导入失败展示增强前端实施计划 + +## 1. 目标 + +- 调整招投标信息维护页面的“导入失败记录”弹窗 +- 让失败列表直接展示失败来源 `Sheet`、失败行号、失败原因 + +## 2. 涉及范围 + +- `ruoyi-ui/src/views/ccdiPurchaseTransaction/index.vue` + +## 3. 实施步骤 + +1. 在失败记录弹窗表格中新增 `失败Sheet` 列 +2. 在失败记录弹窗表格中新增 `失败行号` 列 +3. 保留现有 `采购事项ID / 项目名称 / 标的物名称 / 失败原因` 上下文信息 +4. 对多行场景直接展示后端返回的合并行号,不在前端重复解析 + +## 4. 验证方式 + +- 使用真实页面上传失败样本 +- 打开“查看导入失败记录”弹窗,核对主信息失败能显示 `招投标主信息 + 行号 + 原因` +- 核对供应商失败能显示 `供应商明细 + 行号 + 原因` diff --git a/docs/plans/frontend/2026-04-22-bidding-info-maintenance-frontend-implementation.md b/docs/plans/frontend/2026-04-22-bidding-info-maintenance-frontend-implementation.md new file mode 100644 index 00000000..f1444e5a --- /dev/null +++ b/docs/plans/frontend/2026-04-22-bidding-info-maintenance-frontend-implementation.md @@ -0,0 +1,33 @@ +# 招投标信息维护前端实施计划 + +## 目标 +- 将信息维护下“采购交易管理”页面改造为“招投标信息维护”。 +- 列表页展示中标供应商和参与供应商数,新增/编辑弹窗支持维护全部供应商明细。 +- 详情弹窗与项目详情采购弹窗统一展示供应商明细表。 +- 导入入口文案和模板命名改为招投标信息维护语义。 + +## 实施内容 +- 页面改造 + - 重写 `ruoyi-ui/src/views/ccdiPurchaseTransaction/index.vue`。 + - 将原单供应商表单替换为可增删行的供应商明细表格。 + - 使用单选方式标记唯一中标供应商,并在提交时写回 `supplierList.isBidWinner`。 + - 列表页新增“参与供应商数”列,保留“中标供应商”摘要列。 +- 详情展示 + - 页面详情弹窗改为供应商明细表展示。 + - 项目专项核查采购详情弹窗同步改为供应商明细表展示。 +- 导入交互 + - 导入弹窗文案更新为“招投标信息导入”。 + - 模板下载文件名与提示改为双 Sheet 模板。 + +## 验证 +- `source ~/.nvm/nvm.sh && nvm use 14.21.3 >/dev/null && cd ruoyi-ui && npm run build:prod` +- `source ~/.nvm/nvm.sh && nvm use 14.21.3 >/dev/null && cd ruoyi-ui && npm run dev -- --port 8080` +- 使用 Playwright 打开真实页面: + - 验证菜单显示“招投标信息维护” + - 验证列表显示“中标供应商”“参与供应商数” + - 验证新增弹窗可添加供应商明细 + - 验证详情弹窗展示供应商明细表 + +## 产出文件 +- `ruoyi-ui/src/views/ccdiPurchaseTransaction/index.vue` +- `ruoyi-ui/src/views/ccdiProject/components/detail/ExtendedPurchaseDetailDialog.vue` diff --git a/docs/plans/frontend/2026-04-22-info-maintenance-remove-export-and-menu-sort-frontend-implementation.md b/docs/plans/frontend/2026-04-22-info-maintenance-remove-export-and-menu-sort-frontend-implementation.md new file mode 100644 index 00000000..c472dada --- /dev/null +++ b/docs/plans/frontend/2026-04-22-info-maintenance-remove-export-and-menu-sort-frontend-implementation.md @@ -0,0 +1,36 @@ +# 信息维护移除导出与菜单排序前端实施计划 + +**Goal:** 移除信息维护相关页面中的导出按钮和前端下载逻辑,确保页面仅保留查询、新增、编辑、删除、导入等当前需要的能力。 + +**Architecture:** 前端只在现有信息维护页面内做删除型改动,直接移除按钮 DOM 与 `handleExport` 方法,不改动查询表单、表格展示、导入弹窗和失败记录查看能力;同时清理对应 API 文件中的导出封装,避免保留无效前端调用入口。 + +**Tech Stack:** Vue 2, Element UI, JavaScript, Markdown + +--- + +## 文件结构与职责 + +- `ruoyi-ui/src/views/ccdi*/index.vue` + 移除账户库、员工关系、客户关系、招聘、调动、采购等页面的导出按钮和下载方法。 +- `ruoyi-ui/src/api/ccdi*.js` + 删除不再使用的导出 API 封装。 + +## 实施步骤 + +- [x] 盘点信息维护中仍显示导出按钮的前端页面 +- [x] 移除 8 个页面的导出按钮与 `handleExport` 方法 +- [x] 清理对应 API 文件中的导出封装 +- [x] 运行前端构建校验模板和脚本改动未引入语法问题 +- [x] 使用 Playwright 在真实浏览器中确认侧边栏顺序与页面按钮状态 + +## 验证 + +```bash +source ~/.nvm/nvm.sh && nvm use 14.21.3 >/dev/null && cd ruoyi-ui && npm run build:prod +``` + +## 完成标准 + +- 信息维护相关页面不再展示“导出”按钮 +- 页面中不再保留导出下载方法 +- 前端导入、失败记录、详情和编辑交互保持正常 diff --git a/docs/plans/frontend/2026-04-22-staff-family-asset-single-horizontal-scroll-frontend-implementation.md b/docs/plans/frontend/2026-04-22-staff-family-asset-single-horizontal-scroll-frontend-implementation.md new file mode 100644 index 00000000..b800b379 --- /dev/null +++ b/docs/plans/frontend/2026-04-22-staff-family-asset-single-horizontal-scroll-frontend-implementation.md @@ -0,0 +1,36 @@ +# 员工亲属资产表单单横向滚动实施计划 + +> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 修复员工亲属关系维护页中亲属资产信息表单出现两条横向滚动条的问题,仅保留一条可用横向滚动条。 + +**Architecture:** 本次仅调整 `ccdiStaffFmyRelation` 页面亲属资产编辑表格的容器样式,不改动亲属资产字段、提交参数、接口结构和详情展示逻辑。实现路径与员工信息维护页保持一致:移除外层包裹容器的横向滚动能力,并取消表格根节点的强制最小宽度,让横向滚动统一由 `el-table` 内部负责。 + +**Tech Stack:** Vue 2, Element UI, JavaScript, Markdown + +--- + +## 文件结构与职责 + +- `ruoyi-ui/src/views/ccdiStaffFmyRelation/index.vue` + 调整亲属资产编辑表格容器样式,消除双横向滚动条。 + +## 实施步骤 + +- [x] 定位亲属资产编辑表格双横向滚动条的成因 +- [x] 删除外层滚动容器的横向滚动能力 +- [x] 取消表格根节点的强制最小宽度,保留 `el-table` 内部横向滚动 +- [x] 运行前端构建校验模板与样式改动未引入语法问题 +- [x] 使用浏览器实际打开员工亲属关系维护弹窗,确认亲属资产表单只剩一条横向滚动条 + +## 验证 + +```bash +source ~/.nvm/nvm.sh && nvm use 14.21.3 >/dev/null && cd ruoyi-ui && npm run build:prod +``` + +## 完成标准 + +- 员工亲属关系维护页亲属资产信息编辑表格仅保留一条横向滚动条 +- 亲属资产字段录入、删除和提交交互保持不变 +- 页面未新增额外横向滚动副作用 diff --git a/docs/plans/frontend/2026-04-22-staff-family-dual-sheet-import-frontend-implementation.md b/docs/plans/frontend/2026-04-22-staff-family-dual-sheet-import-frontend-implementation.md new file mode 100644 index 00000000..9e9c4c6c --- /dev/null +++ b/docs/plans/frontend/2026-04-22-staff-family-dual-sheet-import-frontend-implementation.md @@ -0,0 +1,35 @@ +# 员工亲属关系维护双 Sheet 导入前端实施计划 + +## 目标 +- 将员工亲属关系维护页面顶部两个导入按钮合并为一个。 +- 上传弹窗改为双 Sheet 提示和统一模板下载。 +- 保留原有两套任务轮询与失败记录入口,但失败记录列表统一展示 `Sheet / Excel行号 / 失败原因`。 + +## 实施内容 +- 页面入口调整 + - 删除“导入亲属资产信息”独立按钮。 + - 删除独立资产上传弹窗,仅保留统一导入弹窗。 +- 上传交互调整 + - 导入弹窗提示模板包含 `员工亲属关系信息` 和 `亲属资产信息` 两个 Sheet。 + - 下载模板文件名统一为“员工亲属关系维护导入模板”。 + - 上传成功后解析后端返回的 `relationTaskId`、`assetTaskId`。 +- 状态管理调整 + - 有 `relationTaskId` 时沿用原亲属关系任务轮询与失败记录缓存。 + - 有 `assetTaskId` 时沿用原亲属资产任务轮询与失败记录缓存。 + - 未返回的任务类型不主动清空既有历史失败记录状态。 +- 失败记录展示调整 + - 亲属关系失败记录弹窗新增 `Sheet`、`Excel行号` 列。 + - 亲属资产失败记录弹窗新增 `Sheet`、`Excel行号` 列。 + +## 验证 +- `source ~/.nvm/nvm.sh && nvm use 14.21.3 >/dev/null && cd ruoyi-ui && node tests/unit/staff-family-asset-detail-import-ui.test.js` +- `source ~/.nvm/nvm.sh && nvm use 14.21.3 >/dev/null && cd ruoyi-ui && node tests/unit/staff-family-asset-submit-flow.test.js` +- `source ~/.nvm/nvm.sh && nvm use 14.21.3 >/dev/null && cd ruoyi-ui && node tests/unit/staff-family-asset-maintenance-layout.test.js` +- 页面联调覆盖: + - 只导亲属关系 Sheet + - 只导亲属资产 Sheet + - 双 Sheet 同时导入 + - 两类失败记录列表都显示 `Sheet / Excel行号 / 失败原因` + +## 影响范围 +- `ruoyi-ui/src/views/ccdiStaffFmyRelation/index.vue` diff --git a/docs/plans/frontend/2026-04-22-staff-recruitment-dialog-width-frontend-implementation.md b/docs/plans/frontend/2026-04-22-staff-recruitment-dialog-width-frontend-implementation.md new file mode 100644 index 00000000..14ed78b2 --- /dev/null +++ b/docs/plans/frontend/2026-04-22-staff-recruitment-dialog-width-frontend-implementation.md @@ -0,0 +1,36 @@ +# 招聘信息新增编辑弹窗宽度调整前端实施计划 + +> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 将招聘信息管理页“新增招聘信息”“修改招聘信息”共用弹窗的宽度从固定 `900px` 调整为页面宽度的 `80%`,提升表单内容展示空间。 + +**Architecture:** 本次仅调整 `ccdiStaffRecruitment` 页面新增/编辑弹窗的 `el-dialog` 宽度配置,不新增字段、不改动详情弹窗、不调整提交逻辑和接口交互。 + +**Tech Stack:** Vue 2, Element UI, JavaScript, Markdown + +--- + +## 文件结构与职责 + +- `ruoyi-ui/src/views/ccdiStaffRecruitment/index.vue` + 调整新增/编辑招聘信息弹窗宽度配置。 + +## 实施步骤 + +- [x] 定位招聘信息管理页新增/编辑弹窗的实际模板位置 +- [x] 将新增/编辑弹窗宽度从固定像素改为 `80%` +- [x] 确认详情弹窗与其他弹窗保持原样,不扩大影响范围 +- [x] 运行前端构建校验模板改动未引入语法问题 +- [x] 使用浏览器实际打开新增/编辑弹窗,确认宽度按页面宽度 `80%` 展示 + +## 验证 + +```bash +source ~/.nvm/nvm.sh && nvm use 14.21.3 >/dev/null && cd ruoyi-ui && npm run build:prod +``` + +## 完成标准 + +- 招聘信息管理页新增/编辑弹窗宽度调整为页面宽度的 `80%` +- 招聘信息详情弹窗和页面其他交互保持不变 +- 页面构建成功,且浏览器实测可正常打开新增/编辑弹窗 diff --git a/docs/plans/frontend/2026-04-22-staff-recruitment-work-experience-manual-edit-plan.md b/docs/plans/frontend/2026-04-22-staff-recruitment-work-experience-manual-edit-plan.md new file mode 100644 index 00000000..1abac965 --- /dev/null +++ b/docs/plans/frontend/2026-04-22-staff-recruitment-work-experience-manual-edit-plan.md @@ -0,0 +1,35 @@ +# 招聘信息历史工作经历手动编辑前端实施计划 + +## 文档信息 + +- 保存路径:`docs/plans/frontend/2026-04-22-staff-recruitment-work-experience-manual-edit-plan.md` +- 适用范围:`ruoyi-ui/src/views/ccdiStaffRecruitment/index.vue` +- 需求目标:在招聘信息管理编辑页支持手动新增、删除并提交历史工作经历 + +## 实施范围 + +1. 在招聘信息编辑弹窗中,为社招记录增加“候选人历史工作经历”编辑区域。 +2. 提供手动新增、删除经历行的交互能力。 +3. 提交编辑时将历史工作经历列表一并传给后端。 +4. 在前端提交前补充基础校验,避免空工作单位、空入职年月或错误年月范围直接提交。 + +## 实施步骤 + +1. 在编辑弹窗中新增工作经历表格,仅在“编辑 + 社招”场景展示。 +2. 为每条经历提供工作单位、所属部门、岗位名称、入职年月、离职年月、离职原因、主要工作内容、备注输入项。 +3. 将入职时间、离职时间改为月份选择器,避免手输年月格式。 +4. 增加 `handleAddWorkExperience`、`handleRemoveWorkExperience`、`syncWorkExperienceSortOrder` 等方法维护列表顺序。 +5. 在提交前执行工作经历校验,并按顺序规范化后再调用编辑接口。 + +## 交互约束 + +- 本次仅支持编辑页手动维护,不扩展新增页展示范围。 +- 招聘类型为校招时不展示工作经历编辑区域。 +- 保存时以当前表格内容覆盖后端已有历史工作经历。 + +## 验证要点 + +1. 打开社招编辑弹窗时可看到历史工作经历编辑区。 +2. 可新增、删除多条经历,序号自动重排。 +3. 入职年月、离职年月格式错误或离职早于入职时,页面阻止提交并提示。 +4. 保存成功后再次打开详情/编辑页,工作经历展示与提交内容一致。 diff --git a/docs/plans/frontend/2026-04-23-bidding-supplier-enterprise-detail-frontend-implementation.md b/docs/plans/frontend/2026-04-23-bidding-supplier-enterprise-detail-frontend-implementation.md new file mode 100644 index 00000000..d47cf554 --- /dev/null +++ b/docs/plans/frontend/2026-04-23-bidding-supplier-enterprise-detail-frontend-implementation.md @@ -0,0 +1,479 @@ +# 招投标详情弹窗供应商企业信息查看 Frontend Implementation Plan + +> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 在招投标信息维护详情弹窗的供应商明细中新增企业详情按钮,点击后复用实体库详情接口并以二级弹窗展示企业全部字段;无统一信用代码、查无数据或接口异常时统一提示“暂无企业信息”。 + +**Architecture:** 保持 `ruoyi-ui/src/views/ccdiPurchaseTransaction/index.vue` 作为唯一页面入口,在现有招投标详情弹窗内部补充“操作”列、企业详情请求状态和二级弹窗,不新增后端接口、独立组件或权限显隐逻辑。字段展示直接对齐 `ruoyi-ui/src/views/ccdiEnterpriseBaseInfo/index.vue` 的详情顺序与格式化口径,按钮固定显示,不添加 `v-hasPermi`,所有失败分支统一收敛到“暂无企业信息”。 + +**Tech Stack:** Vue 2, Element UI, RuoYi `request`, `@/api/ccdiEnterpriseBaseInfo`, `@/api/ccdiEnum`, Node source-assert tests, Playwright real-page verification + +--- + +## File Structure + +- Modify: `ruoyi-ui/src/views/ccdiPurchaseTransaction/index.vue` + - 在现有招投标详情弹窗中新增供应商操作列、企业详情请求流程、企业详情弹窗、枚举格式化和关闭重置逻辑。 +- Create: `ruoyi-ui/tests/unit/purchase-transaction-enterprise-detail-ui.test.js` + - 用源码断言锁定按钮、状态字段、错误提示、字段展示结构和“不做权限显隐”的实现契约。 +- Create: `docs/reports/implementation/2026-04-23-bidding-supplier-enterprise-detail-implementation.md` + - 记录实施结果、验证命令、真实页面验证结论与测试进程清理。 + +## Reference Files + +- Reference only: `ruoyi-ui/src/views/ccdiEnterpriseBaseInfo/index.vue` + - 复用企业详情字段顺序与格式化口径。 +- Reference only: `ruoyi-ui/src/api/ccdiEnterpriseBaseInfo.js` + - 复用 `getEnterpriseBaseInfo(socialCreditCode)`。 +- Reference only: `ruoyi-ui/src/api/ccdiEnum.js` + - 复用风险等级、企业来源、数据来源枚举选项加载。 + +### Task 1: 锁定供应商企业详情 UI 契约 + +**Files:** +- Create: `ruoyi-ui/tests/unit/purchase-transaction-enterprise-detail-ui.test.js` +- Modify: `ruoyi-ui/src/views/ccdiPurchaseTransaction/index.vue` + +- [ ] **Step 1: 编写失败中的源码断言测试** + +```js +const assert = require("assert"); +const fs = require("fs"); +const path = require("path"); + +const componentPath = path.resolve( + __dirname, + "../../src/views/ccdiPurchaseTransaction/index.vue" +); +const source = fs.readFileSync(componentPath, "utf8"); + +[ + 'from "@/api/ccdiEnterpriseBaseInfo"', + 'from "@/api/ccdiEnum"', + "enterpriseDetailOpen", + "enterpriseDetailLoading", + "enterpriseDetailData", + "handleSupplierEnterpriseDetail(row)", + "resetEnterpriseDetail()", + "暂无企业信息" +].forEach((token) => { + assert(source.includes(token), `招投标供应商企业详情缺少关键结构: ${token}`); +}); + +[ + "v-hasPermi=\\\"['ccdi:enterpriseBaseInfo:query']\\\"", + "v-hasPermi=\"['ccdi:enterpriseBaseInfo:query']\"", + "ccdi:enterpriseBaseInfo:query" +].forEach((token) => { + assert(!source.includes(token), `本次不应新增实体库权限显隐控制: ${token}`); +}); +``` + +- [ ] **Step 2: 运行测试,确认当前实现失败** + +Run: + +```bash +source ~/.nvm/nvm.sh && nvm use 14.21.3 >/dev/null && node ruoyi-ui/tests/unit/purchase-transaction-enterprise-detail-ui.test.js +``` + +Expected: + +```text +FAIL with "招投标供应商企业详情缺少关键结构" +``` + +- [ ] **Step 3: 在页面脚本中补齐企业详情最小状态与方法骨架** + +在 `ruoyi-ui/src/views/ccdiPurchaseTransaction/index.vue` 先补齐以下最小结构: + +```js +import { getEnterpriseBaseInfo } from "@/api/ccdiEnterpriseBaseInfo"; +import { + getDataSourceOptions, + getEnterpriseRiskLevelOptions, + getEnterpriseSourceOptions +} from "@/api/ccdiEnum"; + +data() { + return { + enterpriseDetailOpen: false, + enterpriseDetailLoading: false, + enterpriseDetailData: {}, + enterpriseRiskLevelOptions: [], + enterpriseSourceOptions: [], + enterpriseDataSourceOptions: [] + }; +}, +methods: { + resetEnterpriseDetail() { + this.enterpriseDetailOpen = false; + this.enterpriseDetailLoading = false; + this.enterpriseDetailData = {}; + }, + async handleSupplierEnterpriseDetail(row) { + const socialCreditCode = row && row.supplierUscc ? row.supplierUscc.trim() : ""; + if (!socialCreditCode) { + this.$message.warning("暂无企业信息"); + return; + } + } +} +``` + +先让组件具备后续接线的状态容器和方法入口,不要在这一步引入额外组件拆分。 + +- [ ] **Step 4: 再跑一次测试,确认基础契约已转绿或仅剩模板断言失败** +- [ ] **Step 4: 再跑一次测试,确认脚本侧契约已转绿** + +Run: + +```bash +source ~/.nvm/nvm.sh && nvm use 14.21.3 >/dev/null && node ruoyi-ui/tests/unit/purchase-transaction-enterprise-detail-ui.test.js +``` + +Expected: + +```text +purchase-transaction-enterprise-detail-ui test passed +``` + +- [ ] **Step 5: 提交第一阶段改动** + +```bash +git add ruoyi-ui/src/views/ccdiPurchaseTransaction/index.vue ruoyi-ui/tests/unit/purchase-transaction-enterprise-detail-ui.test.js +git commit -m "补充招投标供应商企业详情状态骨架" +``` + +### Task 2: 接通供应商企业详情请求流与弹窗展示 + +**Files:** +- Modify: `ruoyi-ui/src/views/ccdiPurchaseTransaction/index.vue` +- Test: `ruoyi-ui/tests/unit/purchase-transaction-enterprise-detail-ui.test.js` +- Reference: `ruoyi-ui/src/views/ccdiEnterpriseBaseInfo/index.vue` + +- [ ] **Step 1: 重新运行断言测试,保持实现前的失败信号** + +- [ ] **Step 1: 扩展源码断言测试,锁定模板结构、字段顺序和无权限显隐口径** + +在 `ruoyi-ui/tests/unit/purchase-transaction-enterprise-detail-ui.test.js` 追加以下断言: + +```js +[ + 'label="操作"', + ">详情", + "企业信息详情", + "统一社会信用代码", + "企业名称", + "企业类型", + "企业性质", + "行业分类", + "所属行业", + "法定代表人", + "风险等级", + "企业来源", + "数据来源", + "股东5" +].forEach((token) => { + assert(source.includes(token), `招投标供应商企业详情模板缺少关键结构: ${token}`); +}); +``` + +- [ ] **Step 2: 运行扩展后的断言测试,保持实现前的失败信号** + +Run: + +```bash +source ~/.nvm/nvm.sh && nvm use 14.21.3 >/dev/null && node ruoyi-ui/tests/unit/purchase-transaction-enterprise-detail-ui.test.js +``` + +Expected: + +```text +FAIL with missing template token such as "企业信息详情" or field labels +``` + +- [ ] **Step 3: 在脚本中补齐企业详情请求与枚举格式化** + +按以下口径实现: + +```js +created() { + this.getList(); + this.restoreImportState(); + this.loadEnterpriseDetailOptions(); +}, +methods: { + loadEnterpriseDetailOptions() { + return Promise.all([ + getEnterpriseRiskLevelOptions(), + getEnterpriseSourceOptions(), + getDataSourceOptions() + ]).then(([riskRes, sourceRes, dataSourceRes]) => { + this.enterpriseRiskLevelOptions = riskRes.data || []; + this.enterpriseSourceOptions = sourceRes.data || []; + this.enterpriseDataSourceOptions = dataSourceRes.data || []; + }); + }, + getEnterpriseOptionLabel(options, value) { + if (!value) return "-"; + const matched = (options || []).find(item => item.value === value); + return matched ? matched.label : value; + }, + formatEnterpriseRiskLevel(value) { + return this.getEnterpriseOptionLabel(this.enterpriseRiskLevelOptions, value); + }, + formatEnterpriseSource(value) { + return this.getEnterpriseOptionLabel(this.enterpriseSourceOptions, value); + }, + formatEnterpriseDataSource(value) { + return this.getEnterpriseOptionLabel(this.enterpriseDataSourceOptions, value); + }, + formatEnterpriseStatus(value) { + return value || "-"; + }, + async handleSupplierEnterpriseDetail(row) { + const socialCreditCode = row && row.supplierUscc ? row.supplierUscc.trim() : ""; + if (!socialCreditCode) { + this.$message.warning("暂无企业信息"); + return; + } + this.enterpriseDetailLoading = true; + try { + const response = await getEnterpriseBaseInfo(socialCreditCode); + if (!response || !response.data) { + this.$message.warning("暂无企业信息"); + return; + } + this.enterpriseDetailData = response.data; + this.enterpriseDetailOpen = true; + } catch (error) { + this.$message.warning("暂无企业信息"); + } finally { + this.enterpriseDetailLoading = false; + } + } +} +``` + +固定遵守这三个口径: + +- 只按 `supplierUscc` 查询 +- 不做按钮权限显隐 +- 403 / 404 / 空数据 / 普通异常全部统一提示“暂无企业信息” + +- [ ] **Step 3: 在详情弹窗供应商明细表中新增操作列,并补齐企业详情二级弹窗** +- [ ] **Step 4: 在详情弹窗供应商明细表中新增操作列,并补齐企业详情二级弹窗** + +模板按以下结构接线: + +```vue + + + + + + + {{ enterpriseDetailData.socialCreditCode || "-" }} + {{ enterpriseDetailData.enterpriseName || "-" }} + {{ formatEnterpriseRiskLevel(enterpriseDetailData.riskLevel) }} + {{ formatEnterpriseSource(enterpriseDetailData.entSource) }} + {{ formatEnterpriseDataSource(enterpriseDetailData.dataSource) }} + + +``` + +字段顺序严格对齐 `ruoyi-ui/src/views/ccdiEnterpriseBaseInfo/index.vue` 现有详情弹窗: + +1. 统一社会信用代码 +2. 企业名称 +3. 企业类型 +4. 企业性质 +5. 行业分类 +6. 所属行业 +7. 成立日期 +8. 注册地址 +9. 法定代表人 +10. 法定代表人证件类型 +11. 法定代表人证件号码 +12. 经营状态 +13. 风险等级 +14. 企业来源 +15. 数据来源 +16. 创建时间 +17. 股东1 +18. 股东2 +19. 股东3 +20. 股东4 +21. 股东5 + +- [ ] **Step 4: 跑源码断言测试,确认按钮、方法、字段和“无权限显隐”口径都已到位** +- [ ] **Step 5: 跑源码断言测试,确认按钮、方法、字段和“无权限显隐”口径都已到位** + +Run: + +```bash +source ~/.nvm/nvm.sh && nvm use 14.21.3 >/dev/null && node ruoyi-ui/tests/unit/purchase-transaction-enterprise-detail-ui.test.js +``` + +Expected: + +```text +purchase-transaction-enterprise-detail-ui test passed +``` + +- [ ] **Step 5: 提交主实现** +- [ ] **Step 6: 提交主实现** + +```bash +git add ruoyi-ui/src/views/ccdiPurchaseTransaction/index.vue ruoyi-ui/tests/unit/purchase-transaction-enterprise-detail-ui.test.js +git commit -m "补充招投标供应商企业详情查看" +``` + +### Task 3: 完成构建验证、真实页面验证与实施记录 + +**Files:** +- Modify: `ruoyi-ui/src/views/ccdiPurchaseTransaction/index.vue` +- Test: `ruoyi-ui/tests/unit/purchase-transaction-enterprise-detail-ui.test.js` +- Create: `docs/reports/implementation/2026-04-23-bidding-supplier-enterprise-detail-implementation.md` + +- [ ] **Step 1: 运行源码断言测试,确认静态契约稳定** + +Run: + +```bash +source ~/.nvm/nvm.sh && nvm use 14.21.3 >/dev/null && node ruoyi-ui/tests/unit/purchase-transaction-enterprise-detail-ui.test.js +``` + +Expected: + +```text +purchase-transaction-enterprise-detail-ui test passed +``` + +- [ ] **Step 2: 运行前端生产构建** + +Run: + +```bash +source ~/.nvm/nvm.sh && nvm use 14.21.3 >/dev/null && cd ruoyi-ui && npm run build:prod +``` + +Expected: + +```text +BUILD SUCCESS +``` + +允许出现既有 bundle size warning,但不能有新的编译错误。 + +- [ ] **Step 3: 启动真实页面并用 Playwright 完成页面验证** + +如果本地后端未运行,先启动后端: + +```bash +sh bin/restart_java_backend.sh +``` + +启动前端: + +```bash +source ~/.nvm/nvm.sh && nvm use 14.21.3 >/dev/null && cd ruoyi-ui && npm run dev -- --port 8080 +``` + +Playwright 打开真实页面: + +```text +http://localhost:8080/maintain/purchaseTransaction +``` + +先准备最小可复现场景数据,避免验证阶段卡住: + +1. 进入 `http://localhost:8080/maintain/baseStaff/enterpriseBaseInfo` 或现有“实体库管理”页面,任选一条现有企业记录,记下它的 `socialCreditCode`,作为“可命中”样本。 + - 如果页面没有现成企业数据,先通过现有“新增实体库”流程补一条样本企业,再继续后续验证。 +2. 进入 `http://localhost:8080/maintain/purchaseTransaction`,新增或编辑一条测试用招投标记录,在供应商明细中准备三行: + - 供应商 A:`supplierUscc` 填上步骤 1 记录下来的实体库统一社会信用代码,用于“可命中”场景。 + - 供应商 B:`supplierUscc` 填 `TESTNOENTERPRISE01`,用于“查无数据”场景。 +3. 保存后进入该条记录的详情弹窗,先验证供应商 A 和供应商 B。 +4. “无统一信用代码”场景不通过新增/编辑表单构造,而是在真实页面打开详情弹窗前,对 `**/ccdi/purchaseTransaction/*` 做一次性 Playwright 路由拦截: + - 先 `route.fetch()` 获取真实详情响应 + - 将返回体中任意一条 `supplierList[*].supplierUscc` 改成空字符串 + - 再 `route.fulfill({ response, json: patchedBody })` + - 重新打开同一条招投标详情弹窗,点击被补空的供应商“详情”按钮,验证提示“暂无企业信息” +5. “接口异常”场景通过 Playwright 对 `**/ccdi/enterpriseBaseInfo/**` 做一次性路由拦截,返回 `500` 或直接 `abort`,仍在真实业务页面中点击供应商“详情”按钮完成验证。 + +必须覆盖以下场景: + +- 存在 `supplierUscc` 且实体库可命中时,点击供应商行“详情”打开企业详情弹窗 +- 企业详情弹窗字段顺序、日期格式、枚举中文标签与实体库详情页一致 +- `supplierUscc` 为空时,点击“详情”提示“暂无企业信息” +- 接口异常或查无数据时,点击“详情”仍提示“暂无企业信息” +- “详情”按钮固定显示,不因为实体库权限加前端显隐 +- 关闭企业详情弹窗后再次查看另一家供应商,不残留上一条详情数据 + +- [ ] **Step 4: 关闭测试过程中启动的进程** + +需要主动关闭: + +- 前端 `npm run dev` 进程 +- 若为本次验证启动了后端,则同步关闭对应后端进程 +- Playwright 浏览器会话 + +- [ ] **Step 5: 编写实施记录** + +新增 `docs/reports/implementation/2026-04-23-bidding-supplier-enterprise-detail-implementation.md`,至少包含以下内容: + +```md +# 招投标详情弹窗供应商企业信息查看实施记录 + +## 本次修改 +- 在招投标详情弹窗供应商明细中新增企业详情按钮 +- 复用实体库详情接口展示企业全部字段 +- 无统一信用代码、查无数据、接口异常统一提示“暂无企业信息” + +## 影响范围 +- ruoyi-ui/src/views/ccdiPurchaseTransaction/index.vue +- ruoyi-ui/tests/unit/purchase-transaction-enterprise-detail-ui.test.js + +## 验证方式 +- Node 源码断言测试 +- npm run build:prod +- Playwright 真实页面验证 + +## 测试进程清理 +- 已关闭前端 dev 进程 +- 已关闭本次启动的后端进程 +- 已关闭浏览器会话 +``` + +- [ ] **Step 6: 提交验证与文档** + +```bash +git add docs/reports/implementation/2026-04-23-bidding-supplier-enterprise-detail-implementation.md +git commit -m "记录招投标供应商企业详情实现结果" +``` + +## Final Verification Checklist + +- [ ] `ruoyi-ui/src/views/ccdiPurchaseTransaction/index.vue` 只按 `supplierUscc` 查询企业详情 +- [ ] 供应商“详情”按钮未新增 `v-hasPermi` +- [ ] 企业详情字段顺序与 `ruoyi-ui/src/views/ccdiEnterpriseBaseInfo/index.vue` 一致 +- [ ] 缺少统一信用代码、查无数据、403/异常均统一提示“暂无企业信息” +- [ ] 关闭企业详情弹窗后会清空 `enterpriseDetailData` +- [ ] Node 断言测试通过 +- [ ] 前端构建通过 +- [ ] Playwright 真实页面验证完成 +- [ ] 测试过程中启动的前后端进程已关闭 diff --git a/docs/plans/frontend/2026-04-23-bidding-supplier-validation-frontend-implementation.md b/docs/plans/frontend/2026-04-23-bidding-supplier-validation-frontend-implementation.md new file mode 100644 index 00000000..19e4c944 --- /dev/null +++ b/docs/plans/frontend/2026-04-23-bidding-supplier-validation-frontend-implementation.md @@ -0,0 +1,22 @@ +# 招投标供应商校验前端实施计划 + +## 目标 +- 将招投标信息维护页面新增、编辑弹窗中的供应商明细校验收敛为: + - 供应商名称必填 + - 统一信用代码必填 +- 移除联系人、联系电话、银行账户,以及供应商名称/统一信用代码的内容校验提示。 + +## 实施内容 +- 调整 `ruoyi-ui/src/views/ccdiPurchaseTransaction/index.vue` 中的 `getSupplierFieldRules` + - `supplierName` 仅保留必填规则。 + - `supplierUscc` 改为仅保留必填规则。 + - `contactPerson`、`contactPhone`、`supplierBankAccount` 不再返回校验规则。 + +## 验证 +- `source ~/.nvm/nvm.sh && nvm use 14.21.3 >/dev/null && cd ruoyi-ui && npm run build:prod` +- 使用 Playwright 打开真实页面 `http://127.0.0.1:62319/maintain/purchaseTransaction` + - 新增弹窗录入 `supplierUscc=ABC`、`contactPhone=123` 后可保存 + - 编辑弹窗录入 `supplierUscc=XYZ`、`contactPhone=abc123` 后可保存 + +## 产出文件 +- `ruoyi-ui/src/views/ccdiPurchaseTransaction/index.vue` diff --git a/docs/plans/frontend/2026-04-23-enterprise-base-info-add-dialog-frontend-implementation.md b/docs/plans/frontend/2026-04-23-enterprise-base-info-add-dialog-frontend-implementation.md new file mode 100644 index 00000000..ed2067ca --- /dev/null +++ b/docs/plans/frontend/2026-04-23-enterprise-base-info-add-dialog-frontend-implementation.md @@ -0,0 +1,41 @@ +# 实体库管理新增弹窗前端实施计划 + +## 文档路径确认 + +- 前端实施计划保存路径:`docs/plans/frontend/` +- 本文档文件名:`2026-04-23-enterprise-base-info-add-dialog-frontend-implementation.md` + +## 需求目标 + +- 实体库管理新增弹窗隐藏“数据来源”字段。 +- 经营状态改为非必填。 +- 编辑弹窗仍保留数据来源展示,但不允许修改。 +- 导入入口沿用现有交互,但模板下载出的字段定义要与后端保持一致。 + +## 实施范围 + +- `ruoyi-ui/src/views/ccdiEnterpriseBaseInfo/index.vue` + +## 实施步骤 + +1. 在表单层按 `isAdd` 区分新增与编辑模式,新增时隐藏“数据来源”表单项。 +2. 去除经营状态前端必填校验。 +3. 编辑态将数据来源改为只读展示,避免用户误改。 +4. 调整数据来源校验逻辑,仅用于保持表单已有值,不提供编辑能力。 +5. 新增提交时从请求载荷中删除 `dataSource` 字段,避免旧表单状态误传。 +6. 保留列表、详情、编辑页中的数据来源展示,避免影响已有查看能力。 + +## 验证方案 + +- 使用 `nvm use` 切换到仓库要求的 Node 版本后启动前端: + `npm run dev` +- 浏览器进入 `实体库管理` 页面,核对: + - 新增弹窗没有“数据来源”字段 + - 经营状态没有必填星号 + - 风险等级、企业来源仍为必填 + - 导入弹窗可正常打开 + +## 风险与注意事项 + +- 当前页面文件存在其他在途改动,本次仅追加最小范围修改,不覆盖无关调整。 +- 新增与编辑共用同一个表单对象,需要通过条件渲染和条件校验避免互相影响。 diff --git a/docs/plans/frontend/2026-04-23-info-maintenance-search-grid-frontend-implementation.md b/docs/plans/frontend/2026-04-23-info-maintenance-search-grid-frontend-implementation.md new file mode 100644 index 00000000..bc480d3c --- /dev/null +++ b/docs/plans/frontend/2026-04-23-info-maintenance-search-grid-frontend-implementation.md @@ -0,0 +1,421 @@ +# 信息维护页面搜索区四列栅格统一 Implementation Plan + +> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 将信息维护同批页面的搜索区统一为每行 4 个字段位的栅格布局,超过 4 个字段自动换行,字段内部控件在所属字段位内铺满。 + +**Architecture:** 本次只改前端查询区模板和最小必要样式,不新增公共搜索组件,不改查询逻辑。所有目标页统一使用 `el-row` + `el-col :span="6"` 包裹查询项,最后一行不足 4 项时保留空白字段位,不对现有字段做横向拉伸。实施前先写一个临时结构校验脚本,先跑出失败,再逐类页面改造并回归真实页面。 + +**Tech Stack:** Vue 2, Element UI, JavaScript, Node.js, Playwright, Markdown + +--- + +## 文件结构与职责 + +- `ruoyi-ui/src/views/ccdiBaseStaff/index.vue` + 员工信息维护页,5 个查询字段,目标结构为 `4 + 1`。 +- `ruoyi-ui/src/views/ccdiStaffRecruitment/index.vue` + 招聘信息维护页,6 个查询字段,目标结构为 `4 + 2`。 +- `ruoyi-ui/src/views/ccdiStaffTransfer/index.vue` + 员工调动记录页,6 个查询字段,包含日期范围控件,目标结构为 `4 + 2`。 +- `ruoyi-ui/src/views/ccdiStaffFmyRelation/index.vue` + 员工亲属关系维护页,4 个查询字段,目标结构为单行 4 列。 +- `ruoyi-ui/src/views/ccdiStaffEnterpriseRelation/index.vue` + 员工实体关系维护页,4 个查询字段,目标结构为单行 4 列。 +- `ruoyi-ui/src/views/ccdiCreditInfo/index.vue` + 征信维护页,2 个查询字段,目标结构为 `2 + 2 个空白字段位`,不拉伸。 +- `ruoyi-ui/src/views/ccdiEnterpriseBaseInfo/index.vue` + 实体库管理页,8 个查询字段,目标结构为 `4 + 4`。 +- `ruoyi-ui/src/views/ccdiIntermediary/index.vue` + 中介库管理父页,保持查询逻辑与统一操作行,不接管字段渲染。 +- `ruoyi-ui/src/views/ccdiIntermediary/components/SearchForm.vue` + 中介库管理查询子组件,4 个查询字段,改为单行 4 列栅格。 +- `ruoyi-ui/src/views/ccdiAccountInfo/index.vue` + 账户库管理页,9 个查询字段,目标结构为 `4 + 4 + 1`,并清理局部查询区样式覆盖。 +- `ruoyi-ui/src/views/ccdiCustFmyRelation/index.vue` + 信贷客户家庭关系页,4 个查询字段,目标结构为单行 4 列。 +- `ruoyi-ui/src/views/ccdiCustEnterpriseRelation/index.vue` + 信贷客户实体关联页,4 个查询字段,目标结构为单行 4 列。 +- `ruoyi-ui/src/views/ccdiPurchaseTransaction/index.vue` + 招投标信息维护页,4 个查询字段,包含日期范围控件,目标结构为单行 4 列。 +- `output/playwright/info-maintenance-search-grid-check.js` + 临时结构校验脚本,只用于本轮实现前后的失败/通过校验,不提交到 git。 +- `docs/reports/implementation/2026-04-23-info-maintenance-search-grid-implementation.md` + 实施记录,记录改动范围、验证命令、真实页面回归结果与进程清理情况。 + +## 实施原则 + +- 不新增公共搜索组件,不扩大为整页重构。 +- 不改 `queryParams`、`handleQuery`、`resetQuery`、回车查询、权限和业务按钮逻辑。 +- 搜索区字段统一改为四列栅格:每个查询项固定占 `span=6`。 +- 日期范围按 1 个字段位处理,只在所属列内铺满。 +- 清理查询区内联 `style="width: ...px"` 与局部查询区宽度覆盖样式,但不动弹窗和详情表单。 +- 临时测试脚本放在 `output/playwright/`,不提交到 git。 +- 每个阶段完成后做小步提交,提交说明使用中文。 + +## Task 1: 建立搜索区结构校验基线 + +**Files:** +- Create: `output/playwright/info-maintenance-search-grid-check.js` +- Verify: `ruoyi-ui/src/views/ccdiBaseStaff/index.vue` +- Verify: `ruoyi-ui/src/views/ccdiStaffRecruitment/index.vue` +- Verify: `ruoyi-ui/src/views/ccdiStaffTransfer/index.vue` +- Verify: `ruoyi-ui/src/views/ccdiStaffFmyRelation/index.vue` +- Verify: `ruoyi-ui/src/views/ccdiStaffEnterpriseRelation/index.vue` +- Verify: `ruoyi-ui/src/views/ccdiCreditInfo/index.vue` +- Verify: `ruoyi-ui/src/views/ccdiEnterpriseBaseInfo/index.vue` +- Verify: `ruoyi-ui/src/views/ccdiIntermediary/components/SearchForm.vue` +- Verify: `ruoyi-ui/src/views/ccdiAccountInfo/index.vue` +- Verify: `ruoyi-ui/src/views/ccdiCustFmyRelation/index.vue` +- Verify: `ruoyi-ui/src/views/ccdiCustEnterpriseRelation/index.vue` +- Verify: `ruoyi-ui/src/views/ccdiPurchaseTransaction/index.vue` + +- [ ] **Step 1: 编写临时结构校验脚本** + +```js +const pages = [ + { file: "ruoyi-ui/src/views/ccdiBaseStaff/index.vue", expectedFields: 5 }, + { file: "ruoyi-ui/src/views/ccdiStaffRecruitment/index.vue", expectedFields: 6 }, + { file: "ruoyi-ui/src/views/ccdiStaffTransfer/index.vue", expectedFields: 6 }, + { file: "ruoyi-ui/src/views/ccdiStaffFmyRelation/index.vue", expectedFields: 4 }, + { file: "ruoyi-ui/src/views/ccdiStaffEnterpriseRelation/index.vue", expectedFields: 4 }, + { file: "ruoyi-ui/src/views/ccdiCreditInfo/index.vue", expectedFields: 2 }, + { file: "ruoyi-ui/src/views/ccdiEnterpriseBaseInfo/index.vue", expectedFields: 8 }, + { file: "ruoyi-ui/src/views/ccdiIntermediary/components/SearchForm.vue", expectedFields: 4 }, + { file: "ruoyi-ui/src/views/ccdiAccountInfo/index.vue", expectedFields: 9 }, + { file: "ruoyi-ui/src/views/ccdiCustFmyRelation/index.vue", expectedFields: 4 }, + { file: "ruoyi-ui/src/views/ccdiCustEnterpriseRelation/index.vue", expectedFields: 4 }, + { file: "ruoyi-ui/src/views/ccdiPurchaseTransaction/index.vue", expectedFields: 4 } +]; +``` + +脚本至少检查: +- 只解析页面头部的查询表单:优先圈定首个同时包含 `ref="queryForm"` 与 `v-show="showSearch"` 的表单块;若为子组件场景,则只解析 `SearchForm.vue` 顶层查询表单 +- 查询区中是否出现 `el-col :span="6"` +- 查询项数量是否匹配预期 +- 查询区内是否仍保留任何 `style="width: Npx"` 的固定像素宽度 +- 账户库管理页是否仍存在旧的查询区局部宽度覆盖 + +- [ ] **Step 2: 运行脚本,确认当前结构先失败** + +Run: +```bash +source ~/.nvm/nvm.sh && cd ruoyi-ui && nvm use >/dev/null && cd .. && node output/playwright/info-maintenance-search-grid-check.js +``` + +Expected: FAIL,至少提示目标页面尚未统一为 `span=6` 的四列栅格,且仍存在旧的内联宽度。 + +- [ ] **Step 3: 不提交临时脚本** + +说明: +- 脚本只用于本轮校验 +- 保持在 `output/playwright/` 未跟踪状态 + +## Task 2: 改造字段较多的 5 个页面 + +**Files:** +- Modify: `ruoyi-ui/src/views/ccdiBaseStaff/index.vue` +- Modify: `ruoyi-ui/src/views/ccdiStaffRecruitment/index.vue` +- Modify: `ruoyi-ui/src/views/ccdiStaffTransfer/index.vue` +- Modify: `ruoyi-ui/src/views/ccdiEnterpriseBaseInfo/index.vue` +- Modify: `ruoyi-ui/src/views/ccdiAccountInfo/index.vue` + +- [ ] **Step 1: 将员工信息维护页查询区改为四列栅格** + +```vue + + + + + + + + + +``` + +要求: +- 5 个字段落为 `4 + 1` +- 最后一行保留空白字段位,不拉伸现有字段 +- 输入框、下拉框、树选择等控件在所属字段位内铺满,避免保留旧的固定像素宽度 + +- [ ] **Step 2: 将招聘信息维护页查询区改为四列栅格** + +Run after edit: +```bash +rg -n "|style=\"width: 240px\"" ruoyi-ui/src/views/ccdiStaffRecruitment/index.vue +``` + +Expected: +- 查询区出现 `el-col :span="6"` +- 查询区不再保留旧的 `240px` 固定宽度 +- `el-input`、`el-select` 等控件已改为在列内铺满 + +- [ ] **Step 3: 将员工调动记录页查询区改为四列栅格** + +要求: +- `调动日期` 的 `daterange` 只占 1 个字段位 +- 仍保留 `value-format="yyyy-MM-dd"` +- `el-select`、`el-date-picker` 等控件在所属字段位内铺满 + +- [ ] **Step 4: 将实体库管理页查询区改为四列栅格** + +要求: +- 8 个字段严格排成 `4 + 4` +- 清掉查询区的 `240px` 固定宽度 +- 下拉与输入控件在各自字段位内统一铺满 + +- [ ] **Step 5: 将账户库管理页查询区改为四列栅格** + +要求: +- 9 个字段排成 `4 + 4 + 1` +- 清理查询区中所有 `style="width: Npx"` 固定像素宽度,包括 `160px / 180px / 220px` +- 检查并同步清理底部 `.query-form ::v-deep .el-form-item` 这类会干扰四列布局的局部宽度覆盖 +- `el-select` 与输入控件全部在所属字段位内铺满 + +- [ ] **Step 6: 运行临时结构校验脚本,确认这 5 个页面通过** + +Run: +```bash +source ~/.nvm/nvm.sh && cd ruoyi-ui && nvm use >/dev/null && cd .. && node output/playwright/info-maintenance-search-grid-check.js +``` + +Expected: 这 5 个页面不再报栅格和固定宽度相关失败,其余未改页面仍失败。 + +- [ ] **Step 7: 提交第一批页面改动** + +```bash +git add \ + ruoyi-ui/src/views/ccdiBaseStaff/index.vue \ + ruoyi-ui/src/views/ccdiStaffRecruitment/index.vue \ + ruoyi-ui/src/views/ccdiStaffTransfer/index.vue \ + ruoyi-ui/src/views/ccdiEnterpriseBaseInfo/index.vue \ + ruoyi-ui/src/views/ccdiAccountInfo/index.vue +git commit -m "统一信息维护复杂页面搜索区四列栅格" +``` + +## Task 3: 改造其余常规页面 + +**Files:** +- Modify: `ruoyi-ui/src/views/ccdiStaffFmyRelation/index.vue` +- Modify: `ruoyi-ui/src/views/ccdiStaffEnterpriseRelation/index.vue` +- Modify: `ruoyi-ui/src/views/ccdiCreditInfo/index.vue` +- Modify: `ruoyi-ui/src/views/ccdiCustFmyRelation/index.vue` +- Modify: `ruoyi-ui/src/views/ccdiCustEnterpriseRelation/index.vue` +- Modify: `ruoyi-ui/src/views/ccdiPurchaseTransaction/index.vue` + +- [ ] **Step 1: 将员工亲属关系维护页改为单行四列** + +要求: +- 4 个字段刚好占满一行 +- 不改原有按钮行和表格逻辑 +- 输入框和下拉框在字段位内铺满 + +- [ ] **Step 2: 将员工实体关系维护页改为单行四列** + +要求: +- 保持现有 `label-width="120px"` +- 只替换查询区结构 +- 输入框和下拉框在字段位内铺满 + +- [ ] **Step 3: 将征信维护页改为两字段栅格** + +要求: +- 查询区仍只显示 2 个字段 +- 结构上仍使用 4 列栅格 +- 不把 2 个字段拉伸为 50% + 50% +- 两个输入框在各自字段位内铺满 + +- [ ] **Step 4: 将信贷客户家庭关系与信贷客户实体关联页改为单行四列** + +Run after edit: +```bash +rg -n "|style=\"width: 240px\"" \ + ruoyi-ui/src/views/ccdiCustFmyRelation/index.vue \ + ruoyi-ui/src/views/ccdiCustEnterpriseRelation/index.vue +``` + +Expected: +- 查询区出现 `el-col :span="6"` +- 查询区不再保留旧的 `240px` 固定宽度 +- 输入框和下拉框在字段位内铺满 + +- [ ] **Step 5: 将招投标信息维护页改为单行四列** + +要求: +- `申请日期` 的日期范围只占 1 个字段位 +- 仍保留原有 `dateRange` 查询逻辑 +- 输入框与日期范围控件在字段位内铺满 + +- [ ] **Step 6: 提交第二批页面改动** + +```bash +git add \ + ruoyi-ui/src/views/ccdiStaffFmyRelation/index.vue \ + ruoyi-ui/src/views/ccdiStaffEnterpriseRelation/index.vue \ + ruoyi-ui/src/views/ccdiCreditInfo/index.vue \ + ruoyi-ui/src/views/ccdiCustFmyRelation/index.vue \ + ruoyi-ui/src/views/ccdiCustEnterpriseRelation/index.vue \ + ruoyi-ui/src/views/ccdiPurchaseTransaction/index.vue +git commit -m "统一信息维护常规页面搜索区四列栅格" +``` + +## Task 4: 改造中介库管理搜索组件 + +**Files:** +- Modify: `ruoyi-ui/src/views/ccdiIntermediary/components/SearchForm.vue` +- Verify: `ruoyi-ui/src/views/ccdiIntermediary/index.vue` + +- [ ] **Step 1: 将 `SearchForm.vue` 查询区改为单行四列栅格** + +要求: +- 4 个查询项各占一个 `span=6` +- 保留现有 `query` 事件发射,不改父子通信 +- 输入框和下拉框在字段位内铺满 + +- [ ] **Step 2: 清理 `SearchForm.vue` 内的固定宽度** + +Run: +```bash +rg -n "width: 220px|width: 180px|width: 260px" ruoyi-ui/src/views/ccdiIntermediary/components/SearchForm.vue +``` + +Expected: 无结果。 + +- [ ] **Step 3: 确认父页 `ccdiIntermediary/index.vue` 不需要额外接管布局逻辑** + +Run: +```bash +rg -n "/dev/null && cd .. && node output/playwright/info-maintenance-search-grid-check.js +``` + +Expected: PASS,输出“信息维护搜索区四列栅格结构校验通过”。 + +- [ ] **Step 2: 使用 nvm 切换前端 Node 版本** + +Run: +```bash +source ~/.nvm/nvm.sh && cd ruoyi-ui && nvm use +``` + +Expected: 命中项目 `.nvmrc`,不使用系统默认 Node。 + +- [ ] **Step 3: 执行前端构建** + +Run: +```bash +source ~/.nvm/nvm.sh && cd ruoyi-ui && nvm use >/dev/null && npm run build:prod +``` + +Expected: 构建成功,无新增模板语法错误。 + +- [ ] **Step 4: 启动真实前端页面** + +Run: +```bash +source ~/.nvm/nvm.sh && cd ruoyi-ui && nvm use >/dev/null && npm run dev +``` + +Expected: 前端开发服务启动成功,可访问真实业务页面。 + +说明: +- 该命令需在单独终端中运行,或以后台会话方式启动后保留服务 +- 不要在唯一执行终端里前台挂起后继续不下去 + +- [ ] **Step 5: 准备真实业务页验证所需后端** + +要求: +- 优先复用已在运行的本地后端 +- 若当前没有可用后端,使用 `sh bin/restart_java_backend.sh` 启动后端,不要手工执行 `java -jar` +- 若需要本轮新启动后端,同样在单独终端或后台会话中运行,确保后续还能继续执行 Playwright 验证 +- 记录本轮是否由本次验证启动了后端;若是,则验证结束后要一并关闭 +- 使用真实登录页完成登录,不打开 prototype 页面 +- 默认测试账号可使用 `admin / admin123`(若本地环境已变更,则以当前可用测试账号为准) + +- [ ] **Step 6: 使用 @playwright 打开真实业务页面验证代表页** + +至少覆盖以下路由: +- `/maintain/baseStaff` +- `/maintain/enterpriseBaseInfo` +- `/maintain/accountInfo` +- `/maintain/creditInfo` +- `/maintain/intermediary` +- `/maintain/staffTransfer` +- `/maintain/purchaseTransaction` + +验证点: +- 每行固定 4 个字段位 +- 超过 4 个字段自动换行 +- 最后一行不足 4 项时不拉伸已有字段 +- 日期范围仍只占 1 个字段位 +- 搜索、重置仍可正常工作 + +- [ ] **Step 7: 测试结束后关闭验证过程中启动的进程** + +Expected: +- 不保留 `npm run dev` 相关端口占用 +- 如果本轮验证启动了后端,也使用 `sh bin/restart_java_backend.sh stop` 同步关闭对应后端进程 + +## Task 6: 补实施记录 + +**Files:** +- Create: `docs/reports/implementation/2026-04-23-info-maintenance-search-grid-implementation.md` + +- [ ] **Step 1: 记录改动范围** + +至少包括: +- 12 个页面文件 + 1 个中介库搜索组件 +- 四列栅格规则 +- 最后一行不足 4 项时保留空白字段位 +- 查询区固定宽度清理范围 + +- [ ] **Step 2: 记录验证命令与结果** + +至少包括: +- `node output/playwright/info-maintenance-search-grid-check.js` +- `source ~/.nvm/nvm.sh && cd ruoyi-ui && nvm use` +- `npm run build:prod` +- Playwright 真实页面验证 +- 测试结束后的进程清理 + +- [ ] **Step 3: 提交实施记录** + +```bash +git add docs/reports/implementation/2026-04-23-info-maintenance-search-grid-implementation.md +git commit -m "补充信息维护搜索区四列栅格实施记录" +``` + +## 完成标准 + +- 范围内页面全部改为四列栅格查询区。 +- 每个查询项固定占一个 `span=6` 字段位。 +- 日期范围不占双列。 +- 最后一行不足 4 项时保留空白字段位,不拉伸已有字段。 +- 查询区不再保留任何 `style="width: Npx"` 这类旧固定像素宽度。 +- 搜索、重置、回车查询、业务按钮和 `right-toolbar` 行为不变。 +- 构建通过,真实页面验证通过,测试进程已清理。 diff --git a/docs/plans/frontend/2026-04-23-info-maintenance-toolbar-unification-frontend-implementation.md b/docs/plans/frontend/2026-04-23-info-maintenance-toolbar-unification-frontend-implementation.md new file mode 100644 index 00000000..e7f3fe71 --- /dev/null +++ b/docs/plans/frontend/2026-04-23-info-maintenance-toolbar-unification-frontend-implementation.md @@ -0,0 +1,263 @@ +# 信息维护页面头部按钮统一前端实施计划 + +> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 将“信息维护”菜单下所有前端页面的 `搜索 / 重置` 按钮移动到统一操作行,并放在页面原有业务按钮左侧。 + +**Architecture:** 本次只做前端模板层级调整,不引入新的公共头部组件,不改接口、不改权限、不改查询字段。常规页面统一把查询按钮从表单末尾移到 `el-row.mb8` 左侧;“征信维护”保留 `批量上传征信HTML` 语义;“中介库管理”保留 `SearchForm` 查询字段组件,仅把 `搜索 / 重置` 的展示职责收回父页面。 + +**Tech Stack:** Vue 2, Element UI, JavaScript, Playwright, Markdown + +--- + +## 文件结构与职责 + +- `ruoyi-ui/src/views/ccdiBaseStaff/index.vue` + 员工信息维护页,调整查询按钮与 `新增 / 导入 / 失败记录` 的统一排列。 +- `ruoyi-ui/src/views/ccdiStaffRecruitment/index.vue` + 招聘信息维护页,调整查询按钮与 `新增 / 导入 / 失败记录` 的统一排列。 +- `ruoyi-ui/src/views/ccdiStaffTransfer/index.vue` + 员工调动记录页,调整查询按钮与 `新增 / 导入 / 失败记录` 的统一排列。 +- `ruoyi-ui/src/views/ccdiStaffFmyRelation/index.vue` + 员工亲属关系维护页,调整查询按钮与 `新增 / 导入 / 失败记录` 的统一排列。 +- `ruoyi-ui/src/views/ccdiStaffEnterpriseRelation/index.vue` + 员工实体关系维护页,调整查询按钮与 `新增 / 导入 / 失败记录` 的统一排列。 +- `ruoyi-ui/src/views/ccdiEnterpriseBaseInfo/index.vue` + 实体库管理页,调整查询按钮与 `新增 / 删除 / 导入 / 失败记录` 的统一排列。 +- `ruoyi-ui/src/views/ccdiAccountInfo/index.vue` + 账户库管理页,调整查询按钮与 `新增 / 导入` 的统一排列。 +- `ruoyi-ui/src/views/ccdiCustFmyRelation/index.vue` + 信贷客户家庭关系页,调整查询按钮与 `新增 / 导入 / 失败记录` 的统一排列。 +- `ruoyi-ui/src/views/ccdiCustEnterpriseRelation/index.vue` + 信贷客户实体关联页,调整查询按钮与 `新增 / 导入 / 失败记录` 的统一排列。 +- `ruoyi-ui/src/views/ccdiPurchaseTransaction/index.vue` + 招投标信息维护页,调整查询按钮与 `新增 / 导入 / 失败记录` 的统一排列。 +- `ruoyi-ui/src/views/ccdiCreditInfo/index.vue` + 征信维护页,调整查询按钮与 `批量上传征信HTML` 的统一排列。 +- `ruoyi-ui/src/views/ccdiIntermediary/index.vue` + 中介库管理父页,接管 `搜索 / 重置` 的展示位置,并与 `新增 / 两类导入 / 两类失败记录 / right-toolbar` 组成统一操作行。 +- `ruoyi-ui/src/views/ccdiIntermediary/components/SearchForm.vue` + 中介库管理查询表单子组件,移除 `搜索 / 重置` 展示,仅保留查询字段与表单引用。 +- `docs/reports/implementation/2026-04-23-info-maintenance-toolbar-unification-implementation.md` + 实施完成后补实施记录,记录改动范围、验证方式与结果。 + +## 实施原则 + +- 不新增公共组件。 +- 不改任何页面的业务按钮文案、权限点、显隐条件和点击行为。 +- 不改 `handleQuery`、`resetQuery`、导入、上传、失败记录、详情弹窗等业务逻辑。 +- 样式只做最小补充,目标仅为保证统一操作行换行后仍可读、可点。 +- 不新增测试文件到 git;如浏览器验证需要临时产物,放到 `output/playwright/` 并保持未跟踪。 + +## Task 1: 调整 10 个常规页面的统一操作行 + +**Files:** +- Modify: `ruoyi-ui/src/views/ccdiBaseStaff/index.vue` +- Modify: `ruoyi-ui/src/views/ccdiStaffRecruitment/index.vue` +- Modify: `ruoyi-ui/src/views/ccdiStaffTransfer/index.vue` +- Modify: `ruoyi-ui/src/views/ccdiStaffFmyRelation/index.vue` +- Modify: `ruoyi-ui/src/views/ccdiStaffEnterpriseRelation/index.vue` +- Modify: `ruoyi-ui/src/views/ccdiEnterpriseBaseInfo/index.vue` +- Modify: `ruoyi-ui/src/views/ccdiAccountInfo/index.vue` +- Modify: `ruoyi-ui/src/views/ccdiCustFmyRelation/index.vue` +- Modify: `ruoyi-ui/src/views/ccdiCustEnterpriseRelation/index.vue` +- Modify: `ruoyi-ui/src/views/ccdiPurchaseTransaction/index.vue` + +- [ ] **Step 1: 逐页删除查询表单末尾的按钮型 `el-form-item`** + 目标:让查询表单只保留字段,不再在表单内部展示 `搜索 / 重置`。 + +- [ ] **Step 2: 在每个页面的 `el-row.mb8` 左侧插入统一的 `搜索 / 重置` 按钮** + 顺序固定为 `搜索 -> 重置 -> 页面原有业务按钮 -> right-toolbar`。 + +- [ ] **Step 3: 保持原有业务按钮顺序不变** + 例如: + - 实体库管理仍为 `新增 -> 删除 -> 导入 -> 查看导入失败记录` + - 招投标信息维护仍为 `新增 -> 导入 -> 查看导入失败记录` + +- [ ] **Step 4: 如按钮容器出现换行错位,仅补最小样式** + 仅允许补操作行间距、换行或对齐样式,不扩展为整页样式重构。 + +- [ ] **Step 5: 本地自查这 10 个文件中不再出现“查询表单内按钮 + 下方业务按钮行”的双层结构** + Run: + ```bash + rg -n "\\s*$|el-icon-search|el-icon-refresh|right-toolbar" \ + ruoyi-ui/src/views/ccdiBaseStaff/index.vue \ + ruoyi-ui/src/views/ccdiStaffRecruitment/index.vue \ + ruoyi-ui/src/views/ccdiStaffTransfer/index.vue \ + ruoyi-ui/src/views/ccdiStaffFmyRelation/index.vue \ + ruoyi-ui/src/views/ccdiStaffEnterpriseRelation/index.vue \ + ruoyi-ui/src/views/ccdiEnterpriseBaseInfo/index.vue \ + ruoyi-ui/src/views/ccdiAccountInfo/index.vue \ + ruoyi-ui/src/views/ccdiCustFmyRelation/index.vue \ + ruoyi-ui/src/views/ccdiCustEnterpriseRelation/index.vue \ + ruoyi-ui/src/views/ccdiPurchaseTransaction/index.vue + ``` + Expected: `搜索 / 重置` 只出现在统一操作行区域,不再留在查询表单末尾。 + +- [ ] **Step 6: 提交常规页面改动** + ```bash + git add \ + ruoyi-ui/src/views/ccdiBaseStaff/index.vue \ + ruoyi-ui/src/views/ccdiStaffRecruitment/index.vue \ + ruoyi-ui/src/views/ccdiStaffTransfer/index.vue \ + ruoyi-ui/src/views/ccdiStaffFmyRelation/index.vue \ + ruoyi-ui/src/views/ccdiStaffEnterpriseRelation/index.vue \ + ruoyi-ui/src/views/ccdiEnterpriseBaseInfo/index.vue \ + ruoyi-ui/src/views/ccdiAccountInfo/index.vue \ + ruoyi-ui/src/views/ccdiCustFmyRelation/index.vue \ + ruoyi-ui/src/views/ccdiCustEnterpriseRelation/index.vue \ + ruoyi-ui/src/views/ccdiPurchaseTransaction/index.vue + git commit -m "统一信息维护常规页面头部按钮布局" + ``` + +## Task 2: 调整征信维护页面的特殊按钮布局 + +**Files:** +- Modify: `ruoyi-ui/src/views/ccdiCreditInfo/index.vue` + +- [ ] **Step 1: 删除征信维护查询表单末尾的 `搜索 / 重置`** + 保留姓名、身份证号字段与回车查询能力。 + +- [ ] **Step 2: 在操作行最左侧插入 `搜索 / 重置`** + 调整后顺序固定为 `搜索 -> 重置 -> 批量上传征信HTML -> right-toolbar`。 + +- [ ] **Step 3: 保持 `openUploadDialog`、上传弹窗和列表逻辑完全不变** + 本任务只改按钮位置,不改征信上传链路。 + +- [ ] **Step 4: 自查页面仍然保留“批量上传征信HTML”唯一业务按钮文案** + Run: + ```bash + rg -n "批量上传征信HTML|el-icon-search|el-icon-refresh|right-toolbar" ruoyi-ui/src/views/ccdiCreditInfo/index.vue + ``` + Expected: 页面仍包含 `批量上传征信HTML`,且 `搜索 / 重置` 位于统一操作行。 + +- [ ] **Step 5: 提交征信维护页面改动** + ```bash + git add ruoyi-ui/src/views/ccdiCreditInfo/index.vue + git commit -m "统一征信维护页面头部按钮布局" + ``` + +## Task 3: 调整中介库管理父子组件职责 + +**Files:** +- Modify: `ruoyi-ui/src/views/ccdiIntermediary/index.vue` +- Modify: `ruoyi-ui/src/views/ccdiIntermediary/components/SearchForm.vue` + +- [ ] **Step 1: 从 `SearchForm.vue` 中移除 `搜索 / 重置` 的展示代码** + 保留查询字段、`ref="queryForm"` 和 `showSearch` 控制。 + +- [ ] **Step 2: 评估并补齐父页可用的查询 / 重置入口** + 如果当前父页只有 `handleQuery`,则以最短路径补出父页 `resetQuery`,保证它可以清空 `queryParams` 并刷新列表。 + +- [ ] **Step 3: 在 `ccdiIntermediary/index.vue` 的统一操作行最左侧新增 `搜索 / 重置`** + 顺序固定为: + `搜索 -> 重置 -> 新增 -> 导入中介和亲属信息 -> 导入中介实体关联关系 -> 两类失败记录 -> right-toolbar` + +- [ ] **Step 4: 保持两类导入按钮、两类失败记录按钮和详情维护链路不变** + 不改导入任务状态、不改失败记录弹窗、不改详情容器。 + +- [ ] **Step 5: 自查父子组件职责已收敛** + Run: + ```bash + rg -n "el-icon-search|el-icon-refresh|handleQuery|resetQuery" \ + ruoyi-ui/src/views/ccdiIntermediary/index.vue \ + ruoyi-ui/src/views/ccdiIntermediary/components/SearchForm.vue + ``` + Expected: + - `SearchForm.vue` 不再渲染按钮 + - 父页负责展示 `搜索 / 重置` + +- [ ] **Step 6: 提交中介库管理改动** + ```bash + git add \ + ruoyi-ui/src/views/ccdiIntermediary/index.vue \ + ruoyi-ui/src/views/ccdiIntermediary/components/SearchForm.vue + git commit -m "统一中介库管理页面头部按钮布局" + ``` + +## Task 4: 构建校验与真实页面浏览器验证 + +**Files:** +- Verify: `ruoyi-ui/src/views/ccdiBaseStaff/index.vue` +- Verify: `ruoyi-ui/src/views/ccdiCreditInfo/index.vue` +- Verify: `ruoyi-ui/src/views/ccdiIntermediary/index.vue` + +- [ ] **Step 1: 使用 nvm 切换前端 Node 版本** + Run: + ```bash + source ~/.nvm/nvm.sh && cd ruoyi-ui && nvm use + ``` + Expected: 输出项目要求的 Node 版本,不使用系统默认 Node。 + +- [ ] **Step 2: 执行前端构建,确认模板调整未引入语法或编译错误** + Run: + ```bash + source ~/.nvm/nvm.sh && cd ruoyi-ui && nvm use >/dev/null && npm run build:prod + ``` + Expected: 构建成功,无 Vue 模板语法错误。 + +- [ ] **Step 3: 启动真实前端页面进行浏览器验证** + Run: + ```bash + source ~/.nvm/nvm.sh && cd ruoyi-ui && nvm use >/dev/null && npm run dev + ``` + Expected: 本地前端服务可访问。 + +- [ ] **Step 4: 使用 Playwright 打开真实业务页面验证 3 类代表页** + 页面至少覆盖: + - `/maintain/baseStaff` + - `/maintain/creditInfo` + - `/intermediary` + + 验证点: + - `搜索 / 重置` 已与业务按钮在同一行 + - `搜索 / 重置` 位于业务按钮左侧 + - 点击 `搜索`、`重置` 可正常工作 + - 特殊按钮如 `批量上传征信HTML`、两类中介导入按钮仍可点击 + +- [ ] **Step 5: 测试结束后关闭前端进程** + 禁止遗留开发服务占用端口。 + +- [ ] **Step 6: 提交验证通过后的前端实现** + ```bash + git add \ + ruoyi-ui/src/views/ccdiBaseStaff/index.vue \ + ruoyi-ui/src/views/ccdiStaffRecruitment/index.vue \ + ruoyi-ui/src/views/ccdiStaffTransfer/index.vue \ + ruoyi-ui/src/views/ccdiStaffFmyRelation/index.vue \ + ruoyi-ui/src/views/ccdiStaffEnterpriseRelation/index.vue \ + ruoyi-ui/src/views/ccdiCreditInfo/index.vue \ + ruoyi-ui/src/views/ccdiEnterpriseBaseInfo/index.vue \ + ruoyi-ui/src/views/ccdiIntermediary/index.vue \ + ruoyi-ui/src/views/ccdiIntermediary/components/SearchForm.vue \ + ruoyi-ui/src/views/ccdiAccountInfo/index.vue \ + ruoyi-ui/src/views/ccdiCustFmyRelation/index.vue \ + ruoyi-ui/src/views/ccdiCustEnterpriseRelation/index.vue \ + ruoyi-ui/src/views/ccdiPurchaseTransaction/index.vue + git commit -m "统一信息维护页面头部按钮布局" + ``` + +## Task 5: 补实施记录 + +**Files:** +- Create: `docs/reports/implementation/2026-04-23-info-maintenance-toolbar-unification-implementation.md` + +- [ ] **Step 1: 记录本次改动范围** + 列出 13 个前端文件及三类页面口径:常规页、征信维护、中介库管理。 + +- [ ] **Step 2: 记录验证命令与浏览器实测结论** + 包含 `nvm use`、`npm run build:prod`、Playwright 真实页面验证及关闭进程情况。 + +- [ ] **Step 3: 提交实施记录** + ```bash + git add docs/reports/implementation/2026-04-23-info-maintenance-toolbar-unification-implementation.md + git commit -m "补充信息维护页面头部按钮统一实施记录" + ``` + +## 完成标准 + +- 信息维护菜单下全部前端页面都不再把 `搜索 / 重置` 放在查询表单最后一个 `el-form-item` 中。 +- 所有页面都满足 `搜索 -> 重置 -> 页面原有业务按钮 -> right-toolbar` 的统一顺序。 +- 征信维护仍保留 `批量上传征信HTML` 语义与上传链路。 +- 中介库管理仍保留 `SearchForm` 查询字段组件、两类导入按钮、两类失败记录链路。 +- 前端构建成功,真实浏览器验证通过,测试进程已关闭。 diff --git a/docs/plans/frontend/2026-04-23-staff-family-enterprise-relation-frontend-implementation.md b/docs/plans/frontend/2026-04-23-staff-family-enterprise-relation-frontend-implementation.md new file mode 100644 index 00000000..7a79cb72 --- /dev/null +++ b/docs/plans/frontend/2026-04-23-staff-family-enterprise-relation-frontend-implementation.md @@ -0,0 +1,129 @@ +# 员工亲属实体关联维护前端实施计划 + +> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 将现有员工实体关联前端页面切换为员工亲属实体关联页面,支持亲属身份证模糊搜索下拉、自动带出亲属名称和关联员工、亲属维度列表展示、异步导入与失败记录查看,并完成真实页面测试。 + +**Architecture:** 继续沿用现有 `ccdiStaffEnterpriseRelation/index.vue` 单页实现,不新建平行页面。前端只改字段语义、查询区、列表列、编辑弹窗和导入文案;下拉搜索改为请求新的有效亲属接口,页面验证继续复用现有导入轮询与失败记录交互骨架。 + +**Tech Stack:** Vue 2, Element UI, JavaScript, npm, nvm, Playwright, Markdown + +--- + +## 文件结构与职责 + +**前端页面与 API** + +- `ruoyi-ui/src/views/ccdiStaffEnterpriseRelation/index.vue` + 员工亲属实体关联主页面,负责查询区、表格、详情、编辑弹窗、亲属下拉搜索、导入轮询和失败记录弹窗。 +- `ruoyi-ui/src/api/ccdiStaffEnterpriseRelation.js` + 继续封装实体关联接口,新增亲属下拉搜索接口方法。 + +**真实页面测试产物** + +- `output/playwright/` + 保存浏览器测试截图、录屏或导出文件,不纳入 git。 +- `output/spreadsheet/` + 保存从真实页面模板生成的导入测试文件,不纳入 git。 + +**测试记录** + +- `docs/tests/records/2026-04-23-staff-family-enterprise-relation-browser-test-record.md` + 实施阶段记录真实页面测试步骤、样本和结果。 + +## 实施任务 + +### Task 1: 调整前端 API 契约为亲属语义 + +**Files:** + +- Modify: `ruoyi-ui/src/api/ccdiStaffEnterpriseRelation.js` +- Reference: `ruoyi-ui/src/api/ccdiBaseStaff.js` + +- [ ] 保留现有列表、详情、新增、编辑、删除、导入、状态查询、失败记录接口方法。 +- [ ] 新增亲属下拉接口方法,建议命名为 `listFamilyOptions(query)`,请求后端 `/ccdi/staffEnterpriseRelation/familyOptions`。 +- [ ] 调整接口注释,把“员工实体关系”全部改成“员工亲属实体关联”。 +- [ ] 保持导入接口和轮询接口签名不变,避免页面额外重构。 + +### Task 2: 重构查询区、列表和详情展示 + +**Files:** + +- Modify: `ruoyi-ui/src/views/ccdiStaffEnterpriseRelation/index.vue` +- Reference: `docs/superpowers/specs/2026-04-23-staff-family-enterprise-relation-design.md` + +- [ ] 将查询区字段调整为亲属身份证号、亲属名称、关联员工、统一社会信用代码、企业名称、状态。 +- [ ] 将列表列调整为亲属身份证、亲属名称、关联员工、企业名称、关联人在企业的职务、状态、数据来源、创建时间。 +- [ ] 将“关联员工”格式化为 `员工姓名(员工身份证号)`,避免只显示姓名。 +- [ ] 详情弹窗基础信息改为亲属口径,移除旧的员工本人姓名展示。 +- [ ] 所有标题、按钮文案、空提示、通知文案统一切换为“员工亲属实体关联”。 + +### Task 3: 改造新增/编辑弹窗为亲属下拉选择 + +**Files:** + +- Modify: `ruoyi-ui/src/views/ccdiStaffEnterpriseRelation/index.vue` + +- [ ] 将现有 `searchStaff` / `staffOptions` 逻辑替换为亲属下拉逻辑,例如 `searchFamilyOptions` / `familyOptions`。 +- [ ] 下拉项展示为“亲属身份证号 + 亲属名称 / 关联员工姓名”。 +- [ ] 选中亲属后,自动回填并只读展示亲属名称、关联员工。 +- [ ] 编辑态下保持亲属身份证号不可改,统一社会信用代码不可改。 +- [ ] 将表单规则中的身份证号提示改为亲属身份证号提示。 + +### Task 4: 调整导入模板、轮询与失败记录展示 + +**Files:** + +- Modify: `ruoyi-ui/src/views/ccdiStaffEnterpriseRelation/index.vue` + +- [ ] 导入弹窗标题改为“员工亲属实体关联数据导入”。 +- [ ] 模板下载文件名改为亲属实体关联口径。 +- [ ] 导入结果通知和失败提示改为亲属语义。 +- [ ] 将本地缓存 key 从旧语义改成新的独立 key,例如 `staff_family_enterprise_relation_import_last_task`,避免与历史员工语义缓存混淆。 +- [ ] 失败记录表格调整为亲属身份证号、亲属名称、企业名称、统一社会信用代码、失败原因。 +- [ ] 保留现有轮询节奏和失败记录清理按钮,不增加额外交互分支。 + +### Task 5: 补页面级验证与格式化细节 + +**Files:** + +- Modify: `ruoyi-ui/src/views/ccdiStaffEnterpriseRelation/index.vue` + +- [ ] 更新 `reset()` 初始化结构,增加 `relationName`、`staffPersonId`、`staffPersonName` 只读回显字段。 +- [ ] 提交前保持最短路径,不额外引入转换层,只剔除展示态字段或在提交前做最小数据拷贝。 +- [ ] 对失败记录、详情弹窗和表格统一复用同一组字段格式化逻辑,避免页面口径分裂。 +- [ ] 确认页面在桌面端与常见笔记本分辨率下不出现标题换行、按钮挤压和表格列错位。 + +### Task 6: 执行前端构建与真实页面测试 + +**Files:** + +- Create: `docs/tests/records/2026-04-23-staff-family-enterprise-relation-browser-test-record.md` +- Output only: `output/playwright/` +- Output only: `output/spreadsheet/` + +- [ ] 先在 `ruoyi-ui` 目录执行 `source ~/.nvm/nvm.sh && nvm use`,确认 Node 版本符合仓库要求。 +- [ ] 执行 `npm run build:prod`,确认前端构建通过。 +- [ ] 启动真实前端页面后,使用 Playwright 打开实际业务页面 `ccdiStaffEnterpriseRelation`,禁止使用 prototype 页面。 +- [ ] 在真实页面验证查询、新增、编辑、删除、详情、导入入口、失败记录入口。 +- [ ] 导入测试必须先从页面下载当前模板,再基于模板在 `output/spreadsheet/` 生成测试文件。 +- [ ] 覆盖至少三类导入样本:有效亲属成功导入、无效亲属失败、亲属不存在失败。 +- [ ] 记录页面提示、导入状态变化、失败记录弹窗内容与列表状态是否正确。 +- [ ] 测试完成后关闭本轮启动的前后端进程,并确保 `output/` 测试文件不纳入 git。 + +## 验证命令 + +```bash +cd /Users/wkc/Desktop/ccdi/ccdi/ruoyi-ui +source ~/.nvm/nvm.sh && nvm use +npm run build:prod +``` + +## 完成标准 + +- 查询区、列表和详情全部切换为亲属语义 +- 新增弹窗可按亲属身份证模糊搜索有效亲属,并自动带出亲属名称和关联员工 +- 编辑态保持亲属身份证号和统一社会信用代码不可修改 +- 导入弹窗、模板文件名、轮询通知和失败记录全部切换为亲属语义 +- 本地缓存 key 与历史员工语义隔离 +- 已完成 `nvm` 切换、前端构建和 Playwright 真实页面测试 diff --git a/docs/plans/frontend/2026-04-23-staff-recruitment-dual-sheet-import-frontend-implementation.md b/docs/plans/frontend/2026-04-23-staff-recruitment-dual-sheet-import-frontend-implementation.md new file mode 100644 index 00000000..29952fb9 --- /dev/null +++ b/docs/plans/frontend/2026-04-23-staff-recruitment-dual-sheet-import-frontend-implementation.md @@ -0,0 +1,264 @@ +# Staff Recruitment Dual-Sheet Import Frontend Implementation Plan + +> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 将招聘信息管理前端导入交互改为单入口双 Sheet 模式,统一任务轮询与失败弹窗,并在失败列表中展示失败 Sheet、失败行号、失败原因。 + +**Architecture:** 前端只保留一个导入按钮和一个上传弹窗,统一使用 `/ccdi/staffRecruitment/importTemplate` 与 `/importData`。页面本地状态从“按导入类型区分任务”收口为“按唯一任务 ID 轮询”,失败记录统一通过一个弹窗展示,并用 `sheetName`、`sheetRowNum` 区分失败来源。 + +**Tech Stack:** Vue 2, Element UI, axios request wrapper, Node 14.21.3 via nvm, source-inspection unit tests, Playwright browser validation + +--- + +## File Map + +- Modify: `ruoyi-ui/src/views/ccdiStaffRecruitment/index.vue` + - 删除独立“导入工作经历”入口,收口上传弹窗、轮询状态和失败列表 +- Modify: `ruoyi-ui/src/api/ccdiStaffRecruitment.js` + - 去掉独立工作经历导入模板/上传调用,保留统一导入 API +- Create: `ruoyi-ui/tests/unit/staff-recruitment-import-toolbar.test.js` + - 锁定顶部工具栏已收口为单入口 +- Create: `ruoyi-ui/tests/unit/staff-recruitment-import-state.test.js` + - 锁定统一任务状态与轮询逻辑 +- Create: `ruoyi-ui/tests/unit/staff-recruitment-import-failure-dialog.test.js` + - 锁定失败弹窗列定义与 `sheetRowNum` 展示格式 +- Modify: `docs/reports/implementation/2026-04-23-staff-recruitment-dual-sheet-import-implementation.md` + - 追加前端改造与真实页面验证结果 + +### Task 1: 收口工具栏与上传 API + +**Files:** +- Create: `ruoyi-ui/tests/unit/staff-recruitment-import-toolbar.test.js` +- Modify: `ruoyi-ui/src/views/ccdiStaffRecruitment/index.vue` +- Modify: `ruoyi-ui/src/api/ccdiStaffRecruitment.js` + +- [ ] **Step 1: 先写工具栏与 API 契约失败测试** + +```js +[ + "handleImport()", + '"/ccdi/staffRecruitment/importData"', + "招聘信息管理导入模板" +].forEach((token) => { + assert(source.includes(token), `招聘导入入口缺少统一双Sheet能力: ${token}`) +}) + +[ + "handleWorkImport", + "importWorkData", + "workImportTemplate" +].forEach((token) => { + assert(!source.includes(token), `招聘页不应继续保留独立工作经历导入: ${token}`) +}) +``` + +- [ ] **Step 2: 运行测试确认失败** + +Run: `node ruoyi-ui/tests/unit/staff-recruitment-import-toolbar.test.js` + +Expected: FAIL,提示页面仍保留“导入工作经历”按钮或 API 仍存在旧入口 + +- [ ] **Step 3: 最小化修改页面与 API** + +```js +export function importTemplate() { + return request({ + url: "/ccdi/staffRecruitment/importTemplate", + method: "post" + }) +} +``` + +- [ ] **Step 4: 重跑测试** + +Run: `node ruoyi-ui/tests/unit/staff-recruitment-import-toolbar.test.js` + +Expected: PASS,页面只剩一个导入入口,API 只调用统一模板与上传接口 + +- [ ] **Step 5: 提交这一小步** + +```bash +git add \ + ruoyi-ui/src/views/ccdiStaffRecruitment/index.vue \ + ruoyi-ui/src/api/ccdiStaffRecruitment.js \ + ruoyi-ui/tests/unit/staff-recruitment-import-toolbar.test.js +git commit -m "收口招聘双Sheet导入前端入口" +``` + +### Task 2: 收口上传弹窗文案与统一任务状态 + +**Files:** +- Create: `ruoyi-ui/tests/unit/staff-recruitment-import-state.test.js` +- Modify: `ruoyi-ui/src/views/ccdiStaffRecruitment/index.vue` + +- [ ] **Step 1: 先写统一状态失败测试** + +```js +[ + "模板包含“招聘信息”和“历史工作经历”两个 Sheet。", + "this.currentTaskId = taskId", + "this.showFailureButton = false", + "this.startImportStatusPolling(taskId)" +].forEach((token) => { + assert(source.includes(token), `招聘导入状态未统一到单任务: ${token}`) +}) + +[ + "currentImportType", + "upload.importType", + "getImportTypeLabel" +].forEach((token) => { + assert(!source.includes(token), `招聘导入状态不应再按类型拆分: ${token}`) +}) +``` + +- [ ] **Step 2: 运行测试确认失败** + +Run: `node ruoyi-ui/tests/unit/staff-recruitment-import-state.test.js` + +Expected: FAIL,提示页面仍保留类型切换状态 + +- [ ] **Step 3: 最小化实现统一轮询状态** + +```js +this.saveImportTaskToStorage({ + taskId, + status: "PROCESSING", + hasFailures: false +}) +this.currentTaskId = taskId +this.startImportStatusPolling(taskId) +``` + +- [ ] **Step 4: 重跑测试** + +Run: `node ruoyi-ui/tests/unit/staff-recruitment-import-state.test.js` + +Expected: PASS,弹窗文案改为双 Sheet,页面状态只围绕一个任务 ID 轮询 + +- [ ] **Step 5: 提交这一小步** + +```bash +git add \ + ruoyi-ui/src/views/ccdiStaffRecruitment/index.vue \ + ruoyi-ui/tests/unit/staff-recruitment-import-state.test.js +git commit -m "统一招聘双Sheet导入轮询状态" +``` + +### Task 3: 调整统一失败弹窗列定义 + +**Files:** +- Create: `ruoyi-ui/tests/unit/staff-recruitment-import-failure-dialog.test.js` +- Modify: `ruoyi-ui/src/views/ccdiStaffRecruitment/index.vue` + +- [ ] **Step 1: 先写失败弹窗失败测试** + +```js +[ + 'label="失败Sheet"', + 'label="失败行号"', + "scope.row.sheetName", + "scope.row.sheetRowNum", + "失败原因" +].forEach((token) => { + assert(source.includes(token), `招聘失败弹窗缺少双Sheet定位列: ${token}`) +}) +``` + +- [ ] **Step 2: 运行测试确认失败** + +Run: `node ruoyi-ui/tests/unit/staff-recruitment-import-failure-dialog.test.js` + +Expected: FAIL,提示弹窗仍按旧类型列展示 + +- [ ] **Step 3: 实现统一失败表格** + +```vue + + + + +``` + +- [ ] **Step 4: 重跑测试** + +Run: `node ruoyi-ui/tests/unit/staff-recruitment-import-failure-dialog.test.js` + +Expected: PASS,失败弹窗明确展示失败 Sheet、失败行号、失败原因 + +- [ ] **Step 5: 提交这一小步** + +```bash +git add \ + ruoyi-ui/src/views/ccdiStaffRecruitment/index.vue \ + ruoyi-ui/tests/unit/staff-recruitment-import-failure-dialog.test.js +git commit -m "完善招聘双Sheet失败弹窗展示" +``` + +### Task 4: 做前端构建、真实页面验证与实施记录 + +**Files:** +- Modify: `ruoyi-ui/src/views/ccdiStaffRecruitment/index.vue` +- Modify: `ruoyi-ui/src/api/ccdiStaffRecruitment.js` +- Modify: `docs/reports/implementation/2026-04-23-staff-recruitment-dual-sheet-import-implementation.md` + +- [ ] **Step 1: 切换 Node 版本并执行前端静态回归** + +Run: `source ~/.nvm/nvm.sh && nvm use 14.21.3 >/dev/null && node ruoyi-ui/tests/unit/staff-recruitment-import-toolbar.test.js && node ruoyi-ui/tests/unit/staff-recruitment-import-state.test.js && node ruoyi-ui/tests/unit/staff-recruitment-import-failure-dialog.test.js` + +Expected: PASS,三个静态契约测试全部通过 + +- [ ] **Step 2: 执行前端构建** + +Run: `source ~/.nvm/nvm.sh && nvm use 14.21.3 >/dev/null && cd ruoyi-ui && npm run build:prod` + +Expected: BUILD SUCCESS + +- [ ] **Step 3: 启动真实页面并做浏览器验证** + +Run: + +```bash +source ~/.nvm/nvm.sh && nvm use 14.21.3 >/dev/null +cd ruoyi-ui +npm run dev -- --port 8080 +``` + +Expected: 前端开发服务启动成功,真实页面 `http://localhost:8080` 可访问 + +Playwright 验证最少覆盖: + +- 进入真实 `招聘信息管理` 页面,不使用 prototype 页面 +- 从页面下载双 Sheet 模板 +- 只导 `招聘信息` Sheet +- 只导 `历史工作经历` Sheet +- 双 Sheet 同时导入 +- 已存在工作经历时报错 +- 失败弹窗显示 `失败Sheet / 失败行号 / 失败原因` + +- [ ] **Step 4: 补前端实施记录** + +```md +- 页面导入入口收口为一个按钮 +- 上传弹窗提示调整为双 Sheet 文案 +- 页面状态收口为单任务轮询 +- 失败弹窗新增失败 Sheet、失败行号、失败原因 +- 已完成真实页面 Playwright 验证 +``` + +- [ ] **Step 5: 关闭测试进程并提交前端收尾** + +Run: 关闭本轮 `npm run dev` 与后端联调用到的进程,确保无残留端口占用 + +```bash +git add \ + ruoyi-ui/src/views/ccdiStaffRecruitment/index.vue \ + ruoyi-ui/src/api/ccdiStaffRecruitment.js \ + ruoyi-ui/tests/unit/staff-recruitment-import-toolbar.test.js \ + ruoyi-ui/tests/unit/staff-recruitment-import-state.test.js \ + ruoyi-ui/tests/unit/staff-recruitment-import-failure-dialog.test.js \ + docs/reports/implementation/2026-04-23-staff-recruitment-dual-sheet-import-implementation.md +git commit -m "完成招聘双Sheet导入前端改造" +``` diff --git a/docs/plans/frontend/2026-04-26-enterprise-auto-fill-frontend-implementation.md b/docs/plans/frontend/2026-04-26-enterprise-auto-fill-frontend-implementation.md new file mode 100644 index 00000000..7c5b2f39 --- /dev/null +++ b/docs/plans/frontend/2026-04-26-enterprise-auto-fill-frontend-implementation.md @@ -0,0 +1,244 @@ +# 关联业务自动补入实体库 Frontend Implementation Plan + +> **执行约束:** 按当前项目 `AGENTS.md` 执行;未获得用户明确要求时不启用 subagent。Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 验证前端通过现有企业来源枚举接口展示新增 `SUPPLIER=供应商`,并在真实页面确认自动补入实体库后的展示链路可用。 + +**Architecture:** 本次不新增前端交互,不修改前端源码。企业来源选项由后端 `/ccdi/enum/enterpriseSource` 返回,实体库管理页与招投标详情页沿用 `getEnterpriseSourceOptions()` 展示新增来源;前端工作重点是运行真实页面验证并记录结果。 + +**Tech Stack:** Vue 2, Element UI, npm, nvm, Playwright. + +--- + +## File Structure + +- No source changes expected: `ruoyi-ui/src/views/ccdiEnterpriseBaseInfo/index.vue` + - 已通过 `getEnterpriseSourceOptions()` 获取企业来源。 +- No source changes expected: `ruoyi-ui/src/views/ccdiPurchaseTransaction/index.vue` + - 企业详情弹窗已通过 `getEnterpriseSourceOptions()` 格式化企业来源。 +- Create or update: `docs/reports/implementation/2026-04-26-enterprise-auto-fill-implementation.md` + - 补充前端真实页面验证结果。 +- Generated test files: + - 如需生成导入样本,放在 `output/playwright/` 或 `output/spreadsheet/`,不提交到 git。 + +## Task 1: 前端源码确认 + +**Files:** +- Read: `ruoyi-ui/src/views/ccdiEnterpriseBaseInfo/index.vue` +- Read: `ruoyi-ui/src/views/ccdiPurchaseTransaction/index.vue` +- Read: `ruoyi-ui/src/api/ccdiEnum.js` + +- [ ] **Step 1: 确认企业来源接口使用点** + +Run: + +```bash +rg -n "getEnterpriseSourceOptions|formatEnterpriseSource|enterpriseSourceOptions" ruoyi-ui/src/views/ccdiEnterpriseBaseInfo/index.vue ruoyi-ui/src/views/ccdiPurchaseTransaction/index.vue ruoyi-ui/src/api/ccdiEnum.js +``` + +Expected: + +- 实体库管理页调用 `getEnterpriseSourceOptions()` +- 招投标详情企业弹窗调用 `getEnterpriseSourceOptions()` +- API 路径为 `/ccdi/enum/enterpriseSource` + +- [ ] **Step 2: 确认不存在本地硬编码映射** + +Expected: 未发现页面本地写死企业来源映射;如发现硬编码映射,停止实施并先修订本计划。 + +## Task 2: 前端启动准备 + +**Files:** +- Read: `ruoyi-ui/package.json` +- Use: `ruoyi-ui/.nvmrc` if present + +- [ ] **Step 1: 使用 nvm 确认 Node 版本** + +Run: + +```bash +cd ruoyi-ui +source ~/.nvm/nvm.sh +nvm use +node -v +``` + +Expected: 切换到项目要求的 Node 版本。 + +- [ ] **Step 2: 启动前端开发服务** + +Run: + +```bash +cd ruoyi-ui +source ~/.nvm/nvm.sh +nvm use +npm run dev +``` + +Expected: 前端服务启动成功,记录实际 URL。若端口占用,按 Vite/Vue CLI 输出使用实际端口。 + +- [ ] **Step 3: 启动后端** + +Run: + +```bash +sh bin/restart_java_backend.sh +``` + +Expected: 后端 `62318` 可访问。 + +测试结束后必须关闭本次启动的前后端进程。 + +## Task 3: 实体库管理页面验证 + +**Files:** +- Verify: real page `ruoyi-ui/src/views/ccdiEnterpriseBaseInfo/index.vue` +- Do not use: prototype pages + +- [ ] **Step 1: Playwright 打开真实实体库管理页面** + +进入实际路由: + +```text +http://localhost:8080/maintain/enterpriseBaseInfo +``` + +Expected: 页面正常加载。 + +- [ ] **Step 2: 验证企业来源下拉包含供应商** + +操作: + +- 打开查询区“企业来源”下拉。 +- 检查存在“供应商”选项。 + +Expected: 下拉出现“供应商”。 + +- [ ] **Step 3: 验证列表/详情展示** + +准备后端自动补入的一条 `ent_source=SUPPLIER` 测试企业后: + +- 在实体库管理页面搜索该统一社会信用代码。 +- 检查列表企业来源显示“供应商”。 +- 打开详情,检查企业来源显示“供应商”,风险等级为空时显示为空值占位。 + +Expected: 枚举中文展示正确。 + +## Task 4: 招投标真实页面验证 + +**Files:** +- Verify: `ruoyi-ui/src/views/ccdiPurchaseTransaction/index.vue` + +- [ ] **Step 1: 打开真实招投标信息维护页面** + +进入实际路由,例如: + +```text +http://localhost:8080/maintain/purchaseTransaction +``` + +Expected: 页面正常加载。 + +- [ ] **Step 2: 新建含供应商统一信用代码的招投标记录** + +使用真实页面新增测试数据: + +- 采购事项 ID 使用本轮唯一测试值。 +- 供应商明细中至少一条填写供应商名称和统一信用代码。 + +Expected: 保存成功。 + +- [ ] **Step 3: 回到实体库管理验证供应商自动补入** + +用供应商统一信用代码查询实体库。 + +Expected: + +- 能查到实体库记录。 +- 企业名称为供应商名称。 +- 企业来源显示“供应商”。 +- 风险等级为空。 + +- [ ] **Step 4: 清理测试数据** + +删除本轮新建的招投标测试数据和自动补入的实体库测试数据。若实体库记录已有关联限制,先删除业务数据再删除实体库记录。 + +Expected: 页面列表回到测试前状态。 + +## Task 5: 导入页面验证 + +**Files:** +- Generated samples: `output/playwright/` or `output/spreadsheet/` + +- [ ] **Step 1: 在真实页面下载导入模板** + +必须从当前业务页面点击下载模板,不手工凭记忆构造表头。 + +Expected: 获取当前模板。 + +- [ ] **Step 2: 基于模板生成测试文件** + +至少覆盖: + +- 员工亲属实体关联:页面 `/maintain/staffEnterpriseRelation`,点击“导入”后在弹窗中点击“下载模板”,接口 `ccdi/staffEnterpriseRelation/importTemplate`,上传接口 `/ccdi/staffEnterpriseRelation/importData`,验证成功行实体自动补入 `EMP_RELATION`。 +- 信贷客户实体关联:页面 `/maintain/custEnterpriseRelation`,点击“导入”后在弹窗中点击“下载模板”,接口 `ccdi/custEnterpriseRelation/importTemplate`,上传接口 `/ccdi/custEnterpriseRelation/importData`,验证成功行实体自动补入 `CREDIT_CUSTOMER`。 +- 中介实体关联:页面 `/maintain/intermediary`,点击“导入中介实体关联关系”,在导入弹窗下载模板,接口 `ccdi/intermediary/importEnterpriseRelationTemplate`,上传接口 `/ccdi/intermediary/importEnterpriseRelationData`,验证成功行实体自动补入 `INTERMEDIARY` 且风险等级高风险。 +- 招投标信息维护:页面 `/maintain/purchaseTransaction`,点击“导入”后在弹窗中点击“下载模板”,接口 `ccdi/purchaseTransaction/importTemplate`,上传接口 `/ccdi/purchaseTransaction/importData`,验证供应商统一信用代码自动补入 `SUPPLIER`。 +- 每个页面至少包含一个混合成功失败样本,验证失败行不补实体。 + +Expected: 测试文件保存在 `output/playwright/` 或 `output/spreadsheet/`,不提交 git。 + +- [ ] **Step 3: 上传并核对导入状态** + +在真实页面上传文件,核对: + +- 页面提示 +- 导入状态 +- 失败记录弹窗 +- 列表总数变化 +- 实体库是否新增对应实体 + +Expected: 成功行补实体,失败行不补实体。 + +- [ ] **Step 4: 清理测试数据和任务缓存** + +删除本轮成功写入的业务数据和实体库数据,清理页面本地导入任务缓存。 + +Expected: 页面和数据库不残留本轮测试数据。 + +## Task 6: 记录验证结果 + +**Files:** +- Modify: `docs/reports/implementation/2026-04-26-enterprise-auto-fill-implementation.md` + +- [ ] **Step 1: 补充前端验证记录** + +记录: + +```markdown +## 前端验证 +- Node 版本: +- 前端 URL: +- 后端 URL: +- 实体库企业来源“供应商”展示: +- 招投标供应商自动补入页面验证: +- 导入页面验证: +- 测试数据清理: +``` + +- [ ] **Step 2: 停止测试进程** + +停止本次启动的前端和后端进程。 + +Expected: 无测试进程残留。 + +- [ ] **Step 3: 检查生成文件未进入 git** + +Run: + +```bash +git status --short +``` + +Expected: `output/playwright/`、`output/spreadsheet/` 下生成测试文件不在待提交范围。 diff --git a/docs/reports/implementation/2026-03-31-abnormal-account-bank-tag-backend-implementation.md b/docs/reports/implementation/2026-03-31-abnormal-account-bank-tag-backend-implementation.md new file mode 100644 index 00000000..d6795602 --- /dev/null +++ b/docs/reports/implementation/2026-03-31-abnormal-account-bank-tag-backend-implementation.md @@ -0,0 +1,175 @@ +# 异常账户模型接入银行流水打标后端实施记录 + +**日期**: 2026-03-31 +**类型**: 后端实施记录 +**范围**: 银行流水打标 - 异常账户模型 + +## 1. 已完成实施内容 + +### 1.1 规则与元数据 + +- 新增异常账户模型迁移脚本:`sql/migration/2026-03-31-create-ccdi-account-info-and-abnormal-account-rules.sql` +- 新增模型编码:`ABNORMAL_ACCOUNT` +- 新增规则编码: + - `SUDDEN_ACCOUNT_CLOSURE` + - `DORMANT_ACCOUNT_LARGE_ACTIVATION` +- 两条规则均按 `OBJECT` 结果写入现有结果表 `ccdi_bank_statement_tag_result` + +### 1.2 服务与 SQL + +- `CcdiBankTagServiceImpl` 已补充两条对象型规则分发 +- `CcdiBankTagAnalysisMapper` 已补充两条 Mapper 方法签名 +- `CcdiBankTagAnalysisMapper.xml` 已补充: + - `selectSuddenAccountClosureObjects` + - `selectDormantAccountLargeActivationObjects` + +### 1.3 自动化测试 + +- 已新增 SQL 元数据测试: + - `CcdiAbnormalAccountRuleSqlMetadataTest` +- 已补充服务分发与对象结果断言: + - `CcdiBankTagServiceImplTest` +- 已补充员工聚合承接断言: + - `CcdiProjectOverviewEmployeeResultBuilderTest` + +## 2. 测试数据准备 + +### 2.1 样本设计 + +- 员工 A:命中 `SUDDEN_ACCOUNT_CLOSURE` +- 员工 B:命中 `DORMANT_ACCOUNT_LARGE_ACTIVATION` +- 员工 C:休眠不足 6 个月,不命中 +- 员工 D:销户前 30 天无流水,不命中 + +### 2.2 导入脚本 + +```bash +bin/mysql_utf8_exec.sh sql/migration/2026-03-31-create-ccdi-account-info-and-abnormal-account-rules.sql +bin/mysql_utf8_exec.sh sql/migration/2026-03-31-add-abnormal-account-rule-test-data.sql +``` + +### 2.3 导入结果 + +- 已使用 `bin/mysql_utf8_exec.sh` 成功执行两份 SQL 脚本 +- 远端业务库已写入: + - 项目:`90331 / 异常账户规则测试项目` + - 员工:A、B、C、D 四个最小样本 + - 账户:4 个员工本人账户 + - 项目流水:7 笔 + +## 3. 过程说明 + +- 本轮实现保持最短路径,未新增平行结果表或独立查询链路 +- 异常账户结果仍复用既有项目打标主链路与员工风险聚合 +- 为保证 `ccdi-project` 模块测试可执行,补充了缺失的 `easyexcel` 依赖声明 +- `mvn` 定向测试统一使用 `-am`,确保 `ccdi-lsfx` 依赖以当前源码参与 reactor 构建,避免使用陈旧本地产物 + +## 4. SQL 校验结果 + +### 4.1 环境说明 + +- 项目导入脚本读取的数据库配置为:`jdbc:mysql://116.62.17.81:3307/ccdi` +- 当前 MySQL MCP 会话实际连接为:`ccdi@ca446c6169d2:3306` +- 由于 MySQL MCP 与项目配置数据库不是同一实例,直接在 MCP 中查询不到刚导入的样本数据 +- 因此本次“真实 SQL 命中校验”实际使用项目配置对应库的只读 `mysql` 查询执行 Mapper 等价 SQL;MySQL MCP 仅用于确认环境差异,而未直接承载最终命中校验 + +### 4.2 `SUDDEN_ACCOUNT_CLOSURE` + +- 执行 SQL 摘要: + - 关联 `ccdi_account_info`、`ccdi_base_staff` 与项目内 `ccdi_bank_statement` + - 过滤 `owner_type = 'EMPLOYEE'`、`status = 2`、`invalid_date is not null` + - 统计窗口为 `[invalid_date - 30天, invalid_date)` +- 命中结果: + - 员工 A `330101199001010001` +- `reasonDetail` 快照: + - `账户6222000000000001于2026-03-20销户,销户前30天内最后交易日2026-03-18,累计交易金额180000.00元,单笔最大金额70000.00元` +- 反样本校验: + - 员工 D `330101199001010004` 命中数为 `0` + +### 4.3 `DORMANT_ACCOUNT_LARGE_ACTIVATION` + +- 执行 SQL 摘要: + - 关联 `ccdi_account_info`、`ccdi_base_staff` 与项目内 `ccdi_bank_statement` + - 过滤 `owner_type = 'EMPLOYEE'`、`status = 1`、`effective_date is not null` + - 要求 `first_tx_date >= effective_date + 6个月` + - 要求 `windowTotalAmount >= 500000` 或 `windowMaxSingleAmount >= 100000` +- 命中结果: + - 员工 B `330101199001010002` +- `reasonDetail` 快照: + - `账户6222000000000002开户于2025-01-01,首次交易日期2025-08-01,沉睡时长7个月,启用后累计交易金额550000.00元,单笔最大金额300000.00元` +- 反样本校验: + - 员工 C `330101199001010003` 命中数为 `0` + +### 4.4 口径结论 + +- 两条规则均只命中预期正样本: + - `SUDDEN_ACCOUNT_CLOSURE` 仅命中员工 A + - `DORMANT_ACCOUNT_LARGE_ACTIVATION` 仅命中员工 B +- 反样本满足预期: + - 员工 C 因沉睡期不足 6 个月未命中 + - 员工 D 因销户前 30 天无流水未命中 + +## 5. 最终验证汇总 + +### 5.1 Java 定向测试 + +- 执行命令: + +```bash +mvn -pl ccdi-project -am -Dsurefire.failIfNoSpecifiedTests=false \ + -Dtest=CcdiAbnormalAccountRuleSqlMetadataTest,CcdiBankTagRuleSqlMetadataTest,CcdiBankTagServiceImplTest,CcdiProjectOverviewEmployeeResultBuilderTest \ + test +``` + +- 执行结果: + - `Tests run: 25, Failures: 0, Errors: 0, Skipped: 0` + - `BUILD SUCCESS` + +### 5.2 端到端链路验证 + +- 已重新执行后端打包: + +```bash +mvn -pl ruoyi-admin -am package -DskipTests +``` + +- 已启动 `ruoyi-admin/target/ruoyi-admin.jar`,并使用测试登录接口获取 token +- 已调用手工重建接口: + +```http +POST /ccdi/project/tags/rebuild +{ + "projectId": 90331, + "modelCode": "ABNORMAL_ACCOUNT" +} +``` + +- 后端执行日志确认: + - 异常账户模型规则数为 `2` + - 实际命中数为 `2` + - 员工风险聚合已刷新 +- 结果表校验确认: + - `SUDDEN_ACCOUNT_CLOSURE` 写入员工 `330101199001010001` + - `DORMANT_ACCOUNT_LARGE_ACTIVATION` 写入员工 `330101199001010002` +- 员工总览聚合表校验确认: + - 员工 A 聚合命中 `ABNORMAL_ACCOUNT / SUDDEN_ACCOUNT_CLOSURE` + - 员工 B 聚合命中 `ABNORMAL_ACCOUNT / DORMANT_ACCOUNT_LARGE_ACTIVATION` + +### 5.3 进程关闭 + +- 端到端验证完成后,已主动关闭本轮启动的后端 `java -jar ruoyi-admin.jar` 进程 +- 关闭日志可见 Quartz 调度器与 Druid 数据源正常释放,无残留后端进程 + +## 6. 最终改动文件清单 + +- `ccdi-project/pom.xml` +- `ccdi-project/src/main/java/com/ruoyi/ccdi/project/mapper/CcdiBankTagAnalysisMapper.java` +- `ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/impl/CcdiBankTagServiceImpl.java` +- `ccdi-project/src/main/resources/mapper/ccdi/project/CcdiBankTagAnalysisMapper.xml` +- `ccdi-project/src/test/java/com/ruoyi/ccdi/project/sql/CcdiAbnormalAccountRuleSqlMetadataTest.java` +- `ccdi-project/src/test/java/com/ruoyi/ccdi/project/sql/CcdiBankTagRuleSqlMetadataTest.java` +- `ccdi-project/src/test/java/com/ruoyi/ccdi/project/service/impl/CcdiBankTagServiceImplTest.java` +- `ccdi-project/src/test/java/com/ruoyi/ccdi/project/service/impl/CcdiProjectOverviewEmployeeResultBuilderTest.java` +- `sql/migration/2026-03-31-create-ccdi-account-info-and-abnormal-account-rules.sql` +- `sql/migration/2026-03-31-add-abnormal-account-rule-test-data.sql` +- `docs/reports/implementation/2026-03-31-abnormal-account-bank-tag-backend-implementation.md` diff --git a/docs/reports/implementation/2026-03-31-abnormal-account-bank-tag-design-record.md b/docs/reports/implementation/2026-03-31-abnormal-account-bank-tag-design-record.md new file mode 100644 index 00000000..f95e3db9 --- /dev/null +++ b/docs/reports/implementation/2026-03-31-abnormal-account-bank-tag-design-record.md @@ -0,0 +1,38 @@ +# 异常账户模型接入银行流水打标设计记录 + +**日期**: 2026-03-31 +**类型**: 设计记录 +**范围**: 银行流水打标 - 异常账户模型 + +## 1. 本次变更内容 + +新增正式设计文档: + +- `docs/design/2026-03-31-abnormal-account-bank-tag-design.md` + +设计结论如下: + +- 新增独立模型 `ABNORMAL_ACCOUNT` +- 新增两条对象型规则: + - `SUDDEN_ACCOUNT_CLOSURE` + - `DORMANT_ACCOUNT_LARGE_ACTIVATION` +- 新增账户信息表 `ccdi_account_info` +- 规则结果继续落到 `ccdi_bank_statement_tag_result` +- 通过测试数据 SQL、Java 自动化测试和 MySQL MCP 真实 SQL 验证共同确认命中口径 + +## 2. 设计约束 + +- 不开发异常账户独立查询、分页或详情链路 +- 不改前端展示逻辑 +- 不扩展到关系人或外部账户 +- 不增加动态规则引擎或兼容性补丁方案 +- 不改造 `lsfx-mock-server` + +## 3. 后续文档规划 + +待用户确认设计文档后,继续补充: + +- 后端实施计划 +- 前端实施计划 +- 后端实施记录 +- 前端实施记录 diff --git a/docs/reports/implementation/2026-03-31-abnormal-account-bank-tag-frontend-implementation.md b/docs/reports/implementation/2026-03-31-abnormal-account-bank-tag-frontend-implementation.md new file mode 100644 index 00000000..91e3a8dd --- /dev/null +++ b/docs/reports/implementation/2026-03-31-abnormal-account-bank-tag-frontend-implementation.md @@ -0,0 +1,72 @@ +# 异常账户模型接入银行流水打标前端实施记录 + +**日期**: 2026-03-31 +**类型**: 前端实施记录 +**范围**: 银行流水打标 - 异常账户模型 + +## 1. 前端承接点核查 + +### 1.1 核查命令 + +```bash +rg -n "异常账户人员信息|异常标签|风险模型|hitRules|modelCode" ruoyi-ui/src/views/ccdiProject -S +``` + +### 1.2 核查结论 + +- 风险模型区域由 `RiskModelSection.vue` 直接消费后端返回的 `cardList` 与命中标签列表 +- 风险总览人员区域由 `PreliminaryCheck.vue` 统一加载项目总览接口,再传递给 `RiskPeopleSection.vue` +- 风险详情中的“异常账户人员信息”区域仍由 `RiskDetailSection.vue` 渲染 `sectionData.abnormalAccountList || []` +- `createOverviewLoadedData` 当前固定把 `abnormalAccountList` 置为空数组,说明本轮前端仍处于占位承接状态 + +## 2. 零代码改动边界 + +- 本轮前端不新增页面、按钮、弹窗、路由或独立 API 封装 +- 前端当前已具备通用模型卡片展示和对象型命中标签展示能力 +- 异常账户模型接入后,只要后端项目总览接口返回新增模型与规则,现有页面即可承接 +- “异常账户人员信息”区域本轮仍保持占位,不提前扩展详情链路 + +## 3. 接口联调验证 + +### 3.1 风险模型区域 + +- 调用接口:`GET /ccdi/project/overview/risk-models/cards?projectId=90331` +- 验证结果: + - 返回模型 `ABNORMAL_ACCOUNT` + - `modelName = 异常账户` + - `warningCount = 2` + - `peopleCount = 2` + +### 3.2 风险总览人员区域 + +- 调用接口:`GET /ccdi/project/overview/risk-people?projectId=90331&pageNum=1&pageSize=10` +- 验证结果: + - 员工 A `330101199001010001` 返回命中标签 `SUDDEN_ACCOUNT_CLOSURE / 突然销户` + - 员工 B `330101199001010002` 返回命中标签 `DORMANT_ACCOUNT_LARGE_ACTIVATION / 休眠账户大额启用` +- 说明现有总览人员列表已能直接展示异常账户模型命中规则 + +### 3.3 风险详情占位区域 + +- `RiskDetailSection.vue` 仍以 `sectionData.abnormalAccountList || []` 渲染“异常账户人员信息” +- `preliminaryCheck.mock.js` 中 `createOverviewLoadedData` 仍固定返回 `abnormalAccountList: []` +- 本轮接口联调未发现该占位区域因异常账户模型接入而报错 + +## 4. 构建回归结果 + +- 执行命令: + +```bash +cd ruoyi-ui +npm run build:prod +``` + +- 执行结果: + - 构建成功,`Build complete` + - 仅存在仓库既有的前端产物体积告警,无新增编译错误 + +## 5. 实施结论 + +- 本轮前端保持零代码改动 +- 无需新增前端接口或页面,原因是现有页面已直接消费后端聚合结果 +- 本轮未启动 `npm run dev`,因此不存在需额外关闭的前端本地进程 +- 前端实施产出仅新增本实施记录文档,用于沉淀影响面核查、接口联调和构建验证结论 diff --git a/docs/reports/implementation/2026-03-31-abnormal-account-bank-tag-plan-record.md b/docs/reports/implementation/2026-03-31-abnormal-account-bank-tag-plan-record.md new file mode 100644 index 00000000..69655a4e --- /dev/null +++ b/docs/reports/implementation/2026-03-31-abnormal-account-bank-tag-plan-record.md @@ -0,0 +1,22 @@ +# 异常账户模型接入银行流水打标计划记录 + +**日期**: 2026-03-31 +**类型**: 计划记录 +**范围**: 银行流水打标 - 异常账户模型 + +## 1. 本次变更内容 + +基于设计文档 [2026-03-31-abnormal-account-bank-tag-design.md](/Users/wkc/Desktop/ccdi/ccdi/docs/design/2026-03-31-abnormal-account-bank-tag-design.md),新增两份实施计划文档: + +- [2026-03-31-abnormal-account-bank-tag-backend-implementation-plan.md](/Users/wkc/Desktop/ccdi/ccdi/docs/plans/backend/2026-03-31-abnormal-account-bank-tag-backend-implementation-plan.md) +- [2026-03-31-abnormal-account-bank-tag-frontend-implementation-plan.md](/Users/wkc/Desktop/ccdi/ccdi/docs/plans/frontend/2026-03-31-abnormal-account-bank-tag-frontend-implementation-plan.md) + +## 2. 计划结论 + +- 后端按最小闭环接入现有对象型打标主链路 +- 前端本轮默认零代码改动,仅做承接能力核查与实施记录 +- 测试阶段除 Java 自动化测试外,必须使用 MySQL MCP 执行真实 SQL 校验规则口径 + +## 3. 后续动作 + +待用户确认计划文档后,按后端计划和前端计划分别执行实施与验证。 diff --git a/docs/reports/implementation/2026-03-31-lsfx-mock-default-db-endpoint-update.md b/docs/reports/implementation/2026-03-31-lsfx-mock-default-db-endpoint-update.md new file mode 100644 index 00000000..1ef1b75e --- /dev/null +++ b/docs/reports/implementation/2026-03-31-lsfx-mock-default-db-endpoint-update.md @@ -0,0 +1,23 @@ +# LSFX Mock Server 默认数据库地址调整实施记录 + +**日期**: 2026-03-31 +**范围**: `lsfx-mock-server` 配置 + +## 1. 调整内容 + +- 在 `lsfx-mock-server/config/settings.py` 中显式固定默认数据库地址: + - `CCDI_DB_HOST = 116.62.17.81` + - `CCDI_DB_PORT = 3307` +- 保持数据库名、用户名、密码继续沿用主工程 `application-dev.yml` 中的默认值读取逻辑 + +## 2. 调整原因 + +此前 `lsfx-mock-server` 的数据库 host/port 默认值隐式跟随 `ruoyi-admin` 的开发配置。虽然当前主工程配置本身也是 `116.62.17.81:3307`,但这种依赖关系不够直接。 + +本次改动后,`lsfx-mock-server` 会在自身配置层明确默认连接到 `116.62.17.81:3307`,避免后续主工程开发配置变化时影响 Mock 服务默认库选择。 + +## 3. 验证范围 + +- `lsfx-mock-server/tests/test_settings_sync.py` + - 校验默认 host/port 固定为 `116.62.17.81:3307` + - 校验数据库名、用户名、密码仍沿用主工程开发配置默认值 diff --git a/docs/reports/implementation/2026-03-31-lsfx-mock-server-abnormal-account-backend-implementation.md b/docs/reports/implementation/2026-03-31-lsfx-mock-server-abnormal-account-backend-implementation.md new file mode 100644 index 00000000..b4dd428e --- /dev/null +++ b/docs/reports/implementation/2026-03-31-lsfx-mock-server-abnormal-account-backend-implementation.md @@ -0,0 +1,85 @@ +# LSFX Mock Server 异常账户后端实施记录 + +## 1. 实施范围 + +本次改动仅覆盖 `lsfx-mock-server` 后端 Mock 造数主链路,目标是在不新增接口的前提下,为异常账户规则补齐稳定命中能力。 + +涉及规则: + +- `SUDDEN_ACCOUNT_CLOSURE` +- `DORMANT_ACCOUNT_LARGE_ACTIVATION` + +## 2. 主要改动 + +### 2.1 FileRecord 新增异常账户计划与事实 + +在 `lsfx-mock-server/services/file_service.py` 中扩展了 `FileRecord`: + +- 新增 `abnormal_account_hit_rules` +- 新增 `abnormal_accounts` + +同时把异常账户规则池并入现有规则命中计划生成逻辑: + +- `subset` 模式下按 `logId` 稳定随机命中异常账户规则 +- `all` 模式下自动纳入全部异常账户规则 +- 在上传链路与 `fetch_inner_flow(...)` 中同步生成最小异常账户事实 + +最小账户事实字段包括: + +- `account_no` +- `owner_id_card` +- `account_name` +- `status` +- `effective_date` +- `invalid_date` + +### 2.2 新增两类异常账户样本生成器 + +在 `lsfx-mock-server/services/statement_rule_samples.py` 中新增: + +- `build_sudden_account_closure_samples(...)` +- `build_dormant_account_large_activation_samples(...)` + +口径落实如下: + +- `SUDDEN_ACCOUNT_CLOSURE` 的样本流水全部落在销户日前 30 天窗口内 +- `DORMANT_ACCOUNT_LARGE_ACTIVATION` 的首笔流水晚于开户满 6 个月 +- 休眠激活样本同时满足累计金额阈值与单笔最大金额阈值 + +### 2.3 接入现有种子流水主链路 + +未新增平行入口,直接复用现有: + +- `FileService -> FileRecord` +- `StatementService._generate_statements(...)` +- `build_seed_statements_for_rule_plan(...)` + +接入方式: + +- 在统一种子流水构造入口增加 `abnormal_account_hit_rules` 分支 +- 根据 `abnormal_accounts` 为每条异常账户规则选择匹配账户事实 +- 生成的异常账户样本继续与既有规则样本一起补噪声、编号、打乱和分页 + +## 3. 测试补充 + +新增并通过的关键测试包括: + +- `test_fetch_inner_flow_should_attach_abnormal_account_rule_plan` +- `test_sudden_account_closure_samples_should_stay_within_30_days_before_invalid_date` +- `test_dormant_account_large_activation_samples_should_exceed_threshold_after_6_months` +- `test_generate_statements_should_follow_abnormal_account_rule_plan_from_file_record` + +## 4. 联动修正 + +在 `all` 模式安全噪声测试中,原有用例只清空了旧规则维度,未同步清空新增的 `abnormal_account_hit_rules`。本次已将该测试夹具补齐,保证它继续只验证“月固定收入 + 安全噪声”的原始语义。 + +在合并到 `dev` 后的运行态验证中,又发现 `getBSByLogId` 返回前统一回填主绑定时,会把异常账户样本原本正确的 `accountMaskNo` 覆盖成主账号,导致 HTTP 实际返回数据无法体现异常账户事实。对此补充了以下修正: + +- 新增回归用例 `test_get_bank_statement_should_preserve_abnormal_account_mask_no` +- 将 `StatementService._apply_primary_binding(...)` 调整为只兜底缺失账号,不覆盖已有的异常账户样本账号 + +修正后,接口返回中的异常账户流水可以保留各自独立的账号,与异常账户事实保持一致。 + +## 5. 结果 + +异常账户命中计划、最小账户事实、样本生成器和服务层主链路均已落地,现有 Mock 服务可以为同一个 `logId` 稳定提供异常账户命中流水样本。 diff --git a/docs/reports/implementation/2026-03-31-lsfx-mock-server-abnormal-account-baseline-audit-column-correction.md b/docs/reports/implementation/2026-03-31-lsfx-mock-server-abnormal-account-baseline-audit-column-correction.md new file mode 100644 index 00000000..21c16351 --- /dev/null +++ b/docs/reports/implementation/2026-03-31-lsfx-mock-server-abnormal-account-baseline-audit-column-correction.md @@ -0,0 +1,41 @@ +# LSFX Mock Server 异常账户基线审计字段纠正实施记录 + +**日期**: 2026-03-31 +**范围**: `lsfx-mock-server` 异常账户基线同步链路 + +## 1. 问题说明 + +在前一轮排查中,基于 MCP 表结构结果将 `ccdi_account_info` 的审计列误判为 `created_by`、`updated_by`,并据此调整了异常账户基线 upsert SQL。 + +随后使用 `mysql` 直连 `116.62.17.81:3307/ccdi` 执行: + +- `SHOW COLUMNS FROM ccdi_account_info LIKE 'create_by';` +- `SHOW COLUMNS FROM ccdi_account_info LIKE 'update_by';` +- `SHOW COLUMNS FROM ccdi_account_info;` + +确认真实表结构使用的是 `create_by`、`update_by`。 + +## 2. 本次纠正内容 + +- 修正 `lsfx-mock-server/services/abnormal_account_baseline_service.py` + - upsert 字段改回 `create_by`、`update_by` + - 更新分支改回 `update_by = VALUES(update_by)` +- 修正 `sql/migration/2026-03-31-create-ccdi-account-info-and-abnormal-account-rules.sql` + - `ccdi_account_info` 建表字段改回 `create_by`、`update_by` + - 规则初始化 SQL 的审计字段改回 `create_by` / `update_by` +- 修正 `sql/migration/2026-03-31-add-abnormal-account-rule-test-data.sql` + - `ccdi_account_info` 测试数据插入字段改回 `create_by`、`update_by` +- 修正 `docs/design/2026-03-31-lsfx-mock-server-abnormal-account-baseline-sync-design.md` + - 将设计文档中的账户表审计字段名改回真实库定义 + +## 3. 测试调整 + +- 更新 `lsfx-mock-server/tests/test_abnormal_account_baseline_service.py` + - 锁定 insert SQL 必须包含 `create_by`、`update_by` + - 锁定 upsert update 分支必须写 `update_by = VALUES(update_by)` + +## 4. 结果 + +- 异常账户基线同步 SQL 已与 `116.62.17.81:3307/ccdi` 的真实表结构重新对齐 +- 运行时不会再向不存在的 `created_by`、`updated_by` 字段写值 +- 服务代码、migration、测试数据脚本与设计文档已恢复一致 diff --git a/docs/reports/implementation/2026-03-31-lsfx-mock-server-abnormal-account-baseline-sync-backend-implementation.md b/docs/reports/implementation/2026-03-31-lsfx-mock-server-abnormal-account-baseline-sync-backend-implementation.md new file mode 100644 index 00000000..fd05fd89 --- /dev/null +++ b/docs/reports/implementation/2026-03-31-lsfx-mock-server-abnormal-account-baseline-sync-backend-implementation.md @@ -0,0 +1,54 @@ +# LSFX Mock Server 异常账户基线同步后端实施记录 + +**日期**: 2026-03-31 +**范围**: `lsfx-mock-server` 异常账户基线同步后端 + +## 1. 本次实施内容 + +本次后端完成以下改动: + +- 新增 `AbnormalAccountBaselineService` + - 复用 `settings.CCDI_DB_*` 连接真实数据库 + - 以 `account_no` 为唯一键向 `ccdi_account_info` 执行幂等 upsert + - 固定写入最小命中字段:`DEBIT`、`EMPLOYEE`、`兰溪农商银行`、`LXNCSY`、`CNY`、`HIGH` +- 调整 `FileService` + - 新增 `abnormal_account_baseline_service` 注入点 + - 在 `fetch_inner_flow(...)` 和上传建档链路中,先同步异常账户基线,再写入 `file_records` + - 当存在异常账户命中计划但未生成 `abnormal_accounts` 时直接抛错 +- 锁定 `StatementService` 链路一致性 + - 继续保持只读 `FileRecord` 生成异常账户样本流水 + - 通过新增测试确认不会用主账号覆盖异常账户样本自身的 `accountMaskNo` + +## 2. 关键实现语义 + +- 基线同步触发点固定在建 `logId` 阶段,不放到 `getBSByLogId` +- 异常账户事实为空时直接跳过,不做无意义写库 +- 任一 `owner_id_card` 与当前 `staff_id_card` 不一致时,立即失败 +- 数据库写入失败时执行回滚,并且本次 `logId` 不进入 `file_records` +- 同一个 `logId` 下: + - `record.abnormal_accounts` + - 返回的异常账户样本流水 + - `ccdi_account_info` 中的最小账户事实 + 保持账号级一致 + +## 3. 测试补充 + +本次新增或扩展了以下测试: + +- `tests/test_file_service.py` + - 校验 `fetch_inner_flow(...)` 会在缓存前调用异常账户基线同步 + - 校验同步失败时不会留下半成品 `logId` +- `tests/test_abnormal_account_baseline_service.py` + - 校验空输入跳过 + - 校验证件号不一致直接失败 + - 校验按账号插入 + - 校验按账号更新 +- `tests/test_statement_service.py` + - 校验异常账户样本流水仅使用 `record.abnormal_accounts` 中的账号 + +## 4. 实施结果 + +- `FileService -> AbnormalAccountBaselineService -> StatementService` 的职责边界保持清晰 +- 异常账户基线写库与内存建档顺序已固定为“先同步、后缓存” +- 异常账户样本流水与账户事实的一致性已通过测试锁定 +- 本轮未扩展接口协议,也未新增补丁式降级链路 diff --git a/docs/reports/implementation/2026-03-31-lsfx-mock-server-abnormal-account-design-record.md b/docs/reports/implementation/2026-03-31-lsfx-mock-server-abnormal-account-design-record.md new file mode 100644 index 00000000..a2e6ed01 --- /dev/null +++ b/docs/reports/implementation/2026-03-31-lsfx-mock-server-abnormal-account-design-record.md @@ -0,0 +1,36 @@ +# LSFX Mock Server 异常账户命中流水设计记录 + +**日期**: 2026-03-31 +**类型**: 设计记录 +**范围**: `lsfx-mock-server` 异常账户命中流水 + +## 1. 本次变更内容 + +新增正式设计文档: + +- `docs/design/2026-03-31-lsfx-mock-server-abnormal-account-design.md` + +设计结论如下: + +- 在现有 `rule_hit_plan` 体系中新增 `abnormal_account_hit_rules` +- 在 `FileRecord` 中新增异常账户事实 `abnormal_accounts` +- 通过 `statement_rule_samples.py` 新增两类异常账户命中样本: + - `SUDDEN_ACCOUNT_CLOSURE` + - `DORMANT_ACCOUNT_LARGE_ACTIVATION` +- 保持现有流水接口协议不变,只在 Mock 服务内部补齐“账户事实 + 命中流水”闭环 + +## 2. 设计约束 + +- 不新增异常账户独立接口 +- 不修改现有 `/watson/api/project/getBSByLogId` 返回结构 +- 不把异常账户事实直接暴露给前端 +- 不模拟 `ccdi_account_info` 全字段,只保留规则计算所需最小字段 +- 不开启 subagent;本次设计文档采用本地人工复核替代 spec subagent review + +## 3. 后续文档规划 + +待用户确认设计文档后,继续补充: + +- 后端实施计划 +- 前端实施计划 +- Mock 服务实施记录 diff --git a/docs/reports/implementation/2026-03-31-lsfx-mock-server-abnormal-account-plan-record.md b/docs/reports/implementation/2026-03-31-lsfx-mock-server-abnormal-account-plan-record.md new file mode 100644 index 00000000..e044d3e7 --- /dev/null +++ b/docs/reports/implementation/2026-03-31-lsfx-mock-server-abnormal-account-plan-record.md @@ -0,0 +1,23 @@ +# LSFX Mock Server 异常账户命中流水计划记录 + +**日期**: 2026-03-31 +**类型**: 计划记录 +**范围**: `lsfx-mock-server` 异常账户命中流水 + +## 1. 本次变更内容 + +基于设计文档 [2026-03-31-lsfx-mock-server-abnormal-account-design.md](/Users/wkc/Desktop/ccdi/ccdi/docs/design/2026-03-31-lsfx-mock-server-abnormal-account-design.md),新增两份实施计划文档: + +- [2026-03-31-lsfx-mock-server-abnormal-account-backend-implementation-plan.md](/Users/wkc/Desktop/ccdi/ccdi/docs/plans/backend/2026-03-31-lsfx-mock-server-abnormal-account-backend-implementation-plan.md) +- [2026-03-31-lsfx-mock-server-abnormal-account-frontend-implementation-plan.md](/Users/wkc/Desktop/ccdi/ccdi/docs/plans/frontend/2026-03-31-lsfx-mock-server-abnormal-account-frontend-implementation-plan.md) + +## 2. 计划结论 + +- 后端按最短路径扩展现有 `rule_hit_plan`、`FileRecord` 和种子流水生成链路 +- 异常账户规则仅在 Mock 内部补齐最小账户事实,不新增外部接口 +- 前端本轮默认零代码改动,仅做承接边界核查与记录沉淀 +- 测试以 `pytest` 定向回归和全量回归为主,不启动额外前后端进程 + +## 3. 后续动作 + +待用户确认计划文档后,按后端计划和前端计划分别执行实施与验证。 diff --git a/docs/reports/implementation/2026-03-31-nas-lsfx-mock-db-endpoint-deploy-update.md b/docs/reports/implementation/2026-03-31-nas-lsfx-mock-db-endpoint-deploy-update.md new file mode 100644 index 00000000..aa28ac16 --- /dev/null +++ b/docs/reports/implementation/2026-03-31-nas-lsfx-mock-db-endpoint-deploy-update.md @@ -0,0 +1,34 @@ +# NAS 部署脚本 LSFX Mock 数据库地址调整实施记录 + +**日期**: 2026-03-31 +**范围**: NAS 部署脚本、部署配置 + +## 1. 本次调整 + +- 新增 `deploy/render_nas_env.py` + - 基于根目录 `.env.example` 渲染 NAS 部署专用 `.env` + - 固定输出: + - `CCDI_DB_HOST=192.168.0.111` + - `CCDI_DB_PORT=40628` +- 调整 `deploy/deploy-to-nas.sh` + - 在组装部署目录阶段生成 `${STAGE_ROOT}/.env` +- 调整 `deploy/deploy.ps1` + - 与 Shell 部署入口保持一致,在组装部署目录阶段生成 `${stageRoot}\\.env` + +## 2. 调整目的 + +确保 NAS 部署后的 `lsfx-mock-server` 读取部署包中的 `.env`,从而连接: + +- Host: `192.168.0.111` +- Port: `40628` + +同时保持本地 `docker-compose.yml` 默认值不变,不影响本地开发和手工启动。 + +## 3. 验证范围 + +- `tests/deploy/test_render_nas_env.py` + - 校验渲染后的 `.env` 包含 `CCDI_DB_HOST=192.168.0.111` + - 校验渲染后的 `.env` 包含 `CCDI_DB_PORT=40628` +- `tests/deploy/test_deploy_to_nas.py` + - 校验 `deploy-to-nas.sh` 已接入 `render_nas_env.py` + - 校验部署目录会生成 `${STAGE_ROOT}/.env` diff --git a/docs/reports/implementation/2026-03-31-nas-lsfx-mock-subset-startup-record.md b/docs/reports/implementation/2026-03-31-nas-lsfx-mock-subset-startup-record.md new file mode 100644 index 00000000..8ea1c733 --- /dev/null +++ b/docs/reports/implementation/2026-03-31-nas-lsfx-mock-subset-startup-record.md @@ -0,0 +1,21 @@ +# NAS 部署 lsfx-mock-server subset 启动参数实施记录 + +## 变更目的 + +- 将 NAS 部署环境中的 `lsfx-mock-server` 启动方式显式固定为 `subset` 模式,避免容器启动时仅依赖应用默认值。 + +## 修改内容 + +- 修改根目录 `docker-compose.yml` + - 在 `lsfx-mock-server` 服务上新增启动命令: + - `python main.py --rule-hit-mode subset` + +## 影响范围 + +- 仅影响 NAS 部署链路使用的根目录 `docker-compose.yml` +- 不修改 `lsfx-mock-server` 本地开发脚本 +- 不修改主系统前后端业务逻辑 + +## 核验方式 + +- 执行 `docker compose config`,确认渲染后的 `lsfx-mock-server` 服务命令包含 `--rule-hit-mode subset` diff --git a/docs/reports/implementation/2026-03-31-project-detail-risk-details-abnormal-account-backend-implementation.md b/docs/reports/implementation/2026-03-31-project-detail-risk-details-abnormal-account-backend-implementation.md new file mode 100644 index 00000000..825b10d9 --- /dev/null +++ b/docs/reports/implementation/2026-03-31-project-detail-risk-details-abnormal-account-backend-implementation.md @@ -0,0 +1,136 @@ +# 项目详情风险明细异常账户人员信息后端实施记录 + +## 1. 实施概述 + +- 实施日期:2026-03-31 +- 实施目标:为项目详情风险明细补齐“异常账户人员信息”的真实后端分页查询与统一导出能力 +- 实施范围:`ccdi-project` 模块结果总览控制器、服务层、Mapper SQL、统一工作簿导出器及对应测试 + +## 2. 新增接口与对象 + +### 2.1 新增接口 + +- `GET /ccdi/project/overview/abnormal-account-people` + - 入参:`projectId`、`pageNum`、`pageSize` + - 返回:`rows`、`total` + - 权限:`ccdi:project:query` + +### 2.2 新增 DTO / VO / Excel 对象 + +- `CcdiProjectAbnormalAccountQueryDTO` + - 承载异常账户分页查询入参 +- `CcdiProjectAbnormalAccountItemVO` + - 承载单条异常账户明细 +- `CcdiProjectAbnormalAccountPageVO` + - 承载分页查询结果 `rows/total` +- `CcdiProjectAbnormalAccountExcel` + - 承载统一导出第 3 个 sheet 的行数据 + +## 3. Mapper SQL 口径 + +异常账户分页与导出统一复用同一套基础查询口径: + +- 仅查询当前项目:`tr.project_id = projectId` +- 仅查询异常账户模型:`tr.model_code = 'ABNORMAL_ACCOUNT'` +- 仅查询对象型结果:`tr.bank_statement_id is null` +- 仅查询员工本人账户:`account.owner_type = 'EMPLOYEE'` 且 `account.owner_id = tr.object_key` +- 仅在 `reason_detail` 中命中具体账号时返回:`instr(tr.reason_detail, account.account_no) > 0` +- 排序统一为:`异常发生时间 desc -> 账号 asc -> 规则编码 asc` + +字段映射如下: + +- `accountNo`:`ccdi_account_info.account_no` +- `accountName`:优先 `ccdi_account_info.account_name`,为空回退 `ccdi_base_staff.name` +- `bankName`:`ccdi_account_info.bank` +- `abnormalType`:`ccdi_bank_statement_tag_result.rule_name` +- `abnormalTime` + - `SUDDEN_ACCOUNT_CLOSURE` 取 `invalid_date` + - `DORMANT_ACCOUNT_LARGE_ACTIVATION` 从 `reason_detail` 提取首次交易日期 +- `status` + - `1 -> 正常` + - `2 -> 已销户` + +## 4. 服务层与统一导出改动 + +### 4.1 服务层 + +- 在 `ICcdiProjectOverviewService` 中新增: + - `getAbnormalAccountPeople(queryDTO)` + - `exportAbnormalAccountPeople(projectId)` +- 在 `CcdiProjectOverviewServiceImpl` 中实现: + - 项目存在性校验 + - 分页默认值 `pageNum=1`、`pageSize=5` + - 分页结果直接映射为 `CcdiProjectAbnormalAccountPageVO` + - 导出结果映射为 `CcdiProjectAbnormalAccountExcel` + +### 4.2 统一导出 + +- `exportRiskDetails(...)` 现在会同时查询: + - 涉疑交易明细 + - 员工负面征信信息 + - 异常账户人员信息 +- `CcdiProjectRiskDetailWorkbookExporter.export(...)` 方法签名扩展为接收异常账户列表 +- 第 3 个 sheet `异常账户人员信息` 从“仅表头”改为“表头 + 真实数据行” +- 第 3 个 sheet 列顺序固定为: + - `账号` + - `开户人` + - `银行` + - `异常类型` + - `异常发生时间` + - `状态` + +## 5. 自动化验证 + +### 5.1 基线验证 + +执行命令: + +```bash +mvn -pl ccdi-project -am -Dsurefire.failIfNoSpecifiedTests=false -Dtest=CcdiProjectOverviewControllerContractTest,CcdiProjectOverviewControllerTest,CcdiProjectOverviewMapperSqlTest,CcdiProjectOverviewServiceImplTest,CcdiProjectRiskDetailWorkbookExporterTest test +``` + +验证结果: + +- 40 个相关既有测试通过 + +### 5.2 任务内 TDD 验证 + +按计划分别执行并通过: + +```bash +mvn -pl ccdi-project -am -Dsurefire.failIfNoSpecifiedTests=false -Dtest=CcdiProjectOverviewControllerContractTest,CcdiProjectOverviewControllerTest test +mvn -pl ccdi-project -am -Dsurefire.failIfNoSpecifiedTests=false -Dtest=CcdiProjectOverviewMapperSqlTest test +mvn -pl ccdi-project -am -Dsurefire.failIfNoSpecifiedTests=false -Dtest=CcdiProjectOverviewServiceAbnormalAccountTest test +mvn -pl ccdi-project -am -Dsurefire.failIfNoSpecifiedTests=false -Dtest=CcdiProjectOverviewServiceImplTest,CcdiProjectRiskDetailWorkbookExporterTest test +``` + +### 5.3 最终回归 + +执行命令: + +```bash +mvn -pl ccdi-project -am -Dsurefire.failIfNoSpecifiedTests=false -Dtest=CcdiProjectOverviewControllerContractTest,CcdiProjectOverviewControllerTest,CcdiProjectOverviewMapperSqlTest,CcdiProjectOverviewServiceAbnormalAccountTest,CcdiProjectOverviewServiceImplTest,CcdiProjectRiskDetailWorkbookExporterTest test +``` + +结果: + +- 47 个测试全部通过 +- `BUILD SUCCESS` + +## 6. 手工联调与进程处理 + +- 本次未执行手工联调 +- 未启动新的后端 `java -jar ruoyi-admin.jar` 进程 +- 因未启动额外前后端进程,无额外进程需要关闭 + +## 7. 结果结论 + +- 异常账户人员信息分页接口已具备真实查询能力 +- 页面查询与统一导出第 3 个 sheet 已复用同一套异常账户明细口径 +- 返回字段已覆盖: + - `accountNo` + - `accountName` + - `bankName` + - `abnormalType` + - `abnormalTime` + - `status` diff --git a/docs/reports/implementation/2026-03-31-project-detail-risk-details-abnormal-account-design-record.md b/docs/reports/implementation/2026-03-31-project-detail-risk-details-abnormal-account-design-record.md new file mode 100644 index 00000000..e3d658f6 --- /dev/null +++ b/docs/reports/implementation/2026-03-31-project-detail-risk-details-abnormal-account-design-record.md @@ -0,0 +1,42 @@ +# 风险明细异常账户人员信息设计记录 + +**日期**: 2026-03-31 +**类型**: 设计记录 +**范围**: 项目详情 - 结果总览 - 风险明细 + +## 1. 本次变更内容 + +新增正式设计文档: + +- `docs/design/2026-03-31-project-detail-risk-details-abnormal-account-design.md` + +本次设计结论如下: + +- `异常账户人员信息` 从前端占位数据改为真实查询链路 +- 页面展示与统一导出统一使用 6 个字段: + - `账号` + - `开户人` + - `银行` + - `异常类型` + - `异常发生时间` + - `状态` +- 展示与导出均按“一条命中结果一行”处理,不做账号合并或员工合并 +- 统一导出继续复用 `POST /ccdi/project/overview/risk-details/export` +- 第 3 个 sheet `异常账户人员信息` 改为真实数据导出 + +## 2. 设计约束 + +- 不新增异常账户详情弹窗 +- 不新增筛选器或区块级导出按钮 +- 不扩展到关系人或外部账户 +- 不新增平行导出链路 +- 不增加兼容性补丁或兜底方案 + +## 3. 后续文档规划 + +待用户审核设计文档后,继续补充: + +- 后端实施计划 +- 前端实施计划 +- 后端实施记录 +- 前端实施记录 diff --git a/docs/reports/implementation/2026-03-31-project-detail-risk-details-abnormal-account-frontend-implementation.md b/docs/reports/implementation/2026-03-31-project-detail-risk-details-abnormal-account-frontend-implementation.md new file mode 100644 index 00000000..d15f44c4 --- /dev/null +++ b/docs/reports/implementation/2026-03-31-project-detail-risk-details-abnormal-account-frontend-implementation.md @@ -0,0 +1,75 @@ +# 项目详情风险明细异常账户人员信息前端实施记录 + +**日期**: 2026-03-31 +**范围**: 项目详情 - 结果总览 - 风险明细 - 异常账户人员信息前端 + +## 1. 本次实施内容 + +- 在 `ruoyi-ui/src/api/ccdi/projectOverview.js` 新增 `getOverviewAbnormalAccountPeople`,对接 `GET /ccdi/project/overview/abnormal-account-people` +- 在 `ruoyi-ui/src/views/ccdiProject/components/detail/RiskDetailSection.vue` 为异常账户区块补充独立状态: + - `abnormalAccountLoading` + - `abnormalAccountPageNum` + - `abnormalAccountPageSize` + - `abnormalAccountTotal` + - `abnormalAccountList` +- 在 `RiskDetailSection.vue` 新增 `loadAbnormalAccountPeople` 与 `handleAbnormalAccountPageChange`,使异常账户区块具备独立分页刷新能力 +- 将异常账户人员信息区块从占位表格替换为真实 6 列: + - `账号` + - `开户人` + - `银行` + - `异常类型` + - `异常发生时间` + - `状态` +- 移除旧占位列 `操作 / 查看详情` +- 为异常账户区块补充独立空态文案 `当前项目暂无异常账户人员信息` +- 在 `ruoyi-ui/src/views/ccdiProject/components/detail/preliminaryCheck.mock.js` 中对齐异常账户 mock 字段: + - `accountNo` + - `accountName` + - `bankName` + - `abnormalType` + - `abnormalTime` + - `status` +- 新增并更新静态单测,覆盖异常账户区块的列结构、空态文案、独立分页状态与旧占位断言移除 + +## 2. 影响范围 + +- `ruoyi-ui/src/api/ccdi/projectOverview.js` +- `ruoyi-ui/src/views/ccdiProject/components/detail/RiskDetailSection.vue` +- `ruoyi-ui/src/views/ccdiProject/components/detail/preliminaryCheck.mock.js` +- `ruoyi-ui/tests/unit/risk-detail-abnormal-account-layout.test.js` +- `ruoyi-ui/tests/unit/risk-detail-abnormal-account-pagination.test.js` +- `ruoyi-ui/tests/unit/preliminary-check-model-and-detail.test.js` + +## 3. 验证结果 + +执行静态单测: + +```bash +cd ruoyi-ui +node tests/unit/risk-detail-abnormal-account-layout.test.js +node tests/unit/risk-detail-abnormal-account-pagination.test.js +node tests/unit/preliminary-check-model-and-detail.test.js +node tests/unit/risk-detail-employee-credit-negative-layout.test.js +``` + +执行结果: + +- 全部通过 + +执行生产构建: + +```bash +cd ruoyi-ui +npm run build:prod +``` + +执行结果: + +- 构建成功 +- 仅存在仓库原有的体积告警,没有新增编译错误 + +## 4. 手工联调说明 + +- 本轮未启动 `npm run dev` 做浏览器手工联调 +- 因未启动前端开发服务,本轮不存在额外前端进程需要关闭 +- 真实接口翻页、区块级失败提示与统一导出联动,待结合后端接口联调时继续确认 diff --git a/docs/reports/implementation/2026-03-31-project-detail-risk-details-abnormal-account-plan-record.md b/docs/reports/implementation/2026-03-31-project-detail-risk-details-abnormal-account-plan-record.md new file mode 100644 index 00000000..f0d14952 --- /dev/null +++ b/docs/reports/implementation/2026-03-31-project-detail-risk-details-abnormal-account-plan-record.md @@ -0,0 +1,31 @@ +# 风险明细异常账户人员信息实施计划记录 + +**日期**: 2026-03-31 +**类型**: 实施计划记录 +**范围**: 项目详情 - 结果总览 - 风险明细 + +## 1. 本次新增计划文档 + +- `docs/plans/backend/2026-03-31-project-detail-risk-details-abnormal-account-backend-implementation-plan.md` +- `docs/plans/frontend/2026-03-31-project-detail-risk-details-abnormal-account-frontend-implementation-plan.md` + +## 2. 计划拆分原则 + +- 后端计划只覆盖异常账户结果总览查询接口、Mapper SQL、统一导出第 3 个 sheet 真实化、后端自动化验证 +- 前端计划只覆盖 `RiskDetailSection.vue` 的异常账户真实加载、独立分页、列结构调整、前端静态单测与构建验证 +- 页面展示与统一导出统一采用 `账号 / 开户人 / 银行 / 异常类型 / 异常发生时间 / 状态` 六个字段 +- 展示与导出均按“一条命中结果一行”执行,不做账号合并或员工合并 + +## 3. 执行约束 + +- 前端开发直接在当前分支执行,不使用 git worktree +- 仓库要求不开启 subagent,后续执行阶段统一使用当前会话串行推进 +- 若验证时启动前后端进程,结束后必须主动关闭 +- Git 提交前必须检查暂存区,仅纳入本次任务相关文件 + +## 4. 后续交付物 + +执行实施计划时需补齐以下记录: + +- `docs/reports/implementation/2026-03-31-project-detail-risk-details-abnormal-account-backend-implementation.md` +- `docs/reports/implementation/2026-03-31-project-detail-risk-details-abnormal-account-frontend-implementation.md` diff --git a/docs/reports/implementation/2026-03-31-results-overview-risk-model-card-grid-frontend-implementation.md b/docs/reports/implementation/2026-03-31-results-overview-risk-model-card-grid-frontend-implementation.md new file mode 100644 index 00000000..61c4c5f9 --- /dev/null +++ b/docs/reports/implementation/2026-03-31-results-overview-risk-model-card-grid-frontend-implementation.md @@ -0,0 +1,31 @@ +# 结果总览模型预警次数统计四列布局前端实施记录 + +## 本次改动 + +- 调整 [RiskModelSection.vue](/Users/wkc/Desktop/ccdi/ccdi/ruoyi-ui/src/views/ccdiProject/components/detail/RiskModelSection.vue) 中 `模型预警次数统计` 卡片区的桌面端栅格布局。 +- 将 `.model-card-grid` 的桌面端列数由 `repeat(5, minmax(0, 1fr))` 调整为 `repeat(4, minmax(0, 1fr))`,使桌面端固定每行 4 张卡片。 +- 保留现有响应式断点不变: + - `1200px` 以下 2 列 + - `768px` 以下 1 列 +- 更新 [preliminary-check-model-card-grid.test.js](/Users/wkc/Desktop/ccdi/ccdi/ruoyi-ui/tests/unit/preliminary-check-model-card-grid.test.js) 断言,防止后续回退到 5 列。 + +## 验证结果 + +执行命令: + +```bash +cd ruoyi-ui +node tests/unit/preliminary-check-model-card-grid.test.js +npm run build:prod +``` + +执行结果: + +- 静态测试通过 +- 生产构建通过 +- 构建过程中仅有仓库原有体积告警,没有新增编译错误 + +## 补充说明 + +- 本次仅调整桌面端卡片区排版,不改动模型卡片数据、联动筛选和人员列表逻辑。 +- 本轮未启动前端开发服务进行手工联调,因此不存在新增前端进程需要关闭的情况。 diff --git a/docs/reports/implementation/2026-03-31-sidebar-menu-width-240-frontend-record.md b/docs/reports/implementation/2026-03-31-sidebar-menu-width-240-frontend-record.md new file mode 100644 index 00000000..526fce0f --- /dev/null +++ b/docs/reports/implementation/2026-03-31-sidebar-menu-width-240-frontend-record.md @@ -0,0 +1,21 @@ +# 左侧菜单宽度调整为 240px 前端实施记录 + +## 本次改动 + +- 调整 [variables.scss](/Users/wkc/Desktop/ccdi/ccdi/ruoyi-ui/src/assets/styles/variables.scss) 中全局侧栏宽度变量 `$base-sidebar-width`,将左侧菜单宽度从 `200px` 调整为 `240px`。 +- 保持现有侧栏展开、折叠、移动端抽屉和固定头部联动逻辑不变,由现有布局样式继续复用同一宽度变量。 + +## 实现方式 + +- 直接修改全局样式变量 `$base-sidebar-width`,让 [sidebar.scss](/Users/wkc/Desktop/ccdi/ccdi/ruoyi-ui/src/assets/styles/sidebar.scss) 和 [layout/index.vue](/Users/wkc/Desktop/ccdi/ccdi/ruoyi-ui/src/layout/index.vue) 中依赖该变量的侧栏宽度、主内容区偏移量、固定头部宽度同步生效。 +- 不额外新增局部覆盖样式,避免出现展开态、折叠态和移动端宽度不一致的问题。 + +## 验证情况 + +- 执行 `cd ruoyi-ui && npm run build:prod`,生产构建通过。 +- 构建过程中仅出现仓库原有的静态资源体积告警,没有新增编译错误或构建失败。 + +## 边界说明 + +- 本次仅调整左侧菜单展示宽度,不改动菜单数据、路由、权限、交互行为和页面内容布局逻辑。 +- 本轮未启动前端开发服务进行手工联调,因此不存在新增前端进程需要关闭的情况。 diff --git a/docs/reports/implementation/2026-04-16-account-library-single-table-implementation.md b/docs/reports/implementation/2026-04-16-account-library-single-table-implementation.md new file mode 100644 index 00000000..415f5268 --- /dev/null +++ b/docs/reports/implementation/2026-04-16-account-library-single-table-implementation.md @@ -0,0 +1,62 @@ +# 账户库双表合单表实施记录 + +## 1. 本次实施内容 + +### 1.1 单表模型收敛 + +- 在 `CcdiAccountInfo` 中补齐以下分析字段映射: + - `is_self_account` + - `monthly_avg_trans_count` + - `monthly_avg_trans_amount` + - `trans_freq_type` + - `dr_max_single_amount` + - `cr_max_single_amount` + - `dr_max_daily_amount` + - `cr_max_daily_amount` + - `trans_risk_level` +- 删除 `CcdiAccountResult` 实体与 `CcdiAccountResultMapper` + +### 1.2 查询与写入逻辑调整 + +- `CcdiAccountInfoMapper.xml` 已移除 `ccdi_account_result` 联表 +- 账户库列表、详情、导出统一从 `ccdi_account_info` 读取分析字段 +- `CcdiAccountInfoServiceImpl` 已移除结果表双写逻辑 +- 新增单表分析字段处理规则: + - 行外账户默认补齐分析字段缺省值 + - 行内账户统一清空分析字段 + +### 1.3 数据迁移与种子脚本 + +- 新增增量脚本: + - `sql/migration/2026-04-16-merge-ccdi-account-result-into-info.sql` +- 更新外部场景种子脚本: + - `sql/migration/2026-04-13-seed-ccdi-account-info-external-scenarios.sql` +- 种子脚本已改为直接写入 `ccdi_account_info`,不再依赖旧表 + +### 1.4 测试补充 + +- 新增 `CcdiAccountInfoServiceImplTest` +- 新增 `CcdiAccountInfoMapperTest` +- 新增 `CcdiAccountInfoMergeSqlTest` + +## 2. 验证记录 + +### 2.1 已完成验证 + +- `ccdi-project` 模块执行 `mvn -pl ccdi-project -DskipTests compile` 成功 +- 文件级检查确认: + - 账户库主链路代码已无 `CcdiAccountResult` / `accountResultMapper` 引用 + - `CcdiAccountInfoMapper.xml` 已无 `ccdi_account_result` 联表 + - 新增迁移脚本包含补字段、按 `account_no` 回填、删除旧表逻辑 + +### 2.2 现存仓库阻塞 + +- `ccdi-info-collection` 模块常规编译失败,失败原因为仓库已有依赖/类缺失,与本次账户库改动不直接相关 +- 典型阻塞包括: + - `com.ruoyi.common.annotation` 下若干注解类缺失 + - 多个服务类依赖 `org.springframework.data.redis.core`,当前模块未解析 + - 既有测试代码与当前依赖版本存在不一致 + +## 3. 结论 + +本次账户库已按方案完成“双表合单表”代码与 SQL 收敛,后续若要做完整 Maven 回归,需要先处理仓库当前已有的模块依赖与测试编译问题。 diff --git a/docs/reports/implementation/2026-04-20-agents-global-rules-sync-record.md b/docs/reports/implementation/2026-04-20-agents-global-rules-sync-record.md new file mode 100644 index 00000000..6a727f9e --- /dev/null +++ b/docs/reports/implementation/2026-04-20-agents-global-rules-sync-record.md @@ -0,0 +1,19 @@ +# AGENTS 全局规则同步实施记录 + +## 本次改动 + +- 更新根目录 `AGENTS.md` 的协作约定,补充 `.DS_Store` 忽略要求,以及 `using-superpowers` 只能在用户明确声明时启用的限制 +- 更新根目录 `AGENTS.md` 的前端命令与开发测试提醒,明确前端相关命令执行前必须先通过 `nvm` 切换并确认 Node 版本 +- 更新根目录 `AGENTS.md` 的文档维护要求,补充“每次改动均需留下实施文档”“前后端联动设计需拆分前后端实施计划”“写文档前先确认保存路径正确”等规则 +- 在根目录 `AGENTS.md` 新增“方案规范”章节,约束方案输出必须走最短实现路径,不得擅自扩展兼容、补丁、兜底或降级方案,并要求完成全链路逻辑校验 + +## 影响范围 + +- 影响文件仅限仓库根目录 `AGENTS.md` +- 本次为项目协作规范更新,不涉及业务代码、数据库脚本、前端页面或后端接口变更 + +## 验证说明 + +- 已确认实施记录保存路径为 `docs/reports/implementation/` +- 已人工核对新增规则与本次提供的全局 `AGENTS.md` 要求一致,且未覆盖或破坏仓库原有约束 +- 本次仅修改文档,无需运行代码测试 diff --git a/docs/reports/implementation/2026-04-20-agents-structure-optimization-record.md b/docs/reports/implementation/2026-04-20-agents-structure-optimization-record.md new file mode 100644 index 00000000..d9899d27 --- /dev/null +++ b/docs/reports/implementation/2026-04-20-agents-structure-optimization-record.md @@ -0,0 +1,19 @@ +# AGENTS 文档结构优化实施记录 + +## 本次改动 + +- 重构根目录 `AGENTS.md` 的章节结构,新增“高优先级规则”章节,将高频且强约束的协作规则前置 +- 将原有分散在多个章节中的规则重新归类到“基础协作”“Git 与变更管理”“文档产出”“测试与运行”“数据库与编码”等小节 +- 保留原有核心约束不变,仅优化文档层次、阅读顺序与检索效率 +- 精简重复表达,例如将前后端实施计划拆分、实施记录留痕、`nvm` 使用、测试后关闭进程等规则统一收口到更明确的章节 + +## 影响范围 + +- 影响文件仅限仓库根目录 `AGENTS.md` +- 本次为项目协作规范文档优化,不涉及业务代码、数据库脚本、前端页面或后端接口变更 + +## 验证说明 + +- 已确认实施记录保存路径位于 `docs/reports/implementation/` +- 已人工检查优化后的 `AGENTS.md`,确认原有关键规则仍然保留,且文档结构更清晰 +- 本次仅修改文档,无需运行代码测试 diff --git a/docs/reports/implementation/2026-04-20-intermediary-import-refactor-implementation.md b/docs/reports/implementation/2026-04-20-intermediary-import-refactor-implementation.md new file mode 100644 index 00000000..ed087c51 --- /dev/null +++ b/docs/reports/implementation/2026-04-20-intermediary-import-refactor-implementation.md @@ -0,0 +1,44 @@ +# 中介库导入改造实施记录 + +## 基本信息 + +- 日期:2026-04-20 +- 范围:中介库后端导入改造 + 前端导入入口与状态改造 +- 关联设计:`docs/design/2026-04-20-intermediary-import-refactor-design.md` +- 关联计划: + `docs/plans/backend/2026-04-20-intermediary-import-backend-implementation.md` + `docs/plans/frontend/2026-04-20-intermediary-import-frontend-implementation.md` + +## 实施内容 + +- 后端完成 `related_num_id` 语义切换,统一为“关联中介本人证件号码”,并补齐本人证件号变更同步、亲属唯一性收敛、统一列表联表条件切换。 +- 后端完成“导入中介信息”链路重构,支持本人与亲属混合导入、同文件内引用先成功导入的本人、同亲属证件号挂到不同本人。 +- 后端新增“导入中介实体关联关系”链路,按“本人证件号码 -> 本人 bizId -> 关系表”写入,并支持文件内去重、库内去重、失败记录回看。 +- 前端完成中介导入入口改造,页面顶部改为“导入中介信息”“导入中介实体关联关系”两个按钮,导入弹窗改为 `scene` 驱动。 +- 前端完成两类导入任务状态、本地缓存键、失败记录弹窗、历史任务恢复和完成态刷新逻辑,保留现有详情维护、亲属维护、关联机构维护的 `bizId` 契约。 + +## 验证结果 + +- 后端测试: + `mvn -pl ccdi-info-collection -am -Dsurefire.failIfNoSpecifiedTests=false -Dtest=CcdiIntermediaryServiceImplTest,CcdiIntermediaryMapperTest,CcdiIntermediaryControllerTest,CcdiIntermediaryPersonImportServiceImplTest,CcdiIntermediaryEnterpriseRelationImportServiceImplTest test` + 结果:PASS +- 后端编译: + `mvn -pl ccdi-info-collection -am clean compile` + 结果:PASS +- 前端静态测试: + `source ~/.nvm/nvm.sh && cd ruoyi-ui && nvm use 14.21.3 >/dev/null && node tests/unit/intermediary-import-api.test.js && node tests/unit/intermediary-import-dialog.test.js && node tests/unit/intermediary-import-toolbar.test.js && node tests/unit/intermediary-import-state.test.js && node tests/unit/intermediary-person-edit-ui.test.js` + 结果:PASS +- 前端构建: + `source ~/.nvm/nvm.sh && cd ruoyi-ui && nvm use 14.21.3 >/dev/null && npm run build:prod` + 结果:PASS(仅有原有 bundle size warning) + +## SQL 执行结果 + +- 已执行: + `bin/mysql_utf8_exec.sh sql/migration/2026-04-20-fix-ccdi-person-sub-type-dict.sql` + `bin/mysql_utf8_exec.sh sql/migration/2026-04-20-migrate-intermediary-related-num-id-to-person-id.sql` +- 迁移后核查: + `post_migration_missing_parent = 1025` + `legacy_biz_id_reference = 0` + `owner_person_id_empty_after_migration = 754` +- 结论:可迁移数据已经完成语义切换,旧 `biz_id` 语义残留已清零;历史中仍有缺失本人映射或 `related_num_id` 为空的脏数据,需要后续专项清洗。 diff --git a/docs/reports/implementation/2026-04-21-enterprise-base-info-import-browser-test-record.md b/docs/reports/implementation/2026-04-21-enterprise-base-info-import-browser-test-record.md new file mode 100644 index 00000000..1368d9a0 --- /dev/null +++ b/docs/reports/implementation/2026-04-21-enterprise-base-info-import-browser-test-record.md @@ -0,0 +1,75 @@ +# 实体库管理导入浏览器测试记录 + +## 测试目标 + +- 在真实页面中下载“实体库管理”导入模板 +- 基于下载模板生成覆盖全部后端显式校验分支的测试数据 +- 通过页面上传测试文件并核对导入结果、失败记录与列表落库情况 + +## 测试环境 + +- 测试日期:2026-04-21 +- 前端地址:`http://127.0.0.1:8080` +- 后端地址:`http://127.0.0.1:62318` +- 登录账号:`admin` +- 登录方式:浏览器页面登录 + +## 测试文件 + +- 页面下载模板:`/Users/wkc/Desktop/ccdi/ccdi/.playwright-cli/实体库管理模板-1776753846277.xlsx` +- 生成测试文件:`/Users/wkc/Desktop/ccdi/ccdi/output/spreadsheet/enterprise-base-info-import-browser-test.xlsx` + +## 测试步骤 + +1. 登录系统后进入“信息维护 -> 实体库管理”页面。 +2. 打开导入弹窗并点击“下载模板”。 +3. 基于下载模板填写 11 条测试数据。 +4. 在导入弹窗上传测试文件并点击“确定”。 +5. 等待异步导入完成。 +6. 核对列表新增数据、失败记录条数与失败原因。 + +## 测试数据覆盖范围 + +- 成功导入完整数据 1 条 +- 成功导入最简数据 1 条 +- 与库内已存在统一社会信用代码重复 1 条 +- 文件内统一社会信用代码重复 1 条 +- 企业名称为空 1 条 +- 统一社会信用代码为空 1 条 +- 统一社会信用代码格式错误 1 条 +- 经营状态为空 1 条 +- 风险等级非法 1 条 +- 企业来源非法 1 条 +- 数据来源非法 1 条 + +## 页面验证结果 + +- 模板下载成功。 +- 上传请求成功发送到 `/dev-api/ccdi/enterpriseBaseInfo/importData`。 +- 导入任务状态写入浏览器本地存储: + - `status=PARTIAL_SUCCESS` + - `totalCount=11` + - `successCount=2` + - `failureCount=9` +- 列表总数由 `2001` 增加到 `2003`,与成功导入 2 条一致。 +- 新增记录已出现在列表顶部: + - `浏览器测试实体企业A / 992604210000000001` + - `浏览器测试实体企业B / 992604210000000002` +- “查看导入失败记录”弹窗成功展示 9 条失败数据。 + +## 失败原因核对 + +- `111333432959145585`:统一社会信用代码已存在 +- `992604210000000002`:统一社会信用代码在导入文件中重复 +- `992604210000000003`:企业名称不能为空 +- 空统一社会信用代码:统一社会信用代码不能为空 +- `ABC123`:统一社会信用代码格式不正确 +- `992604210000000004`:经营状态不能为空 +- `992604210000000005`:风险等级不在允许范围内 +- `992604210000000006`:企业来源不在允许范围内 +- `992604210000000007`:数据来源不在允许范围内 + +## 结论 + +- 实体库管理导入功能在真实浏览器场景下可正常完成模板下载、文件上传、异步导入、成功入库和失败记录展示。 +- 本次基于页面和后端实际行为验证,后端当前显式校验分支均已命中且返回结果符合预期。 diff --git a/docs/reports/implementation/2026-04-21-enterprise-delete-relation-check-implementation.md b/docs/reports/implementation/2026-04-21-enterprise-delete-relation-check-implementation.md new file mode 100644 index 00000000..fb067b08 --- /dev/null +++ b/docs/reports/implementation/2026-04-21-enterprise-delete-relation-check-implementation.md @@ -0,0 +1,22 @@ +# 实体库删除关联校验实施记录 + +## 基本信息 + +- 日期:2026-04-21 +- 范围:实体库管理后端删除校验 +- 关联计划:`docs/plans/backend/2026-04-21-enterprise-delete-relation-check-backend-implementation.md` + +## 实施内容 + +- 在 `CcdiEnterpriseBaseInfoServiceImpl` 的 `deleteEnterpriseBaseInfoByIds` 中增加删除前校验逻辑。 +- 删除前分别查询员工实体关联、信贷客户实体关联、中介关联机构三张关系表,只要任一表存在当前统一社会信用代码的关联记录,即终止删除。 +- 失败提示按实际命中的关联类型拼装中文文案,例如“已关联员工”“已关联员工、信贷客户、中介”,便于页面直接回显原因。 +- 保持“先全部校验、后统一删除”的处理方式,避免批量删除时出现部分删除成功、部分失败的不一致情况。 +- 补充 `CcdiEnterpriseBaseInfoServiceImplTest`,覆盖删除成功、员工关联拦截、多关联拦截场景。 + +## 验证结果 + +- 执行命令: + `mvn -pl ccdi-info-collection -am -Dsurefire.failIfNoSpecifiedTests=false -Dtest=CcdiEnterpriseBaseInfoServiceImplTest test` +- 结果:PASS +- 备注:Maven 构建过程中仍存在项目原有 `ccdi-lsfx` 重复依赖声明 warning,本次改动未触碰该问题,不影响本次测试通过。 diff --git a/docs/reports/implementation/2026-04-21-intermediary-import-browser-test-record.md b/docs/reports/implementation/2026-04-21-intermediary-import-browser-test-record.md new file mode 100644 index 00000000..02e829ba --- /dev/null +++ b/docs/reports/implementation/2026-04-21-intermediary-import-browser-test-record.md @@ -0,0 +1,76 @@ +# 中介库管理导入功能浏览器测试实施记录 + +## 本次操作 + +- 使用真实浏览器进入 `中介库管理` 页面。 +- 分别下载两类导入模板: + - 中介和亲属信息导入模板 + - 中介实体关联关系导入模板 +- 基于下载模板生成浏览器回传测试文件,并在页面上传验证。 + +## 测试文件 + +- `output/spreadsheet/intermediary_person_import_browser_phase1.xlsx` +- `output/spreadsheet/intermediary_person_import_browser_phase2_existing_db_cases.xlsx` +- `output/spreadsheet/intermediary_enterprise_relation_import_browser_phase1.xlsx` +- `output/spreadsheet/intermediary_enterprise_relation_import_browser_phase2_db_duplicate.xlsx` + +## 验证结果 + +### 中介和亲属信息导入 + +- 第一轮导入结果:总数 `13`,成功 `4`,失败 `9` +- 第二轮导入结果:总数 `2`,成功 `0`,失败 `2` +- 成功数据已在页面列表可见: + - `自动化中介本人A` + - `自动化中介A配偶` + - `文件内重复本人1` + - `文件内重复亲属1` +- 页面失败记录已确认命中: + - 本人行关联字段错误 + - 亲属缺少关联本人 + - 姓名为空 + - 人员子类型为空 + - 证件号非法 + - 文件内本人重复 + - 关联本人不存在 + - 文件内亲属重复 + - 库内本人重复 + - 库内亲属重复 + +### 中介实体关联关系导入 + +- 第一轮导入结果:总数 `11`,成功 `3`,失败 `8` +- 第二轮导入结果:总数 `1`,成功 `0`,失败 `1` +- 成功数据已在页面列表可见: + - `成都市资产企业 / 自动化中介本人A / 董事` + - `上海市资产企业 / 自动化中介本人A / 监事` + - `杭州市不动产合伙企业 / 自动化中介本人A / 法人` +- 页面失败记录已确认命中: + - 中介本人为空 + - 中介本人证件号非法 + - 中介本人不存在 + - 统一社会信用代码为空 + - 统一社会信用代码不存在 + - 关联人职务超长 + - 备注超长 + - 文件内关系重复 + - 库内关系重复 + +## 影响范围 + +- 页面:`ruoyi-ui/src/views/ccdiIntermediary/` +- 后端接口: + - `/ccdi/intermediary/importPersonTemplate` + - `/ccdi/intermediary/importPersonData` + - `/ccdi/intermediary/importPersonStatus/{taskId}` + - `/ccdi/intermediary/importPersonFailures/{taskId}` + - `/ccdi/intermediary/importEnterpriseRelationTemplate` + - `/ccdi/intermediary/importEnterpriseRelationData` + - `/ccdi/intermediary/importEnterpriseRelationStatus/{taskId}` + - `/ccdi/intermediary/importEnterpriseRelationFailures/{taskId}` + +## 说明 + +- `docs/tests/records/2026-04-21-intermediary-import-browser-test-record.md` 也已生成更完整测试记录,但该目录受当前仓库 `.gitignore` 规则影响,不进入版本管理。 +- 测试结束后,已关闭本次启动的前端开发进程和 Playwright 浏览器;后端沿用原有 `62318` 进程,未做重启或停止。 diff --git a/docs/reports/implementation/2026-04-21-intermediary-import-copy-update.md b/docs/reports/implementation/2026-04-21-intermediary-import-copy-update.md new file mode 100644 index 00000000..b55563b8 --- /dev/null +++ b/docs/reports/implementation/2026-04-21-intermediary-import-copy-update.md @@ -0,0 +1,19 @@ +# 中介导入文案调整实施记录 + +## 基本信息 + +- 日期:2026-04-21 +- 范围:中介库前端导入入口文案调整 + +## 修改内容 + +- 将页面主按钮文案从“导入中介信息”改为“导入中介和亲属信息”。 +- 将失败记录入口和失败记录弹窗标题同步改为“中介和亲属信息”表述。 +- 将导入弹窗下载模板名称同步改为“中介和亲属信息导入模板”。 +- 同步更新前端静态测试断言,确保页面文案与测试基线一致。 + +## 验证结果 + +- 已执行: + `source ~/.nvm/nvm.sh && cd ruoyi-ui && nvm use 14.21.3 >/dev/null && node tests/unit/intermediary-import-toolbar.test.js && node tests/unit/intermediary-import-dialog.test.js && node tests/unit/intermediary-import-state.test.js && node tests/unit/intermediary-person-edit-ui.test.js` +- 结果:PASS diff --git a/docs/reports/implementation/2026-04-21-redis断连自动重连修复实施记录.md b/docs/reports/implementation/2026-04-21-redis断连自动重连修复实施记录.md new file mode 100644 index 00000000..c7725495 --- /dev/null +++ b/docs/reports/implementation/2026-04-21-redis断连自动重连修复实施记录.md @@ -0,0 +1,41 @@ +# Redis 断连自动重连修复实施记录 + +## 1. 本次修改内容 + +- 在 `ruoyi-framework` 的 Redis 配置中新增 Lettuce 连接工厂后处理器。 +- 对 Spring Boot 自动装配的 `LettuceConnectionFactory` 统一开启 `validateConnection`。 +- 新增回归测试,校验连接工厂初始化时已开启连接校验。 +- 为 `ruoyi-framework` 补充测试依赖。 + +## 2. 修改原因 + +后端 Redis 连接在发生断连后,存在持续复用失效连接的风险,导致 Redis 恢复后应用侧仍无法正常访问缓存。此次修复通过在连接工厂层开启连接校验,缩短恢复路径,避免业务代码层面持续拿到不可用连接。 + +## 3. 实际变更文件 + +- `ruoyi-framework/src/main/java/com/ruoyi/framework/config/RedisConfig.java` +- `ruoyi-framework/src/test/java/com/ruoyi/framework/config/RedisConfigTest.java` +- `ruoyi-framework/pom.xml` + +## 4. 验证结果 + +已执行: + +```bash +mvn -pl ruoyi-framework -am test -Dtest=RedisConfigTest -Dsurefire.failIfNoSpecifiedTests=false +``` + +执行结果: + +- `BUILD SUCCESS` +- `RedisConfigTest` 通过 + +## 5. 影响范围 + +- 后端 Redis 连接恢复行为 +- 所有依赖 `RedisTemplate` 与 `RedisCache` 的缓存、登录态、验证码、限流等功能 + +## 6. 备注 + +- 本次未改动 Redis 地址、密码、库索引、连接池大小等运行参数。 +- 本次未引入新的 Redis 客户端,仍保持现有 Spring Data Redis + Lettuce 方案。 diff --git a/docs/reports/implementation/2026-04-22-base-staff-asset-single-horizontal-scroll-implementation.md b/docs/reports/implementation/2026-04-22-base-staff-asset-single-horizontal-scroll-implementation.md new file mode 100644 index 00000000..99fb137a --- /dev/null +++ b/docs/reports/implementation/2026-04-22-base-staff-asset-single-horizontal-scroll-implementation.md @@ -0,0 +1,28 @@ +# 员工信息维护资产表单单横向滚动实施记录 + +## 文档信息 + +- 保存路径:`docs/reports/implementation/2026-04-22-base-staff-asset-single-horizontal-scroll-implementation.md` +- 实施日期:2026-04-22 +- 关联范围:员工信息维护前端页面 + +## 本次修改内容 + +1. 定位到员工信息维护页资产编辑表格存在双横向滚动条,原因是外层 `assets-table-wrapper` 与 `el-table` 内层同时承担横向滚动。 +2. 移除外层容器的 `overflow-x: auto`,避免包裹层再次生成横向滚动条。 +3. 将资产编辑表格根节点从强制 `min-width` 调整为 `width: 100%`,保持表格横向滚动由 `el-table` 自身管理。 +4. 新增本次前端实施计划与实施记录,沉淀问题定位、改动范围与验证要求。 + +## 影响范围 + +- 前端:`ruoyi-ui/src/views/ccdiBaseStaff/index.vue` 资产信息编辑弹窗样式。 +- 文档:新增前端实施计划与实施记录。 + +## 验证情况 + +1. 前端构建校验: + - 执行命令:`source ~/.nvm/nvm.sh && nvm use 14.21.3 && cd ruoyi-ui && npm run build:prod` + - 结果:构建成功;存在项目原有的 bundle size warnings,本次改动未引入构建失败。 +2. Playwright 浏览器实测: + - 执行方式:通过 `nvm use 25.9.0` 启动 `playwright-cli`,在浏览器中 mock 登录态与员工信息维护页最小依赖接口后打开 `/maintain/baseStaff`,进入“新增员工”弹窗并新增一条资产记录。 + - 结果:资产表单外层包装器 `clientWidth=984`、`scrollWidth=984`、`overflowX=visible`;`el-table` 内部滚动区 `clientWidth=982`、`scrollWidth=1560`、`overflowX=auto`。说明外层不再生成横向滚动条,仅保留表格内部一条横向滚动条。 diff --git a/docs/reports/implementation/2026-04-22-base-staff-dual-sheet-import-implementation.md b/docs/reports/implementation/2026-04-22-base-staff-dual-sheet-import-implementation.md new file mode 100644 index 00000000..fd18e056 --- /dev/null +++ b/docs/reports/implementation/2026-04-22-base-staff-dual-sheet-import-implementation.md @@ -0,0 +1,40 @@ +# 员工信息维护双 Sheet 导入实施记录 + +## 本次修改 +- 后端将员工信息维护导入模板改为双 Sheet: + - `员工信息` + - `员工资产信息` +- 后端导入入口统一到 `/ccdi/baseStaff/importData`,按有数据的 Sheet 分别提交员工导入任务与员工资产导入任务。 +- 员工信息导入移除了更新现有员工能力,现有员工 ID 或身份证号冲突统一进入失败记录。 +- 员工资产导入补充了重复校验,当前按 `personId + assetMainType + assetSubType + assetName` 识别重复,命中数据库或导入文件内重复时直接进入失败记录。 +- 前端删除独立“导入资产信息”按钮与独立资产上传弹窗,改为单入口上传双 Sheet 模板。 +- 前端上传成功后,按返回的两个任务 ID 分别沿用原有轮询、失败记录缓存和失败记录弹窗能力。 +- 修正员工导入失败记录字段为 `staffId`,保证失败记录列表能正确显示柜员号。 +- 员工失败记录与员工资产失败记录都增加了 `Sheet`、`Excel行号`、`失败原因` 定位信息。 + +## 影响范围 +- 后端 + - `ccdi-info-collection` 员工导入控制器、服务接口、异步导入服务、导入结果 VO +- 前端 + - `ruoyi-ui/src/views/ccdiBaseStaff/index.vue` + - `ruoyi-ui/src/api/ccdiBaseStaff.js` + +## 验证情况 +- 后端编译 + - `mvn -pl ccdi-info-collection,ruoyi-admin -am -DskipTests compile` + - 结果:通过 +- 前端构建 + - `source ~/.nvm/nvm.sh && nvm use 14.21.3 >/dev/null && cd ruoyi-ui && npm run build:prod` + - 结果:通过 + - 备注:构建输出仍有既有体积告警,但未阻断构建 + +## 测试说明 +- 已补充员工双 Sheet 导入相关单测文件,并执行: + - `mvn -pl ccdi-info-collection -am -Dtest=CcdiBaseStaffControllerTest,CcdiBaseStaffDualImportServiceTest,CcdiBaseStaffAssetImportServiceImplTest -Dsurefire.failIfNoSpecifiedTests=false test` + - 结果:通过 +- 已通过 Playwright 在真实员工信息维护页面验证: + - 页面仅保留一个“导入”按钮 + - 导入弹窗展示双 Sheet 提示文案 + - 两个失败入口可恢复显示 + - 员工失败弹窗显示 `Sheet / Excel行号 / 失败原因` + - 资产失败弹窗显示 `Sheet / Excel行号 / 失败原因` diff --git a/docs/reports/implementation/2026-04-22-base-staff-maintenance-browser-test-execution.md b/docs/reports/implementation/2026-04-22-base-staff-maintenance-browser-test-execution.md new file mode 100644 index 00000000..3813b432 --- /dev/null +++ b/docs/reports/implementation/2026-04-22-base-staff-maintenance-browser-test-execution.md @@ -0,0 +1,51 @@ +# 2026-04-22 员工信息维护真实页面测试执行记录 + +## 1. 执行内容 + +- 启动前端开发服务,端口使用 `1025` +- 使用真实浏览器进入“信息维护-员工信息维护”页面 +- 完整验证以下链路: + - 列表加载 + - 详情查看 + - 新增员工及资产 + - 编辑员工及资产 + - 删除 + - 导入模板下载 + - 双 Sheet 导入 + - 员工导入失败记录查看 + - 员工资产导入失败记录查看 + +## 2. 环境信息 + +- 前端地址:`http://127.0.0.1:1025` +- 后端地址:`http://127.0.0.1:62318` +- Mock 服务:`http://127.0.0.1:8000` +- Node 版本:`v14.21.3` +- 浏览器方式:Playwright headed 模式真实浏览器 + +## 3. 结果摘要 + +- 页面新增员工 `9260422` 成功,编辑成功,详情可正常展示资产明细。 +- 双 Sheet 导入成功写入员工 `9260423`,并成功向员工 `9260422` 导入资产 `导入资产车位A-0422`。 +- 员工导入失败记录正确命中 `该员工ID已存在`。 +- 员工资产导入失败记录正确命中 `员工资产导入仅支持员工本人证件号`。 +- 本轮新增与导入成功数据均已通过真实页面删除清理。 +- 页面本地导入缓存 `employee_import_last_task`、`employee_asset_import_last_task` 已清理。 + +## 4. 发现问题 + +- 员工详情弹窗中“所属部门”未正确回显。 +- 同一员工在列表中显示 `若依科技`,进入详情后该字段显示为 `-`。 + +## 5. 产出物 + +- 测试记录: + - `/Users/wkc/Desktop/ccdi/ccdi/docs/tests/records/2026-04-22-base-staff-maintenance-browser-test-record.md` +- 导入样本: + - `/Users/wkc/Desktop/ccdi/ccdi/output/spreadsheet/base_staff_import_browser_mixed.xlsx` + +## 6. 收尾情况 + +- 已关闭 Playwright 浏览器会话 +- 已关闭前端 `1025` 进程 +- 已停止本轮通过 `bin/restart_java_backend.sh` 拉起的后端进程 diff --git a/docs/reports/implementation/2026-04-22-bidding-dialog-width-and-supplier-sequence-implementation.md b/docs/reports/implementation/2026-04-22-bidding-dialog-width-and-supplier-sequence-implementation.md new file mode 100644 index 00000000..1b81cc55 --- /dev/null +++ b/docs/reports/implementation/2026-04-22-bidding-dialog-width-and-supplier-sequence-implementation.md @@ -0,0 +1,24 @@ +# 招投标信息维护弹窗宽度与供应商序号实施记录 + +## 保存路径确认 +- 实施记录保存到 `docs/reports/implementation/` + +## 本次修改 +- 将招投标信息新增/编辑弹窗宽度由固定 `1200px` 调整为 `80%` +- 将供应商明细中的“排序”录入列改为只读“序号”展示列 +- 供应商新增行不再预填可编辑排序值 +- 提交时按供应商当前行顺序自动回填 `sortOrder` +- 空白供应商行判定不再依赖 `sortOrder` + +## 影响范围 +- `ruoyi-ui/src/views/ccdiPurchaseTransaction/index.vue` + +## 验证方式 +- 启动前端开发服务进行页面热更新验证 +- 使用 Playwright 打开真实页面 `http://localhost:8080/maintain/purchaseTransaction` +- 验证编辑弹窗宽度、供应商明细列展示与新增供应商录入行为 + +## 结果 +- 弹窗宽度调整生效 +- 供应商排序输入已移除 +- 供应商录入功能正常 diff --git a/docs/reports/implementation/2026-04-22-bidding-import-browser-test-and-agents-update-implementation.md b/docs/reports/implementation/2026-04-22-bidding-import-browser-test-and-agents-update-implementation.md new file mode 100644 index 00000000..64a3b1a3 --- /dev/null +++ b/docs/reports/implementation/2026-04-22-bidding-import-browser-test-and-agents-update-implementation.md @@ -0,0 +1,65 @@ +# 2026-04-22 招投标导入页面测试与 AGENTS 规范补充实施记录 + +## 1. 本次变更 + +- 更新 `AGENTS.md`,补充“导入页面测试规范” +- 基于真实页面下载模板,生成招投标信息维护导入测试样本并完成页面实测 + +## 2. 文档落点确认 + +- 规范更新文件:`/Users/wkc/Desktop/ccdi/ccdi/AGENTS.md` +- 实施记录文件:`/Users/wkc/Desktop/ccdi/ccdi/docs/reports/implementation/2026-04-22-bidding-import-browser-test-and-agents-update-implementation.md` + +## 3. 页面测试方式沉淀 + +- 先启动真实前端页面并进入 `招投标信息维护` 页面,在页面内下载当前导入模板 +- 基于页面下载的双 Sheet 模板生成测试文件,不手工重建表头 +- 将测试样本按校验维度拆分成多个工作簿,避免多个互斥错误互相覆盖 +- 每个测试样本都通过页面“导入”弹窗上传,不走原型页、不绕过页面直接调用导入服务完成主验证 +- 每次上传后同时核对页面提示、导入状态、失败记录弹窗、列表总数变化,以及后端日志中的导入任务结果 +- 对成功导入的测试数据在测试结束前执行删除回滚,保证列表数据恢复到测试前状态 + +## 4. 本次覆盖的导入场景 + +- 空模板上传 +- 主信息必填校验 +- 主信息工号/金额格式校验 +- 主从关系异常:已存在采购事项 ID、供应商有数据但主信息缺失、主信息重复、供应商 Sheet 中采购事项 ID 为空 +- 供应商校验:多条中标、重复供应商、供应商名称为空、名称超长、联系人超长、银行账户超长、联系电话非法、统一信用代码非法、是否中标枚举非法 +- 缺少供应商 Sheet +- 成功导入 +- 成功导入与异常主信息混合 +- 页面内查看导入失败记录 + +## 5. 关键测试结论 + +- 主信息必填、主信息格式/金额、主从关系异常、供应商校验等页面导入链路均已通过真实页面验证 +- “重复供应商”场景单独补充了专用样本后已命中 `采购事项ID[...]存在重复供应商` +- “缺少供应商 Sheet” 当前实现不会报错,而是允许主信息成功导入,导入记录中供应商信息为空 +- “成功导入 + 空采购事项ID主信息” 场景中,后端按 `总数 1 / 成功 1 / 失败 0` 处理,说明空采购事项 ID 的主信息行被实现层静默过滤,未触发 `采购事项ID不能为空` +- 因此,`采购事项ID不能为空` 这条校验目前无法通过真实页面导入样本直接命中,需要结合实现修正后再补测 + +## 6. 生成的测试文件 + +- 测试文件统一放在 `output/spreadsheet/` 与 `output/playwright/` 下,未纳入 git +- 本次使用的样本包括: +- `bidding_import_empty_template.xlsx` +- `bidding_import_main_required.xlsx` +- `bidding_import_main_numeric.xlsx` +- `bidding_import_structure.xlsx` +- `bidding_import_supplier_validation.xlsx` +- `bidding_import_supplier_duplicate.xlsx` +- `bidding_import_missing_supplier_sheet.xlsx` +- `bidding_import_success_and_gap.xlsx` + +## 7. 环境与清理 + +- 页面测试期间使用真实浏览器会话完成导入、失败记录查看与状态确认 +- 本轮成功写入的测试数据 `IMPCOV20260422154511SUP02`、`IMPCOV20260422154511MS01`、`IMPCOV20260422154511OK01` 已全部删除 +- 页面本地导入任务缓存已清理 +- 刷新真实页面后,列表总数已恢复到 `2004` + +## 8. 风险备注 + +- 测试过程中后端日志出现过 Redis 超时与应用重启记录,个别导入提交存在等待时间偏长现象 +- 虽然最终所有目标场景都已完成验证,但后续若继续执行批量页面导入测试,建议优先关注 Redis 连接稳定性 diff --git a/docs/reports/implementation/2026-04-22-bidding-import-failure-display-implementation.md b/docs/reports/implementation/2026-04-22-bidding-import-failure-display-implementation.md new file mode 100644 index 00000000..a3f47236 --- /dev/null +++ b/docs/reports/implementation/2026-04-22-bidding-import-failure-display-implementation.md @@ -0,0 +1,46 @@ +# 2026-04-22 招投标导入失败展示增强实施记录 + +## 1. 本次变更 + +- 招投标导入失败记录新增失败来源 `Sheet` +- 招投标导入失败记录新增 Excel 失败行号 +- 招投标信息维护页面失败弹窗新增 `失败Sheet`、`失败行号` 展示列 + +## 2. 文档落点确认 + +- 后端实施计划:`/Users/wkc/Desktop/ccdi/ccdi/docs/plans/backend/2026-04-22-bidding-import-failure-display-backend-implementation.md` +- 前端实施计划:`/Users/wkc/Desktop/ccdi/ccdi/docs/plans/frontend/2026-04-22-bidding-import-failure-display-frontend-implementation.md` +- 实施记录:`/Users/wkc/Desktop/ccdi/ccdi/docs/reports/implementation/2026-04-22-bidding-import-failure-display-implementation.md` + +## 3. 实施内容 + +- 在后端失败记录 VO 中新增 `sheetName`、`sheetRowNum` +- 在导入服务中为主信息、供应商明细两类导入行建立行号上下文 +- 在主信息校验失败、供应商校验失败、主从关系失败等场景下统一返回失败来源 Sheet 与行号 +- 在前端失败弹窗中新增 `失败Sheet`、`失败行号` 列,直接展示后端返回值 + +## 4. 预期验证点 + +- 主信息失败记录显示 `招投标主信息` +- 供应商失败记录显示 `供应商明细` +- 失败行号与 Excel 实际数据行一致 +- 同一失败由多行触发时,页面可直接展示合并行号 + +## 5. 实际验证结果 + +- 后端执行 `mvn -pl ccdi-info-collection -am compile -DskipTests` 编译通过 +- 后端通过 `bin/restart_java_backend.sh` 完成重启,前端通过 `nvm use` 后启动真实页面进行验证 +- 在真实页面上传 `bidding_import_structure.xlsx` 后,导入任务 `f1026563-4bf3-4f1d-ae27-d3f9623547f4` 成功生成失败记录 +- 页面“查看导入失败记录”弹窗已展示以下表头: +- `失败Sheet` +- `失败行号` +- `采购事项ID` +- `项目名称` +- `标的物名称` +- `失败原因` +- 真实页面已验证的失败展示样例: +- `供应商明细 | 第8行 | 供应商明细Sheet中的采购事项ID不能为空` +- `招投标主信息 | 第2行 | 采购事项ID[LSFXMOCKP2PUR001]已存在,请勿重复导入` +- `招投标主信息 | 第3、4行 | 采购事项ID[IMPCOV20260422154511STR03]在招投标主信息Sheet中重复` +- `供应商明细 | 第4、5行 | 采购事项ID[IMPCOV20260422154511STR02]缺少招投标主信息` +- 本次验证样本未产生成功导入数据,无需额外回滚业务数据 diff --git a/docs/reports/implementation/2026-04-22-bidding-info-maintenance-implementation.md b/docs/reports/implementation/2026-04-22-bidding-info-maintenance-implementation.md new file mode 100644 index 00000000..90627ae8 --- /dev/null +++ b/docs/reports/implementation/2026-04-22-bidding-info-maintenance-implementation.md @@ -0,0 +1,47 @@ +# 招投标信息维护实施记录 + +## 本次改动 +- 将信息维护下“采购交易管理”改为“招投标信息维护”,保留原有 `purchaseTransaction` 技术标识。 +- 新增供应商明细子表 `ccdi_purchase_transaction_supplier`,支持维护全部参标供应商,并使用 `is_bid_winner` 标记中标方。 +- 主表继续保留中标供应商摘要字段,新增/修改/导入时从供应商明细自动回填。 +- 列表查询新增供应商数聚合;详情查询和项目专项核查采购详情新增供应商明细返回。 +- 导入模板改为“招投标主信息 + 供应商明细”双 Sheet,并按 `purchaseId` 聚合校验。 +- 前端页面改造为多供应商明细表单,详情弹窗与项目详情弹窗改为供应商明细表展示。 + +## 关键文件 +- 后端 + - `ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/impl/CcdiPurchaseTransactionServiceImpl.java` + - `ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/impl/CcdiPurchaseTransactionImportServiceImpl.java` + - `ccdi-info-collection/src/main/resources/mapper/info/collection/CcdiPurchaseTransactionMapper.xml` + - `ccdi-project/src/main/resources/mapper/ccdi/project/CcdiProjectSpecialCheckMapper.xml` +- 前端 + - `ruoyi-ui/src/views/ccdiPurchaseTransaction/index.vue` + - `ruoyi-ui/src/views/ccdiProject/components/detail/ExtendedPurchaseDetailDialog.vue` +- SQL + - `sql/ccdi_purchase_transaction.sql` + - `sql/ccdi_purchase_transaction_menu.sql` + - `sql/migration/2026-04-22-bidding-info-maintenance-supplier-detail.sql` + +## 验证结果 +- 后端编译 + - 命令:`mvn -pl ccdi-info-collection,ccdi-project -am -DskipTests compile` + - 结果:通过。 +- 前端构建 + - 命令:`source ~/.nvm/nvm.sh && nvm use 14.21.3 >/dev/null && cd ruoyi-ui && npm run build:prod` + - 结果:通过,仅有既有产物体积 warning。 +- 数据迁移 + - 命令:`bin/mysql_utf8_exec.sh sql/migration/2026-04-22-bidding-info-maintenance-supplier-detail.sql` + - 结果:执行成功,本地联调库菜单名已更新为“招投标信息维护”。 +- Playwright 实页验证 + - 验证页面:`http://localhost:8080/maintain/purchaseTransaction` + - 结果: + - 菜单与面包屑显示“招投标信息维护” + - 列表显示“中标供应商”“参与供应商数” + - 新增弹窗显示供应商明细表,并可新增供应商行 + - 详情弹窗显示供应商明细表与中标标识 + - 联调过程中发现列表 SQL 因 join 后字段未加别名导致 `purchase_id is ambiguous`,已修复并复验通过。 + +## 测试进程清理 +- 已关闭 Playwright 浏览器会话。 +- 已停止前端 `npm run dev` 进程。 +- 已停止测试期间启动的后端进程。 diff --git a/docs/reports/implementation/2026-04-22-ccdi-database-default-collation-implementation.md b/docs/reports/implementation/2026-04-22-ccdi-database-default-collation-implementation.md new file mode 100644 index 00000000..d8659021 --- /dev/null +++ b/docs/reports/implementation/2026-04-22-ccdi-database-default-collation-implementation.md @@ -0,0 +1,32 @@ +# CCDI 数据库默认排序规则修复实施记录 + +## 基本信息 + +- 日期:2026-04-22 +- 范围:`ccdi` 数据库默认字符集与默认排序规则 +- 关联计划:`docs/plans/backend/2026-04-22-ccdi-database-default-collation-backend-implementation.md` + +## 实施内容 + +- 新增 `sql/migration/2026-04-22-fix-ccdi-database-default-collation.sql` +- 通过 `ALTER DATABASE ccdi CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci` 修正数据库级默认值 +- 执行后回查 `information_schema.SCHEMATA` 验证默认字符集与默认排序规则 + +## 验证结果 + +- 执行前: + - `DEFAULT_CHARACTER_SET_NAME = utf8mb4` + - `DEFAULT_COLLATION_NAME = utf8mb4_unicode_ci` +- 执行脚本: + `bin/mysql_utf8_exec.sh sql/migration/2026-04-22-fix-ccdi-database-default-collation.sql` + 结果:PASS +- 执行后回查: + - `DEFAULT_CHARACTER_SET_NAME = utf8mb4` + - `DEFAULT_COLLATION_NAME = utf8mb4_general_ci` + - `SHOW CREATE DATABASE ccdi` 返回: + `CREATE DATABASE \`ccdi\` DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci` + +## 说明 + +- 本次仅修改数据库默认字符集与默认排序规则,不会自动改写已存在表或已有字符字段的排序规则。 +- 已存在对象若仍为其他排序规则,需要通过表级或字段级迁移脚本单独修复。 diff --git a/docs/reports/implementation/2026-04-22-info-maintenance-remove-export-and-menu-sort-implementation.md b/docs/reports/implementation/2026-04-22-info-maintenance-remove-export-and-menu-sort-implementation.md new file mode 100644 index 00000000..e4c6ec30 --- /dev/null +++ b/docs/reports/implementation/2026-04-22-info-maintenance-remove-export-and-menu-sort-implementation.md @@ -0,0 +1,75 @@ +# 信息维护移除导出与菜单排序实施记录 + +## 文档信息 + +- 保存路径:`docs/reports/implementation/2026-04-22-info-maintenance-remove-export-and-menu-sort-implementation.md` +- 实施日期:2026-04-22 +- 关联计划: + - `docs/plans/backend/2026-04-22-info-maintenance-remove-export-and-menu-sort-backend-implementation.md` + - `docs/plans/frontend/2026-04-22-info-maintenance-remove-export-and-menu-sort-frontend-implementation.md` + +## 本次修改内容 + +1. 移除信息维护下 8 个前端页面中的“导出”按钮和对应 `handleExport` 下载逻辑,覆盖账户库、员工/客户关系、采购交易、招聘、调动等当前仍暴露导出的页面。 +2. 删除信息维护相关控制器中的 `/export` 接口,包括员工信息维护中仅后端残留的导出接口,避免前后端出现一端已删、一端仍可访问的链路分叉。 +3. 清理对应前端 API 文件中的导出封装,避免保留无实际使用价值的调用入口。 +4. 新增 `sql/migration/2026-04-22-remove-info-maintenance-export-and-sort-menus.sql`,用于删除信息维护导出权限按钮并统一“信息维护”子菜单顺序。 +5. 同步修正仓库内已有菜单 SQL,确保新库初始化时不再带出导出按钮,且菜单默认顺序与本次规则一致。 + +## 菜单排序口径 + +“信息维护”目录下菜单统一按以下顺序排列: + +1. 员工信息维护 +2. 招聘信息维护 +3. 员工调动记录 +4. 员工亲属关系维护 +5. 员工实体关系维护 +6. 征信维护 +7. 实体库管理 +8. 中介库管理 +9. 账户库管理 +10. 信贷客户家庭关系 +11. 信贷客户实体关联 +12. 采购交易管理 + +## 影响范围 + +- 前端: + - `ruoyi-ui/src/views/ccdiAccountInfo/index.vue` + - `ruoyi-ui/src/views/ccdiCustEnterpriseRelation/index.vue` + - `ruoyi-ui/src/views/ccdiCustFmyRelation/index.vue` + - `ruoyi-ui/src/views/ccdiPurchaseTransaction/index.vue` + - `ruoyi-ui/src/views/ccdiStaffEnterpriseRelation/index.vue` + - `ruoyi-ui/src/views/ccdiStaffFmyRelation/index.vue` + - `ruoyi-ui/src/views/ccdiStaffRecruitment/index.vue` + - `ruoyi-ui/src/views/ccdiStaffTransfer/index.vue` +- 后端: + - `ccdi-info-collection/src/main/java/com/ruoyi/info/collection/controller/` 下 9 个信息维护控制器 +- SQL: + - `sql/migration/2026-04-22-remove-info-maintenance-export-and-sort-menus.sql` + - `sql/ccdi_credit_info_menu.sql` + - `sql/ccdi_cust_fmy_relation_menu.sql` + - `sql/ccdi_purchase_transaction_menu.sql` + - `sql/ccdi_staff_fmy_relation_menu.sql` + - `sql/dpc_intermediary_blacklist.sql` + - `sql/menu-intermediary.sql` + - `sql/migration/2026-04-13-add-ccdi-account-info-menu.sql` + - `sql/migration/2026-04-17-add-enterprise-base-info-menu.sql` + +## 验证情况 + +1. 文本回归校验: + - 使用 `rg` 检查信息维护相关页面后,已不再发现“导出”按钮文本、`handleExport` 方法以及对应的导出下载路径。 + - 使用 `rg` 检查后端控制器后,已不再发现信息维护相关 `/export` 接口和 `ccdi:*:export` 权限声明。 +2. 后端编译校验: + - 执行命令:`mvn -pl ccdi-info-collection -am compile -DskipTests` + - 结果:编译成功;存在模块 `ccdi-info-collection` 的历史重复依赖告警,但本次改动未引入新的编译错误。 +3. 前端构建校验: + - 执行命令:`source ~/.nvm/nvm.sh && nvm use 14.21.3 >/dev/null && cd ruoyi-ui && npm run build:prod` + - 结果:构建成功;仅存在项目原有的 bundle size warnings,本次改动未引入新的构建错误。 +4. Playwright 浏览器实测: + - 执行方式:通过 `nvm` 切换 Node 版本后启动前端页面,使用 Playwright 打开真实浏览器验证信息维护菜单顺序及页面按钮状态。 + - 结果:在 mock 登录态与最小接口夹具下打开 `http://127.0.0.1:8080/maintain/baseStaff`,读取到“信息维护”子菜单顺序为“员工信息维护 -> 招聘信息维护 -> 员工调动记录 -> 员工亲属关系维护 -> 员工实体关系维护 -> 征信维护 -> 实体库管理 -> 中介库管理 -> 账户库管理 -> 信贷客户家庭关系 -> 信贷客户实体关联 -> 采购交易管理”,与本次排序一致。 + - 结果:继续在 `accountInfo`、`custEnterpriseRelation`、`custFmyRelation`、`purchaseTransaction`、`staffEnterpriseRelation`、`staffFmyRelation`、`staffRecruitment`、`staffTransfer` 8 个页面读取按钮文本,均未出现“导出”按钮。 + - 结果:已生成浏览器截图 `.playwright-cli/page-2026-04-22T03-10-32-002Z.png` 作为页面验证留痕,并在验证后关闭 Playwright 浏览器与前端 dev server。 diff --git a/docs/reports/implementation/2026-04-22-ruoyi-ui-nvmrc-implementation.md b/docs/reports/implementation/2026-04-22-ruoyi-ui-nvmrc-implementation.md new file mode 100644 index 00000000..6a4b33df --- /dev/null +++ b/docs/reports/implementation/2026-04-22-ruoyi-ui-nvmrc-implementation.md @@ -0,0 +1,28 @@ +# ruoyi-ui 添加 .nvmrc 实施记录 + +## 文档信息 + +- 保存路径:`docs/reports/implementation/2026-04-22-ruoyi-ui-nvmrc-implementation.md` +- 实施日期:2026-04-22 +- 关联范围:`ruoyi-ui` 前端工程本地 Node 版本约束 + +## 本次修改内容 + +1. 确认 `docs/` 内近期前端实施计划与实施记录均使用 `nvm use 14.21.3` 作为 `ruoyi-ui` 的实际 Node 版本。 +2. 在 `ruoyi-ui` 目录新增 `.nvmrc`,写入 `14.21.3`,统一本地开发、构建与测试时的 Node 版本入口。 +3. 保持 `package.json` 现有 `engines` 配置不变,本次仅补充 `nvm` 版本声明文件,不扩展其他构建配置。 + +## 影响范围 + +- 前端:`ruoyi-ui/.nvmrc` +- 文档:本实施记录 + +## 验证情况 + +1. 版本依据核对: + - 已核对 `docs/plans/frontend/` 与 `docs/reports/implementation/` 中近期前端构建命令,当前统一使用 `nvm use 14.21.3`。 +2. 本地命令校验: + - 执行命令:`source ~/.nvm/nvm.sh && cd ruoyi-ui && nvm use` + - 预期结果:自动读取 `.nvmrc` 并切换到 `v14.21.3`。 +3. 说明: + - 本次为 Node 版本声明文件补充,不涉及业务代码、页面交互或接口行为变更,未执行额外前端构建与页面测试。 diff --git a/docs/reports/implementation/2026-04-22-staff-family-asset-single-horizontal-scroll-implementation.md b/docs/reports/implementation/2026-04-22-staff-family-asset-single-horizontal-scroll-implementation.md new file mode 100644 index 00000000..a8f80d4d --- /dev/null +++ b/docs/reports/implementation/2026-04-22-staff-family-asset-single-horizontal-scroll-implementation.md @@ -0,0 +1,28 @@ +# 员工亲属资产表单单横向滚动实施记录 + +## 文档信息 + +- 保存路径:`docs/reports/implementation/2026-04-22-staff-family-asset-single-horizontal-scroll-implementation.md` +- 实施日期:2026-04-22 +- 关联范围:员工亲属关系维护前端页面 + +## 本次修改内容 + +1. 定位到员工亲属关系维护页亲属资产编辑表格存在双横向滚动条,原因是外层 `assets-table-wrapper` 与 `el-table` 内层同时承担横向滚动。 +2. 移除外层容器的 `overflow-x: auto`,避免包裹层再次生成横向滚动条。 +3. 将亲属资产编辑表格根节点从强制 `min-width` 调整为 `width: 100%`,保持表格横向滚动由 `el-table` 自身管理。 +4. 新增本次前端实施计划与实施记录,沉淀问题定位、改动范围与验证要求。 + +## 影响范围 + +- 前端:`ruoyi-ui/src/views/ccdiStaffFmyRelation/index.vue` 亲属资产信息编辑弹窗样式。 +- 文档:新增前端实施计划与实施记录。 + +## 验证情况 + +1. 前端构建校验: + - 执行命令:`source ~/.nvm/nvm.sh && nvm use 14.21.3 && cd ruoyi-ui && npm run build:prod` + - 结果:构建成功;存在项目原有的 bundle size warnings,本次改动未引入构建失败。 +2. Playwright 浏览器实测: + - 执行方式:通过 `nvm use 25.9.0` 启动 `playwright-cli`,在浏览器中 mock 登录态与员工亲属关系维护页最小依赖接口后打开 `/maintain/staffFmyRelation`,进入“添加员工亲属关系”弹窗并新增一条亲属资产记录。 + - 结果:亲属资产表单外层包装器 `clientWidth=984`、`scrollWidth=984`、`overflowX=visible`;`el-table` 内部滚动区 `clientWidth=982`、`scrollWidth=1530`、`overflowX=auto`。说明外层不再生成横向滚动条,仅保留表格内部一条横向滚动条。 diff --git a/docs/reports/implementation/2026-04-22-staff-family-dual-sheet-import-implementation.md b/docs/reports/implementation/2026-04-22-staff-family-dual-sheet-import-implementation.md new file mode 100644 index 00000000..19783b11 --- /dev/null +++ b/docs/reports/implementation/2026-04-22-staff-family-dual-sheet-import-implementation.md @@ -0,0 +1,48 @@ +# 员工亲属关系维护双 Sheet 导入实施记录 + +## 本次修改 +- 将员工亲属关系维护顶部两个导入按钮合并为一个统一导入入口。 +- 导入模板改为双 Sheet: + - `员工亲属关系信息` + - `亲属资产信息` +- 导入提交入口统一到 `/ccdi/staffFmyRelation/importData`,按有数据的 Sheet 分别提交亲属关系任务和亲属资产任务。 +- 新增 `StaffFmyRelationImportSubmitResultVO`,返回 `relationTaskId`、`assetTaskId` 和提交提示文案。 +- 员工亲属关系失败记录与亲属资产失败记录都增加了 `Sheet`、`Excel行号`、`失败原因` 定位信息。 +- 前端删除独立亲属资产上传弹窗,但保留原有两套轮询和失败记录查看入口。 + +## 影响范围 +- 后端 + - `ccdi-info-collection` 员工亲属关系导入控制器、两类异步导入服务、失败记录 VO、导入提交结果 VO、相关控制器测试 +- 前端 + - `ruoyi-ui/src/views/ccdiStaffFmyRelation/index.vue` + - `ruoyi-ui/tests/unit/staff-family-asset-detail-import-ui.test.js` + +## 验证情况 +- 后端构建 + - `sh bin/restart_java_backend.sh restart` + - 结果:通过,`ruoyi-admin` 已基于本次代码重新打包并启动成功。 +- 前端单测 + - `source ~/.nvm/nvm.sh && nvm use 14.21.3 >/dev/null && cd ruoyi-ui && node tests/unit/staff-family-asset-detail-import-ui.test.js` + - `source ~/.nvm/nvm.sh && nvm use 14.21.3 >/dev/null && cd ruoyi-ui && node tests/unit/staff-family-asset-submit-flow.test.js` + - `source ~/.nvm/nvm.sh && nvm use 14.21.3 >/dev/null && cd ruoyi-ui && node tests/unit/staff-family-asset-maintenance-layout.test.js` + - 结果:通过 +- 后端编译/测试 + - `mvn -pl ccdi-info-collection -am -Dtest=CcdiStaffFmyRelationControllerTest,CcdiAssetInfoControllerTest -Dsurefire.failIfNoSpecifiedTests=false test` + - 结果:通过,`Tests run: 10, Failures: 0, Errors: 0` +- 真实页面实测 + - 前端端口:`1026` + - 使用 Playwright 打开真实页面 `/maintain/staffFmyRelation` + - 实测结果: + - 页面顶部仅保留一个“导入”按钮 + - 从页面内下载到新的双 Sheet 模板,实际包含 `员工亲属关系信息`、`亲属资产信息` 两张表 + - 通过双 Sheet 失败样本上传后,页面同时出现“查看导入失败记录”和“查看亲属资产导入失败记录”两个失败入口 +- 后端接口核验 + - 使用双 Sheet 失败样本调用 `/ccdi/staffFmyRelation/importData` + - 再分别查询 `/ccdi/staffFmyRelation/importFailures/{relationTaskId}` 与 `/ccdi/assetInfo/importFailures/{assetTaskId}` + - 返回结果已确认包含: + - 亲属关系失败记录:`sheetName=员工亲属关系信息`、`rowNum=2`、`errorMessage=员工身份证号格式不正确` + - 亲属资产失败记录:`sheetName=亲属资产信息`、`rowNum=2`、`errorMessage=未找到亲属资产归属员工` + +## 后续实测 +- 本轮已完成真实页面双 Sheet 导入实测与失败接口核验。 +- 若后续需要补充界面截图或失败弹窗截图,可直接复用 `output/playwright/base-staff-maintenance-test/` 下本轮产物继续追踪。 diff --git a/docs/reports/implementation/2026-04-22-staff-recruitment-collation-fix-implementation.md b/docs/reports/implementation/2026-04-22-staff-recruitment-collation-fix-implementation.md new file mode 100644 index 00000000..98af2ae3 --- /dev/null +++ b/docs/reports/implementation/2026-04-22-staff-recruitment-collation-fix-implementation.md @@ -0,0 +1,34 @@ +# 员工招聘列表排序规则冲突修复实施记录 + +## 基本信息 + +- 日期:2026-04-22 +- 范围:员工招聘列表后端查询与数据库排序规则修复 +- 关联计划:`docs/plans/backend/2026-04-22-staff-recruitment-collation-fix-backend-implementation.md` + +## 实施内容 + +- 修复 `CcdiStaffRecruitmentMapper.xml` 中招聘列表查询,对历史工作经历子查询的 `recruit_id` 聚合与关联显式指定 `utf8mb4_general_ci`,避免主表与子表排序规则不一致时在查询阶段报错。 +- 修复 `2026-04-15-add-staff-recruitment-social-work-summary.sql`,为 `ccdi_staff_recruitment_work` 建表语句补齐 `COLLATE=utf8mb4_general_ci`,防止新环境建表时继承 MySQL 默认 `utf8mb4_0900_ai_ci`。 +- 新增 `2026-04-22-fix-staff-recruitment-work-collation.sql`,用于修复已有库中 `ccdi_staff_recruitment_work` 的存量排序规则。 +- 更新 `2026-04-17-unify-all-table-collation-to-utf8mb4-general-ci.sql`,将 `ccdi_staff_recruitment_work` 纳入统一排序规则脚本。 + +## 根因结论 + +- `ccdi_staff_recruitment` 已统一为 `utf8mb4_general_ci`。 +- `ccdi_staff_recruitment_work` 的建表脚本未显式指定排序规则,且全库统一脚本遗漏该表,在 MySQL 8 默认排序规则为 `utf8mb4_0900_ai_ci` 的环境中会产生漂移。 +- 招聘列表 SQL 使用 `w.recruit_id = r.recruit_id` 进行关联时触发不同排序规则比较,导致查询失败。 + +## 验证结果 + +- 后端编译: + `mvn -pl ccdi-info-collection -am compile` + 结果:PASS +- 数据库修复脚本: + `bin/mysql_utf8_exec.sh sql/migration/2026-04-22-fix-staff-recruitment-work-collation.sql` + 结果:未执行(本次仅完成代码与脚本修复,待目标库执行) + +## 后续执行说明 + +- 代码发布前,需先在目标库执行本次新增的排序规则修复脚本。 +- 若目标库此前执行过 `2026-04-17-unify-all-table-collation-to-utf8mb4-general-ci.sql`,也仍需补执行本次新增脚本,因为旧脚本未覆盖 `ccdi_staff_recruitment_work`。 diff --git a/docs/reports/implementation/2026-04-22-staff-recruitment-dialog-width-implementation.md b/docs/reports/implementation/2026-04-22-staff-recruitment-dialog-width-implementation.md new file mode 100644 index 00000000..27206267 --- /dev/null +++ b/docs/reports/implementation/2026-04-22-staff-recruitment-dialog-width-implementation.md @@ -0,0 +1,30 @@ +# 招聘信息新增编辑弹窗宽度调整实施记录 + +## 文档信息 + +- 保存路径:`docs/reports/implementation/2026-04-22-staff-recruitment-dialog-width-implementation.md` +- 实施日期:2026-04-22 +- 关联范围:招聘信息管理前端页面 + +## 本次修改内容 + +1. 定位到招聘信息管理页新增/编辑共用同一个 `el-dialog`,原宽度固定为 `900px`。 +2. 将新增/编辑弹窗宽度改为 `80%`,使弹窗按页面可视宽度自适应放大。 +3. 保持招聘信息详情弹窗宽度和原有表单提交流程不变,避免影响非本次需求范围的交互。 +4. 新增本次前端实施计划与实施记录,沉淀改动背景、影响范围与验证结论。 + +## 影响范围 + +- 前端:`ruoyi-ui/src/views/ccdiStaffRecruitment/index.vue` 新增/编辑招聘信息弹窗。 +- 文档:新增前端实施计划与实施记录。 + +## 验证情况 + +1. 前端构建校验: + - 执行命令:`source ~/.nvm/nvm.sh && nvm use 14.21.3 >/dev/null && cd ruoyi-ui && npm run build:prod` + - 结果:构建成功;存在项目原有的 bundle size warnings,本次改动未引入新的构建错误。 +2. Playwright 浏览器实测: + - 执行方式:通过 `nvm use 25.9.0` 启动 `playwright-cli`,打开 `http://127.0.0.1:1025/prototype/staff-recruitment?preview=1&mode=add` 与 `http://127.0.0.1:1025/prototype/staff-recruitment?preview=1&mode=edit`,在真实浏览器中读取可见 `el-dialog` 的实际宽度。 + - 结果:新增弹窗标题为“添加招聘信息”,页面宽度 `1280px`、弹窗宽度 `1024px`、宽度占比 `0.8`;编辑弹窗标题为“修改招聘信息”,页面宽度 `1280px`、弹窗宽度 `1024px`、宽度占比 `0.8`,与需求一致。 +3. 测试进程清理: + - 已关闭本次测试过程中临时启动的 Playwright 浏览器与前端 dev server。 diff --git a/docs/reports/implementation/2026-04-22-staff-recruitment-work-experience-manual-edit-implementation.md b/docs/reports/implementation/2026-04-22-staff-recruitment-work-experience-manual-edit-implementation.md new file mode 100644 index 00000000..a1fcc187 --- /dev/null +++ b/docs/reports/implementation/2026-04-22-staff-recruitment-work-experience-manual-edit-implementation.md @@ -0,0 +1,48 @@ +# 招聘信息编辑页手动维护历史工作经历实施记录 + +## 文档信息 + +- 保存路径:`docs/reports/implementation/2026-04-22-staff-recruitment-work-experience-manual-edit-implementation.md` +- 实施日期:2026-04-22 +- 关联范围:招聘信息管理前后端 + +## 本次修改内容 + +### 后端 + +1. 新增 `CcdiStaffRecruitmentWorkEditDTO`,接收编辑页提交的历史工作经历子项。 +2. 在 `CcdiStaffRecruitmentEditDTO` 中补充 `workExperienceList` 字段,并启用嵌套校验。 +3. 在 `CcdiStaffRecruitmentServiceImpl.updateRecruitment` 中增加历史工作经历覆盖保存逻辑: + - 社招且前端传入工作经历列表时,按当前表单内容覆盖 `ccdi_staff_recruitment_work`; + - 切换为校招时,自动清空该招聘记录的历史工作经历。 + +### 前端 + +1. 在招聘信息编辑弹窗中新增“候选人历史工作经历”编辑表格。 +2. 支持手动新增、删除历史工作经历,并在前端自动重排序号。 +3. 将入职时间、离职时间改为月份选择器,避免手输格式错误。 +4. 提交编辑前增加年月格式、开始结束时间顺序、必填项校验。 +5. 编辑提交时将规范化后的历史工作经历列表一并提交给后端;新增请求继续沿用原字段集,不携带额外子表字段。 + +## 影响范围 + +- 后端:招聘信息编辑接口、历史工作经历子表保存逻辑。 +- 前端:招聘信息管理编辑弹窗。 +- 文档:新增前后端实施计划与本实施记录。 + +## 验证情况 + +1. Maven 编译校验通过: + - 执行命令:`mvn -pl ccdi-info-collection -am compile -DskipTests` + - 结果:编译成功。 +2. 前端构建校验通过: + - 执行命令:`cd ruoyi-ui && source ~/.nvm/nvm.sh && nvm use 14.21.3 && npm run build:prod` + - 结果:构建成功,仅存在原有体积告警,无新增构建错误。 +3. 浏览器实测通过: + - 先通过真实登录页进入实际业务路由 `/maintain/staffRecruitment`; + - 在真实“招聘信息管理”页面编辑社招记录 `RC2025001805`,手动新增一条历史工作经历并保存; + - 保存后列表中的“历史工作经历”由 `0段` 变为 `1段`; + - 打开真实详情弹窗后,新增的工作经历可正常展示。 + - 在另一条 `0段` 记录的编辑弹窗中确认“入职时间 / 离职时间”已切换为月份选择器,点击后会弹出月份面板。 +4. 测试进程清理: + - 已关闭本次测试过程中打开的 Playwright 浏览器、前端 dev server 和后端进程。 diff --git a/docs/reports/implementation/2026-04-23-116-62-17-81-docker-deploy-record.md b/docs/reports/implementation/2026-04-23-116-62-17-81-docker-deploy-record.md new file mode 100644 index 00000000..dff3439b --- /dev/null +++ b/docs/reports/implementation/2026-04-23-116-62-17-81-docker-deploy-record.md @@ -0,0 +1,63 @@ +# 2026-04-23 `116.62.17.81:9444` Docker 部署记录 + +## 保存路径确认 + +- 目标目录:`docs/reports/implementation/` +- 文档用途:记录本次部署执行过程、影响范围与验证结果 +- 路径检查结果:符合仓库实施记录归档规范 + +## 本次操作 + +- 在本地仓库 `/Users/wkc/Desktop/ccdi/ccdi` 执行部署 +- 前端先通过 `nvm use` 切换到 `ruoyi-ui/.nvmrc` 指定版本:`Node v14.21.3` +- 执行后端打包:`mvn clean package -DskipTests` +- 执行前端打包:`cd ruoyi-ui && npm run build:prod` +- 执行部署脚本:`./deploy/deploy-to-nas.sh` +- 部署目标: + - SSH:`116.62.17.81:9444` + - 远端目录:`/volume1/webapp/ccdi` + - 宿主机内网地址:`192.168.0.111` + +## 影响范围 + +- 远端部署目录 `/volume1/webapp/ccdi` 已刷新为本次构建产物 +- Docker 服务已重建: + - `ccdi-backend` + - `ccdi-frontend` + - `ccdi-lsfx-mock` +- 本次仓库内仅新增本实施记录文档,无业务代码改动 + +## 验证结果 + +### 构建验证 + +- Maven 聚合打包成功,`ruoyi-admin/target/ruoyi-admin.jar` 已生成 +- Vue 生产构建成功,`ruoyi-ui/dist` 已生成 +- 前端构建期间仅出现体积告警,无构建失败 + +### 远端容器验证 + +- `docker compose ps` 结果: + - `ccdi-backend`:`Up` + - `ccdi-frontend`:`Up` + - `ccdi-lsfx-mock`:`Up` +- 端口监听结果: + - `62318 -> backend:8080` + - `62319 -> frontend:80` + - `62320 -> backend network / mock:8000` + +### 应用可用性验证 + +- 宿主机本机访问 `127.0.0.1` 返回正常: + - `http://127.0.0.1:62319` 返回 `200 OK` + - `http://127.0.0.1:62318/swagger-ui/index.html` 返回 `200` + - `http://127.0.0.1:62320/docs` 返回 `200 OK` +- 后端日志确认: + - `nas` profile 已启用 + - TongWeb `8080` 已启动 + - `RuoYiApplication` 启动完成 + +## 额外说明 + +- 通过公网地址 `116.62.17.81:62318/62319/62320` 访问时,当前观测到的是 `Empty reply from server` +- 由于宿主机实际网卡地址为 `192.168.0.111`,`116.62.17.81` 属于外层公网映射地址,因此当前应用侧与 Docker 侧均已正常,剩余问题若需继续处理,应排查公网入口到宿主机 `62318/62319/62320` 的端口转发或 NAT/网关链路 diff --git a/docs/reports/implementation/2026-04-23-base-staff-import-dept-validation-implementation.md b/docs/reports/implementation/2026-04-23-base-staff-import-dept-validation-implementation.md new file mode 100644 index 00000000..d9f70680 --- /dev/null +++ b/docs/reports/implementation/2026-04-23-base-staff-import-dept-validation-implementation.md @@ -0,0 +1,28 @@ +# 员工信息导入机构号校验实施记录 + +## 文档信息 +- 保存路径:`docs/reports/implementation/2026-04-23-base-staff-import-dept-validation-implementation.md` +- 实施日期:2026-04-23 +- 关联范围:员工信息维护后端导入链路 + +## 本次修改内容 +1. 在 `CcdiBaseStaffImportServiceImpl` 中新增 `deptId` 校验逻辑,要求导入员工的所属部门必须在 `sys_dept` 中存在且处于正常、未删除状态。 +2. 机构号校验失败时,导入记录不入库,失败原因统一写入现有失败记录字段 `errorMessage`,错误文案为 `所属部门ID[xxx]不存在或已停用/删除,请检查机构号`。 +3. 补齐 `SysDeptMapper.xml` 中 `selectDeptById` 的 `del_flag` 字段映射,避免导入服务误把逻辑删除部门识别为有效部门。 +4. 补充员工导入服务层与异步导入流程单元测试,覆盖部门不存在、部门停用、部门删除和合法/非法混合导入场景。 + +## 影响范围 +- 后端: + - `ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/impl/CcdiBaseStaffImportServiceImpl.java` + - `ruoyi-system/src/main/resources/mapper/system/SysDeptMapper.xml` +- 测试: + - `ccdi-info-collection/src/test/java/com/ruoyi/info/collection/service/CcdiBaseStaffImportServiceImplTest.java` + - `ccdi-info-collection/src/test/java/com/ruoyi/info/collection/service/CcdiBaseStaffDualImportServiceTest.java` + +## 验证情况 +1. 定向单测: + - 命令:`mvn -pl ccdi-info-collection -am -Dsurefire.failIfNoSpecifiedTests=false -Dtest=CcdiBaseStaffImportServiceImplTest,CcdiBaseStaffDualImportServiceTest test` + - 结果:通过,`Tests run: 13, Failures: 0, Errors: 0, Skipped: 0` +2. 编译校验: + - 命令:`mvn -pl ccdi-info-collection,ruoyi-admin -am -DskipTests compile` + - 结果:通过,Reactor Summary 中 `ccdi-info-collection`、`ruoyi-admin` 及其依赖模块均为 `SUCCESS` diff --git a/docs/reports/implementation/2026-04-23-bidding-supplier-enterprise-detail-design-record.md b/docs/reports/implementation/2026-04-23-bidding-supplier-enterprise-detail-design-record.md new file mode 100644 index 00000000..661b76a6 --- /dev/null +++ b/docs/reports/implementation/2026-04-23-bidding-supplier-enterprise-detail-design-record.md @@ -0,0 +1,29 @@ +# 招投标详情弹窗供应商企业信息查看设计文档沉淀记录 + +## 本次改动 + +- 新增设计文档 `docs/superpowers/specs/2026-04-23-bidding-supplier-enterprise-detail-design.md` +- 固化“招投标详情弹窗供应商企业信息查看”需求的边界、查询口径、交互方式、错误处理和测试范围 +- 补充权限口径:本次不新增权限控制,不做按钮显隐,接口权限失败也统一提示“暂无企业信息” +- 明确本次需求为纯前端改动,后续实施阶段仅需输出前端实施计划 + +## 关键文件 + +- `docs/superpowers/specs/2026-04-23-bidding-supplier-enterprise-detail-design.md` +- `docs/reports/implementation/2026-04-23-bidding-supplier-enterprise-detail-design-record.md` + +## 验证结果 + +- 文档保存路径已确认存在: + - `docs/superpowers/specs/` + - `docs/reports/implementation/` +- 设计文档内容已覆盖: + - 方案比较与选型结论 + - 页面交互边界 + - 查询与展示字段口径 + - 错误处理策略 + - 实页测试要求 + +## 测试进程清理 + +- 本次仅进行文档沉淀,未启动前端、后端或浏览器测试进程,无需额外清理。 diff --git a/docs/reports/implementation/2026-04-23-bidding-supplier-enterprise-detail-implementation.md b/docs/reports/implementation/2026-04-23-bidding-supplier-enterprise-detail-implementation.md new file mode 100644 index 00000000..e65683ad --- /dev/null +++ b/docs/reports/implementation/2026-04-23-bidding-supplier-enterprise-detail-implementation.md @@ -0,0 +1,38 @@ +# 招投标详情弹窗供应商企业信息查看实施记录 + +## 本次修改 +- 在招投标信息维护详情弹窗的供应商明细中新增“详情”按钮,固定显示且未新增实体库权限显隐控制。 +- 复用实体库详情接口,按 `supplierUscc` 查询企业信息,并以二级弹窗展示全部字段。 +- 缺少统一信用代码、查无数据、接口 500/普通异常时,统一提示“暂无企业信息”。 + +## 影响范围 +- `ruoyi-ui/src/views/ccdiPurchaseTransaction/index.vue` +- `ruoyi-ui/tests/unit/purchase-transaction-enterprise-detail-ui.test.js` + +## 验证方式 +- Node 源码断言测试 + - `source ~/.nvm/nvm.sh && nvm use 14.21.3 >/dev/null && node ruoyi-ui/tests/unit/purchase-transaction-enterprise-detail-ui.test.js` +- 前端生产构建 + - `source ~/.nvm/nvm.sh && nvm use 14.21.3 >/dev/null && cd ruoyi-ui && npm run build:prod` +- Playwright 真实页面验证 + - 页面地址:`http://localhost:8080/maintain/purchaseTransaction` + - 覆盖场景: + - 供应商 `supplierUscc` 命中实体库时可打开企业详情弹窗 + - 企业详情字段顺序、日期格式、枚举中文标签与实体库详情页口径一致 + - `supplierUscc` 为空时提示“暂无企业信息” + - 查无数据时提示“暂无企业信息” + - 接口 500 时提示“暂无企业信息” + - 命中后关闭企业详情弹窗,再查看未命中供应商时不残留上一条详情数据 + +## 真实页面验证结论 +- 使用真实业务页面完成验证,供应商明细“详情”按钮在详情弹窗中固定显示。 +- 命中实体库样本时,二级弹窗成功展示统一社会信用代码、企业名称、企业类型、企业性质、行业分类、所属行业、法定代表人、风险等级、企业来源、数据来源、股东信息等字段。 +- 查无数据、缺少统一信用代码、接口异常三类异常分支均统一显示“暂无企业信息”,未出现残留旧详情数据的问题。 + +## 测试进程清理 +- 已关闭本次启动的前端 `npm run dev -- --port 8080` 进程。 +- 后端 `62318` 端口服务在验证前已存在,本次未重新启动后端进程。 +- 已关闭 Playwright 浏览器会话,并清理残留 daemon 进程。 + +## 备注 +- 计划中的中间提交步骤未执行:`ruoyi-ui/src/views/ccdiPurchaseTransaction/index.vue` 在实施前已存在未提交改动,为避免混入同文件既有变更,本次仅完成实现、验证与文档沉淀。 diff --git a/docs/reports/implementation/2026-04-23-bidding-supplier-validation-implementation.md b/docs/reports/implementation/2026-04-23-bidding-supplier-validation-implementation.md new file mode 100644 index 00000000..28b5478c --- /dev/null +++ b/docs/reports/implementation/2026-04-23-bidding-supplier-validation-implementation.md @@ -0,0 +1,44 @@ +# 招投标供应商校验调整实施记录 + +## 本次改动 +- 招投标信息维护页面新增、编辑弹窗中的供应商明细校验调整为仅保留: + - 供应商名称必填 + - 统一信用代码必填 +- 移除了前端弹窗中供应商名称长度、统一信用代码格式、联系人长度、联系电话格式、银行账户长度校验。 +- 移除了新增/编辑接口 DTO 中对应的供应商内容校验,确保真实页面保存链路与弹窗规则一致。 + +## 关键文件 +- 前端 + - `ruoyi-ui/src/views/ccdiPurchaseTransaction/index.vue` +- 后端 + - `ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/dto/CcdiPurchaseTransactionSupplierDTO.java` + +## 验证结果 +- 后端编译 + - 命令:`mvn -pl ccdi-info-collection -am -DskipTests compile` + - 结果:通过。 +- 后端重启 + - 命令:`sh bin/restart_java_backend.sh` + - 结果:构建并重启成功。 +- 前端构建 + - 命令:`source ~/.nvm/nvm.sh && nvm use 14.21.3 >/dev/null && cd ruoyi-ui && npm run build:prod` + - 结果:通过,仅有既有产物体积 warning。 +- Playwright 实页验证 + - 页面:`http://127.0.0.1:62319/maintain/purchaseTransaction` + - 新增验证: + - 采购事项ID:`AUTOBID20260423145630` + - 供应商统一信用代码:`ABC` + - 供应商联系电话:`123` + - 结果:新增成功。 + - 编辑验证: + - 项目名称改为:`校验放开回归项目-145630-编辑` + - 供应商统一信用代码改为:`XYZ` + - 供应商联系电话改为:`abc123` + - 结果:修改成功。 + - 清理验证: + - 删除 `AUTOBID20260423145630` + - 结果:删除成功,列表总数回到 `2004`。 + +## 过程说明 +- 首轮真实页验证发现,前端规则放开后,新增接口仍因 `CcdiPurchaseTransactionSupplierDTO` 的 Bean Validation 拦截 `supplierUscc` 和 `contactPhone`。 +- 因此本次最终方案同时调整了前端弹窗规则与后端 DTO 校验,保证页面行为与需求一致。 diff --git a/docs/reports/implementation/2026-04-23-credit-info-remove-page-title-implementation.md b/docs/reports/implementation/2026-04-23-credit-info-remove-page-title-implementation.md new file mode 100644 index 00000000..b503c5e9 --- /dev/null +++ b/docs/reports/implementation/2026-04-23-credit-info-remove-page-title-implementation.md @@ -0,0 +1,75 @@ +# 征信记录维护页面去掉顶部标题实施记录 + +## 1. 变更目标 + +去掉征信记录维护页面最上方单独显示的标题“征信维护”,保留页面其余查询区、按钮区和列表逻辑不变。 + +说明: + +- 因当前工作区中该文件同时叠加了尚未提交的“搜索区四列栅格”调整,本次按用户确认口径一并提交该文件中的两类前端改动 + +## 2. 实际修改 + +修改文件: + +- `ruoyi-ui/src/views/ccdiCreditInfo/index.vue` + +本次改动内容: + +- 保留并一并提交当前文件中已完成的搜索区四列栅格调整 +- 删除模板中的 `
征信维护
` +- 删除仅供该标题使用的 `.page-title` 样式 + +未改动内容: + +- 查询区字段与布局 +- 搜索 / 重置 / 批量上传征信HTML 按钮 +- 列表、分页、上传弹窗、详情弹窗逻辑 + +## 3. 构建验证 + +执行命令: + +```bash +source ~/.nvm/nvm.sh && cd ruoyi-ui && nvm use >/dev/null && npm run build:prod +``` + +结果: + +- 构建成功 +- 无新增模板语法错误 +- 仅保留项目原有打包体积告警 + +## 4. 真实页面验证 + +启动服务: + +```bash +sh bin/restart_java_backend.sh start +source ~/.nvm/nvm.sh && cd ruoyi-ui && nvm use >/dev/null && npm_config_port=1025 npm run dev +``` + +验证页面: + +- `http://localhost:1025/maintain/creditInfo` + +验证方式: + +- 通过真实登录页进入系统 +- 使用 Playwright 打开征信记录维护页面 +- 检查页面中是否仍存在 `.page-title` + +验证结果: + +- 页面顶部单独标题已移除 +- `document.querySelector(".page-title")` 返回 `false` +- 征信页查询区仍保持四列栅格实现中的双字段布局,不影响当前搜索区显示 +- 页面查询区直接出现在顶部,按钮与列表显示正常 + +## 5. 进程清理 + +验证结束后关闭: + +- 前端开发服务 `npm run dev` +- 后端服务,使用 `sh bin/restart_java_backend.sh stop` +- Playwright 浏览器会话 diff --git a/docs/reports/implementation/2026-04-23-enterprise-base-info-add-dialog-implementation.md b/docs/reports/implementation/2026-04-23-enterprise-base-info-add-dialog-implementation.md new file mode 100644 index 00000000..96a3499c --- /dev/null +++ b/docs/reports/implementation/2026-04-23-enterprise-base-info-add-dialog-implementation.md @@ -0,0 +1,80 @@ +# 实体库管理新增弹窗与导入模板调整实施记录 + +## 文档路径确认 + +- 实施记录保存路径:`docs/reports/implementation/` +- 本文档文件名:`2026-04-23-enterprise-base-info-add-dialog-implementation.md` + +## 本次修改内容 + +- 新增弹窗隐藏“数据来源”字段。 +- 新增时后端自动写入 `MANUAL`,不再依赖前端传值。 +- 编辑时数据来源改为只读展示,后端更新时保留原值不变。 +- 经营状态改为非必填,空值统一按 `null` 落库。 +- 导入模板移除“数据来源”列,并将“经营状态*”改为“经营状态”。 +- 导入时后端自动写入 `IMPORT`。 + +## 影响范围 + +- 后端: + - `ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/dto/CcdiEnterpriseBaseInfoAddDTO.java` + - `ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/dto/CcdiEnterpriseBaseInfoEditDTO.java` + - `ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/excel/CcdiEnterpriseBaseInfoExcel.java` + - `ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/impl/CcdiEnterpriseBaseInfoServiceImpl.java` + - `ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/impl/CcdiEnterpriseBaseInfoImportServiceImpl.java` +- 前端: + - `ruoyi-ui/src/views/ccdiEnterpriseBaseInfo/index.vue` +- 测试: + - `ccdi-info-collection/src/test/java/com/ruoyi/info/collection/service/CcdiEnterpriseBaseInfoServiceImplTest.java` + - `ccdi-info-collection/src/test/java/com/ruoyi/info/collection/service/CcdiEnterpriseBaseInfoImportServiceImplTest.java` + - `ccdi-info-collection/src/test/java/com/ruoyi/info/collection/utils/EasyExcelUtilTemplateTest.java` + +## 验证结果 + +### 1. 定向单元测试 + +- 命令: + `mvn -pl ccdi-info-collection -am -Dsurefire.failIfNoSpecifiedTests=false -Dtest=CcdiEnterpriseBaseInfoServiceImplTest,CcdiEnterpriseBaseInfoImportServiceImplTest,EasyExcelUtilTemplateTest test` +- 结果: + 17 个定向测试全部通过。 + +### 补充调整 + +- 根据追加要求,编辑弹窗中的数据来源已改为不可修改展示。 +- 后端更新接口忽略请求里的 `dataSource` 变更,统一沿用数据库原值。 + +### 2. 真实后端接口验证 + +- 使用 `/login/test` 获取 token 后,调用新增接口仅传: + - `socialCreditCode` + - `enterpriseName` + - `riskLevel` + - `entSource` +- 结果: + - 新增成功 + - 查询结果中 `status=null` + - 查询结果中 `dataSource=MANUAL` + - 删除测试数据成功 + +### 3. 导入模板实际下载校验 + +- 通过真实接口下载模板文件到 `output/spreadsheet/enterprise-base-info-template-20260423.xlsx` +- 核对首行表头结果: + - 包含 `经营状态` + - 包含 `风险等级*` + - 包含 `企业来源*` + - 不包含 `数据来源` + +### 4. 浏览器实际页面验证 + +- 前端按仓库要求执行 `nvm use`,确认使用 `Node v14.21.3` +- 启动 `ruoyi-ui` 开发服务并用 Playwright 打开真实页面 `实体库管理` +- 实际观察结果: + - 新增弹窗中“数据来源”字段已隐藏 + - 经营状态不再显示为必填项 + - 导入入口可正常打开 + +## 过程说明 + +- 验证时发现 `62318` 初始运行的是旧后端进程,仍返回“经营状态不能为空”,随后已按规范使用 `bin/restart_java_backend.sh restart` 重启后端并完成回归验证。 +- 浏览器内尝试直接提交测试数据时页面没有明确反馈,因此补充了真实接口新增/查询/删除验证来完成链路闭环,并已清理测试数据。 diff --git a/docs/reports/implementation/2026-04-23-info-maintenance-search-grid-implementation.md b/docs/reports/implementation/2026-04-23-info-maintenance-search-grid-implementation.md new file mode 100644 index 00000000..2cfcad47 --- /dev/null +++ b/docs/reports/implementation/2026-04-23-info-maintenance-search-grid-implementation.md @@ -0,0 +1,188 @@ +# 信息维护页面搜索区四列栅格统一实施记录 + +## 1. 实施目标 + +将信息维护同批页面的搜索区统一为每行 4 个字段位的栅格布局,超过 4 个字段自动换行,最后一行不足 4 项时保留空白字段位,不拉伸现有字段;同时清理查询区内遗留的固定像素宽度写法。 + +## 2. 实施范围 + +本次实际调整的前端文件如下: + +- `ruoyi-ui/src/views/ccdiBaseStaff/index.vue` +- `ruoyi-ui/src/views/ccdiStaffRecruitment/index.vue` +- `ruoyi-ui/src/views/ccdiStaffTransfer/index.vue` +- `ruoyi-ui/src/views/ccdiStaffFmyRelation/index.vue` +- `ruoyi-ui/src/views/ccdiStaffEnterpriseRelation/index.vue` +- `ruoyi-ui/src/views/ccdiCreditInfo/index.vue` +- `ruoyi-ui/src/views/ccdiEnterpriseBaseInfo/index.vue` +- `ruoyi-ui/src/views/ccdiIntermediary/components/SearchForm.vue` +- `ruoyi-ui/src/views/ccdiAccountInfo/index.vue` +- `ruoyi-ui/src/views/ccdiCustFmyRelation/index.vue` +- `ruoyi-ui/src/views/ccdiCustEnterpriseRelation/index.vue` +- `ruoyi-ui/src/views/ccdiPurchaseTransaction/index.vue` + +本轮未提交的临时验证文件: + +- `output/playwright/info-maintenance-search-grid-check.js` + +## 3. 实施内容 + +### 3.1 查询区统一为四列栅格 + +- 将目标页面的头部查询表单从 `:inline="true"` 流式排布改为 `el-row + el-col :span="6"` 栅格排布 +- 每个查询项固定占一个字段位 +- 超过 4 个字段自动换到下一行 +- 最后一行不足 4 项时保留空白字段位,不做横向拉伸 + +### 3.2 字段内部控件统一铺满字段位 + +- 将查询区里的输入框、下拉框、日期范围、树选择统一改为 `style="width: 100%"` +- 清理查询区中遗留的固定像素宽度写法,不再依赖 `style="width: Npx"` 控制布局 +- 补充查询区专用样式,使 `el-row` 使用换行布局,`el-col` 不再依赖旧浮动行为导致错位 + +### 3.3 保持原有交互和业务逻辑不变 + +- 未修改 `queryParams`、`handleQuery`、`resetQuery` +- 未修改搜索、重置、业务按钮、`right-toolbar` 的行为 +- 日期范围仍按一个字段位处理,仅在所属字段位内铺满 + +## 4. TDD / 结构校验 + +### 4.1 先写临时结构校验脚本 + +新增临时脚本: + +- `output/playwright/info-maintenance-search-grid-check.js` + +脚本校验口径: + +- 只识别头部查询表单,不误扫弹窗表单 +- 查询区是否采用 `el-col :span="6"` +- 查询项数量是否符合预期 +- 查询区内是否仍保留 `style="width: Npx"` 固定像素宽度 +- 账户库管理页是否仍残留旧的查询区局部覆盖样式 + +### 4.2 先失败,再通过 + +首次执行命令: + +```bash +source ~/.nvm/nvm.sh && cd ruoyi-ui && nvm use >/dev/null && cd .. && node output/playwright/info-maintenance-search-grid-check.js +``` + +初次结果: + +- 12 个目标页面全部报出“未使用 `span=6`”和“仍存在固定像素宽度”的失败 + +页面改造完成后再次执行同一命令,结果为: + +- `信息维护搜索区四列栅格结构校验通过` + +## 5. 构建验证 + +### 5.1 Node 版本 + +执行命令: + +```bash +source ~/.nvm/nvm.sh && cd ruoyi-ui && nvm use +``` + +结果: + +- 命中项目 `nvm` 配置 +- 使用项目要求的 Node 版本完成后续脚本和构建 + +### 5.2 前端构建 + +执行命令: + +```bash +source ~/.nvm/nvm.sh && cd ruoyi-ui && nvm use >/dev/null && npm run build:prod +``` + +结果: + +- 构建成功 +- 无新增模板语法错误 +- 仅保留项目原有的体积告警 + +## 6. 真实页面浏览器验证 + +### 6.1 启动服务 + +前端开发服务: + +```bash +source ~/.nvm/nvm.sh && cd ruoyi-ui && nvm use >/dev/null && npm_config_port=1025 npm run dev +``` + +后端服务: + +```bash +sh bin/restart_java_backend.sh start +``` + +说明: + +- 本轮验证时后端原本未运行,因此使用仓库规定脚本启动 +- 使用真实登录页进入系统,账号为 `admin / admin123` + +### 6.2 布局验证结果 + +使用 Playwright 进入以下代表页并读取查询区实际列位位置: + +- `/maintain/baseStaff` +- `/maintain/enterpriseBaseInfo` +- `/maintain/accountInfo` +- `/maintain/creditInfo` +- `/maintain/intermediary` +- `/maintain/staffTransfer` +- `/maintain/purchaseTransaction` + +验证结果: + +- 员工信息维护:5 个字段,实际排布为 `4 + 1` +- 实体库管理:8 个字段,实际排布为 `4 + 4` +- 账户库管理:9 个字段,实际排布为 `4 + 4 + 1` +- 征信维护:2 个字段,实际排布为单行 2 项,未拉伸成半屏布局 +- 中介库管理:4 个字段,实际排布为单行 4 列 +- 员工调动记录:6 个字段,实际排布为 `4 + 2` +- 招投标信息维护:4 个字段,日期范围与其他查询项等宽,占 1 个字段位 + +浏览器读取到的列位位置表现一致: + +- 同一行 4 列的左边界稳定为四等分位置 +- 超过 4 个字段的新一行从第一列起始位置重新排列 +- 最后一行不足 4 项时未发生拉伸 + +### 6.3 重置验证 + +在真实业务页面中使用 Playwright 做了两组输入与重置验证: + +1. 员工信息维护 + - 向“姓名”输入框填入 `测试姓名` + - 点击页面 `重置` + - 再次读取输入框值,结果为空字符串 + +2. 账户库管理 + - 向“员工姓名”输入框填入 `验证员工` + - 点击页面 `重置` + - 再次读取输入框值,结果为空字符串 + +结论: + +- 搜索区改为四列栅格后,`重置` 交互未受影响 + +## 7. 进程清理 + +本轮验证结束后已关闭: + +- 前端开发服务 `npm run dev` +- 后端服务 `sh bin/restart_java_backend.sh start` 启动的后端进程,使用 `sh bin/restart_java_backend.sh stop` 停止 +- Playwright 浏览器会话 + +## 8. 风险与说明 + +- 当前工作区存在其他与本任务无关的未提交改动,本次未回退这些既有改动,仅在搜索区四列栅格范围内继续修改目标页面 +- 临时结构校验脚本位于 `output/playwright/`,用于本轮 TDD 校验,不纳入 git 提交 diff --git a/docs/reports/implementation/2026-04-23-info-maintenance-toolbar-unification-implementation.md b/docs/reports/implementation/2026-04-23-info-maintenance-toolbar-unification-implementation.md new file mode 100644 index 00000000..84b06de1 --- /dev/null +++ b/docs/reports/implementation/2026-04-23-info-maintenance-toolbar-unification-implementation.md @@ -0,0 +1,173 @@ +# 信息维护页面头部按钮统一实施记录 + +## 1. 实施目标 + +将“信息维护”菜单下各前端页面的 `搜索 / 重置` 按钮统一移动到业务按钮所在操作行,并放在业务按钮左侧,覆盖常规页、征信维护页和中介库管理页。 + +## 2. 实施范围 + +本次实际调整的前端文件如下: + +- `ruoyi-ui/src/views/ccdiBaseStaff/index.vue` +- `ruoyi-ui/src/views/ccdiStaffRecruitment/index.vue` +- `ruoyi-ui/src/views/ccdiStaffTransfer/index.vue` +- `ruoyi-ui/src/views/ccdiStaffFmyRelation/index.vue` +- `ruoyi-ui/src/views/ccdiStaffEnterpriseRelation/index.vue` +- `ruoyi-ui/src/views/ccdiCreditInfo/index.vue` +- `ruoyi-ui/src/views/ccdiEnterpriseBaseInfo/index.vue` +- `ruoyi-ui/src/views/ccdiIntermediary/index.vue` +- `ruoyi-ui/src/views/ccdiIntermediary/components/SearchForm.vue` +- `ruoyi-ui/src/views/ccdiAccountInfo/index.vue` +- `ruoyi-ui/src/views/ccdiCustFmyRelation/index.vue` +- `ruoyi-ui/src/views/ccdiCustEnterpriseRelation/index.vue` +- `ruoyi-ui/src/views/ccdiPurchaseTransaction/index.vue` + +页面口径分为三类: + +1. 常规页 + 员工信息维护、招聘信息维护、员工调动记录、员工亲属关系维护、员工实体关系维护、实体库管理、账户库管理、信贷客户家庭关系、信贷客户实体关联、招投标信息维护。 + +2. 特殊业务按钮页 + 征信维护,保留 `批量上传征信HTML` 语义,只统一按钮所在行。 + +3. 组件拆分页 + 中介库管理,保留 `SearchForm` 查询字段组件,但把 `搜索 / 重置` 的展示职责收回父页面。 + +## 3. 实施内容 + +### 3.1 常规页 + +- 删除查询表单末尾用于展示 `搜索 / 重置` 的 `el-form-item` +- 在原有 `el-row.mb8` 操作行最左侧新增 `搜索`、`重置` +- 保留原有业务按钮顺序、权限和显示逻辑 + +统一后的顺序为: + +`搜索 -> 重置 -> 页面原有业务按钮 -> right-toolbar` + +### 3.2 征信维护 + +- 删除查询表单末尾的 `搜索 / 重置` +- 在操作行最左侧新增 `搜索 / 重置` +- 保持 `批量上传征信HTML` 按钮文案和上传链路不变 + +统一后的顺序为: + +`搜索 -> 重置 -> 批量上传征信HTML -> right-toolbar` + +### 3.3 中介库管理 + +- `SearchForm.vue` 移除 `搜索 / 重置` 的展示代码 +- 父页面 `index.vue` 在操作行最左侧新增 `搜索 / 重置` +- 父页面补充 `resetQuery`,用于清空查询参数并刷新列表 +- 保持两类导入按钮、两类失败记录按钮和详情维护链路不变 + +统一后的顺序为: + +`搜索 -> 重置 -> 新增 -> 导入中介和亲属信息 -> 导入中介实体关联关系 -> 失败记录按钮 -> right-toolbar` + +## 4. TDD / 结构校验 + +为避免直接改模板后再“凭感觉确认”,本次先编写了临时结构校验脚本,先跑出失败,再修改页面: + +```bash +node output/playwright/info-maintenance-toolbar-check.js +``` + +初次运行结果: + +- 当前各页面查询表单内仍然包含 `搜索 / 重置` +- 中介库 `SearchForm` 仍然包含 `搜索 / 重置` +- 各页面操作行中缺少统一放置在左侧的 `搜索 / 重置` + +页面改造完成后再次运行,同一脚本返回: + +- `信息维护头部按钮结构校验通过` + +## 5. 构建验证 + +### 5.1 Node 版本 + +执行命令: + +```bash +source ~/.nvm/nvm.sh && cd ruoyi-ui && nvm use +``` + +结果: + +- 命中 `ruoyi-ui/.nvmrc` +- 使用 `node v14.21.3` + +### 5.2 前端构建 + +执行命令: + +```bash +source ~/.nvm/nvm.sh && cd ruoyi-ui && nvm use >/dev/null && npm run build:prod +``` + +结果: + +- 构建成功 +- 无新增 Vue 模板语法错误 +- 仅保留项目原有的打包体积告警 + +## 6. 真实页面浏览器验证 + +### 6.1 验证方式 + +- 使用真实前端开发服务,不打开 prototype 页面 +- 使用 Playwright 打开真实业务路由 +- 先登录系统,再进入代表页验证 + +启动前端: + +```bash +source ~/.nvm/nvm.sh && cd ruoyi-ui && nvm use >/dev/null && npm run dev +``` + +登录与页面验证覆盖以下三类代表页: + +- `http://localhost:1025/maintain/baseStaff` +- `http://localhost:1025/maintain/creditInfo` +- `http://localhost:1025/maintain/intermediary` + +### 6.2 员工信息维护 + +验证结果: + +- 查询条件区不再展示 `搜索 / 重置` +- 操作行顺序为 `搜索 -> 重置 -> 新增 -> 导入 -> right-toolbar` +- 在页面内输入姓名后点击 `重置`,输入框成功清空 + +### 6.3 征信维护 + +验证结果: + +- 查询条件区不再展示 `搜索 / 重置` +- 操作行顺序为 `搜索 -> 重置 -> 批量上传征信HTML -> right-toolbar` +- 在页面内输入姓名后点击 `重置`,输入框成功清空 +- `批量上传征信HTML` 按钮仍正常显示 + +### 6.4 中介库管理 + +验证结果: + +- `SearchForm` 仅保留查询字段,不再展示 `搜索 / 重置` +- 父页面操作行顺序为 `搜索 -> 重置 -> 新增 -> 导入中介和亲属信息 -> 导入中介实体关联关系 -> right-toolbar` +- 在页面内输入名称后点击 `重置`,输入框成功清空 + +## 7. 进程清理 + +测试完成后已主动关闭: + +- Playwright 浏览器会话 +- 前端开发进程 `npm run dev` + +未保留测试进程占用端口。 + +## 8. 风险说明 + +- 当前工作区中部分前端页面和后端文件本身已存在其他未提交改动,本次实现没有回退这些既有改动,只在头部按钮结构范围内继续追加修改。 +- 因同名文件中存在先前改动,本轮未直接执行代码提交,避免误把无关改动一起纳入提交。 diff --git a/docs/reports/implementation/2026-04-23-staff-family-enterprise-relation-implementation.md b/docs/reports/implementation/2026-04-23-staff-family-enterprise-relation-implementation.md new file mode 100644 index 00000000..79ec8fa4 --- /dev/null +++ b/docs/reports/implementation/2026-04-23-staff-family-enterprise-relation-implementation.md @@ -0,0 +1,156 @@ +# 员工亲属实体关联实施记录 + +## 1. 变更背景 + +根据以下实施计划执行员工亲属实体关联改造: + +- 后端计划:`docs/plans/backend/2026-04-23-staff-family-enterprise-relation-backend-implementation.md` +- 前端计划:`docs/plans/frontend/2026-04-23-staff-family-enterprise-relation-frontend-implementation.md` + +本次改造目标是在不新增平行模块和表结构的前提下,将原员工实体关系维护切换为员工亲属实体关联维护。 + +## 2. 后端实施内容 + +### 2.1 契约对象调整 + +- 调整 `CcdiStaffEnterpriseRelationQueryDTO`,新增亲属姓名、关联员工查询字段 +- 调整 `CcdiStaffEnterpriseRelationAddDTO` / `CcdiStaffEnterpriseRelationEditDTO` 的注释与校验提示,明确 `personId` 为亲属身份证号 +- 调整 `CcdiStaffEnterpriseRelationVO`,新增: + - `relationName` + - `staffPersonId` + - `staffPersonName` +- 新增 `CcdiStaffEnterpriseRelationOptionVO` 作为有效亲属下拉返回对象 +- 调整 `StaffEnterpriseRelationImportFailureVO`,补充 `relationName` +- 调整 `CcdiStaffEnterpriseRelationExcel`,将模板列头切换为亲属语义 + +### 2.2 Mapper 与查询链路 + +- 在 `CcdiStaffEnterpriseRelationMapper` 中新增: + - `selectFamilyOptions` + - `invalidateByFamilyCertNo` +- 重写 `CcdiStaffEnterpriseRelationMapper.xml` 中的列表与详情 SQL +- 查询链路改为: + - `ccdi_staff_enterprise_relation` + - `LEFT JOIN ccdi_staff_fmy_relation` + - `LEFT JOIN ccdi_base_staff` +- 查询条件支持: + - 亲属身份证号 + - 亲属姓名 + - 关联员工姓名/身份证号 + - 统一社会信用代码 + - 企业名称 + - 状态 + +### 2.3 Service / Controller 改造 + +- `CcdiStaffEnterpriseRelationServiceImpl` 新增有效亲属校验逻辑 +- 新增有效亲属下拉接口 `/ccdi/staffEnterpriseRelation/familyOptions` +- 新增和导入均改为以有效员工亲属为准入条件 +- 控制器 Swagger 标题、日志标题、模板标题和导入返回文案切换为员工亲属实体关联口径 + +### 2.4 异步导入改造 + +- 导入逻辑取消对 `ccdi_base_staff` 的存在性校验 +- 改为批量读取 `ccdi_staff_fmy_relation`,区分: + - 亲属不存在 + - 亲属无效 + - 库内重复 + - 文件内重复 +- 失败记录中回填亲属姓名 +- 导入状态文案切换为员工亲属实体关联口径 + +### 2.5 亲属失效联动 + +- 在 `CcdiStaffFmyRelationServiceImpl.updateRelation` 中识别“有效 -> 无效”状态变更 +- 当亲属关系失效时,调用 `invalidateByFamilyCertNo` 将对应实体关联批量置为无效 +- 不实现“无效 -> 有效”反向恢复 + +## 3. 前端实施内容 + +### 3.1 API 与页面语义切换 + +- `ruoyi-ui/src/api/ccdiStaffEnterpriseRelation.js` 新增 `listFamilyOptions(query)` +- `ruoyi-ui/src/views/ccdiStaffEnterpriseRelation/index.vue` 切换为员工亲属实体关联页面 + +### 3.2 页面改造点 + +- 查询区切换为: + - 亲属身份证号 + - 亲属姓名 + - 关联员工 + - 统一社会信用代码 + - 企业名称 + - 状态 +- 列表列切换为: + - 亲属身份证号 + - 亲属姓名 + - 关联员工 + - 企业名称 + - 关联人在企业的职务 + - 状态 + - 数据来源 + - 创建时间 +- 新增/编辑弹窗切换为有效亲属远程下拉选择 +- 选中亲属后自动带出亲属姓名和关联员工 +- 详情弹窗切换为亲属口径展示 +- 导入标题、模板文件名、通知文案、失败记录标题切换为亲属语义 +- 本地缓存 key 切换为 `staff_family_enterprise_relation_import_last_task` +- 新增菜单迁移脚本 `sql/migration/2026-04-23-rename-staff-enterprise-relation-menu.sql` + - 将 `sys_menu` 中“员工实体关系/员工实体关系维护”主菜单改名为“员工亲属实体关联” + - 将对应查询、新增、修改、删除、导入、导出按钮权限菜单名称同步切换为亲属语义 + +## 4. 验证结果 + +### 4.1 后端验证 + +- 通过定向测试: + +```bash +mvn -pl ccdi-info-collection -am -Dtest=CcdiStaffEnterpriseRelationServiceImplTest,CcdiStaffEnterpriseRelationImportServiceImplTest,CcdiStaffFmyRelationServiceImplTest,CcdiStaffEnterpriseRelationMapperTest -Dsurefire.failIfNoSpecifiedTests=false test +``` + +- 通过编译验证: + +```bash +mvn -pl ccdi-info-collection -am -DskipTests compile +``` + +### 4.2 前端验证 + +- 通过 Node 版本校验: + +```bash +cd ruoyi-ui +source ~/.nvm/nvm.sh && nvm use +``` + +- 通过前端构建: + +```bash +npm run build:prod +``` + +- 已完成真实页面基础访问与展示项核对,详见: + +`docs/tests/records/2026-04-23-staff-family-enterprise-relation-browser-test-record.md` + +### 4.3 旧数据清理 + +- 新增并执行脚本: + +```bash +bin/mysql_utf8_exec.sh sql/migration/2026-04-23-clean-legacy-staff-enterprise-relation-data.sql +``` + +- 清理口径: + - 删除 `ccdi_staff_enterprise_relation` 中无法匹配任何 `ccdi_staff_fmy_relation.is_emp_family = 1` 记录的旧数据 +- 本次清理结果: + - 清理前总数:15 + - 可匹配亲属数:0 + - 清理后剩余总数:0 + +## 5. 风险与后续 + +- 历史员工本人语义数据未迁移,列表中旧记录的亲属姓名和关联员工可能为空,符合本次设计范围 +- 浏览器导入实操验证受后端测试进程稳定性影响,尚未完成完整上传闭环 +- 若继续补齐导入实操测试,需要先确保后端测试进程在桌面会话中可稳定保活 diff --git a/docs/reports/implementation/2026-04-23-staff-recruitment-dual-sheet-import-design-record.md b/docs/reports/implementation/2026-04-23-staff-recruitment-dual-sheet-import-design-record.md new file mode 100644 index 00000000..42a019bf --- /dev/null +++ b/docs/reports/implementation/2026-04-23-staff-recruitment-dual-sheet-import-design-record.md @@ -0,0 +1,23 @@ +# 招聘信息管理双 Sheet 导入设计记录 + +## 本次产出 + +- 新增设计文档:`docs/design/2026-04-23-staff-recruitment-dual-sheet-import-design.md` + +## 设计结论 + +- 招聘信息管理导入入口收口为单按钮 +- 导入模板调整为 `招聘信息` + `历史工作经历` 双 Sheet +- 后端采用单异步任务统一处理整份文件 +- 工作经历支持独立导入 +- 若数据库中已存在某招聘记录的历史工作经历,则再次导入时直接失败,不覆盖旧数据 +- 失败列表统一展示 `失败Sheet`、`失败行号`、`失败原因` + +## 影响范围 + +- 当前仅新增设计文档与设计记录 +- 本轮未修改后端业务代码、前端页面代码、数据库脚本 + +## 后续动作 + +- 待用户确认设计文档后,分别输出后端与前端实施计划 diff --git a/docs/reports/implementation/2026-04-23-staff-recruitment-dual-sheet-import-implementation.md b/docs/reports/implementation/2026-04-23-staff-recruitment-dual-sheet-import-implementation.md new file mode 100644 index 00000000..50103b61 --- /dev/null +++ b/docs/reports/implementation/2026-04-23-staff-recruitment-dual-sheet-import-implementation.md @@ -0,0 +1,84 @@ +# 招聘信息管理双 Sheet 导入实施记录 + +## 文档信息 + +- 保存路径:`docs/reports/implementation/2026-04-23-staff-recruitment-dual-sheet-import-implementation.md` +- 实施日期:2026-04-23 +- 关联范围:招聘信息管理前后端 + +## 本次修改内容 + +### 后端 + +1. 将招聘导入控制器收口为单一模板下载接口与单一导入入口: + - 模板改为 `招聘信息` + `历史工作经历` 双 Sheet; + - 移除独立 `workImportTemplate`、`importWorkData` 入口。 +2. 调整 `ICcdiStaffRecruitmentService`、`ICcdiStaffRecruitmentImportService` 签名,统一为双 Sheet 单任务提交。 +3. 在 `CcdiStaffRecruitmentServiceImpl` 中统一初始化 Redis 任务状态,任务总数按 `recruitmentList.size() + workList.size()` 统计。 +4. 重写 `CcdiStaffRecruitmentImportServiceImpl` 导入编排: + - 主 Sheet 先校验并落库; + - 工作经历 Sheet 按 `recruitId` 分组校验; + - 工作经历既支持匹配“本次主 Sheet 成功数据”,也支持匹配数据库已有招聘主信息; + - 若数据库已存在该招聘记录的历史工作经历,则整组失败,不做覆盖。 +5. 为 `RecruitmentImportFailureVO` 补充 `sheetName`、`sheetRowNum` 字段,失败记录可直接定位到具体 Sheet 和 Excel 行号。 +6. 新增/补充招聘导入回归测试: + - `CcdiStaffRecruitmentDualImportContractTest` + - `CcdiStaffRecruitmentImportServiceImplTest` + - `EasyExcelUtilTemplateTest` + +### 前端 + +1. 招聘信息管理页工具栏只保留一个“导入”按钮,删除独立“导入工作经历”入口。 +2. 上传弹窗文案统一为双 Sheet 模式,模板说明明确为“招聘信息 + 历史工作经历”。 +3. 页面本地状态收口为单任务轮询: + - 只保存一个 `currentTaskId`; + - 删除按导入类型分流的状态与提示文案。 +4. 失败弹窗统一展示: + - `失败Sheet` + - `失败行号` + - `失败原因` + - 以及招聘编号/项目/岗位/候选人/工作单位等辅助字段。 +5. 新增前端静态契约测试: + - `staff-recruitment-import-toolbar.test.js` + - `staff-recruitment-import-state.test.js` + - `staff-recruitment-import-failure-dialog.test.js` + +## 影响范围 + +- 后端:招聘导入模板下载、导入提交、异步导入编排、失败记录返回。 +- 前端:招聘信息管理页导入入口、上传弹窗、导入轮询、失败弹窗。 +- 测试:招聘导入后端定向测试、前端静态契约测试、真实页面 Playwright 验证。 +- 文档:新增本实施记录。 + +## 验证情况 + +1. 后端定向测试通过: + - 命令: + `mvn -pl ccdi-info-collection -am -Dtest=CcdiStaffRecruitmentDualImportContractTest,CcdiStaffRecruitmentImportServiceImplTest,EasyExcelUtilTemplateTest -Dsurefire.failIfNoSpecifiedTests=false test` + - 结果:7 个测试全部通过。 +2. 后端编译通过: + - 命令: + `mvn -pl ccdi-info-collection,ruoyi-admin -am -DskipTests compile` + - 结果:`BUILD SUCCESS`。 +3. 前端静态契约测试通过: + - 命令: + `source ~/.nvm/nvm.sh && nvm use 14.21.3 >/dev/null && node ruoyi-ui/tests/unit/staff-recruitment-import-toolbar.test.js && node ruoyi-ui/tests/unit/staff-recruitment-import-state.test.js && node ruoyi-ui/tests/unit/staff-recruitment-import-failure-dialog.test.js` + - 结果:3 个脚本全部通过。 +4. 前端生产构建通过: + - 命令: + `source ~/.nvm/nvm.sh && nvm use 14.21.3 >/dev/null && cd ruoyi-ui && npm run build:prod` + - 结果:构建成功,仅存在原有体积告警,无新增构建错误。 +5. 真实页面 Playwright 验证通过: + - 从真实登录页进入 `/maintain/staffRecruitment`; + - 在真实导入弹窗中下载双 Sheet 模板; + - 使用真实模板生成并上传 `只导招聘信息 Sheet` 样本,成功新增 `RC202604230901`; + - 使用真实模板生成并上传 `只导历史工作经历 Sheet` 样本,`RC202604230901` 从 `0段` 变为 `1段`; + - 使用真实模板生成并上传 `双 Sheet 同时导入` 样本,成功新增 `RC202604230902` 且直接显示 `1段`; + - 再次上传 `RC202604230902` 的工作经历样本,页面出现失败按钮; + - 打开失败弹窗后,确认展示了 `失败Sheet / 失败行号 / 失败原因`,并看到错误文案: + `招聘记录编号[RC202604230902]已存在历史工作经历,不允许重复导入`。 +6. 测试数据与缓存清理完成: + - 通过后端删除接口清理 `RC202604230901`、`RC202604230902`; + - 再次查询两条测试招聘名称,返回 `total=0`; + - 浏览器侧 `localStorage.staff_recruitment_import_last_task` 已清空; + - 已关闭本轮 Playwright 浏览器、前端 `8080` dev server 和后端 `62318` 进程。 diff --git a/docs/reports/implementation/2026-04-23-staff-recruitment-dual-sheet-import-plan-record.md b/docs/reports/implementation/2026-04-23-staff-recruitment-dual-sheet-import-plan-record.md new file mode 100644 index 00000000..4e388e16 --- /dev/null +++ b/docs/reports/implementation/2026-04-23-staff-recruitment-dual-sheet-import-plan-record.md @@ -0,0 +1,19 @@ +# 招聘信息管理双 Sheet 导入实施计划记录 + +## 本次产出 + +- 后端实施计划: + - `docs/plans/backend/2026-04-23-staff-recruitment-dual-sheet-import-backend-implementation.md` +- 前端实施计划: + - `docs/plans/frontend/2026-04-23-staff-recruitment-dual-sheet-import-frontend-implementation.md` + +## 计划基线 + +- 设计文档: + - `docs/design/2026-04-23-staff-recruitment-dual-sheet-import-design.md` + +## 计划结论 + +- 后端按“控制器收口 -> 统一任务初始化 -> 两阶段异步编排 -> 失败定位补齐”推进 +- 前端按“单入口上传 -> 单任务轮询 -> 单失败弹窗 -> 真实页面验证”推进 +- 实施过程中必须补实施记录,并在真实页面完成 Playwright 导入验证 diff --git a/docs/reports/implementation/2026-04-26-agents-browser-use-sync-implementation.md b/docs/reports/implementation/2026-04-26-agents-browser-use-sync-implementation.md new file mode 100644 index 00000000..9ea3efc3 --- /dev/null +++ b/docs/reports/implementation/2026-04-26-agents-browser-use-sync-implementation.md @@ -0,0 +1,27 @@ +# AGENTS.md 使用 browser-use 技能同步实施记录 + +## 变更内容 + +- 更新 `/Users/wkc/Desktop/ccdi/ccdi/AGENTS.md`。 +- 将页面功能开发完成后的真实页面测试要求由调用 Playwright 调整为使用 `browser-use` 技能打开浏览器测试。 +- 将导入测试文件推荐目录由 `output/playwright/` 调整为 `output/browser-use/`。 + +## 影响范围 + +- 影响后续代理执行前端页面功能开发后的浏览器测试方式。 +- 影响后续导入测试临时产物的推荐保存目录。 +- 不涉及业务代码、脚本、接口或数据库变更。 + +## 保存路径确认 + +- 本次实施记录保存路径为 `/Users/wkc/Desktop/ccdi/ccdi/docs/reports/implementation/`。 +- 已确认该路径为 `ccdi` 项目既有实施记录目录。 + +## 验证情况 + +- 已确认 `AGENTS.md` 中页面功能测试规则改为使用 `browser-use` 技能。 +- 已确认 `AGENTS.md` 中测试产物目录改为 `output/browser-use/`。 + +## 说明 + +- 历史实施记录、历史计划和历史设计文档中的 Playwright 表述保留为当时执行记录,不作为本次规则同步范围。 diff --git a/docs/reports/implementation/2026-04-26-agents-md-update.md b/docs/reports/implementation/2026-04-26-agents-md-update.md new file mode 100644 index 00000000..b75adb0c --- /dev/null +++ b/docs/reports/implementation/2026-04-26-agents-md-update.md @@ -0,0 +1,22 @@ +# AGENTS.md 更新实施记录 + +## 修改时间 + +2026-04-26 + +## 修改内容 + +- 在根目录 `AGENTS.md` 中补充全局执行规则,覆盖 Git、AGENT、文档、测试与方案规范。 +- 明确 `using-superpowers` 与 subagent 的启用条件及模型要求。 +- 明确设计文档审查、前后端实施计划拆分、实施文档留存、Playwright 页面测试、测试文件不提交等要求。 + +## 影响范围 + +- 仅影响 AI 编码助手在本仓库内的协作规则与执行约束。 +- 未修改业务代码、数据库脚本、前端页面或后端接口。 + +## 验证情况 + +- 已确认 `AGENTS.md` 位于仓库根目录。 +- 已确认实施记录保存路径为 `docs/reports/implementation/`。 +- 本次为文档规则更新,不涉及编译、单元测试或页面测试。 diff --git a/docs/reports/implementation/2026-04-27-nvmrc-configuration-implementation.md b/docs/reports/implementation/2026-04-27-nvmrc-configuration-implementation.md new file mode 100644 index 00000000..ecb867cb --- /dev/null +++ b/docs/reports/implementation/2026-04-27-nvmrc-configuration-implementation.md @@ -0,0 +1,16 @@ +# nvmrc 配置实施记录 + +## 修改内容 + +- 在仓库根目录新增 `.nvmrc`,统一指定 Node 版本为 `14.21.3`。 +- 保留并对齐前端目录 `ruoyi-ui/.nvmrc` 的既有配置,确保在仓库根目录或前端目录执行 `nvm use` 时使用同一 Node 版本。 + +## 影响范围 + +- 仅影响本地前端开发、构建、调试命令的 Node 版本选择。 +- 不涉及后端代码、数据库结构、菜单权限或业务逻辑调整。 + +## 验证情况 + +- 已检查 `ruoyi-ui/.nvmrc` 当前内容为 `14.21.3`。 +- 已检查 `ruoyi-ui/package.json`,当前前端为 Vue 2 / Vue CLI 4 依赖栈,适合继续使用 Node `14.21.3`。 diff --git a/docs/reports/implementation/2026-04-28-prod-java-backend-start-script-implementation.md b/docs/reports/implementation/2026-04-28-prod-java-backend-start-script-implementation.md new file mode 100644 index 00000000..eb3fa5e0 --- /dev/null +++ b/docs/reports/implementation/2026-04-28-prod-java-backend-start-script-implementation.md @@ -0,0 +1,88 @@ +# 生产服务器 Java 后端启动脚本实施记录 + +## 保存路径确认 + +- 本次新增生产服务器后端启停脚本,实施记录保存到 `docs/reports/implementation/`,符合仓库实施文档目录规范。 + +## 修改目标 + +- 新写一个可在生产服务器上运行的 Java 后端启停脚本。 +- 脚本支持配置 Java Home,不依赖 Maven,不执行本地构建,只负责运行已上传到服务器的 `ruoyi-admin.jar`。 + +## 修改内容 + +- 新增 `deploy/start-java-backend-prod.sh` + - 在脚本顶部新增“生产配置区”,生产服务器上的 Java Home、Jar 目录、Profile、JVM 参数和额外应用参数均直接写在脚本文件中。 + - 按生产服务器目录结构调整默认 Jar 路径:启动脚本位于外层目录,后端 Jar 位于 `backend/ruoyi-admin.jar`。 + - 通过脚本内 `APP_HOME="${SCRIPT_DIR}/backend"` 指定生产服务器上的 Jar 所在目录。 + - 通过脚本内 `BACKEND_JAVA_HOME` 指定脚本使用的 JDK,优先级高于系统 `JAVA_HOME`。 + - 脚本内 `BACKEND_JAVA_HOME` 留空时读取系统 `JAVA_HOME`;两者都未配置时使用 `PATH` 中的 `java`。 + - 支持 `start`、`stop`、`restart`、`status`、`logs` 操作。 + - `start` 会先调用 `stop_backend`,通过 `ps -ef` 关闭旧后端进程,再启动新的 `backend/ruoyi-admin.jar`。 + - `start` 和 `restart` 在后端启动成功后会自动持续输出 `backend/logs/backend-console.log`,按 `Ctrl+C` 仅退出日志查看,不停止后端进程。 + - 支持 `stop` 单独停止后端进程。 + - 使用 `APP_MARKER` 标记脚本启动的新进程,停止旧进程时统一通过 `ps -ef` 扫描进程列表,匹配当前 Jar 绝对路径或生产目录下的相对路径 `backend/ruoyi-admin.jar`。 + - `stop` 可停止没有脚本标记但由同一 `backend/ruoyi-admin.jar` 启动的旧进程,用于覆盖生产服务器已有手工启动进程。 + - 进程扫描会忽略 `` 行,避免僵尸进程或历史残留干扰启停判断。 + - 若 `ps -ef` 执行失败,脚本会明确报错并中止旧进程扫描,避免误判为“后端未运行”。 + - 默认 Spring Profile 为 `uat`,可通过 `SPRING_PROFILES_ACTIVE` 覆盖。 + +## 使用方式 + +将 `deploy/start-java-backend-prod.sh` 放到生产服务器,并先修改脚本顶部“生产配置区”: + +```bash +BACKEND_JAVA_HOME="" +APP_HOME="${SCRIPT_DIR}/backend" +JAR_NAME="ruoyi-admin.jar" +SPRING_PROFILES_ACTIVE="uat" +JAVA_OPTS="-Duser.timezone=Asia/Shanghai -Xms512m -Xmx1024m -XX:MetaspaceSize=128m -XX:MaxMetaspaceSize=512m -XX:+HeapDumpOnOutOfMemoryError" +APP_ARGS="" +``` + +配置完成后直接执行: + +常用命令: + +```bash +./start-java-backend-prod.sh start +./start-java-backend-prod.sh stop +./start-java-backend-prod.sh restart +./start-java-backend-prod.sh status +./start-java-backend-prod.sh logs +``` + +## 验证记录 + +- 执行 `bash -n deploy/start-java-backend-prod.sh` + - 结果:通过 + - 说明:脚本 Bash 语法正确。 +- 执行 `bash deploy/start-java-backend-prod.sh help` + - 结果:通过 + - 说明:帮助信息正常输出,并说明启动成功后会持续输出控制台日志。 +- 执行 `rg -n "start_backend|follow_logs" deploy/start-java-backend-prod.sh` + - 结果:通过 + - 说明:已确认 `start` 与 `restart` 分支均使用 `start_action`,流程为先 `stop_backend`,再 `start_backend`,最后 `follow_logs`。 +- 执行 `bash deploy/start-java-backend-prod.sh status` + - 结果:通过 + - 说明:在允许执行 `ps -ef` 后,无后端进程时可正常输出未运行状态。 +- 执行 `rg -n "pgrep" deploy/start-java-backend-prod.sh` + - 结果:无匹配 + - 说明:已确认停止旧进程不再依赖 `pgrep`。 +- 执行 `rg -n "ps -ef" deploy/start-java-backend-prod.sh` + - 结果:通过 + - 说明:已确认旧进程扫描逻辑使用 `ps -ef`。 +- 使用临时脚本副本和临时后端目录启动一个命令行包含 `-jar backend/ruoyi-admin.jar`、但不带脚本标记的模拟旧进程,再执行 `bash /tmp/start-java-backend-prod-test.sh stop` + - 结果:通过 + - 说明:已验证 `stop` 可以停止同一 Jar 路径的旧进程,不要求旧进程必须由当前脚本启动。 +- 修改临时脚本副本,将脚本内 `BACKEND_JAVA_HOME` 设置为 `/not-exist` 后执行 `bash /tmp/start-java-backend-prod-test.sh start` + - 结果:按预期失败 + - 说明:脚本能在启动前拦截无效 Java Home,并输出明确错误。 +- 执行 `bash deploy/start-java-backend-prod.sh start` + - 结果:按预期失败 + - 说明:脚本能正确解析当前 Java 命令,并在当前本地未提供 `deploy/backend/ruoyi-admin.jar` 时中止启动。 + +## 影响范围 + +- 仅新增生产服务器后端启停脚本与本实施记录。 +- 不修改 Java 业务代码、数据库脚本、前端页面和现有发布包生成脚本。 diff --git a/docs/reports/implementation/2026-04-28-prod-release-deploy-script-implementation.md b/docs/reports/implementation/2026-04-28-prod-release-deploy-script-implementation.md new file mode 100644 index 00000000..ee6c8270 --- /dev/null +++ b/docs/reports/implementation/2026-04-28-prod-release-deploy-script-implementation.md @@ -0,0 +1,68 @@ +# 生产上线部署脚本实施记录 + +## 保存路径确认 + +- 本次为生产上线部署脚本改动,实施记录保存到 `docs/reports/implementation/`,符合仓库实施文档目录规范。 + +## 修改目标 + +- 生成一个可放在上线环境执行的部署脚本。 +- 上线环境目录下已有 `backend/`、`frontend/` 和一个上线压缩包。 +- 上线压缩包根层包含 `ruoyi-admin.jar` 和 `dist.zip`。 +- 执行脚本后先备份 `backend/` 与 `frontend/` 旧文件,再解压上线包并部署到对应目录,最后调用 `start-java-backend-prod.sh` 重启后端并输出日志。 + +## 修改内容 + +- 新增 `deploy/deploy-release-prod.sh` + - 默认按脚本同级目录解析 `backend/`、`frontend/`、`start-java-backend-prod.sh` 和上线压缩包。 + - 使用 `/bin/sh` 写法,避免依赖 Bash 进程替换等服务器环境不一定支持的语法。 + - 支持显式传入上线压缩包路径:`./deploy-release-prod.sh /path/to/ccdi_YYYYMMDD.zip`。 + - 未传入压缩包时,自动使用脚本同级目录下唯一的 `.zip` 文件,并排除 `dist.zip`。 + - 部署前将 `backend/` 和 `frontend/` 当前内容备份到 `backups/YYYYMMDDHHMMSS/`。 + - 解压上线包后校验根层必须存在 `ruoyi-admin.jar` 和 `dist.zip`。 + - 解压 `dist.zip` 后校验必须存在 `dist/index.html`。 + - 后端部署为覆盖 `backend/ruoyi-admin.jar`。 + - 前端部署为清空 `frontend/` 后复制 `dist/` 内文件到 `frontend/`。 + - 部署完成后执行 `bash start-java-backend-prod.sh restart`,由现有启动脚本完成后端重启并持续输出后端日志。 + +## 使用方式 + +生产环境目录结构: + +```text +上线目录/ +├── deploy-release-prod.sh +├── start-java-backend-prod.sh +├── backend/ +├── frontend/ +└── ccdi_YYYYMMDD.zip +``` + +执行: + +```bash +./deploy-release-prod.sh +``` + +或显式指定压缩包: + +```bash +./deploy-release-prod.sh /path/to/ccdi_YYYYMMDD.zip +``` + +## 验证记录 + +- 执行 `sh -n deploy/deploy-release-prod.sh` + - 结果:通过 + - 说明:脚本 Shell 语法正确。 +- 执行 `sh deploy/deploy-release-prod.sh --help` + - 结果:通过 + - 说明:帮助信息正常输出。 +- 使用 `/tmp` 构造最小上线目录、旧 `backend/`、旧 `frontend/`、上线压缩包和假的 `start-java-backend-prod.sh` 后执行部署脚本 + - 结果:通过 + - 说明:已验证旧文件备份、新 Jar 覆盖、前端 `dist/` 文件部署,以及最终调用启动脚本 `restart`。 + +## 影响范围 + +- 仅新增生产上线部署脚本与本实施记录。 +- 不修改 Java 业务代码、前端业务代码、数据库脚本和现有后端启动脚本。 diff --git a/docs/reports/implementation/2026-04-28-production-init-sql-implementation.md b/docs/reports/implementation/2026-04-28-production-init-sql-implementation.md new file mode 100644 index 00000000..930eb96a --- /dev/null +++ b/docs/reports/implementation/2026-04-28-production-init-sql-implementation.md @@ -0,0 +1,78 @@ +# 生产上线初始化 SQL 生成实施记录 + +## 保存路径确认 + +- 生产初始化 SQL:`sql/ccdi_prod_init_20260428.sql` +- 实施记录:`docs/reports/implementation/2026-04-28-production-init-sql-implementation.md` + +## 修改内容 + +- 新增 `sql/ccdi_prod_init_20260428.sql`,用于生产空库初始化。 +- SQL 内容包含当前 `ccdi` 库最终态的 57 张表结构。 +- SQL 必要数据范围: + - 若依基础配置、部门、岗位、用户、角色、菜单、角色菜单、字典、定时任务、公告。 + - CCDI 默认模型参数,仅包含 `ccdi_model_param.project_id = 0` 的系统默认参数。 + - 流水打标规则 `ccdi_bank_tag_rule`。 +- SQL 不包含运行期业务数据: + - 项目、员工、流水、导入记录、风险结果、采购事项、实体库、中介库、操作日志、登录日志等数据均保持空表。 +- 将导出结构中的非规范排序规则统一修正为 `utf8mb4_general_ci`,未保留 `utf8mb4_0900_ai_ci`。 +- 针对生产执行时报错 `Specified key was too long; max key length is 767 bytes`,按生产要求删除旧库 767 bytes 限制下会超长的索引定义,保留字段长度、表结构和必要初始化数据不变。 +- 删除的超长索引范围: + - Quartz 表中的长字符复合主键和依赖这些长字符复合键的外键索引。 + - `ccdi_account_info.idx_ccdi_account_info_account_no`。 + - `ccdi_asset_info.idx_family_person`。 + - `ccdi_bank_statement.uk_bank_statement_dedup`。 + - `ccdi_bank_statement.idx_batch_id_account`。 + - `ccdi_bank_statement.c4c_bank_statement_stg_batch_id_IDX`。 + - `ccdi_bank_statement_tag_result.uk_ccdi_bank_tag_object_hit`。 + - `ccdi_enterprise_base_info.idx_enterprise_name`。 + - `ccdi_evidence.idx_ccdi_evidence_source`。 + - `ccdi_model_param.uk_project_model_param`。 + - `ccdi_project.idx_project_name`。 + +## 验证情况 + +- 使用本机临时 MySQL 实例导入 `sql/ccdi_prod_init_20260428.sql` 验证通过。 +- 导入后验证结果: + - 表数量:57。 + - 非 `utf8mb4_general_ci` 表数量:0。 + - 基础数据行数: + - `sys_config`:8。 + - `sys_dept`:10。 + - `sys_dict_type`:26。 + - `sys_dict_data`:98。 + - `sys_menu`:166。 + - `sys_role`:2。 + - `sys_role_menu`:134。 + - `sys_user`:3。 + - `sys_job`:3。 + - `sys_notice`:2。 + - `ccdi_bank_tag_rule`:35。 + - `ccdi_model_param`:17,且全部为 `project_id = 0`。 + - 业务数据抽查为空: + - `ccdi_project`:0。 + - `ccdi_base_staff`:0。 + - `ccdi_bank_statement`:0。 + - `ccdi_file_upload_record`:0。 + - `ccdi_purchase_transaction`:0。 +- 测试完成后已关闭本机临时 MySQL 实例。 +- 生产索引长度修复后,再次计算脚本内所有剩余索引长度,确认超过 767 bytes 的索引数量为 0。 +- 删除超长索引后,再次使用本机临时 MySQL 实例导入验证通过: + - 表数量:57。 + - 非 `utf8mb4_general_ci` 表数量:0。 + - `ccdi_model_param`:17。 + - `ccdi_bank_tag_rule`:35。 + - `sys_menu`:166。 + - `ccdi_project`:0。 + - `ccdi_bank_statement`:0。 + +## 执行说明 + +- 目标生产库需为空库。 +- 目标库字符集和排序规则建议使用: + +```sql +CREATE DATABASE ccdi DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; +``` + +- 导入时需使用 `utf8mb4` 会话字符集。 diff --git a/docs/reports/implementation/2026-04-28-release-package-script-implementation.md b/docs/reports/implementation/2026-04-28-release-package-script-implementation.md new file mode 100644 index 00000000..12e22b71 --- /dev/null +++ b/docs/reports/implementation/2026-04-28-release-package-script-implementation.md @@ -0,0 +1,35 @@ +# CCDI 上线压缩包生成脚本实施记录 + +## 修改内容 + +- 新增根目录脚本 `build_release_ccdi.sh`。 +- 脚本执行后会重新构建后端 `ruoyi-admin.jar`,并进入 `ruoyi-ui` 通过 `nvm use` 切换前端 Node 版本后执行 `npm run build:prod`。 +- 脚本会在根目录生成 `ccdi_YYYYMMDD.zip`,压缩包根层仅包含 `ruoyi-admin.jar` 和 `dist.zip`,不再额外包裹 `deploy` 目录。 +- `.gitignore` 新增 `/ccdi_????????.zip`,避免生成的上线压缩包进入 Git。 + +## 影响范围 + +- 仅新增发布包生成脚本与忽略规则,不修改业务代码。 +- 临时打包目录使用 `.deploy/ccdi-release-package/`,该目录已作为本地部署产物被 Git 忽略。 + +## 使用方式 + +```bash +./build_release_ccdi.sh +``` + +生成结果示例: + +```text +ccdi_20260428.zip +├── ruoyi-admin.jar +└── dist.zip +``` + +## 验证情况 + +- 已执行 `sh -n build_release_ccdi.sh`,脚本语法检查通过。 +- 已执行 `git diff --check`,未发现空白错误。 +- 已执行 `./build_release_ccdi.sh`,后端 Maven 打包成功,前端生产构建成功,并生成 `ccdi_20260428.zip`。 +- 已执行 `unzip -l ccdi_20260428.zip`,确认压缩包根层仅包含 `ruoyi-admin.jar` 与 `dist.zip` 两个文件。 +- 已执行 `git check-ignore -v ccdi_20260428.zip`,确认根目录上线压缩包会被 `.gitignore` 忽略。 diff --git a/docs/superpowers/specs/2026-04-17-enterprise-base-info-management-design.md b/docs/superpowers/specs/2026-04-17-enterprise-base-info-management-design.md new file mode 100644 index 00000000..75a309c5 --- /dev/null +++ b/docs/superpowers/specs/2026-04-17-enterprise-base-info-management-design.md @@ -0,0 +1,431 @@ +# 实体库管理设计文档 + +## 1. 背景 + +当前仓库已经存在企业主体表 `ccdi_enterprise_base_info`,并且“中介管理”中的实体中介部分已经在复用该表完成部分新增、编辑、详情和导入能力。 + +本次需求不是新建表,也不是扩展复杂关联能力,而是基于现有企业主体表,单独建设一个“实体库管理”页面,并严格参照“员工信息维护”的实现方式交付完整的新增、查看、编辑、删除、导入能力。 + +## 2. 目标 + +建设一套独立的“实体库管理”模块,满足以下要求: + +- 页面名称固定为“实体库管理” +- 数据表固定为 `ccdi_enterprise_base_info` +- 功能范围仅包含单表维护,不引入子表或聚合编辑 +- 支持新建、查看、编辑、删除、导入 +- 页面交互、接口组织、异步导入、失败记录查看方式整体对齐员工信息维护 +- `riskLevel`、`entSource` 允许维护,不写死 +- 导入时若统一社会信用代码已存在,一律按失败处理,不覆盖更新 + +## 3. 非目标 + +- 不改造“中介管理”现有混合页面 +- 不在本次中引入企业附属信息子表 +- 不在本次中引入员工企业关系、客户企业关系、中介关系联动维护 +- 不设计兼容性补丁式方案,不保留中介实体 DTO 命名给新模块复用 + +## 4. 现状分析 + +### 4.1 已有基础 + +仓库中已经存在以下可复用基础: + +- 实体表对应领域对象:`CcdiEnterpriseBaseInfo` +- 实体表 Mapper:`CcdiEnterpriseBaseInfoMapper` +- 中介模块中的实体新增、编辑、详情、导入能力 +- 员工信息维护的完整单表维护范式: + - 独立 Controller + - 独立 DTO / VO / Excel / ImportFailureVO + - 独立前端页面与 API + - 异步导入状态轮询 + - 失败记录分页查看 + +### 4.2 已有问题 + +当前实体企业数据虽然已经能在中介模块中被部分维护,但存在以下问题: + +- 业务语义属于“中介管理”,不等于“实体库管理” +- DTO / VO / Excel 命名与字段口径绑定在 `IntermediaryEntity` 语义上 +- 现有实体中介字段未完整覆盖本次需要维护的 `status`、`riskLevel`、`entSource`、`dataSource` +- 页面是中介混合模式,不符合“完全按照员工信息维护方式实现”的要求 + +因此本次应建设独立模块,而不是继续把“实体库管理”能力挂靠在中介模块之下。 + +## 5. 方案比较 + +### 5.1 方案一:新建独立实体库管理模块 + +做法: + +- 新建独立的后端 Controller / Service / DTO / VO / Excel / 导入服务 +- 新建独立前端页面、独立 API、独立菜单和权限 +- 底层复用 `ccdi_enterprise_base_info` + +优点: + +- 与“员工信息维护”模式最一致 +- 业务语义清晰,后续维护成本最低 +- 不会把“实体库管理”和“中介管理”概念揉在一起 + +缺点: + +- 开发量略高于直接复用中介实体代码 + +### 5.2 方案二:直接复用中介管理中的实体部分 + +做法: + +- 将中介管理中的“实体中介”能力直接暴露为实体库入口 + +优点: + +- 改动少 + +缺点: + +- 模块语义错误 +- DTO / VO / 页面命名与业务不一致 +- 后续权限、菜单、字段扩展容易混乱 + +### 5.3 方案三:页面独立,后端继续复用中介实体 DTO / 导入类 + +做法: + +- 页面新建 +- 后端尽量套用 `IntermediaryEntity` 的类和导入逻辑 + +优点: + +- 开发速度较快 + +缺点: + +- 代码命名混乱 +- 字段口径不完整 +- 后续扩展时会继续带来语义污染 + +### 5.4 结论 + +采用方案一:新建独立“实体库管理”模块,底层复用 `ccdi_enterprise_base_info`,实现方式全面对齐员工信息维护。 + +## 6. 总体设计 + +### 6.1 模块边界 + +本次实体库管理模块只负责维护 `ccdi_enterprise_base_info` 单表数据,不承担其他关系表维护职责。 + +### 6.2 后端结构 + +新增独立链路: + +- `CcdiEnterpriseBaseInfoController` +- `ICcdiEnterpriseBaseInfoService` +- `CcdiEnterpriseBaseInfoServiceImpl` +- `CcdiEnterpriseBaseInfoQueryDTO` +- `CcdiEnterpriseBaseInfoAddDTO` +- `CcdiEnterpriseBaseInfoEditDTO` +- `CcdiEnterpriseBaseInfoVO` +- `CcdiEnterpriseBaseInfoExcel` +- `EnterpriseBaseInfoImportFailureVO` +- `ICcdiEnterpriseBaseInfoImportService` +- `CcdiEnterpriseBaseInfoImportServiceImpl` + +Mapper 层优先复用现有 `CcdiEnterpriseBaseInfoMapper`,若分页查询或批量导入需要独立 SQL,则在现有 Mapper 基础上补充对应方法。 + +### 6.3 前端结构 + +新增独立页面与 API: + +- `ruoyi-ui/src/views/ccdiEnterpriseBaseInfo/index.vue` +- `ruoyi-ui/src/api/ccdiEnterpriseBaseInfo.js` + +页面交互模式整体对齐员工信息维护: + +- 查询表单 +- 工具栏按钮 +- 数据表格 +- 新增/编辑弹窗 +- 详情弹窗 +- 导入弹窗 +- 导入失败记录查看 + +## 7. 数据口径设计 + +### 7.1 主键 + +主键固定为 `socialCreditCode`。 + +规则如下: + +- 新增时必填 +- 编辑时不可修改 +- 详情、删除、导入去重均以该字段为准 + +### 7.2 维护字段 + +本次页面维护字段覆盖 `ccdi_enterprise_base_info` 核心单表字段: + +- `socialCreditCode` +- `enterpriseName` +- `enterpriseType` +- `enterpriseNature` +- `industryClass` +- `industryName` +- `establishDate` +- `registerAddress` +- `legalRepresentative` +- `legalCertType` +- `legalCertNo` +- `shareholder1` +- `shareholder2` +- `shareholder3` +- `shareholder4` +- `shareholder5` +- `status` +- `riskLevel` +- `entSource` +- `dataSource` + +### 7.3 字段策略 + +- `riskLevel` 允许查询、展示、编辑、导入 +- `entSource` 允许查询、展示、编辑、导入 +- `dataSource` 允许查询、展示、编辑、导入 +- 不新增动态股东列表,继续保持 `shareholder1-5` 固定字段结构 + +## 8. 页面设计 + +### 8.1 查询区 + +查询条件按员工信息维护的密度设计,包含: + +- 企业名称 +- 统一社会信用代码 +- 企业类型 +- 企业性质 +- 行业分类 +- 经营状态 +- 风险等级 +- 企业来源 + +### 8.2 列表区 + +建议展示列: + +- 企业名称 +- 统一社会信用代码 +- 企业类型 +- 企业性质 +- 行业分类 +- 所属行业 +- 法定代表人 +- 经营状态 +- 风险等级 +- 企业来源 +- 数据来源 +- 创建时间 +- 操作 + +操作列固定为: + +- 详情 +- 编辑 +- 删除 + +### 8.3 新增/编辑弹窗 + +交互方式对齐员工信息维护,采用整页弹窗表单。 + +表单项包含: + +- 统一社会信用代码 +- 企业名称 +- 企业类型 +- 企业性质 +- 行业分类 +- 所属行业 +- 成立日期 +- 注册地址 +- 法定代表人 +- 法定代表人证件类型 +- 法定代表人证件号码 +- 股东1 +- 股东2 +- 股东3 +- 股东4 +- 股东5 +- 经营状态 +- 风险等级 +- 企业来源 +- 数据来源 + +其中: + +- 新增时 `socialCreditCode` 可填写 +- 编辑时 `socialCreditCode` 禁止修改 + +### 8.4 详情弹窗 + +详情展示字段与编辑表单保持同口径,采用只读方式展示。 + +### 8.5 导入交互 + +导入流程完全对齐员工信息维护: + +- 下载模板 +- 上传 Excel +- 提交异步导入任务 +- 轮询导入状态 +- 本地缓存最近一次任务信息 +- 导入失败时展示“查看导入失败记录”按钮 +- 失败记录支持分页查看 + +## 9. 接口设计 + +接口前缀建议统一为 `/ccdi/enterpriseBaseInfo`。 + +### 9.1 列表查询 + +- `GET /ccdi/enterpriseBaseInfo/list` + +入参为查询 DTO,返回分页表格结构。 + +### 9.2 详情查询 + +- `GET /ccdi/enterpriseBaseInfo/{socialCreditCode}` + +### 9.3 新增 + +- `POST /ccdi/enterpriseBaseInfo` + +### 9.4 编辑 + +- `PUT /ccdi/enterpriseBaseInfo` + +### 9.5 删除 + +- `DELETE /ccdi/enterpriseBaseInfo/{socialCreditCodes}` + +支持批量删除。 + +### 9.6 导入模板 + +- `POST /ccdi/enterpriseBaseInfo/importTemplate` + +### 9.7 导入数据 + +- `POST /ccdi/enterpriseBaseInfo/importData` + +### 9.8 查询导入状态 + +- `GET /ccdi/enterpriseBaseInfo/importStatus/{taskId}` + +### 9.9 查询导入失败记录 + +- `GET /ccdi/enterpriseBaseInfo/importFailures/{taskId}` + +## 10. 权限与菜单设计 + +新增菜单名称固定为“实体库管理”。 + +建议菜单与权限如下: + +- 菜单路径:`enterpriseBaseInfo` +- 组件路径:`ccdiEnterpriseBaseInfo/index` +- 列表权限:`ccdi:enterpriseBaseInfo:list` +- 查询权限:`ccdi:enterpriseBaseInfo:query` +- 新增权限:`ccdi:enterpriseBaseInfo:add` +- 修改权限:`ccdi:enterpriseBaseInfo:edit` +- 删除权限:`ccdi:enterpriseBaseInfo:remove` +- 导入权限:`ccdi:enterpriseBaseInfo:import` + +菜单应挂到“信息维护”目录下,方式与员工信息维护等现有业务菜单一致。 + +## 11. 校验规则 + +### 11.1 新增/编辑校验 + +- 企业名称不能为空 +- 统一社会信用代码不能为空 +- 统一社会信用代码必须符合 18 位社会信用代码格式 +- 新增时若数据库中已存在相同统一社会信用代码,则报错 +- 编辑时若记录不存在,则报错 +- `status`、`riskLevel`、`entSource`、`dataSource` 必须在允许值内 +- 其他字段按长度、日期格式做基础校验 + +### 11.2 导入校验 + +- Excel 至少包含一条数据 +- 企业名称不能为空 +- 统一社会信用代码不能为空 +- 统一社会信用代码格式必须正确 +- 同一导入文件中若统一社会信用代码重复,则该重复记录失败 +- 数据库中若已存在相同统一社会信用代码,则该记录失败 + +## 12. 导入策略 + +本次导入采用“严格新增”策略,不支持覆盖更新。 + +即: + +- 不提供 `updateSupport` 覆盖更新能力 +- 已存在统一社会信用代码的记录直接记为失败 +- Excel 内重复记录直接记为失败 +- 成功记录批量插入 +- 失败记录落 Redis,保留最近任务失败明细查询能力 + +导入状态结构与员工信息维护保持一致: + +- `PROCESSING` +- `SUCCESS` +- `PARTIAL_SUCCESS` + +## 13. 错误处理 + +错误处理遵循现有员工维护风格,使用直接明确的业务提示: + +- “该统一社会信用代码已存在” +- “实体信息不存在” +- “至少需要一条数据” +- “任务不存在或已过期” + +不引入额外异常框架或复杂回退逻辑。 + +## 14. 测试设计 + +### 14.1 后端测试重点 + +- 列表分页和多条件查询正确 +- 详情返回正确 +- 新增成功,主键重复时报错 +- 编辑成功,编辑不存在记录时报错 +- 删除成功 +- 导入成功、部分成功、失败状态正确 +- 导入失败记录查询正确 + +### 14.2 前端测试重点 + +- 查询、重置、分页交互正常 +- 新增、详情、编辑、删除链路正常 +- 编辑态主键不可修改 +- 导入弹窗、状态轮询、失败记录查看正常 +- 最近一次导入任务缓存与失败按钮显示逻辑正常 +- 权限按钮显示与菜单路由正确 + +## 15. 实施文档要求 + +由于本次需求涉及前后端改动,后续实施阶段需按仓库规范输出两份实施计划: + +- 后端实施计划:`docs/plans/backend/` +- 前端实施计划:`docs/plans/frontend/` + +## 16. 最终结论 + +本次采用“独立实体库管理模块 + 复用现有企业主体表”的实现方式,以最短路径满足业务目标,同时保证: + +- 业务语义清晰 +- 代码结构与员工信息维护一致 +- `riskLevel`、`entSource`、`dataSource` 均可维护 +- 导入严格新增,重复统一社会信用代码直接失败 +- 不引入超出需求范围的扩展能力 diff --git a/docs/superpowers/specs/2026-04-23-bidding-supplier-enterprise-detail-design.md b/docs/superpowers/specs/2026-04-23-bidding-supplier-enterprise-detail-design.md new file mode 100644 index 00000000..20aee4ca --- /dev/null +++ b/docs/superpowers/specs/2026-04-23-bidding-supplier-enterprise-detail-design.md @@ -0,0 +1,276 @@ +# 招投标详情弹窗供应商企业信息查看设计文档 + +## 1. 背景 + +当前“招投标信息维护”页面已经支持在详情弹窗中查看供应商明细列表,供应商行中也已经保留了统一信用代码字段。 + +仓库中同时已经存在独立的“实体库管理”模块,底层使用 `ccdi_enterprise_base_info` 表,并提供了现成的企业详情查询接口 `GET /ccdi/enterpriseBaseInfo/{socialCreditCode}`。 + +本次需求不是新建企业信息维护能力,也不是扩展招投标详情聚合接口,而是在现有招投标详情弹窗中,为供应商列表补充一个最短路径的“企业详情查看”入口。 + +## 2. 目标 + +在招投标信息维护页面的详情弹窗中: + +- 为供应商明细列表新增“操作”列 +- 每行新增“详情”按钮 +- 点击后只按供应商统一信用代码查询实体库企业详情 +- 若能查到企业,则通过弹窗展示实体库全部字段 +- 若查不到企业,或者当前供应商没有统一信用代码,则统一提示“暂无企业信息” + +## 3. 非目标 + +- 不改造招投标新增/编辑弹窗中的供应商录入逻辑 +- 不改造招投标详情接口返回结构,不在详情接口中聚合企业详情 +- 不新增按企业名称模糊查询或兜底查询 +- 不跳转到“实体库管理”页面查看 +- 不为企业详情弹窗增加编辑、删除、跳转等附加操作 +- 不新增后端接口 + +## 4. 现状分析 + +### 4.1 已有基础 + +仓库中已经具备以下可复用基础: + +- 招投标详情弹窗页面:`ruoyi-ui/src/views/ccdiPurchaseTransaction/index.vue` +- 实体库前端 API:`ruoyi-ui/src/api/ccdiEnterpriseBaseInfo.js` +- 实体库详情接口:`GET /ccdi/enterpriseBaseInfo/{socialCreditCode}` +- 实体库详情展示字段口径:`ruoyi-ui/src/views/ccdiEnterpriseBaseInfo/index.vue` + +### 4.2 当前缺口 + +当前招投标详情弹窗的供应商明细仅支持只读查看以下字段: + +- 排序 +- 中标结果 +- 供应商名称 +- 统一信用代码 +- 联系人 +- 联系电话 +- 银行账户 + +但尚未提供从供应商直接查看实体库企业详情的入口,因此用户无法在招投标详情场景下快速确认该供应商在实体库中的完整企业信息。 + +## 5. 方案比较 + +### 5.1 方案一:在招投标详情弹窗中直接新增企业详情二级弹窗 + +做法: + +- 在供应商明细表新增“操作”列和“详情”按钮 +- 点击后调用现有实体库详情接口 +- 查询成功则在当前页面内打开只读企业详情弹窗 + +优点: + +- 改动范围最小 +- 不改后端 +- 不影响实体库管理页面现有逻辑 +- 最符合本次“最短路径实现”的要求 + +缺点: + +- 企业详情展示结构会在招投标页面内复用一份 + +### 5.2 方案二:抽取通用企业详情组件供多个页面复用 + +做法: + +- 将实体库管理页面现有详情展示抽成独立组件 +- 招投标详情弹窗和实体库管理页面共同复用该组件 + +优点: + +- 后续多个页面查看企业详情时更统一 + +缺点: + +- 会额外改造现有实体库管理页面 +- 本次改动面超出当前需求 + +### 5.3 方案三:改造招投标详情后端接口,聚合返回企业详情 + +做法: + +- 后端在查询招投标详情时,顺带按供应商统一信用代码查询实体库 +- 前端点击时直接展示已返回的企业详情 + +优点: + +- 前端点击时无需追加请求 + +缺点: + +- 后端改动范围扩大 +- 会给现有详情接口增加本次非必须数据 +- 不符合最短路径原则 + +### 5.4 结论 + +采用方案一:在招投标详情弹窗中直接新增“操作列 + 企业详情二级弹窗”,并复用现有实体库详情接口。 + +## 6. 总体设计 + +### 6.1 模块边界 + +本次只改前端页面: + +- `ruoyi-ui/src/views/ccdiPurchaseTransaction/index.vue` + +复用现有前端 API: + +- `ruoyi-ui/src/api/ccdiEnterpriseBaseInfo.js` + +不新增后端 Controller、Service、Mapper、SQL 和菜单权限配置。 + +### 6.2 页面交互 + +在“招投标信息详情”弹窗中的供应商明细表新增“操作”列。 + +交互规则固定如下: + +1. 每行固定显示一个“详情”按钮 +2. 点击后只读取当前行 `supplierUscc` +3. 若 `supplierUscc` 为空,直接提示“暂无企业信息” +4. 若 `supplierUscc` 不为空,则调用现有实体库详情接口 +5. 若接口返回企业详情,则打开只读企业详情弹窗 +6. 若接口查不到企业,则提示“暂无企业信息” + +### 6.3 弹窗层级 + +- 一级弹窗:现有“招投标信息详情”弹窗 +- 二级弹窗:新增“企业信息详情”弹窗 + +二级弹窗在当前页面内打开,不跳转页面,也不关闭一级弹窗。 + +### 6.4 权限口径 + +本次不新增权限控制,也不对按钮做权限显隐控制。 + +具体口径固定如下: + +- 供应商明细表中的“详情”按钮固定显示 +- 前端不新增 `v-hasPermi` 限制 +- 本次不新增菜单权限配置,不调整现有角色授权 +- 若接口因权限或其他原因请求失败,前端仍统一按“暂无企业信息”处理 + +因此本次方案仍按纯前端页面改造收口,不扩展为权限链路改造需求。 + +## 7. 数据口径设计 + +### 7.1 查询口径 + +企业详情查询只允许按统一信用代码命中: + +- 查询字段固定为 `supplierUscc` +- 不按 `supplierName` 查询 +- 不做统一信用代码查询失败后的名称兜底 + +### 7.2 展示字段 + +企业详情弹窗展示字段与“实体库管理”页面现有详情弹窗保持同口径,展示全部现有详情字段: + +- 统一社会信用代码 +- 企业名称 +- 企业类型 +- 企业性质 +- 行业分类 +- 所属行业 +- 成立日期 +- 注册地址 +- 法定代表人 +- 法定代表人证件类型 +- 法定代表人证件号码 +- 经营状态 +- 风险等级 +- 企业来源 +- 数据来源 +- 创建时间 +- 股东1 +- 股东2 +- 股东3 +- 股东4 +- 股东5 + +## 8. 前端实现设计 + +### 8.1 状态设计 + +在 `ccdiPurchaseTransaction` 页面内新增企业详情查看状态: + +- `enterpriseDetailOpen`:企业详情弹窗开关 +- `enterpriseDetailLoading`:企业详情请求中状态 +- `enterpriseDetailData`:企业详情数据对象 + +### 8.2 行为设计 + +点击供应商行“详情”按钮时,执行以下流程: + +1. 读取当前供应商行 `supplierUscc` +2. 若为空,则直接提示“暂无企业信息” +3. 若不为空,则置 `enterpriseDetailLoading = true` +4. 调用 `getEnterpriseBaseInfo(supplierUscc)` +5. 请求成功且返回有效详情数据时: + - 写入 `enterpriseDetailData` + - 打开 `enterpriseDetailOpen` +6. 请求失败、接口返回空数据或记录不存在时: + - 不打开详情弹窗 + - 统一提示“暂无企业信息” +7. 请求结束后恢复 loading 状态 + +### 8.3 关闭设计 + +企业详情弹窗关闭时清空本次详情数据,避免下一次打开时短暂残留上一次企业信息。 + +## 9. 错误处理设计 + +错误处理口径统一如下: + +- 供应商没有统一信用代码:提示“暂无企业信息” +- 统一信用代码存在但实体库查不到:提示“暂无企业信息” +- 接口请求失败:提示“暂无企业信息” +- 接口因权限校验失败:提示“暂无企业信息” + +本次不区分“缺失统一信用代码”“企业不存在”“接口异常”“接口权限失败”四类用户提示文案,统一收敛为同一提示,避免页面出现多套业务语义。 + +## 10. 测试设计 + +### 10.1 验证范围 + +本次为纯前端改动,复用现有后端详情接口,因此后续实施阶段只需要输出前端实施计划,不需要新增后端实施计划。 + +### 10.2 核心验证场景 + +- 供应商有统一信用代码,且实体库存在对应企业时,点击“详情”能打开企业详情弹窗并展示完整字段 +- 供应商没有统一信用代码时,点击“详情”提示“暂无企业信息” +- 供应商有统一信用代码但实体库不存在对应企业时,点击“详情”提示“暂无企业信息” +- “详情”按钮固定显示,不因实体库权限做前端显隐控制 +- 关闭企业详情弹窗后,再查看另一家供应商时,不残留上一家企业详情数据 + +### 10.3 测试执行要求 + +- 前端相关命令执行前,先通过 `nvm use` 切换项目 Node 版本 +- 使用真实业务页面进行浏览器验证,不使用 prototype 页面 +- 验证结束后关闭测试过程中启动的前端进程和浏览器进程 + +## 11. 实施文档要求 + +由于本次需求只涉及前端改动,实施阶段只输出一份前端实施计划,保存到: + +- `docs/plans/frontend/` + +实施完成后补充一份实施记录,优先保存到: + +- `docs/reports/implementation/` + +## 12. 最终结论 + +本次采用“前端页面最小改造 + 复用实体库现有详情接口”的方案,在不新增后端链路、不引入名称兜底查询、不扩展页面职责的前提下,为招投标详情弹窗中的供应商明细补充企业详情查看能力。 + +该方案满足以下约束: + +- 只按统一信用代码查询 +- 查不到统一提示“暂无企业信息” +- 查到后通过弹窗展示实体库全部字段 +- 保持实现路径最短,不引入超出需求范围的扩展设计 diff --git a/docs/superpowers/specs/2026-04-23-info-maintenance-search-grid-design.md b/docs/superpowers/specs/2026-04-23-info-maintenance-search-grid-design.md new file mode 100644 index 00000000..0c3a0f30 --- /dev/null +++ b/docs/superpowers/specs/2026-04-23-info-maintenance-search-grid-design.md @@ -0,0 +1,275 @@ +# 信息维护页面搜索区四列栅格统一设计文档 + +## 1. 背景 + +“信息维护”菜单下各页面的头部按钮已经完成统一,但搜索区字段布局仍保持各页各自实现: + +- 大部分页面仍使用 `el-form` 的 `inline` 横向流式排列 +- 字段控件宽度存在 `180px`、`220px`、`240px`、`260px` 等多种写法 +- 当字段数量较多时,不同页面的换行位置和视觉节奏不一致 +- 中介库管理页面的搜索区还拆分在独立 `SearchForm` 组件内 + +本次需求很明确:信息维护菜单下同一批页面的头部搜索框固定为每行 4 个字段,超过后自动换行,并且每个字段位宽度统一平分。 + +## 2. 目标 + +在沿用上一轮“信息维护页面头部按钮统一”范围的前提下,统一实现以下搜索区规则: + +- 搜索区每行固定展示 4 个字段位 +- 每个字段位宽度统一平分当前行 +- 超过 4 个字段后自动换到下一行 +- 日期范围等宽控件仍按 1 个字段位处理 +- 字段内部控件在所属字段位内铺满可用宽度 +- 不改变原有搜索、重置、回车查询和业务按钮逻辑 + +## 3. 范围 + +本次范围与上一轮“信息维护页面头部按钮统一”保持一致,覆盖以下页面: + +- 员工信息维护 +- 招聘信息维护 +- 员工调动记录 +- 员工亲属关系维护 +- 员工实体关系维护 +- 征信维护 +- 实体库管理 +- 中介库管理 +- 账户库管理 +- 信贷客户家庭关系 +- 信贷客户实体关联 +- 招投标信息维护 + +对应前端文件包括: + +- `ruoyi-ui/src/views/ccdiBaseStaff/index.vue` +- `ruoyi-ui/src/views/ccdiStaffRecruitment/index.vue` +- `ruoyi-ui/src/views/ccdiStaffTransfer/index.vue` +- `ruoyi-ui/src/views/ccdiStaffFmyRelation/index.vue` +- `ruoyi-ui/src/views/ccdiStaffEnterpriseRelation/index.vue` +- `ruoyi-ui/src/views/ccdiCreditInfo/index.vue` +- `ruoyi-ui/src/views/ccdiEnterpriseBaseInfo/index.vue` +- `ruoyi-ui/src/views/ccdiIntermediary/index.vue` +- `ruoyi-ui/src/views/ccdiIntermediary/components/SearchForm.vue` +- `ruoyi-ui/src/views/ccdiAccountInfo/index.vue` +- `ruoyi-ui/src/views/ccdiCustFmyRelation/index.vue` +- `ruoyi-ui/src/views/ccdiCustEnterpriseRelation/index.vue` +- `ruoyi-ui/src/views/ccdiPurchaseTransaction/index.vue` + +## 4. 非目标 + +- 不新增公共搜索组件 +- 不调整头部按钮统一方案 +- 不新增展开/收起搜索区交互 +- 不改查询参数结构、接口、权限和业务流程 +- 不对整页样式做额外重构 +- 不引入“部分页面特殊规则”的兼容性分支 + +## 5. 当前现状分析 + +### 5.1 字段数量差异明显 + +当前目标页面的搜索字段数量并不一致: + +- 2 个字段:征信维护 +- 4 个字段:员工亲属关系、员工实体关系、信贷客户家庭关系、信贷客户实体关联、招投标信息维护、中介库管理 +- 5 个字段:员工信息维护 +- 6 个字段:招聘信息维护、员工调动记录 +- 8 个字段:实体库管理 +- 9 个字段:账户库管理 + +这意味着仅靠现有 `inline` 布局无法稳定保证“一行 4 个字段”的统一结果。 + +### 5.2 控件宽度分散 + +同一批页面中存在大量固定宽度写法,例如: + +- `180px` +- `220px` +- `240px` +- `260px` + +这会让字段宽度规则依赖单个控件实现,而不是依赖统一布局容器,无法满足“每行平分”的要求。 + +### 5.3 特殊控件和特殊页面共存 + +存在以下差异场景: + +- 日期范围选择器仍要参与 4 列规则 +- 账户库管理字段较多且控件宽度差异明显 +- 中介库管理搜索字段由独立 `SearchForm` 组件维护 + +如果仅追加样式补丁,容易造成个别页面仍然偏离统一规则。 + +## 6. 方案比较 + +### 6.1 方案一:仅追加样式类约束 + +做法: + +- 保留现有 `inline` 结构 +- 通过统一 class 和宽度样式约束字段排列 + +优点: + +- 模板改动较少 + +缺点: + +- 受现有 `el-form-item` 结构和控件内联宽度影响较大 +- 难以严格保证所有页面都稳定呈现为 4 列 + +### 6.2 方案二:页内统一改为四列栅格 + +做法: + +- 各页面搜索区统一改为 `el-row` + `el-col :span="6"` 结构 +- 每个查询项占一个固定字段位 +- 控件宽度统一改为在字段位内铺满 +- 中介库管理 `SearchForm` 同步采用相同骨架 + +优点: + +- 能严格保证每行 4 个字段位 +- 超过 4 个字段时自动按栅格换行 +- 实现路径短,且结果稳定 + +缺点: + +- 需要逐页调整模板结构 + +### 6.3 方案三:抽公共搜索组件 + +做法: + +- 新增统一搜索区公共组件 +- 各页面通过配置驱动字段渲染 + +优点: + +- 长期统一性最好 + +缺点: + +- 明显超出本次需求的最短路径 +- 会引入额外抽象和页面适配成本 + +### 6.4 结论 + +采用方案二:页内统一改为四列栅格。 + +该方案既能满足“固定 4 个字段位、每行平分、超出自动换行”的明确需求,又不会引入超出本次范围的公共组件重构。 + +## 7. 总体设计 + +### 7.1 查询区骨架 + +所有目标页面的搜索区统一收敛为以下结构: + +1. `el-form` +2. 表单内部使用 `el-row` +3. 每个查询项由一个 `el-col :span="6"` 包裹 +4. `el-col` 内部放对应 `el-form-item` + +这样每行固定为 4 个字段位,总宽度按 24 栅格平分,每个字段位占 6 栅格。 + +### 7.2 字段占位规则 + +统一采用以下规则: + +- 普通输入框按 1 个字段位处理 +- 下拉框按 1 个字段位处理 +- 树选择按 1 个字段位处理 +- 日期范围按 1 个字段位处理 +- 不为任何字段引入“占 2 格”的特例 + +对应效果如下: + +- 5 个字段排布为 `4 + 1` +- 6 个字段排布为 `4 + 2` +- 8 个字段排布为 `4 + 4` +- 9 个字段排布为 `4 + 4 + 1` + +### 7.3 字段宽度规则 + +本次固定的是“字段位宽度”,不是“控件像素宽度”。 + +因此需要统一收敛以下做法: + +- 去掉分散在字段控件上的固定像素宽度 +- 让输入框、下拉框、日期范围在所属字段位内铺满 +- 保持 `label-width` 沿用页面现有值,避免标签对齐被一并重构 + +### 7.4 操作行边界 + +搜索区改造不改变上一轮已统一完成的操作行规则: + +- 搜索区只负责字段排布 +- 搜索、重置、业务按钮、`right-toolbar` 继续保留在独立操作行 +- 不把按钮重新并回查询表单 + +## 8. 页面分类设计 + +### 8.1 常规页面 + +适用于大多数直接在 `index.vue` 中维护查询表单的页面。 + +处理方式: + +- 保留原有 `queryParams`、`handleQuery`、`resetQuery` +- 仅改查询区模板骨架为四列栅格 +- 保留原有回车查询和显隐逻辑 + +### 8.2 中介库管理 + +中介库管理页保持当前父子组件职责边界: + +- 父页 `ccdiIntermediary/index.vue` 不接管字段渲染 +- 子组件 `SearchForm.vue` 内部改为四列栅格 +- 父页继续负责统一操作行和列表逻辑 + +这样可以在不扩大改造面的前提下,让中介库管理与其余页面保持同一布局规则。 + +### 8.3 特殊控件页面 + +针对日期范围等宽控件,统一采用单字段位处理: + +- 员工调动记录中的调动日期范围 +- 招投标信息维护中的申请日期范围 + +这些控件不获得额外列宽,只在自身字段位内铺满,以保证全页面规则始终一致。 + +## 9. 风险与控制 + +### 9.1 风险 + +- 个别页面原有固定宽度移除后,字段视觉宽度会发生变化 +- 字段标签宽度如果处理不当,可能造成个别页面对齐不一致 +- 中介库管理的独立组件如果只做局部样式修改,容易与其他页面规则不一致 + +### 9.2 控制方式 + +- 统一使用栅格结构,而不是继续依赖零散内联宽度 +- 仅收敛字段控件宽度,不改页面既有业务逻辑 +- 保持页面既有 `label-width`,减少额外样式扰动 +- 对中介库管理直接按同一栅格骨架处理,而不是加兼容补丁 + +## 10. 验证口径 + +实施完成后,按以下口径判定是否达成目标: + +- 范围内页面全部命中,无遗漏 +- 所有页面搜索区均为四列栅格结构 +- 每行最多 4 个字段位 +- 超过 4 个字段后自动换行 +- 日期范围仍只占 1 个字段位 +- 字段内部控件在所属字段位内铺满 +- 搜索、重置、回车查询、`showSearch`、业务按钮和 `right-toolbar` 行为不变 + +## 11. 完成标准 + +如果以下结果全部满足,则本次设计落地完成: + +- 信息维护同批页面的搜索区统一为“四列平分栅格” +- 不再依赖页面各自的固定像素宽度控制字段排布 +- 不新增额外交互模式和兼容性方案 +- 不修改业务逻辑,只统一查询字段布局规则 diff --git a/docs/superpowers/specs/2026-04-23-info-maintenance-toolbar-unification-design.md b/docs/superpowers/specs/2026-04-23-info-maintenance-toolbar-unification-design.md new file mode 100644 index 00000000..e305709c --- /dev/null +++ b/docs/superpowers/specs/2026-04-23-info-maintenance-toolbar-unification-design.md @@ -0,0 +1,338 @@ +# 信息维护页面头部按钮统一设计文档 + +## 1. 背景 + +当前“信息维护”菜单下各页面的头部结构并不统一。 + +多数页面仍沿用若依常见写法:查询条件表单位于上方,`搜索 / 重置` 按钮放在查询表单最后一个 `el-form-item` 中;查询表单下方再单独放一行业务按钮,如 `新增`、`导入`、`查看导入失败记录` 等。 + +这导致同属“信息维护”目录的页面在操作起点上表现不一致: + +- 用户需要先在查询表单区域找到 `搜索 / 重置`,再把视线切到下一行找 `新增 / 导入` +- 页面首屏操作被拆成两层,视觉上不够统一 +- 特殊页如“征信维护”“中介库管理”也各自使用不同结构,进一步放大了差异 + +本次需求非常明确:将“信息维护”菜单下所有页面统一调整为把 `搜索 / 重置` 移到 `新增 / 导入` 同一行,并放在这些业务按钮左边。 + +## 2. 目标 + +在“信息维护”菜单下所有页面中,统一实现以下头部规则: + +- 查询条件区域继续保留在上方 +- 查询条件下方保留一条统一操作行 +- `搜索 / 重置` 从查询表单内部移出,进入统一操作行 +- 统一操作行的顺序固定为:`搜索 -> 重置 -> 页面原有业务按钮 -> right-toolbar` +- 页面原有业务按钮文案、权限、显隐条件、点击行为保持不变 + +## 3. 范围 + +本次范围覆盖“信息维护”菜单下全部当前前端页面: + +- 员工信息维护 +- 招聘信息维护 +- 员工调动记录 +- 员工亲属关系维护 +- 员工实体关系维护 +- 征信维护 +- 实体库管理 +- 中介库管理 +- 账户库管理 +- 信贷客户家庭关系 +- 信贷客户实体关联 +- 招投标信息维护 + +其中两类特殊页面也纳入本次统一范围: + +- 征信维护:业务按钮不是“新增 / 导入”,而是 `批量上传征信HTML` +- 中介库管理:查询条件与 `搜索 / 重置` 目前封装在独立 `SearchForm` 组件中 + +## 4. 非目标 + +- 不新增头部公共组件 +- 不重构查询表单字段布局 +- 不新增或删除任何业务按钮 +- 不修改接口、数据结构、权限点和页面路由 +- 不调整失败记录按钮的显示逻辑 +- 不顺带处理与本次需求无关的样式问题 + +## 5. 当前现状分析 + +### 5.1 常规页面 + +常规页面的结构基本一致: + +1. 查询表单 +2. 查询表单中最后一个 `el-form-item` 放 `搜索 / 重置` +3. 下一行 `el-row.mb8` 放 `新增 / 导入 / 失败记录 / right-toolbar` + +这类页面包括: + +- 员工信息维护 +- 员工调动记录 +- 员工亲属关系维护 +- 员工实体关系维护 +- 招聘信息维护 +- 实体库管理 +- 账户库管理 +- 信贷客户家庭关系 +- 信贷客户实体关联 +- 招投标信息维护 + +### 5.2 征信维护 + +“征信维护”页面没有 `新增 / 导入` 组合,当前写法为: + +1. 查询表单中放 `搜索 / 重置` +2. 下一行操作区只放 `批量上传征信HTML` 和 `right-toolbar` + +虽然语义不同,但本质上仍属于“查询按钮与业务按钮分两行”的问题。 + +### 5.3 中介库管理 + +“中介库管理”页面的查询条件被拆到 `SearchForm.vue` 组件中,当前结构为: + +1. 父页渲染 `SearchForm` +2. `SearchForm` 内部自己渲染查询字段和 `搜索 / 重置` +3. 父页下方单独渲染 `新增 / 两类导入 / 两类失败记录 / right-toolbar` + +这类页面不能像常规页那样只移动模板片段,需要同时调整父子职责边界。 + +## 6. 方案比较 + +### 6.1 方案一:逐页手工挪动按钮 + +做法: + +- 每个页面各自把 `搜索 / 重置` 从查询表单中移到下方操作行 +- 中介库管理单独做特殊适配 + +优点: + +- 改法直接 +- 不引入新抽象 + +缺点: + +- 页面数量较多,重复改动偏多 +- 容易因为逐页处理导致结构细节不一致 + +### 6.2 方案二:抽头部公共组件 + +做法: + +- 新增一个统一头部组件,接收查询按钮、业务按钮和 `right-toolbar` +- 所有信息维护页面接入该组件 + +优点: + +- 理论上后续最统一 + +缺点: + +- 需要同步改造事件透传、插槽结构和特殊页适配 +- 对本次“只改按钮位置”的需求来说路径偏长 + +### 6.3 方案三:统一骨架,页内最小改动 + +做法: + +- 不抽公共组件 +- 仅把各页 `搜索 / 重置` 调整到统一操作行 +- 保留每页现有业务按钮定义与语义 +- 中介库管理只收敛 `SearchForm` 的职责,不扩展为组件体系重构 + +优点: + +- 满足最短路径要求 +- 风险最低 +- 能覆盖常规页、征信维护和中介库管理三类结构 + +缺点: + +- 统一规则仍分散在各页面模板中 + +### 6.4 结论 + +采用方案三:统一骨架,页内最小改动。 + +## 7. 总体设计 + +### 7.1 统一头部骨架 + +所有信息维护页面统一收敛为以下结构: + +1. 查询条件表单 +2. 统一操作行 +3. 表格与分页区域 + +统一操作行内部顺序固定为: + +- 搜索 +- 重置 +- 当前页面原有业务按钮 +- `right-toolbar` + +其中: + +- `搜索 / 重置` 总是排在最左侧 +- 当前页面原有业务按钮保持原有顺序与文案 +- `right-toolbar` 继续保持在最右侧 + +### 7.2 查询表单边界 + +查询表单只保留查询字段本身,不再承担查询按钮展示职责。 + +这意味着: + +- 常规页删除查询表单末尾的按钮型 `el-form-item` +- 查询条件字段、`@keyup.enter.native="handleQuery"` 等行为保留 +- `resetQuery` 方法语义保持不变,只是触发入口位置改变 + +### 7.3 业务按钮边界 + +页面当前已有的业务按钮全部保留,不因为统一布局而更名或改语义。 + +例如: + +- 征信维护继续使用 `批量上传征信HTML` +- 中介库管理继续使用 `导入中介和亲属信息`、`导入中介实体关联关系` +- 实体库管理继续保留 `删除` +- 各页面失败记录按钮继续保留各自原有文案和显隐条件 + +## 8. 页面分类设计 + +### 8.1 常规页面 + +常规页面直接采用相同处理方式: + +1. 从查询表单中删掉 `搜索 / 重置` +2. 在 `el-row.mb8` 左侧新增 `搜索 / 重置` +3. 原有业务按钮整体顺延 + +适用于: + +- 员工信息维护 +- 招聘信息维护 +- 员工调动记录 +- 员工亲属关系维护 +- 员工实体关系维护 +- 实体库管理 +- 账户库管理 +- 信贷客户家庭关系 +- 信贷客户实体关联 +- 招投标信息维护 + +### 8.2 征信维护 + +征信维护页面也按统一骨架处理,但不强行改成“新增 / 导入”语义。 + +目标结构为: + +- `搜索` +- `重置` +- `批量上传征信HTML` +- `right-toolbar` + +即仅统一布局,不改按钮名称和后续上传链路。 + +### 8.3 中介库管理 + +中介库管理页面需要额外收敛 `SearchForm` 的职责边界。 + +调整后职责如下: + +- `SearchForm` 只负责渲染查询字段 +- 父页面负责渲染 `搜索 / 重置` +- 父页面继续负责 `新增 / 导入 / 失败记录 / right-toolbar` + +事件口径保持不变: + +- `搜索` 仍触发父页 `handleQuery` +- `重置` 仍清空查询条件并触发列表刷新 + +但 `搜索 / 重置` 的展示位置统一收口到父页操作行中。 + +## 9. 实现边界 + +### 9.1 本次允许改动 + +- 各页面模板中查询按钮的位置 +- 少量样式,用于保证统一操作行换行时可读 +- 中介库管理父子组件的头部展示职责划分 + +### 9.2 本次禁止改动 + +- 查询参数字段 +- 列表字段 +- 按钮权限控制 +- 接口调用方式 +- 导入、上传、失败记录、详情弹窗等业务逻辑 +- 页面菜单、路由和 SQL + +## 10. 测试与验收设计 + +### 10.1 页面验收点 + +所有纳入范围的页面都需要满足: + +- 查询表单内不再显示 `搜索 / 重置` +- `搜索 / 重置` 均位于统一操作行最左侧 +- 业务按钮位于 `搜索 / 重置` 右侧 +- `right-toolbar` 位于操作行最右侧 + +### 10.2 行为验收点 + +所有页面都需要保证以下行为不回归: + +- 输入查询条件后点击 `搜索` 能正常刷新列表 +- 点击 `重置` 能正常清空条件并刷新列表 +- 查询条件回车搜索能力保留 +- 原有业务按钮点击行为不变 +- 权限控制与失败记录显隐逻辑不变 + +### 10.3 特殊页面验收点 + +征信维护: + +- `批量上传征信HTML` 仍可正常打开上传弹窗 + +中介库管理: + +- `搜索 / 重置` 虽然移出 `SearchForm`,但查询与重置行为保持一致 +- 两类导入按钮和两类失败记录按钮行为不变 + +### 10.4 浏览器实测口径 + +实现完成后至少需要使用真实浏览器验证三类代表页面: + +- 常规页:员工信息维护 +- 特殊业务按钮页:征信维护 +- 组件拆分页:中介库管理 + +实测重点: + +- 页面加载后头部布局是否符合统一规则 +- `搜索`、`重置` 是否可用 +- 业务按钮点击是否仍按原链路工作 + +## 11. 风险与控制 + +### 11.1 风险 + +- 页面较多,逐页调整时可能遗漏个别信息维护页面 +- 直接移动按钮后,某些页面在窄屏下可能出现换行拥挤 +- 中介库管理如果只移动视觉位置但没处理好重置逻辑,可能出现父子状态不同步 + +### 11.2 控制措施 + +- 以“信息维护菜单全部页面”为清单逐页核对,不按模糊搜索结果做不完整改动 +- 样式只做最小补充,优先保证按钮换行后仍可点、可读 +- 中介库管理调整后单独验证重置行为,确保查询参数与列表刷新保持一致 + +## 12. 预期交付 + +- 设计文档 1 份 +- 后续前端实施计划 1 份 +- 后续实施完成记录 1 份 + +本次设计只涉及前端页面布局调整,不涉及后端改造,因此不拆分后端实施计划。 diff --git a/docs/superpowers/specs/2026-04-23-staff-family-enterprise-relation-design.md b/docs/superpowers/specs/2026-04-23-staff-family-enterprise-relation-design.md new file mode 100644 index 00000000..d0cc389f --- /dev/null +++ b/docs/superpowers/specs/2026-04-23-staff-family-enterprise-relation-design.md @@ -0,0 +1,544 @@ +# 员工亲属实体关联维护设计 + +## 1. 背景 + +当前信息维护中的员工实体关联维护模块,底层使用 `ccdi_staff_enterprise_relation` 表,页面、接口、导入能力均已完整存在,但现有语义以“员工本人”为主对象。 + +本次需求要求将该模块整体切换为“员工亲属与实体的关联关系维护”: + +- 主对象从员工本人切换为员工亲属 +- 员工亲属必须来源于员工亲属关系维护中已维护的亲属 +- 新增和导入时仅允许使用员工亲属关系中状态为有效的亲属 +- 列表需要展示亲属身份证、亲属名称、亲属关联的员工 +- 现有导入能力同步切换为员工亲属实体关联导入 +- 若亲属关系后续被改为无效,则该亲属名下已有实体关联自动更新为无效 + +本次设计按最短路径实施,不新增平行模块,不做兼容性方案,不额外扩展用户未提出的兜底逻辑。 + +## 2. 目标与范围 + +### 2.1 目标 + +在保留现有员工实体关联模块入口、表结构和基本交互骨架的前提下,将其业务语义改造为员工亲属实体关联维护,并保证新增、编辑、查询、详情、导入、失效联动链路闭环。 + +### 2.2 本次范围 + +- 改造员工实体关联模块的查询、详情、新增、编辑、导入语义 +- 接入员工亲属关系表作为唯一合法亲属来源 +- 页面支持按亲属身份证模糊搜索有效亲属并自动带出信息 +- 列表和详情展示亲属名称及关联员工 +- 员工亲属关系失效时,自动将对应实体关联更新为无效 + +### 2.3 非本次范围 + +- 不新增独立“员工亲属实体关联”新表 +- 不迁移旧的员工本人实体关联历史数据 +- 不设计旧数据兼容展示逻辑 +- 不增加亲属关系失效后自动恢复实体关联为有效的反向联动 +- 不扩展用户未提出的新查询条件、新统计能力或新菜单入口 + +## 3. 现状分析 + +### 3.1 现有实体关联模块 + +当前模块主要位置如下: + +- 后端控制器:`ccdi-info-collection/.../controller/CcdiStaffEnterpriseRelationController.java` +- 后端服务:`ccdi-info-collection/.../service/impl/CcdiStaffEnterpriseRelationServiceImpl.java` +- 查询 Mapper:`ccdi-info-collection/.../mapper/info/collection/CcdiStaffEnterpriseRelationMapper.xml` +- 前端页面:`ruoyi-ui/src/views/ccdiStaffEnterpriseRelation/index.vue` +- 前端 API:`ruoyi-ui/src/api/ccdiStaffEnterpriseRelation.js` + +当前主表为 `ccdi_staff_enterprise_relation`,核心字段包括: + +- `person_id`:当前语义为员工身份证号 +- `social_credit_code` +- `enterprise_name` +- `relation_person_post` +- `status` +- `remark` +- `data_source` + +当前列表通过 `ccdi_base_staff.id_card = person_id` 查询员工姓名。 + +### 3.2 现有员工亲属关系模块 + +员工亲属关系维护模块已完整存在,主要使用: + +- 表:`ccdi_staff_fmy_relation` +- 员工身份证:`person_id` +- 亲属名称:`relation_name` +- 亲属身份证:`relation_cert_no` +- 员工亲属标记:`is_emp_family` +- 状态:`status` + +该表已经能够稳定表达“某亲属属于哪个员工”,可以直接作为本次改造的唯一合法来源。 + +## 4. 方案对比 + +### 4.1 方案 A:沿用现有表并切换业务语义 + +做法: + +- 继续使用 `ccdi_staff_enterprise_relation` +- 将 `person_id` 改为表示“亲属身份证号” +- 查询时通过亲属关系表回补亲属名称和关联员工 + +优点: + +- 改动最小 +- 现有增删改查和导入框架可复用 +- 不需要新增表、菜单、权限和页面 + +缺点: + +- `person_id` 字段命名不再直观表达“亲属” + +### 4.2 方案 B:沿用现有表并增加冗余字段 + +做法: + +- 在原表上补充亲属名称、关联员工身份证、关联员工姓名等冗余字段 + +优点: + +- 查询 SQL 简单 + +缺点: + +- 引入冗余数据 +- 与员工亲属关系、员工基础信息容易产生不一致 +- 超出最短路径原则 + +### 4.3 方案 C:新建员工亲属实体关联表 + +做法: + +- 新建全新表结构与全新模块 + +优点: + +- 语义最干净 + +缺点: + +- 改造范围明显扩大 +- 需要增加整套表、接口、页面和权限 +- 不符合本次“最短路径实现”要求 + +### 4.4 选型结论 + +采用方案 A。 + +原因: + +- 可以在现有模块上直接改造,成本最低 +- 满足业务闭环,不需要额外兜底 +- 与当前仓库已有的员工亲属关系维护能力天然衔接 + +## 5. 数据模型设计 + +## 5.1 主表沿用 + +继续使用 `ccdi_staff_enterprise_relation`,但字段语义调整如下: + +| 字段 | 新语义 | +|------|--------| +| `person_id` | 亲属身份证号 | +| `social_credit_code` | 统一社会信用代码 | +| `enterprise_name` | 企业名称 | +| `relation_person_post` | 亲属在企业中的职务 | +| `status` | 关联关系状态 | +| `remark` | 补充说明 | +| `data_source` | 数据来源 | + +本次不修改表结构,不新增字段。 + +## 5.2 查询回填链路 + +列表和详情展示使用以下关联链路: + +1. 以 `ccdi_staff_enterprise_relation` 为主表 +2. 通过 `ccdi_staff_fmy_relation.relation_cert_no = ccdi_staff_enterprise_relation.person_id` +3. 并限定 `ccdi_staff_fmy_relation.is_emp_family = 1` +4. 从 `ccdi_staff_fmy_relation` 获取: + - `relation_name` 作为亲属名称 + - `person_id` 作为关联员工身份证 +5. 再通过 `ccdi_base_staff.id_card = ccdi_staff_fmy_relation.person_id` 获取员工姓名 + +最终返回给前端的核心展示字段为: + +- `personId`:亲属身份证 +- `relationName`:亲属名称 +- `staffPersonId`:关联员工身份证 +- `staffPersonName`:关联员工姓名 + +现有 `personName` 字段不再承载员工姓名语义,避免前后端继续误用,改造后统一使用新的返回字段。 + +## 6. 业务规则 + +### 6.1 合法亲属来源 + +新增和导入时,亲属身份证号必须匹配到一条满足以下条件的员工亲属关系: + +- `is_emp_family = 1` +- `status = 1` +- `relation_cert_no = person_id` + +只要任一条件不满足,即判定该亲属不允许用于实体关联维护。 + +### 6.2 唯一性规则 + +业务唯一性继续沿用现有模式,但语义变更为: + +- `亲属身份证号 + 统一社会信用代码` 唯一 + +新增与导入都必须校验该组合唯一,禁止重复录入。 + +### 6.3 编辑规则 + +编辑时延续现有“业务主键不可修改”的方式: + +- 亲属身份证号不可修改 +- 统一社会信用代码不可修改 + +只允许修改: + +- 企业名称 +- 关联人在企业的职务 +- 状态 +- 补充说明 + +### 6.4 亲属失效联动规则 + +当员工亲属关系模块中某条亲属关系状态从有效改为无效时: + +- 自动将该亲属名下所有实体关联记录的 `status` 更新为 `0` + +联动范围限定为: + +- `ccdi_staff_enterprise_relation.person_id = ccdi_staff_fmy_relation.relation_cert_no` + +本次仅实现“改为无效时自动同步为无效”的单向联动,不设计反向自动恢复为有效。 + +## 7. 后端设计 + +### 7.1 接口延用 + +继续沿用现有接口路径: + +- `GET /ccdi/staffEnterpriseRelation/list` +- `GET /ccdi/staffEnterpriseRelation/{id}` +- `POST /ccdi/staffEnterpriseRelation` +- `PUT /ccdi/staffEnterpriseRelation` +- `DELETE /ccdi/staffEnterpriseRelation/{ids}` +- `POST /ccdi/staffEnterpriseRelation/importTemplate` +- `POST /ccdi/staffEnterpriseRelation/importData` +- `GET /ccdi/staffEnterpriseRelation/importStatus/{taskId}` +- `GET /ccdi/staffEnterpriseRelation/importFailures/{taskId}` + +不新增平行接口组。 + +### 7.2 查询 DTO 与 VO + +查询条件建议调整为围绕亲属语义: + +- 亲属身份证号 +- 亲属名称 +- 关联员工 +- 统一社会信用代码 +- 企业名称 +- 状态 + +VO 增加或切换为以下字段: + +- `personId` +- `relationName` +- `staffPersonId` +- `staffPersonName` +- `relationPersonPost` +- `socialCreditCode` +- `enterpriseName` +- `status` +- `remark` +- `dataSource` +- `createTime` +- `updateTime` +- `createdBy` +- `updatedBy` + +### 7.3 查询 SQL + +列表和详情 SQL 改为从亲属关系表回补字段,示意逻辑如下: + +```sql +SELECT + ser.id, + ser.person_id, + sfr.relation_name, + sfr.person_id AS staff_person_id, + bs.name AS staff_person_name, + ser.relation_person_post, + ser.social_credit_code, + ser.enterprise_name, + ser.status, + ser.remark, + ser.data_source, + ser.created_by, + ser.create_time, + ser.updated_by, + ser.update_time +FROM ccdi_staff_enterprise_relation ser +LEFT JOIN ccdi_staff_fmy_relation sfr + ON sfr.relation_cert_no = ser.person_id + AND sfr.is_emp_family = 1 +LEFT JOIN ccdi_base_staff bs + ON bs.id_card = sfr.person_id +``` + +其中: + +- 列表查询不额外过滤 `sfr.status = 1` +- 原因是历史记录需要正常展示,即使亲属后续失效,记录仍可查到,但实体关联状态会被联动改为无效 + +### 7.4 新增与导入校验 + +新增和导入都统一改为校验有效亲属关系: + +1. 根据亲属身份证号查询 `ccdi_staff_fmy_relation` +2. 要求命中 `is_emp_family = 1 and status = 1` +3. 未命中则报错 +4. 命中后继续校验 `亲属身份证号 + 统一社会信用代码` 唯一 + +### 7.5 亲属下拉搜索接口 + +前端新增/编辑弹窗需要按身份证号模糊搜索有效亲属。 + +实现方式: + +- 在员工实体关联模块后端新增一个用于下拉搜索的查询接口 +- 返回有效亲属列表 + +返回项建议包含: + +- `relationCertNo` +- `relationName` +- `personId` +- `personName` + +查询条件: + +- `relation_cert_no` 模糊匹配输入值 +- `is_emp_family = 1` +- `status = 1` + +### 7.6 亲属失效联动落点 + +联动逻辑放在员工亲属关系保存链路中处理,即: + +- `CcdiStaffFmyRelationServiceImpl.updateRelation` + +处理规则: + +1. 查询更新前旧记录 +2. 判断旧状态是否为有效、更新后状态是否为无效 +3. 若是,则按当前亲属身份证号批量更新实体关联表状态为无效 + +该联动需和亲属关系更新处于同一事务内,保证状态一致。 + +## 8. 前端设计 + +### 8.1 页面延用 + +继续使用: + +- `ruoyi-ui/src/views/ccdiStaffEnterpriseRelation/index.vue` + +不新建新页面。 + +### 8.2 查询区 + +查询区改为亲属语义,保留或新增: + +- 亲属身份证号 +- 亲属名称 +- 关联员工 +- 统一社会信用代码 +- 企业名称 +- 状态 + +### 8.3 列表区 + +列表核心列调整为: + +- 亲属身份证 +- 亲属名称 +- 关联员工 +- 企业名称 +- 关联人在企业的职务 +- 状态 +- 数据来源 +- 创建时间 +- 操作 + +其中“关联员工”展示建议为: + +- `员工姓名(员工身份证号)` + +这样可以避免用户只看到姓名无法定位员工。 + +### 8.4 新增弹窗 + +新增弹窗保留现有骨架,调整主录入方式: + +- 身份证号输入改为“按亲属身份证模糊搜索” +- 下拉数据来源改为有效员工亲属 + +下拉项显示建议: + +- 左侧:亲属身份证号 +- 右侧:亲属名称 / 关联员工姓名 + +用户选中后,自动回填并只读展示: + +- 亲属名称 +- 关联员工 + +其余字段继续录入: + +- 统一社会信用代码 +- 企业名称 +- 关联人在企业的职务 +- 补充说明 + +新增时状态仍默认有效。 + +### 8.5 编辑弹窗 + +编辑弹窗延续现有模式: + +- 亲属身份证号不可编辑 +- 统一社会信用代码不可编辑 +- 亲属名称和关联员工仅展示,不可改 + +### 8.6 详情弹窗 + +详情弹窗基础信息展示: + +- 亲属身份证 +- 亲属名称 +- 关联员工 +- 统一社会信用代码 +- 企业名称 +- 关联人在企业的职务 +- 状态 +- 数据来源 +- 补充说明 +- 审计信息 + +## 9. 导入设计 + +### 9.1 导入模板 + +继续沿用现有异步导入机制,但模板标题、文案和字段语义改为亲属口径。 + +模板标题改为: + +- 员工亲属实体关联信息 + +模板中的“身份证号”字段实际表示: + +- 亲属身份证号 + +### 9.2 导入校验规则 + +每行导入数据按以下顺序校验: + +1. 基础字段非空与格式合法 +2. 亲属身份证号必须存在于员工亲属关系中 +3. 且该亲属必须为员工亲属、状态有效 +4. `亲属身份证号 + 统一社会信用代码` 在库中不能重复 +5. 导入文件内同组合不能重复 + +### 9.3 失败记录展示 + +失败记录弹窗字段调整为: + +- 亲属身份证号 +- 亲属名称 +- 企业名称 +- 统一社会信用代码 +- 失败原因 + +失败原因示例: + +- 亲属身份证号不存在于员工亲属关系中 +- 亲属已失效,无法导入实体关联 +- 亲属身份证号和统一社会信用代码组合已存在 + +### 9.4 导入结果提示 + +所有导入提示文案统一改为: + +- 员工亲属实体关联导入 + +避免继续出现“员工实体关系”旧语义。 + +## 10. 错误处理 + +后端错误提示统一切换为亲属语义,示例: + +- 所选身份证号不是有效的员工亲属,无法新增实体关联 +- 亲属身份证号和统一社会信用代码组合已存在 +- 亲属身份证号不存在于员工亲属关系中,或该亲属已无效 +- 亲属身份证号不可修改 + +前端不新增特殊兜底逻辑,直接按现有错误提示机制回显后端返回信息。 + +## 11. 测试要点 + +### 11.1 列表与详情 + +- 列表正确展示亲属身份证、亲属名称、关联员工 +- 详情页展示字段与列表语义一致 + +### 11.2 新增 + +- 输入亲属身份证可模糊搜索有效亲属 +- 选中亲属后自动带出亲属名称和关联员工 +- 无效亲属、非员工亲属、不存在的亲属身份证不能新增 +- 重复的亲属身份证号 + 统一社会信用代码不能新增 + +### 11.3 编辑 + +- 亲属身份证号不可修改 +- 统一社会信用代码不可修改 +- 企业名称、职务、状态、备注可正常编辑 + +### 11.4 导入 + +- 有效亲属导入成功 +- 亲属不存在导入失败 +- 亲属无效导入失败 +- 导入文件内重复失败 +- 数据库内重复失败 +- 失败记录文案与字段正确 + +### 11.5 失效联动 + +- 将员工亲属关系从有效改为无效后 +- 该亲属名下已有实体关联自动更新为无效 +- 列表仍能查到该记录,但状态为无效 + +## 12. 实施边界总结 + +本次改造严格限定为“在原员工实体关联模块上切换主对象为员工亲属”,核心边界如下: + +- 不新建表 +- 不新建页面 +- 不迁移旧数据 +- 不做兼容性兜底 +- 不做反向恢复联动 +- 所有合法性判断均以员工亲属关系表为准 + +在此边界内,前后端和导入链路均可形成完整闭环,并满足本次需求。 diff --git a/docs/superpowers/specs/2026-04-26-enterprise-auto-fill-design.md b/docs/superpowers/specs/2026-04-26-enterprise-auto-fill-design.md new file mode 100644 index 00000000..be79eb46 --- /dev/null +++ b/docs/superpowers/specs/2026-04-26-enterprise-auto-fill-design.md @@ -0,0 +1,337 @@ +# 关联业务自动补入实体库设计 + +## 1. 背景 + +当前信息维护中多条业务链路都会录入或导入统一社会信用代码和企业名称,但实体库 `ccdi_enterprise_base_info` 不一定已经存在对应企业。现状中部分链路只保存业务关联表,部分链路会因为实体库不存在而失败,导致“关联业务已经知道企业信息,但实体库没有沉淀”的数据断点。 + +本次需求要求以下模块在新建、导入时,如果要关联的实体在系统实体库不存在,则自动将不存在的实体添加到实体库中: + +- 员工亲属实体关联 +- 中介库与实体关联 +- 信贷客户实体关联 +- 招投标信息维护 + +本次设计按最短路径实现,不增加兼容性分支,不扩展用户未提出的兜底或降级逻辑。 + +## 2. 目标与范围 + +### 2.1 目标 + +在保留现有页面交互和业务校验规则的前提下,为上述四类业务的新建和导入链路补齐实体库自动沉淀能力,保证业务数据成功落库时,对应实体也已存在于实体库。 + +### 2.2 本次范围 + +- 新增后端内部实体库自动补全能力 +- 改造员工亲属实体关联新建、导入链路 +- 改造中介实体关联新建、导入链路 +- 改造信贷客户实体关联新建、导入链路 +- 改造招投标信息维护新建、导入链路中的供应商实体补入 +- 新增企业来源 `SUPPLIER=供应商` +- 保证中介库管理新增、导入实体时风险等级默认为高风险 +- 补充对应后端、前端与测试文档 + +### 2.3 非本次范围 + +- 不修改实体库主键规则,仍以统一社会信用代码作为实体唯一标识 +- 不在实体库已存在时覆盖企业名称、来源、风险等级、数据来源等字段 +- 不从外部接口拉取企业工商详情 +- 不为没有统一社会信用代码的招投标供应商创建实体库记录 +- 不新增用户确认弹窗或前端交互分支 +- 不修改关联表与招投标主从表结构 + +## 3. 现状分析 + +### 3.1 实体库 + +实体库表为 `ccdi_enterprise_base_info`,主键为 `social_credit_code`。核心字段包括: + +- `social_credit_code` +- `enterprise_name` +- `risk_level` +- `ent_source` +- `data_source` +- `created_by` +- `updated_by` + +当前企业来源枚举包含: + +- `GENERAL`:一般企业 +- `EMP_RELATION`:员工关系人 +- `CREDIT_CUSTOMER`:信贷客户 +- `INTERMEDIARY`:中介 +- `BOTH`:兼有 + +当前缺少供应商来源,需要新增 `SUPPLIER`。 + +### 3.2 各业务链路 + +员工亲属实体关联: + +- 新建、导入会校验有效员工亲属 +- 业务关联表保存统一社会信用代码和企业名称 +- 当前不会补入实体库 + +中介实体关联: + +- 新建时要求关联机构必须已存在 +- 导入时当前会因“统一社会信用代码不存在于系统机构表”失败 +- 本次需要改为实体库缺失时自动补入 + +信贷客户实体关联: + +- 新建、导入保存统一社会信用代码和企业名称 +- 当前不会补入实体库 + +招投标信息维护: + +- 新建、导入维护供应商明细 +- 供应商明细包含供应商名称和统一信用代码 +- 当前不会补入实体库 + +## 4. 实现方案 + +### 4.1 新增后端内部实体库自动补全服务 + +本次采用单一路径实现: + +- 新增一个内部复用能力,统一接收统一社会信用代码、企业名称、企业来源、数据来源和操作人 +- 各业务 Service 在成功落业务数据前调用 +- 已存在则不处理,不存在则插入最小实体记录 + +采用该方案的原因: + +- 规则集中,避免四处重复 +- 新建与导入可复用同一口径 +- 后续新增业务来源时扩展点明确 +- 需要各业务链路接入该内部服务 +- 满足最短路径实现 +- 业务规则集中可测 +- 不改变前端交互 +- 可以明确保证已存在实体不被覆盖 + +## 5. 数据规则 + +### 5.1 实体识别规则 + +自动补入实体库时,只按统一社会信用代码判断实体是否存在。 + +- 若 `ccdi_enterprise_base_info.social_credit_code` 已存在:不更新实体库任何字段 +- 若不存在:新增一条最小实体记录 + +### 5.2 最小实体记录字段 + +自动补入时写入以下字段: + +| 字段 | 规则 | +|------|------| +| `social_credit_code` | 来源业务中的统一社会信用代码 | +| `enterprise_name` | 来源业务中的企业名称或供应商名称 | +| `ent_source` | 按业务来源映射 | +| `risk_level` | 仅中介来源默认为 `1`,其他来源按 `NULL` 落库 | +| `data_source` | 新建为 `MANUAL`,导入为 `IMPORT` | +| `created_by` | 当前操作人 | +| `updated_by` | 当前操作人 | + +其他实体字段保持为空。自动补入能力必须使用独立插入路径,不复用实体库管理手工新增中要求风险等级必填的校验逻辑;员工亲属、信贷客户、供应商三类自动补入记录必须显式保证 `risk_level` 按 `NULL` 落库,不能吃到历史表默认低风险值。 + +### 5.3 企业来源映射 + +| 触发业务 | `ent_source` | `risk_level` | +|----------|--------------|--------------| +| 员工亲属实体关联 | `EMP_RELATION` | 空 | +| 中介实体关联 | `INTERMEDIARY` | `1` | +| 中介库管理新增实体 | `INTERMEDIARY` | `1` | +| 中介库管理导入实体 | `INTERMEDIARY` | `1` | +| 信贷客户实体关联 | `CREDIT_CUSTOMER` | 空 | +| 招投标供应商 | `SUPPLIER` | 空 | + +需要新增企业来源枚举: + +- `SUPPLIER("SUPPLIER", "供应商")` + +企业来源选项由现有 `EnterpriseSource` 枚举接口下发,实体库管理页面应通过该接口正常显示和筛选“供应商”。 + +### 5.4 同批重复规则 + +导入场景中,如果同一批成功数据内同一个统一社会信用代码出现多次,且实体库当前不存在: + +- 只补入一次实体库 +- 使用首次有效出现的企业名称或供应商名称 +- 后续相同统一社会信用代码不覆盖已补入实体 + +## 6. 业务链路设计 + +### 6.1 员工亲属实体关联 + +新建: + +1. 校验亲属身份证号必须是有效员工亲属 +2. 校验亲属身份证号和统一社会信用代码组合唯一 +3. 调用实体库自动补全,来源为 `EMP_RELATION`,数据来源为 `MANUAL` +4. 插入员工亲属实体关联 + +导入: + +1. 保持现有每行基础校验、有效亲属校验、重复组合校验 +2. 仅对校验成功行收集实体信息 +3. 批量自动补入缺失实体,来源为 `EMP_RELATION`,数据来源为 `IMPORT` +4. 批量插入员工亲属实体关联成功行 +5. 校验失败行不补入实体库 + +### 6.2 中介实体关联 + +新建: + +1. 校验中介本人存在 +2. 不再将“关联机构不存在”作为失败条件 +3. 校验中介和统一社会信用代码组合唯一 +4. 调用实体库自动补全,来源为 `INTERMEDIARY`,风险等级为 `1`,数据来源为 `MANUAL` +5. 插入中介实体关联 + +导入: + +1. 保持中介本人存在、字段格式、重复组合等校验 +2. 取消“统一社会信用代码不存在于系统机构表”失败条件 +3. 对校验成功行收集实体信息 +4. 批量自动补入缺失实体,来源为 `INTERMEDIARY`,风险等级为 `1`,数据来源为 `IMPORT` +5. 批量插入中介实体关联成功行 + +中介库管理新增、导入实体: + +- 新增实体时继续直接写实体库,风险等级默认为 `1` +- 导入实体时成功记录的风险等级默认为 `1` + +### 6.3 信贷客户实体关联 + +新建: + +1. 校验身份证号和统一社会信用代码组合唯一 +2. 调用实体库自动补全,来源为 `CREDIT_CUSTOMER`,数据来源为 `MANUAL` +3. 插入信贷客户实体关联 + +导入: + +1. 保持现有字段格式、重复组合等校验 +2. 对校验成功行收集实体信息 +3. 批量自动补入缺失实体,来源为 `CREDIT_CUSTOMER`,数据来源为 `IMPORT` +4. 批量插入信贷客户实体关联成功行 + +### 6.4 招投标信息维护 + +新建: + +1. 保持采购事项 ID 唯一性校验 +2. 保持供应商唯一中标、重复供应商等校验 +3. 从供应商明细中收集 `supplierUscc` 不为空的供应商 +4. 调用实体库自动补全,来源为 `SUPPLIER`,数据来源为 `MANUAL` +5. 插入招投标主信息和供应商明细 + +导入: + +1. 保持双 Sheet 主从关系、主信息字段、供应商字段、重复供应商等校验 +2. 仅对校验成功的采购事项收集供应商实体信息 +3. 只处理供应商统一信用代码不为空的供应商 +4. 批量自动补入缺失实体,来源为 `SUPPLIER`,数据来源为 `IMPORT` +5. 批量插入招投标主信息和供应商明细 + +没有统一信用代码的供应商不补实体库,也不因此失败。 + +## 7. 事务与并发 + +### 7.1 新建事务 + +新建场景中,实体库补全和业务数据插入在同一事务内执行。任一环节失败,本次新建整体回滚。 + +### 7.2 导入事务 + +导入场景保持现有异步任务机制。每个导入任务在成功数据批量落库前先执行实体库补全,再写业务表。若实体补全失败,对应成功候选数据不得静默写入业务表。 + +### 7.3 并发重复 + +如果两个请求同时补入同一统一社会信用代码: + +- 查询时不存在但插入时遇到主键重复,应按“实体已存在”处理 +- 不覆盖并发方已写入的实体字段 +- 不影响当前业务数据继续落库 + +## 8. 异常处理 + +保留现有业务校验错误: + +- 字段必填失败 +- 统一社会信用代码格式错误 +- 身份证号格式错误 +- 亲属不存在或无效 +- 中介本人不存在 +- 重复关联组合 +- 重复供应商 +- 招投标主从 Sheet 关系异常 + +不再作为失败原因: + +- 中介实体关联中的“统一社会信用代码不存在于系统机构表” + +自动补入实体库时,企业名称为空或超过长度不单独新增兜底规则,沿用各业务现有字段校验结果。 + +## 9. 前端与枚举 + +前端不新增交互。 + +需要同步新增或调整: + +- 后端 `EnterpriseSource` 枚举新增 `SUPPLIER("SUPPLIER", "供应商")` +- 现有企业来源枚举接口应返回供应商选项 +- 实体库管理页面企业来源下拉、列表、详情中的枚举展示应能正确显示“供应商” + +## 10. 测试设计 + +### 10.1 后端测试 + +需要覆盖: + +- 员工亲属实体关联新建:实体库无记录时补入 `EMP_RELATION`,风险为空 +- 员工亲属实体关联导入:成功行补入实体库,失败行不补 +- 中介实体关联新建:实体库无记录时补入 `INTERMEDIARY`,风险为 `1` +- 中介实体关联导入:原实体不存在场景由失败改为成功 +- 中介库管理新增实体:未填写风险等级时写入 `riskLevel=1` +- 中介库管理导入实体:成功记录写入 `riskLevel=1` +- 信贷客户实体关联新建:实体库无记录时补入 `CREDIT_CUSTOMER`,风险为空 +- 信贷客户实体关联导入:成功行补入实体库,失败行不补 +- 招投标新建:供应商统一信用代码存在时补入 `SUPPLIER`,风险为空 +- 招投标导入:成功采购事项的供应商补入实体库,失败采购事项不补 +- 已存在实体不覆盖原有名称、来源、风险等级、数据来源 +- 同批重复统一社会信用代码只补一次,首次名称生效 +- 并发或插入重复时按已存在处理 +- 员工亲属、信贷客户、招投标供应商自动补入记录的 `risk_level` 为 `NULL` + +### 10.2 前端测试 + +需要覆盖: + +- 实体库管理企业来源筛选项包含“供应商” +- 实体库列表和详情能够显示供应商来源 +- 招投标页面新建供应商后,实体库可查询到自动补入的供应商实体 + +完成页面功能开发后,需要使用 Playwright 打开真实业务页面验证,不使用原型页面。 + +### 10.3 导入测试 + +导入测试必须进入真实业务页面执行: + +- 下载当前页面模板 +- 基于模板生成测试文件 +- 覆盖成功导入、失败行不补实体、混合成功失败、同批重复统一社会信用代码等场景 +- 测试结束后清理本轮成功写入的测试数据和导入任务缓存 + +## 11. 实施文档要求 + +本次功能涉及后端服务、企业来源枚举和前端枚举展示,因此后续实施计划需要拆分: + +- 后端实施计划:`docs/plans/backend/` +- 前端实施计划:`docs/plans/frontend/` + +实现完成后需要新增实施记录: + +- `docs/reports/implementation/` + +实施记录需包含修改内容、影响范围和验证情况。 diff --git a/docs/tests/records/2026-03-31-abnormal-account-bank-tag-validation.md b/docs/tests/records/2026-03-31-abnormal-account-bank-tag-validation.md new file mode 100644 index 00000000..68cac86c --- /dev/null +++ b/docs/tests/records/2026-03-31-abnormal-account-bank-tag-validation.md @@ -0,0 +1,155 @@ +# 异常账户标签识别联调验证记录 + +## 1. 验证目标 + +- 验证异常账户模型在真实数据库中可稳定命中 +- 验证页面展示结果与数据库、后端聚合结果一致 + +## 2. 关联文档 + +- 后端实施计划:`docs/plans/backend/2026-03-31-abnormal-account-bank-tag-backend-implementation-plan.md` +- 前端实施计划:`docs/plans/frontend/2026-03-31-abnormal-account-bank-tag-frontend-implementation-plan.md` + +## 3. 验证环境 + +- 验证日期:2026-03-31 +- 后端地址:`http://127.0.0.1:62318` +- 前端地址:`http://127.0.0.1:1026` +- 数据库连接:以 `ruoyi-admin/src/main/resources/application-dev.yml` 为准 + +说明: + +- MySQL MCP 当前连接的库与应用实际使用库不一致,本次最终 SQL 校验统一使用项目配置文件解析出的真实数据库连接执行,避免出现“页面有数据、MCP 无数据”的误判。 + +## 4. 数据准备 + +执行以下脚本补齐规则元数据与最小测试样本: + +```bash +bin/mysql_utf8_exec.sh sql/migration/2026-03-31-create-ccdi-account-info-and-abnormal-account-rules.sql +bin/mysql_utf8_exec.sh sql/migration/2026-03-31-add-abnormal-account-rule-test-data.sql +``` + +补齐后确认以下样本存在: + +- 项目:`90331 / 异常账户规则测试项目` +- 员工 A:`330101199001010001` +- 员工 B:`330101199001010002` +- 员工 C:`330101199001010003` +- 员工 D:`330101199001010004` +- 账户样本:4 条 +- 流水样本:7 条 + +## 5. SQL 校验结果 + +### 5.1 规则元数据 + +- `ABNORMAL_ACCOUNT / SUDDEN_ACCOUNT_CLOSURE / OBJECT / HIGH` +- `ABNORMAL_ACCOUNT / DORMANT_ACCOUNT_LARGE_ACTIVATION / OBJECT / HIGH` + +### 5.2 原始规则 SQL 命中结果 + +- `SUDDEN_ACCOUNT_CLOSURE` + - 命中员工:`330101199001010001` + - 账户:`6222000000000001` + - 销户日期:`2026-03-20` + - 销户前最后交易日:`2026-03-18` + - 窗口累计交易金额:`180000.00` + - 单笔最大金额:`70000.00` + +- `DORMANT_ACCOUNT_LARGE_ACTIVATION` + - 命中员工:`330101199001010002` + - 账户:`6222000000000002` + - 开户日期:`2025-01-01` + - 首次交易日期:`2025-08-01` + - 沉睡月数:`7` + - 启用后累计交易金额:`550000.00` + - 单笔最大金额:`300000.00` + +结论: + +- 仅员工 A 命中 `突然销户` +- 仅员工 B 命中 `休眠账户大额启用` +- 员工 C、员工 D 未误命中 + +## 6. 后端接口与落库验证 + +### 6.1 手动重算 + +调用: + +```bash +POST /login/test +POST /ccdi/project/tags/rebuild +``` + +入参: + +```json +{ + "projectId": 90331, + "modelCode": "ABNORMAL_ACCOUNT" +} +``` + +返回: + +- `code = 200` +- `msg = 标签重算任务已提交` + +### 6.2 任务与结果表 + +- 最新任务:`ccdi_bank_tag_task.id = 67` +- 任务状态:`SUCCESS` +- 命中数:`2` +- `ccdi_bank_statement_tag_result` 中 `ABNORMAL_ACCOUNT` 结果数:`2` +- `ccdi_project_overview_employee_result` 聚合结果数:`2` + +### 6.3 总览接口返回 + +- 风险模型卡片返回:`异常账户 / warningCount=2 / peopleCount=2` +- 风险人员总览返回: + - 测试员工 A:`突然销户` + - 测试员工 B:`休眠账户大额启用` +- 项目分析详情返回: + - 员工 A 的 `reasonDetail` 与 SQL 命中说明一致 + - 员工 B 的 `reasonDetail` 与 SQL 命中说明一致 + +## 7. 页面联调结果 + +通过前端页面 `http://127.0.0.1:1026/ccdiProject/detail/90331?tab=overview` 验证: + +- 项目列表中 `异常账户规则测试项目` 显示 `已完成` +- 项目列表中目标人数显示 `4`,预警人数显示 `2` +- 结果总览风险人员区域显示: + - `测试员工A / 突然销户` + - `测试员工B / 休眠账户大额启用` +- 风险模型区域显示模型卡片: + - `异常账户 / 2 / 涉及 2 人` +- 风险模型命中人员列表显示: + - `测试员工A / 异常账户 / 突然销户` + - `测试员工B / 异常账户 / 休眠账户大额启用` +- 点击 `测试员工A` 的 `查看详情` 后,项目分析详情弹窗展示: + - `异常对象摘要` + - `测试员工A` + - `员工对象` + - `账户6222000000000001于2026-03-20销户,销户前30天内最后交易日2026-03-18,累计交易金额180000.00元,单笔最大金额70000.00元` +- 风险详情页中的 `异常账户人员信息` 区块正常渲染占位文案,未因本轮后端接入报错 + +## 8. 发现与结论 + +### 8.1 验证结论 + +- 异常账户标签可以被正确识别 +- 数据库原始规则命中结果、后端聚合结果、前端页面展示结果三者一致 + +### 8.2 联调观察 + +- 打开项目分析详情弹窗时,前端控制台出现 2 条 Vue 告警: + - `Duplicate keys detected: '关联人员往来-object'` +- 该告警未阻断本次异常账户标签展示,但建议后续排查 `ProjectAnalysisAbnormalTab.vue` 中的 key 生成逻辑,避免详情弹窗在更多数据场景下出现渲染更新问题 + +## 9. 进程处理 + +- 本次验证复用了已运行的后端进程,未额外启动新的后端服务 +- 本次验证启动了前端开发服务,验证结束后需主动关闭 diff --git a/docs/tests/records/2026-03-31-lsfx-mock-server-abnormal-account-backend-verification.md b/docs/tests/records/2026-03-31-lsfx-mock-server-abnormal-account-backend-verification.md new file mode 100644 index 00000000..0de06532 --- /dev/null +++ b/docs/tests/records/2026-03-31-lsfx-mock-server-abnormal-account-backend-verification.md @@ -0,0 +1,72 @@ +# LSFX Mock Server 异常账户后端验证记录 + +## 1. 验证命令 + +按实施过程实际执行了以下命令: + +```bash +cd lsfx-mock-server +python3 -m pytest tests/test_file_service.py::test_fetch_inner_flow_should_attach_abnormal_account_rule_plan -v +python3 -m pytest tests/test_statement_service.py -k "sudden_account_closure or dormant_account_large_activation" -v +python3 -m pytest tests/test_statement_service.py::test_generate_statements_should_follow_abnormal_account_rule_plan_from_file_record -v +python3 -m pytest tests/test_file_service.py tests/test_statement_service.py -v +python3 -m pytest tests/ -v +``` + +README 补充后按计划追加执行: + +```bash +cd lsfx-mock-server +python3 -m pytest tests/test_statement_service.py -v +``` + +合并到 `dev` 后补充执行: + +```bash +python3 -m pytest lsfx-mock-server/tests/test_statement_service.py::test_get_bank_statement_should_preserve_abnormal_account_mask_no -v +python3 -m pytest lsfx-mock-server/tests/test_file_service.py lsfx-mock-server/tests/test_statement_service.py -v +python3 main.py --rule-hit-mode all +``` + +启动服务后,使用标准库 `urllib` 调用了以下两个接口做运行态核验: + +```text +POST /watson/api/project/getJZFileOrZjrcuFile +POST /watson/api/project/getBSByLogId +``` + +## 2. 验证结果摘要 + +- `tests/test_file_service.py::test_fetch_inner_flow_should_attach_abnormal_account_rule_plan`:通过 +- `tests/test_statement_service.py -k "sudden_account_closure or dormant_account_large_activation"`:通过 +- `tests/test_statement_service.py::test_generate_statements_should_follow_abnormal_account_rule_plan_from_file_record`:通过 +- `python3 -m pytest tests/test_file_service.py tests/test_statement_service.py -v`:`43 passed` +- `python3 -m pytest tests/ -v`:`84 passed` +- `python3 -m pytest tests/test_statement_service.py -v`:`26 passed` +- `python3 -m pytest lsfx-mock-server/tests/test_statement_service.py::test_get_bank_statement_should_preserve_abnormal_account_mask_no -v`:通过 +- `python3 -m pytest lsfx-mock-server/tests/test_file_service.py lsfx-mock-server/tests/test_statement_service.py -v`:`44 passed` + +运行态 HTTP 验证结果: + +- `logId=16724` +- 返回流水总数:`200` +- `SUDDEN_ACCOUNT_CLOSURE` 命中样本:`3` 条 +- `DORMANT_ACCOUNT_LARGE_ACTIVATION` 命中样本:`3` 条 +- 销户规则账号:`6222006485425901` + 日期:`2026-02-18`、`2026-03-07`、`2026-03-18` + 结论:全部落在销户日前 30 天窗口内 +- 休眠激活规则账号:`6222004693652802` + 日期:`2025-07-01`、`2025-07-10`、`2025-07-19` + 累计金额:`560000.0` + 单笔最大金额:`260000.0` + 结论:满足开户满 6 个月后激活、累计金额阈值和单笔最大金额阈值 + +## 3. 过程说明 + +- 回归期间发现 `all` 模式安全噪声测试未同步清空新增的异常账户规则维度,导致异常账户样本被计入噪声断言 +- 已通过补齐测试夹具方式修正,随后重新执行聚焦回归和全量回归,结果均通过 +- 合并到 `dev` 后的 HTTP 验证又发现 `getBSByLogId` 返回前会覆盖异常账户样本账号,已通过新增回归用例与最小实现修正 + +## 4. 进程清理 + +本轮验证过程中临时启动了 `lsfx-mock-server` 的 `python3 main.py --rule-hit-mode all` 进程,验证完成后已主动停止,无残留端口占用。 diff --git a/docs/tests/records/2026-03-31-lsfx-mock-server-abnormal-account-baseline-sync-backend-verification.md b/docs/tests/records/2026-03-31-lsfx-mock-server-abnormal-account-baseline-sync-backend-verification.md new file mode 100644 index 00000000..94f802f8 --- /dev/null +++ b/docs/tests/records/2026-03-31-lsfx-mock-server-abnormal-account-baseline-sync-backend-verification.md @@ -0,0 +1,35 @@ +# LSFX Mock Server 异常账户基线同步后端验证记录 + +## 1. 验证命令 + +本次按实施过程实际执行了以下命令: + +```bash +cd lsfx-mock-server +python3 -m pytest tests/test_file_service.py -k "abnormal_account_baseline" -v +python3 -m pytest tests/test_abnormal_account_baseline_service.py -v +python3 -m pytest tests/test_statement_service.py::test_get_bank_statement_should_only_use_abnormal_account_numbers_from_file_record -v +python3 -m pytest tests/test_statement_service.py -k "abnormal_account" -v +python3 -m pytest tests/test_abnormal_account_baseline_service.py tests/test_file_service.py tests/test_statement_service.py -k "abnormal_account or abnormal_account_baseline" -v +``` + +## 2. 验证结果摘要 + +- `tests/test_file_service.py -k "abnormal_account_baseline" -v`:`2 passed` +- `tests/test_abnormal_account_baseline_service.py -v`:`4 passed` +- `tests/test_statement_service.py::test_get_bank_statement_should_only_use_abnormal_account_numbers_from_file_record -v`:通过 +- `tests/test_statement_service.py -k "abnormal_account" -v`:`3 passed` +- 聚合回归: + - `python3 -m pytest tests/test_abnormal_account_baseline_service.py tests/test_file_service.py tests/test_statement_service.py -k "abnormal_account or abnormal_account_baseline" -v` + - 结果:`10 passed, 41 deselected` + +## 3. 关键通过点 + +- `FileService` 已在缓存 `logId` 前触发异常账户基线同步 +- 基线同步失败时不会把半成品 `logId` 写入内存缓存 +- `AbnormalAccountBaselineService` 已覆盖空输入、校验失败、插入、更新四类行为 +- `StatementService` 返回的异常账户样本流水账号与 `record.abnormal_accounts` 保持一致 + +## 4. 进程清理 + +本轮验证仅执行了 `pytest` 命令,未启动前后端或 Mock 服务长驻进程,因此无需额外清理进程。 diff --git a/docs/tests/records/2026-04-23-staff-family-enterprise-relation-browser-test-record.md b/docs/tests/records/2026-04-23-staff-family-enterprise-relation-browser-test-record.md new file mode 100644 index 00000000..d59c6d18 --- /dev/null +++ b/docs/tests/records/2026-04-23-staff-family-enterprise-relation-browser-test-record.md @@ -0,0 +1,82 @@ +# 员工亲属实体关联浏览器测试记录 + +## 1. 测试目标 + +- 验证员工实体关系页面已切换为员工亲属实体关联页面 +- 验证查询区、列表列、详情弹窗、导入入口文案是否已切换为亲属语义 +- 验证页面通过真实前端服务和真实后端服务访问,不使用 prototype 页面 + +## 2. 测试环境 + +- 测试时间:2026-04-23 +- 前端:`ruoyi-ui` +- 前端 Node 版本:`v14.21.3` +- 前端访问地址: + - 首次稳定访问:`http://localhost:1025/` + - 后续 dev server 热重启后访问:`http://127.0.0.1:1027/` +- 后端访问地址:`http://localhost:62318` +- 浏览器驱动:Playwright CLI + +## 3. 执行过程 + +### 3.1 前端构建 + +- 执行 `source ~/.nvm/nvm.sh && nvm use` +- 执行 `npm run build:prod` +- 构建结果:成功 +- 备注:存在既有包体积告警,不影响本次功能构建结果 + +### 3.2 真实页面访问 + +- 使用 Playwright 打开真实前端页面并进入系统首页 +- 通过菜单进入“信息维护” +- 成功访问员工实体关系业务页,页面地址为 `/maintain/staffEnterpriseRelation` + +### 3.3 已验证项 + +- 查询区已切换为以下字段: + - 亲属身份证号 + - 亲属姓名 + - 关联员工 + - 统一社会信用代码 + - 企业名称 + - 状态 +- 列表表头已切换为以下字段: + - 亲属身份证号 + - 亲属姓名 + - 关联员工 + - 企业名称 + - 关联人在企业的职务 + - 状态 + - 数据来源 + - 创建时间 +- 页面按钮文案已切换为亲属语义: + - 新增 + - 导入 + - 查看导入失败记录 +- 后端日志已确认列表查询走新 SQL: + - `ccdi_staff_enterprise_relation ser` + - `LEFT JOIN ccdi_staff_fmy_relation sfr ON ser.person_id = sfr.relation_cert_no AND sfr.is_emp_family = 1` + - `LEFT JOIN ccdi_base_staff bs ON sfr.person_id = bs.id_card` + +## 4. 结果与发现 + +### 4.1 页面表现 + +- 页面主体已按本次需求切换为员工亲属实体关联口径 +- 历史数据因未迁移旧员工本人语义记录,当前列表中的亲属姓名和关联员工存在空值/`-` +- 该现象符合设计文档中“不迁移旧的员工本人实体关联历史数据、不设计旧数据兼容展示逻辑”的范围说明 + +### 4.2 阻塞项 + +- 浏览器测试阶段,后端通过 `bin/restart_java_backend.sh` 启动后可正常服务,但在桌面会话中会跟随当前测试会话退出,导致后端在一段时间后自动关闭 +- 受该稳定性问题影响,本轮未能完成以下真实页面闭环验证: + - 新增弹窗中的有效亲属下拉选择 + - 编辑弹窗完整提交流程 + - 通过页面下载模板并完成三类导入样本上传 + - 失败记录弹窗完整回查 + +## 5. 结论 + +- 已完成真实页面基础访问与核心展示项核对,确认本次前端改造已切换到亲属语义 +- 后续若继续执行导入实操验证,需先确保后端测试进程在桌面会话中可稳定保活,再继续完成导入样本生成、上传与回收验证 diff --git a/docs/tests/scripts/test-backend-package-and-deploy-conventions.sh b/docs/tests/scripts/test-backend-package-and-deploy-conventions.sh new file mode 100644 index 00000000..b842da37 --- /dev/null +++ b/docs/tests/scripts/test-backend-package-and-deploy-conventions.sh @@ -0,0 +1,70 @@ +#!/bin/sh + +set -eu + +ROOT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")/../../.." && pwd) +POM_FILE="$ROOT_DIR/ruoyi-admin/pom.xml" +DEPLOY_SH="$ROOT_DIR/deploy/deploy-to-nas.sh" +DEPLOY_PS1="$ROOT_DIR/deploy/deploy.ps1" +DOCKERFILE="$ROOT_DIR/docker/backend/Dockerfile" +TARGET_DIR="$ROOT_DIR/ruoyi-admin/target" + +echo "[检查] 后端打包必须同时产出 jar 与 war,部署脚本只能使用 war" + +if ! grep -Fq 'jar' "$POM_FILE"; then + echo "失败: ruoyi-admin 仍需保持 jar 打包类型以支持本地内嵌 Tomcat 运行" + exit 1 +fi + +if ! grep -Fq 'war' "$POM_FILE"; then + echo "失败: 未显式执行 war 打包目标" + exit 1 +fi + +if ! grep -Fq 'ruoyi-admin.war' "$DEPLOY_SH"; then + echo "失败: deploy-to-nas.sh 未改为使用 ruoyi-admin.war" + exit 1 +fi + +if grep -Fq 'ruoyi-admin.jar' "$DEPLOY_SH"; then + echo "失败: deploy-to-nas.sh 仍引用 ruoyi-admin.jar" + exit 1 +fi + +if ! grep -Fq 'ruoyi-admin.war' "$DEPLOY_PS1"; then + echo "失败: deploy.ps1 未改为使用 ruoyi-admin.war" + exit 1 +fi + +if grep -Fq 'ruoyi-admin.jar' "$DEPLOY_PS1"; then + echo "失败: deploy.ps1 仍引用 ruoyi-admin.jar" + exit 1 +fi + +if ! grep -Fq 'COPY backend/ruoyi-admin.war /app/ruoyi-admin.war' "$DOCKERFILE"; then + echo "失败: Dockerfile 未改为复制 ruoyi-admin.war" + exit 1 +fi + +if grep -Fq 'ruoyi-admin.jar' "$DOCKERFILE"; then + echo "失败: Dockerfile 仍引用 ruoyi-admin.jar" + exit 1 +fi + +echo "[检查] 执行 Maven 打包产物校验" +( + cd "$ROOT_DIR" + mvn -pl ruoyi-admin -am package -DskipTests +) + +if [ ! -f "$TARGET_DIR/ruoyi-admin.jar" ]; then + echo "失败: 未生成 $TARGET_DIR/ruoyi-admin.jar" + exit 1 +fi + +if [ ! -f "$TARGET_DIR/ruoyi-admin.war" ]; then + echo "失败: 未生成 $TARGET_DIR/ruoyi-admin.war" + exit 1 +fi + +echo "通过" diff --git a/lsfx-mock-server/README.md b/lsfx-mock-server/README.md index 3233e3b2..8e70b13a 100644 --- a/lsfx-mock-server/README.md +++ b/lsfx-mock-server/README.md @@ -37,6 +37,12 @@ python dev.py --reload --rule-hit-mode all - `subset`:默认模式,按 `logId` 稳定随机命中部分规则 - `all`:全部兼容规则命中模式,会命中当前可共存的全部规则 +补充说明: + +- `fetch_inner_flow` 与上传链路会在内部生成 `abnormal_account_hit_rules` +- 当前异常账户规则样本包含 `SUDDEN_ACCOUNT_CLOSURE` 与 `DORMANT_ACCOUNT_LARGE_ACTIVATION` +- `/watson/api/project/getBSByLogId` 会沿用现有种子流水主链路,自动混入与异常账户事实匹配的命中流水样本 + ### 3. 访问 API 文档 - **Swagger UI**: http://localhost:8000/docs diff --git a/lsfx-mock-server/config/settings.py b/lsfx-mock-server/config/settings.py index a2fa65d0..84a47802 100644 --- a/lsfx-mock-server/config/settings.py +++ b/lsfx-mock-server/config/settings.py @@ -26,6 +26,8 @@ def _load_ruoyi_mysql_defaults() -> dict: MYSQL_DEFAULTS = _load_ruoyi_mysql_defaults() +LSFX_DEFAULT_CCDI_DB_HOST = "116.62.17.81" +LSFX_DEFAULT_CCDI_DB_PORT = 3307 class Settings(BaseSettings): @@ -50,8 +52,8 @@ class Settings(BaseSettings): INITIAL_LOG_ID: int = 10000 # 员工库只读配置 - CCDI_DB_HOST: str = MYSQL_DEFAULTS.get("host", "") - CCDI_DB_PORT: int = int(MYSQL_DEFAULTS.get("port", 3306)) + CCDI_DB_HOST: str = LSFX_DEFAULT_CCDI_DB_HOST + CCDI_DB_PORT: int = LSFX_DEFAULT_CCDI_DB_PORT CCDI_DB_NAME: str = MYSQL_DEFAULTS.get("database", "") CCDI_DB_USERNAME: str = MYSQL_DEFAULTS.get("username", "") CCDI_DB_PASSWORD: str = MYSQL_DEFAULTS.get("password", "") diff --git a/lsfx-mock-server/services/abnormal_account_baseline_service.py b/lsfx-mock-server/services/abnormal_account_baseline_service.py new file mode 100644 index 00000000..7f68b714 --- /dev/null +++ b/lsfx-mock-server/services/abnormal_account_baseline_service.py @@ -0,0 +1,114 @@ +from typing import List + +from config.settings import settings + + +class AbnormalAccountBaselineService: + """异常账户基线写库服务。""" + + UPSERT_SQL = """ + INSERT INTO ccdi_account_info ( + account_no, + account_type, + account_name, + owner_type, + owner_id, + bank, + bank_code, + currency, + is_self_account, + trans_risk_level, + status, + effective_date, + invalid_date, + create_by, + update_by + ) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) + ON DUPLICATE KEY UPDATE + account_name = VALUES(account_name), + owner_type = VALUES(owner_type), + owner_id = VALUES(owner_id), + bank = VALUES(bank), + bank_code = VALUES(bank_code), + currency = VALUES(currency), + is_self_account = VALUES(is_self_account), + trans_risk_level = VALUES(trans_risk_level), + status = VALUES(status), + effective_date = VALUES(effective_date), + invalid_date = VALUES(invalid_date), + update_by = VALUES(update_by), + update_time = NOW() + """ + + def __init__(self): + self.db_config = { + "host": settings.CCDI_DB_HOST, + "port": settings.CCDI_DB_PORT, + "database": settings.CCDI_DB_NAME, + "username": settings.CCDI_DB_USERNAME, + "password": settings.CCDI_DB_PASSWORD, + "connect_timeout_seconds": settings.CCDI_DB_CONNECT_TIMEOUT_SECONDS, + } + + def _connect(self): + try: + import pymysql + except ImportError as exc: + raise RuntimeError("缺少 PyMySQL 依赖,无法写入异常账户基线") from exc + + return pymysql.connect( + host=settings.CCDI_DB_HOST, + port=settings.CCDI_DB_PORT, + user=settings.CCDI_DB_USERNAME, + password=settings.CCDI_DB_PASSWORD, + database=settings.CCDI_DB_NAME, + charset="utf8mb4", + connect_timeout=settings.CCDI_DB_CONNECT_TIMEOUT_SECONDS, + autocommit=False, + ) + + def _validate_fact_owner(self, staff_id_card: str, abnormal_accounts: List[dict]) -> None: + for account_fact in abnormal_accounts: + owner_id_card = account_fact.get("owner_id_card") + if owner_id_card != staff_id_card: + raise RuntimeError( + f"异常账户 owner_id_card 与 staff_id_card 不一致: {owner_id_card}" + ) + + def _build_upsert_params(self, account_fact: dict) -> tuple: + return ( + account_fact["account_no"], + "DEBIT", + account_fact["account_name"], + "EMPLOYEE", + account_fact["owner_id_card"], + "兰溪农商银行", + "LXNCSY", + "CNY", + 1, + "HIGH", + account_fact["status"], + account_fact["effective_date"], + account_fact.get("invalid_date"), + "lsfx-mock-server", + "lsfx-mock-server", + ) + + def apply(self, staff_id_card: str, abnormal_accounts: List[dict]) -> None: + if not abnormal_accounts: + return + + self._validate_fact_owner(staff_id_card, abnormal_accounts) + + connection = self._connect() + try: + with connection.cursor() as cursor: + for account_fact in abnormal_accounts: + cursor.execute(self.UPSERT_SQL, self._build_upsert_params(account_fact)) + connection.commit() + except Exception: + connection.rollback() + raise + finally: + connection.close() diff --git a/lsfx-mock-server/services/file_service.py b/lsfx-mock-server/services/file_service.py index 7f4a769a..b81c5827 100644 --- a/lsfx-mock-server/services/file_service.py +++ b/lsfx-mock-server/services/file_service.py @@ -1,6 +1,7 @@ from fastapi import BackgroundTasks, UploadFile from utils.response_builder import ResponseBuilder from config.settings import settings +from services.abnormal_account_baseline_service import AbnormalAccountBaselineService from services.phase2_baseline_service import Phase2BaselineService from services.staff_identity_repository import StaffIdentityRepository from typing import Dict, List, Union @@ -48,6 +49,11 @@ PHASE2_BASELINE_RULE_CODES = [ "SUPPLIER_CONCENTRATION", ] +ABNORMAL_ACCOUNT_RULE_CODES = [ + "SUDDEN_ACCOUNT_CLOSURE", + "DORMANT_ACCOUNT_LARGE_ACTIVATION", +] + MONTHLY_FIXED_INCOME_ISOLATED_LARGE_TRANSACTION_RULE_CODES = { "SINGLE_LARGE_INCOME", "CUMULATIVE_INCOME", @@ -127,7 +133,8 @@ class FileRecord: phase1_hit_rules: List[str] = field(default_factory=list) phase2_statement_hit_rules: List[str] = field(default_factory=list) phase2_baseline_hit_rules: List[str] = field(default_factory=list) - + abnormal_account_hit_rules: List[str] = field(default_factory=list) + abnormal_accounts: List[dict] = field(default_factory=list) class FileService: """文件上传和解析服务""" @@ -136,11 +143,19 @@ class FileService: LOG_ID_MIN = settings.INITIAL_LOG_ID LOG_ID_MAX = 99999 - def __init__(self, staff_identity_repository=None, phase2_baseline_service=None): + def __init__( + self, + staff_identity_repository=None, + phase2_baseline_service=None, + abnormal_account_baseline_service=None, + ): self.file_records: Dict[int, FileRecord] = {} # logId -> FileRecord self.log_counter = settings.INITIAL_LOG_ID self.staff_identity_repository = staff_identity_repository or StaffIdentityRepository() self.phase2_baseline_service = phase2_baseline_service or Phase2BaselineService() + self.abnormal_account_baseline_service = ( + abnormal_account_baseline_service or AbnormalAccountBaselineService() + ) def get_file_record(self, log_id: int) -> FileRecord: """按 logId 获取已存在的文件记录。""" @@ -213,6 +228,9 @@ class FileService: "phase2_baseline_hit_rules": self._pick_rule_subset( rng, PHASE2_BASELINE_RULE_CODES, 2, 4 ), + "abnormal_account_hit_rules": self._pick_rule_subset( + rng, ABNORMAL_ACCOUNT_RULE_CODES, 1, len(ABNORMAL_ACCOUNT_RULE_CODES) + ), } def _build_all_compatible_rule_hit_plan(self) -> dict: @@ -222,6 +240,7 @@ class FileService: "phase1_hit_rules": list(PHASE1_RULE_CODES), "phase2_statement_hit_rules": list(PHASE2_STATEMENT_RULE_CODES), "phase2_baseline_hit_rules": list(PHASE2_BASELINE_RULE_CODES), + "abnormal_account_hit_rules": list(ABNORMAL_ACCOUNT_RULE_CODES), } def _build_monthly_fixed_income_isolated_rule_hit_plan(self) -> dict: @@ -284,6 +303,52 @@ class FileService: file_record.phase2_baseline_hit_rules = list( rule_hit_plan.get("phase2_baseline_hit_rules", []) ) + file_record.abnormal_account_hit_rules = list( + rule_hit_plan.get("abnormal_account_hit_rules", []) + ) + file_record.abnormal_accounts = self._build_abnormal_accounts( + log_id=file_record.log_id, + staff_id_card=file_record.staff_id_card, + abnormal_account_hit_rules=file_record.abnormal_account_hit_rules, + ) + + def _build_abnormal_accounts( + self, + *, + log_id: int, + staff_id_card: str, + abnormal_account_hit_rules: List[str], + ) -> List[dict]: + """按命中规则生成最小异常账户事实。""" + if not abnormal_account_hit_rules: + return [] + + rng = random.Random(f"abnormal-account:{log_id}") + accounts = [] + for index, rule_code in enumerate(abnormal_account_hit_rules, start=1): + account_no = f"622200{rng.randint(10**9, 10**10 - 1)}" + account_fact = { + "account_no": account_no, + "owner_id_card": staff_id_card, + "account_name": "测试员工工资卡", + "status": 1, + "effective_date": "2025-01-01", + "invalid_date": None, + "rule_code": rule_code, + } + if rule_code == "SUDDEN_ACCOUNT_CLOSURE": + account_fact["status"] = 2 + account_fact["effective_date"] = "2024-01-01" + account_fact["invalid_date"] = "2026-03-20" + elif rule_code == "DORMANT_ACCOUNT_LARGE_ACTIVATION": + account_fact["status"] = 1 + account_fact["effective_date"] = "2025-01-01" + account_fact["invalid_date"] = None + + account_fact["account_no"] = f"{account_no[:-2]}{index:02d}" + accounts.append(account_fact) + + return accounts def _rebalance_all_mode_group_rule_plans(self, group_id: int) -> None: """同项目存在多文件时,隔离月固定收入样本,避免被其他正向流入规则污染。""" @@ -332,6 +397,8 @@ class FileService: phase1_hit_rules: List[str] = None, phase2_statement_hit_rules: List[str] = None, phase2_baseline_hit_rules: List[str] = None, + abnormal_account_hit_rules: List[str] = None, + abnormal_accounts: List[dict] = None, parsing: bool = True, status: int = -5, ) -> FileRecord: @@ -366,6 +433,8 @@ class FileService: phase1_hit_rules=list(phase1_hit_rules or []), phase2_statement_hit_rules=list(phase2_statement_hit_rules or []), phase2_baseline_hit_rules=list(phase2_baseline_hit_rules or []), + abnormal_account_hit_rules=list(abnormal_account_hit_rules or []), + abnormal_accounts=[dict(account) for account in (abnormal_accounts or [])], parsing=parsing, status=status, ) @@ -391,6 +460,17 @@ class FileService: baseline_rule_codes=baseline_rule_codes, ) + def _apply_abnormal_account_baselines(self, file_record: FileRecord) -> None: + """按当前记录命中的异常账户规则幂等补齐账户事实。""" + if not file_record.abnormal_account_hit_rules: + return + if not file_record.abnormal_accounts: + raise RuntimeError("异常账户命中计划存在,但未生成账户事实") + self.abnormal_account_baseline_service.apply( + staff_id_card=file_record.staff_id_card, + abnormal_accounts=file_record.abnormal_accounts, + ) + async def upload_file( self, group_id: int, file: UploadFile, background_tasks: BackgroundTasks ) -> Dict: @@ -444,9 +524,15 @@ class FileService: phase1_hit_rules=rule_hit_plan.get("phase1_hit_rules", []), phase2_statement_hit_rules=rule_hit_plan.get("phase2_statement_hit_rules", []), phase2_baseline_hit_rules=rule_hit_plan.get("phase2_baseline_hit_rules", []), + abnormal_account_hit_rules=rule_hit_plan.get("abnormal_account_hit_rules", []), + abnormal_accounts=self._build_abnormal_accounts( + log_id=log_id, + staff_id_card=identity_scope["staff_id_card"], + abnormal_account_hit_rules=rule_hit_plan.get("abnormal_account_hit_rules", []), + ), ) - # 存储记录 + self._apply_abnormal_account_baselines(file_record) self.file_records[log_id] = file_record self._rebalance_all_mode_group_rule_plans(group_id) self._apply_phase2_baselines(file_record) @@ -775,9 +861,16 @@ class FileService: phase1_hit_rules=rule_hit_plan.get("phase1_hit_rules", []), phase2_statement_hit_rules=rule_hit_plan.get("phase2_statement_hit_rules", []), phase2_baseline_hit_rules=rule_hit_plan.get("phase2_baseline_hit_rules", []), + abnormal_account_hit_rules=rule_hit_plan.get("abnormal_account_hit_rules", []), + abnormal_accounts=self._build_abnormal_accounts( + log_id=log_id, + staff_id_card=identity_scope["staff_id_card"], + abnormal_account_hit_rules=rule_hit_plan.get("abnormal_account_hit_rules", []), + ), parsing=False, ) + self._apply_abnormal_account_baselines(file_record) self.file_records[log_id] = file_record self._rebalance_all_mode_group_rule_plans(group_id) self._apply_phase2_baselines(file_record) diff --git a/lsfx-mock-server/services/statement_rule_samples.py b/lsfx-mock-server/services/statement_rule_samples.py index 67242902..0a60a6e8 100644 --- a/lsfx-mock-server/services/statement_rule_samples.py +++ b/lsfx-mock-server/services/statement_rule_samples.py @@ -811,6 +811,117 @@ def build_salary_unused_samples(group_id: int, log_id: int, **kwargs) -> List[Di ] +def build_sudden_account_closure_samples( + group_id: int, + log_id: int, + *, + account_fact: Dict, + le_name: str = "模型测试主体", +) -> List[Dict]: + invalid_date = datetime.strptime(account_fact["invalid_date"], "%Y-%m-%d") + owner_id_card = account_fact["owner_id_card"] + account_no = account_fact["account_no"] + account_name = account_fact["account_name"] + + return [ + _build_statement( + group_id, + log_id, + trx_datetime=invalid_date - timedelta(days=30, hours=-1), + cret_no=owner_id_card, + customer_name="杭州临时往来款账户", + user_memo=f"{account_name}销户前资金回笼", + cash_type="对私转账", + cr_amount=88000.0, + le_name=le_name, + account_mask_no=account_no, + customer_account_mask_no="6222024666610001", + ), + _build_statement( + group_id, + log_id, + trx_datetime=invalid_date - timedelta(days=12, hours=2), + cret_no=owner_id_card, + customer_name="杭州消费支付商户", + user_memo=f"{account_name}销户前集中支出", + cash_type="快捷支付", + dr_amount=62000.0, + le_name=le_name, + account_mask_no=account_no, + customer_account_mask_no="6222024666610002", + ), + _build_statement( + group_id, + log_id, + trx_datetime=invalid_date - timedelta(days=1, hours=3), + cret_no=owner_id_card, + customer_name="浙江异常账户清理专户", + user_memo=f"{account_name}异常账户销户前转出", + cash_type="对私转账", + dr_amount=126000.0, + le_name=le_name, + account_mask_no=account_no, + customer_account_mask_no="6222024666610003", + ), + ] + + +def build_dormant_account_large_activation_samples( + group_id: int, + log_id: int, + *, + account_fact: Dict, + le_name: str = "模型测试主体", +) -> List[Dict]: + effective_date = datetime.strptime(account_fact["effective_date"], "%Y-%m-%d") + activation_start = datetime(effective_date.year, effective_date.month, effective_date.day) + timedelta(days=181) + owner_id_card = account_fact["owner_id_card"] + account_no = account_fact["account_no"] + account_name = account_fact["account_name"] + + return [ + _build_statement( + group_id, + log_id, + trx_datetime=activation_start, + cret_no=owner_id_card, + customer_name="浙江存量资产回收账户", + user_memo=f"{account_name}休眠后异常账户激活入账", + cash_type="对公转账", + cr_amount=180000.0, + le_name=le_name, + account_mask_no=account_no, + customer_account_mask_no="6222024666620001", + ), + _build_statement( + group_id, + log_id, + trx_datetime=activation_start + timedelta(days=9, hours=2), + cret_no=owner_id_card, + customer_name="浙江大额往来备付金专户", + user_memo=f"{account_name}休眠激活后大额转入", + cash_type="对公转账", + cr_amount=260000.0, + le_name=le_name, + account_mask_no=account_no, + customer_account_mask_no="6222024666620002", + ), + _build_statement( + group_id, + log_id, + trx_datetime=activation_start + timedelta(days=18, hours=1), + cret_no=owner_id_card, + customer_name="杭州临时资金调拨账户", + user_memo=f"{account_name}休眠账户异常账户激活转出", + cash_type="对私转账", + dr_amount=120000.0, + le_name=le_name, + account_mask_no=account_no, + customer_account_mask_no="6222024666620003", + ), + ] + + LARGE_TRANSACTION_BUILDERS = { "HOUSE_OR_CAR_EXPENSE": build_house_or_car_samples, "TAX_EXPENSE": build_tax_samples, @@ -842,6 +953,39 @@ PHASE2_STATEMENT_RULE_BUILDERS = { "SALARY_UNUSED": build_salary_unused_samples, } +ABNORMAL_ACCOUNT_RULE_BUILDERS = { + "SUDDEN_ACCOUNT_CLOSURE": build_sudden_account_closure_samples, + "DORMANT_ACCOUNT_LARGE_ACTIVATION": build_dormant_account_large_activation_samples, +} + + +def _resolve_abnormal_account_fact(rule_code: str, abnormal_accounts: List[Dict]) -> Optional[Dict]: + for account_fact in abnormal_accounts: + if account_fact.get("rule_code") == rule_code: + return account_fact + + if rule_code == "SUDDEN_ACCOUNT_CLOSURE": + return next( + ( + account_fact + for account_fact in abnormal_accounts + if account_fact.get("status") == 2 and account_fact.get("invalid_date") + ), + None, + ) + + if rule_code == "DORMANT_ACCOUNT_LARGE_ACTIVATION": + return next( + ( + account_fact + for account_fact in abnormal_accounts + if account_fact.get("status") == 1 and account_fact.get("effective_date") + ), + None, + ) + + return None + def build_seed_statements_for_rule_plan( group_id: int, @@ -850,21 +994,36 @@ def build_seed_statements_for_rule_plan( **kwargs, ) -> List[Dict]: statements: List[Dict] = [] + abnormal_accounts = list(kwargs.get("abnormal_accounts") or []) + common_kwargs = {key: value for key, value in kwargs.items() if key != "abnormal_accounts"} for rule_code in rule_plan.get("large_transaction_hit_rules", []): builder = LARGE_TRANSACTION_BUILDERS.get(rule_code) if builder is not None: - statements.extend(builder(group_id, log_id, **kwargs)) + statements.extend(builder(group_id, log_id, **common_kwargs)) for rule_code in rule_plan.get("phase1_hit_rules", []): builder = PHASE1_RULE_BUILDERS.get(rule_code) if builder is not None: - statements.extend(builder(group_id, log_id, **kwargs)) + statements.extend(builder(group_id, log_id, **common_kwargs)) for rule_code in rule_plan.get("phase2_statement_hit_rules", []): builder = PHASE2_STATEMENT_RULE_BUILDERS.get(rule_code) if builder is not None: - statements.extend(builder(group_id, log_id, **kwargs)) + statements.extend(builder(group_id, log_id, **common_kwargs)) + + for rule_code in rule_plan.get("abnormal_account_hit_rules", []): + builder = ABNORMAL_ACCOUNT_RULE_BUILDERS.get(rule_code) + account_fact = _resolve_abnormal_account_fact(rule_code, abnormal_accounts) + if builder is not None and account_fact is not None: + statements.extend( + builder( + group_id, + log_id, + account_fact=account_fact, + le_name=common_kwargs.get("primary_enterprise_name", "模型测试主体"), + ) + ) return statements diff --git a/lsfx-mock-server/services/statement_service.py b/lsfx-mock-server/services/statement_service.py index 8de23532..a51f2d14 100644 --- a/lsfx-mock-server/services/statement_service.py +++ b/lsfx-mock-server/services/statement_service.py @@ -166,6 +166,9 @@ class StatementService: "phase2_statement_hit_rules": ( list(record.phase2_statement_hit_rules) if record is not None else [] ), + "abnormal_account_hit_rules": ( + list(record.abnormal_account_hit_rules) if record is not None else [] + ), } if record is not None and record.staff_id_card: allowed_identity_cards = tuple([record.staff_id_card, *record.family_id_cards]) @@ -180,6 +183,7 @@ class StatementService: primary_account_no=primary_account_no, staff_id_card=record.staff_id_card if record is not None else None, family_id_cards=record.family_id_cards if record is not None else None, + abnormal_accounts=record.abnormal_accounts if record is not None else None, ) safe_all_mode_noise = settings.RULE_HIT_MODE == "all" and record is not None @@ -212,7 +216,7 @@ class StatementService: """将解析出的主绑定统一回填到已有流水记录。""" for statement in statements: statement["leName"] = primary_enterprise_name - statement["accountMaskNo"] = primary_account_no + statement["accountMaskNo"] = statement.get("accountMaskNo") or primary_account_no def get_bank_statement(self, request: Union[Dict, object]) -> Dict: """获取银行流水列表。""" diff --git a/lsfx-mock-server/tests/test_abnormal_account_baseline_service.py b/lsfx-mock-server/tests/test_abnormal_account_baseline_service.py new file mode 100644 index 00000000..3a222710 --- /dev/null +++ b/lsfx-mock-server/tests/test_abnormal_account_baseline_service.py @@ -0,0 +1,154 @@ +import pytest + +from services.abnormal_account_baseline_service import AbnormalAccountBaselineService + + +class FakeCursor: + def __init__(self, connection): + self.connection = connection + + def execute(self, sql, params=None): + self.connection.executed_sql.append( + { + "sql": sql, + "params": params, + } + ) + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, tb): + return False + + +class FakeConnection: + def __init__(self): + self.executed_sql = [] + self.commit_count = 0 + self.rollback_count = 0 + + def cursor(self): + return FakeCursor(self) + + def commit(self): + self.commit_count += 1 + + def rollback(self): + self.rollback_count += 1 + + def close(self): + return None + + +def test_apply_should_skip_when_abnormal_accounts_is_empty(): + service = AbnormalAccountBaselineService() + fake_connection = FakeConnection() + service._connect = lambda: fake_connection + + service.apply("330101199001010001", []) + + assert fake_connection.executed_sql == [] + assert fake_connection.commit_count == 0 + assert fake_connection.rollback_count == 0 + + +def test_apply_should_raise_when_fact_owner_mismatches_staff(): + service = AbnormalAccountBaselineService() + + with pytest.raises(RuntimeError, match="owner_id_card"): + service.apply( + "330101199001010001", + [ + { + "account_no": "6222000000000001", + "owner_id_card": "330101199001010099", + "account_name": "测试员工工资卡", + "status": 2, + "effective_date": "2024-01-01", + "invalid_date": "2026-03-20", + "rule_code": "SUDDEN_ACCOUNT_CLOSURE", + } + ], + ) + + +def test_apply_should_insert_new_account_fact_by_account_no(): + service = AbnormalAccountBaselineService() + fake_connection = FakeConnection() + service._connect = lambda: fake_connection + + service.apply( + "330101199001010001", + [ + { + "account_no": "6222000000000001", + "owner_id_card": "330101199001010001", + "account_name": "测试员工工资卡", + "status": 2, + "effective_date": "2024-01-01", + "invalid_date": "2026-03-20", + "rule_code": "SUDDEN_ACCOUNT_CLOSURE", + } + ], + ) + + assert len(fake_connection.executed_sql) == 1 + executed = fake_connection.executed_sql[0] + assert "INSERT INTO ccdi_account_info" in executed["sql"] + assert "create_by" in executed["sql"] + assert "update_by" in executed["sql"] + assert "created_by" not in executed["sql"] + assert "updated_by" not in executed["sql"] + assert executed["params"] == ( + "6222000000000001", + "DEBIT", + "测试员工工资卡", + "EMPLOYEE", + "330101199001010001", + "兰溪农商银行", + "LXNCSY", + "CNY", + 1, + "HIGH", + 2, + "2024-01-01", + "2026-03-20", + "lsfx-mock-server", + "lsfx-mock-server", + ) + assert fake_connection.commit_count == 1 + assert fake_connection.rollback_count == 0 + + +def test_apply_should_update_existing_account_fact_by_account_no(): + service = AbnormalAccountBaselineService() + fake_connection = FakeConnection() + service._connect = lambda: fake_connection + + service.apply( + "330101199001010001", + [ + { + "account_no": "6222000000000001", + "owner_id_card": "330101199001010001", + "account_name": "测试员工结算卡", + "status": 1, + "effective_date": "2025-01-01", + "invalid_date": None, + "rule_code": "DORMANT_ACCOUNT_LARGE_ACTIVATION", + } + ], + ) + + assert len(fake_connection.executed_sql) == 1 + executed = fake_connection.executed_sql[0] + assert "ON DUPLICATE KEY UPDATE" in executed["sql"] + assert "update_by = VALUES(update_by)" in executed["sql"] + assert executed["params"][0] == "6222000000000001" + assert executed["params"][2] == "测试员工结算卡" + assert executed["params"][10] == 1 + assert executed["params"][11] == "2025-01-01" + assert executed["params"][12] is None + assert fake_connection.commit_count == 1 + assert fake_connection.rollback_count == 0 diff --git a/lsfx-mock-server/tests/test_file_service.py b/lsfx-mock-server/tests/test_file_service.py index 26510cde..d62251cc 100644 --- a/lsfx-mock-server/tests/test_file_service.py +++ b/lsfx-mock-server/tests/test_file_service.py @@ -5,6 +5,7 @@ FileService 单一主绑定语义测试 import asyncio import io +import pytest from fastapi import BackgroundTasks from fastapi.datastructures import UploadFile @@ -27,6 +28,22 @@ class FakeStaffIdentityRepository: } +class FakeAbnormalAccountBaselineService: + def __init__(self, should_fail=False): + self.should_fail = should_fail + self.calls = [] + + def apply(self, staff_id_card, abnormal_accounts): + self.calls.append( + { + "staff_id_card": staff_id_card, + "abnormal_accounts": [dict(item) for item in abnormal_accounts], + } + ) + if self.should_fail: + raise RuntimeError("baseline sync failed") + + def test_upload_file_primary_binding_response(monkeypatch): """同一 logId 的主绑定必须稳定且只保留一组主体/账号信息。""" service = FileService(staff_identity_repository=FakeStaffIdentityRepository()) @@ -163,6 +180,79 @@ def test_fetch_inner_flow_persists_primary_binding_record(monkeypatch): assert record.total_records == 200 +def test_fetch_inner_flow_should_attach_abnormal_account_rule_plan(): + service = FileService(staff_identity_repository=FakeStaffIdentityRepository()) + + response = service.fetch_inner_flow( + { + "groupId": 1001, + "customerNo": "customer_abnormal_account", + "dataChannelCode": "test_code", + "requestDateId": 20240101, + "dataStartDateId": 20240101, + "dataEndDateId": 20240131, + "uploadUserId": 902001, + } + ) + log_id = response["data"][0] + record = service.file_records[log_id] + + assert hasattr(record, "abnormal_account_hit_rules") + assert hasattr(record, "abnormal_accounts") + assert isinstance(record.abnormal_account_hit_rules, list) + assert isinstance(record.abnormal_accounts, list) + + +def test_fetch_inner_flow_should_sync_abnormal_account_baselines_before_caching(): + baseline_service = FakeAbnormalAccountBaselineService() + service = FileService( + staff_identity_repository=FakeStaffIdentityRepository(), + abnormal_account_baseline_service=baseline_service, + ) + + response = service.fetch_inner_flow( + { + "groupId": 1001, + "customerNo": "customer_abnormal_baseline", + "dataChannelCode": "test_code", + "requestDateId": 20240101, + "dataStartDateId": 20240101, + "dataEndDateId": 20240131, + "uploadUserId": 902001, + } + ) + + log_id = response["data"][0] + record = service.file_records[log_id] + + assert baseline_service.calls + assert baseline_service.calls[0]["staff_id_card"] == record.staff_id_card + assert baseline_service.calls[0]["abnormal_accounts"] == record.abnormal_accounts + + +def test_fetch_inner_flow_should_not_cache_log_id_when_abnormal_account_baseline_sync_fails(): + baseline_service = FakeAbnormalAccountBaselineService(should_fail=True) + service = FileService( + staff_identity_repository=FakeStaffIdentityRepository(), + abnormal_account_baseline_service=baseline_service, + ) + + with pytest.raises(RuntimeError, match="baseline sync failed"): + service.fetch_inner_flow( + { + "groupId": 1001, + "customerNo": "customer_abnormal_baseline_fail", + "dataChannelCode": "test_code", + "requestDateId": 20240101, + "dataStartDateId": 20240101, + "dataEndDateId": 20240131, + "uploadUserId": 902001, + } + ) + + assert service.file_records == {} + + def test_generate_log_id_should_retry_when_random_value_conflicts(monkeypatch): """随机 logId 命中已存在记录时必须重试并返回未占用值。""" service = FileService(staff_identity_repository=FakeStaffIdentityRepository()) diff --git a/lsfx-mock-server/tests/test_schema_migration_scripts.py b/lsfx-mock-server/tests/test_schema_migration_scripts.py new file mode 100644 index 00000000..374cccda --- /dev/null +++ b/lsfx-mock-server/tests/test_schema_migration_scripts.py @@ -0,0 +1,19 @@ +from pathlib import Path + + +def test_ccdi_account_info_should_have_incremental_migration_for_abnormal_account_columns(): + project_root = Path(__file__).resolve().parents[2] + migration_path = ( + project_root + / "sql" + / "migration" + / "2026-04-15-sync-ccdi-account-info-abnormal-account-columns.sql" + ) + + assert migration_path.exists(), "缺少 ccdi_account_info 异常账户字段补迁移脚本" + + sql = migration_path.read_text(encoding="utf-8") + + assert "information_schema.columns" in sql + assert "ALTER TABLE `ccdi_account_info` ADD COLUMN `is_self_account`" in sql + assert "ALTER TABLE `ccdi_account_info` ADD COLUMN `trans_risk_level`" in sql diff --git a/lsfx-mock-server/tests/test_settings_sync.py b/lsfx-mock-server/tests/test_settings_sync.py index 7b2f21c4..b56a1e2c 100644 --- a/lsfx-mock-server/tests/test_settings_sync.py +++ b/lsfx-mock-server/tests/test_settings_sync.py @@ -15,11 +15,14 @@ def test_ruoyi_mysql_defaults_should_follow_application_dev_config(): assert _load_ruoyi_mysql_defaults()["port"] == match.group("port") -def test_settings_should_use_ruoyi_mysql_defaults(): +def test_settings_should_default_to_lsfx_target_mysql_host_and_port(): + assert settings.CCDI_DB_HOST == "116.62.17.81" + assert settings.CCDI_DB_PORT == 3307 + + +def test_settings_should_still_use_ruoyi_mysql_defaults_for_db_name_and_credentials(): defaults = _load_ruoyi_mysql_defaults() - assert settings.CCDI_DB_HOST == defaults["host"] - assert settings.CCDI_DB_PORT == int(defaults["port"]) assert settings.CCDI_DB_NAME == defaults["database"] assert settings.CCDI_DB_USERNAME == defaults["username"] assert settings.CCDI_DB_PASSWORD == defaults["password"] diff --git a/lsfx-mock-server/tests/test_statement_service.py b/lsfx-mock-server/tests/test_statement_service.py index 4e9973e1..deabc6d4 100644 --- a/lsfx-mock-server/tests/test_statement_service.py +++ b/lsfx-mock-server/tests/test_statement_service.py @@ -4,6 +4,7 @@ StatementService 主绑定注入测试 from collections import Counter, defaultdict +import services.statement_rule_samples as statement_rule_samples from services.file_service import FileService from services.statement_service import StatementService from services.statement_rule_samples import ( @@ -27,6 +28,11 @@ class FakeStaffIdentityRepository: } +class FakeAbnormalAccountBaselineService: + def apply(self, staff_id_card, abnormal_accounts): + return None + + def test_generate_statements_should_include_seeded_samples_before_noise_when_rule_plan_exists(): """存在规则命中计划时,生成流水必须先混入被选中的命中样本。""" file_service = FileService(staff_identity_repository=FakeStaffIdentityRepository()) @@ -234,6 +240,187 @@ def test_generate_statements_should_follow_rule_hit_plan_from_file_record(): assert not any("购汇" in item["userMemo"] for item in statements) +def test_sudden_account_closure_samples_should_stay_within_30_days_before_invalid_date(): + statements = statement_rule_samples.build_sudden_account_closure_samples( + group_id=1000, + log_id=20001, + account_fact={ + "account_no": "6222000000000001", + "owner_id_card": "320101199001010030", + "account_name": "测试员工工资卡", + "status": 2, + "effective_date": "2024-01-01", + "invalid_date": "2026-03-20", + }, + le_name="测试主体", + ) + + assert statements + assert all("6222000000000001" == item["accountMaskNo"] for item in statements) + assert all("2026-02-18" <= item["trxDate"][:10] < "2026-03-20" for item in statements) + + +def test_dormant_account_large_activation_samples_should_exceed_threshold_after_6_months(): + statements = statement_rule_samples.build_dormant_account_large_activation_samples( + group_id=1000, + log_id=20001, + account_fact={ + "account_no": "6222000000000002", + "owner_id_card": "320101199001010030", + "account_name": "测试员工工资卡", + "status": 1, + "effective_date": "2025-01-01", + "invalid_date": None, + }, + le_name="测试主体", + ) + + assert statements + assert min(item["trxDate"][:10] for item in statements) >= "2025-07-01" + assert sum(item["drAmount"] + item["crAmount"] for item in statements) >= 500000 + assert max(item["drAmount"] + item["crAmount"] for item in statements) >= 100000 + + +def test_generate_statements_should_follow_abnormal_account_rule_plan_from_file_record(): + file_service = FileService(staff_identity_repository=FakeStaffIdentityRepository()) + statement_service = StatementService(file_service=file_service) + + response = file_service.fetch_inner_flow( + { + "groupId": 1001, + "customerNo": "customer_abnormal_rule_plan", + "dataChannelCode": "test_code", + "requestDateId": 20240101, + "dataStartDateId": 20240101, + "dataEndDateId": 20240131, + "uploadUserId": 902001, + } + ) + log_id = response["data"][0] + record = file_service.file_records[log_id] + record.abnormal_account_hit_rules = ["SUDDEN_ACCOUNT_CLOSURE"] + record.abnormal_accounts = [ + { + "account_no": "6222000000000001", + "owner_id_card": record.staff_id_card, + "account_name": "测试员工工资卡", + "status": 2, + "effective_date": "2024-01-01", + "invalid_date": "2026-03-20", + } + ] + + statements = statement_service._generate_statements(group_id=1001, log_id=log_id, count=80) + + assert any(item["accountMaskNo"] == "6222000000000001" for item in statements) + assert any("销户" in item["userMemo"] or "异常账户" in item["userMemo"] for item in statements) + + +def test_get_bank_statement_should_preserve_abnormal_account_mask_no(): + file_service = FileService(staff_identity_repository=FakeStaffIdentityRepository()) + statement_service = StatementService(file_service=file_service) + + response = file_service.fetch_inner_flow( + { + "groupId": 1001, + "customerNo": "customer_abnormal_api", + "dataChannelCode": "test_code", + "requestDateId": 20240101, + "dataStartDateId": 20240101, + "dataEndDateId": 20240131, + "uploadUserId": 902001, + } + ) + log_id = response["data"][0] + record = file_service.file_records[log_id] + record.abnormal_account_hit_rules = [ + "SUDDEN_ACCOUNT_CLOSURE", + "DORMANT_ACCOUNT_LARGE_ACTIVATION", + ] + record.abnormal_accounts = [ + { + "account_no": "6222000000000001", + "owner_id_card": record.staff_id_card, + "account_name": "测试员工工资卡", + "status": 2, + "effective_date": "2024-01-01", + "invalid_date": "2026-03-20", + "rule_code": "SUDDEN_ACCOUNT_CLOSURE", + }, + { + "account_no": "6222000000000002", + "owner_id_card": record.staff_id_card, + "account_name": "测试员工工资卡", + "status": 1, + "effective_date": "2025-01-01", + "invalid_date": None, + "rule_code": "DORMANT_ACCOUNT_LARGE_ACTIVATION", + }, + ] + + response = statement_service.get_bank_statement( + { + "groupId": 1001, + "logId": log_id, + "pageNow": 1, + "pageSize": 500, + } + ) + statements = response["data"]["bankStatementList"] + abnormal_statements = [ + item for item in statements if "销户" in item["userMemo"] or "激活" in item["userMemo"] + ] + + assert abnormal_statements + assert any(item["accountMaskNo"] == "6222000000000001" for item in abnormal_statements) + assert any(item["accountMaskNo"] == "6222000000000002" for item in abnormal_statements) + + +def test_get_bank_statement_should_only_use_abnormal_account_numbers_from_file_record(): + file_service = FileService( + staff_identity_repository=FakeStaffIdentityRepository(), + abnormal_account_baseline_service=FakeAbnormalAccountBaselineService(), + ) + statement_service = StatementService(file_service=file_service) + + response = file_service.fetch_inner_flow( + { + "groupId": 1001, + "customerNo": "customer_abnormal_statement_consistency", + "dataChannelCode": "test_code", + "requestDateId": 20240101, + "dataStartDateId": 20240101, + "dataEndDateId": 20240131, + "uploadUserId": 902001, + } + ) + log_id = response["data"][0] + record = file_service.file_records[log_id] + record.abnormal_account_hit_rules = ["SUDDEN_ACCOUNT_CLOSURE"] + record.abnormal_accounts = [ + { + "account_no": "6222000000000099", + "owner_id_card": record.staff_id_card, + "account_name": "测试员工工资卡", + "status": 2, + "effective_date": "2024-01-01", + "invalid_date": "2026-03-20", + "rule_code": "SUDDEN_ACCOUNT_CLOSURE", + } + ] + + result = statement_service.get_bank_statement( + {"groupId": 1001, "logId": log_id, "pageNow": 1, "pageSize": 500} + ) + abnormal_numbers = { + item["accountMaskNo"] + for item in result["data"]["bankStatementList"] + if "销户" in item["userMemo"] or "异常账户" in item["userMemo"] + } + + assert abnormal_numbers == {"6222000000000099"} + + def test_generate_statements_should_stay_within_single_employee_scope_per_log_id(): """同一 logId 的流水只能落在 FileRecord 绑定的员工及亲属身份证内。""" file_service = FileService(staff_identity_repository=FakeStaffIdentityRepository()) @@ -572,6 +759,8 @@ def test_generate_statements_should_keep_all_mode_noise_as_safe_debits(monkeypat "MONTHLY_FIXED_INCOME", "FIXED_COUNTERPARTY_TRANSFER", ] + record.abnormal_account_hit_rules = [] + record.abnormal_accounts = [] statements = statement_service._generate_statements(group_id=1001, log_id=log_id, count=30) noise_statements = [ diff --git a/pom.xml b/pom.xml index 3f861011..29d91914 100644 --- a/pom.xml +++ b/pom.xml @@ -36,6 +36,7 @@ 2.3.1 6.0.0 2.8.14 + 7.0.E.7 @@ -110,6 +111,12 @@ ${springdoc.version}
+ + com.tongweb.springboot + tongweb-spring-boot-starter-3.x + ${tongweb.version} + + commons-io @@ -261,6 +268,17 @@ true + + tongweb-releases + TongWeb Maven Releases + https://mvn.elitescloud.com/nexus/repository/maven-releases/ + + true + + + false + + @@ -277,4 +295,4 @@ - \ No newline at end of file + diff --git a/ruoyi-admin/pom.xml b/ruoyi-admin/pom.xml index 1bf6c9ef..b9143e5d 100644 --- a/ruoyi-admin/pom.xml +++ b/ruoyi-admin/pom.xml @@ -40,6 +40,12 @@ com.ruoyi ruoyi-framework + + + org.springframework.boot + spring-boot-starter-tomcat + + @@ -73,6 +79,11 @@ 3.9.1 + + com.tongweb.springboot + tongweb-spring-boot-starter-3.x + + org.springframework.boot spring-boot-starter-test @@ -82,6 +93,15 @@ + + + src/main/resources + false + + **/* + + + org.springframework.boot diff --git a/ruoyi-admin/src/main/resources/application-dev.yml b/ruoyi-admin/src/main/resources/application-dev.yml index bbbbd276..efffd776 100644 --- a/ruoyi-admin/src/main/resources/application-dev.yml +++ b/ruoyi-admin/src/main/resources/application-dev.yml @@ -89,11 +89,11 @@ spring: # 地址 host: 116.62.17.81 # 端口,默认为6379 - port: 6379 + port: 56379 # 数据库索引 - database: 0 + database: 9 # 密码 - password: Kfcx@1234 + password: N0f3d12c4a927eee1+ # 连接超时时间 timeout: 10s lettuce: diff --git a/ruoyi-admin/src/main/resources/application-pro.yml b/ruoyi-admin/src/main/resources/application-pro.yml new file mode 100644 index 00000000..547b70cc --- /dev/null +++ b/ruoyi-admin/src/main/resources/application-pro.yml @@ -0,0 +1,146 @@ +# 开发环境配置 +ruoyi: + # 文件路径 示例( Windows配置D:/ruoyi/uploadPath,Linux配置 /home/ruoyi/uploadPath) + profile: backend/uploadPath + + +# 开发环境配置 +server: + # 服务器的HTTP端口,默认为8080 + port: 62318 + servlet: + # 应用的访问路径 + context-path: / + tomcat: + # tomcat的URI编码 + uri-encoding: UTF-8 + # 连接数满后的排队数,默认为100 + accept-count: 1000 + threads: + # tomcat最大线程数,默认为200 + max: 800 + # Tomcat启动初始化的线程数,默认值10 + min-spare: 100 + + +# 数据源配置 +spring: + datasource: + type: com.alibaba.druid.pool.DruidDataSource + driverClassName: com.mysql.cj.jdbc.Driver + druid: + # 主库数据源 + master: + url: jdbc:mysql://158.234.199.250:3306/ccdi?useUnicode=true&characterEncoding=UTF-8&zeroDateTimeBehavior=convertToNull&useSSL=true&serverTimezone=GMT%2B8 + username: dbicm + password: Kfcx@1234 + # 从库数据源 + slave: + # 从数据源开关/默认关闭 + enabled: false + url: + username: + password: + # 初始连接数 + initialSize: 5 + # 最小连接池数量 + minIdle: 10 + # 最大连接池数量 + maxActive: 20 + # 配置获取连接等待超时的时间 + maxWait: 60000 + # 配置连接超时时间 + connectTimeout: 30000 + # 配置网络超时时间 + socketTimeout: 60000 + # 配置间隔多久才进行一次检测,检测需要关闭的空闲连接,单位是毫秒 + timeBetweenEvictionRunsMillis: 60000 + # 配置一个连接在池中最小生存的时间,单位是毫秒 + minEvictableIdleTimeMillis: 300000 + # 配置一个连接在池中最大生存的时间,单位是毫秒 + maxEvictableIdleTimeMillis: 900000 + # 配置检测连接是否有效 + validationQuery: SELECT 1 FROM DUAL + testWhileIdle: true + testOnBorrow: false + testOnReturn: false + webStatFilter: + enabled: true + statViewServlet: + enabled: true + # 设置白名单,不填则允许所有访问 + allow: + url-pattern: /druid/* + # 控制台管理用户名和密码 + login-username: ruoyi + login-password: 123456 + filter: + stat: + enabled: true + # 慢SQL记录 + log-slow-sql: true + slow-sql-millis: 1000 + merge-sql: true + wall: + config: + multi-statement-allow: true + data: + # redis 配置 + redis: + # 地址 + host: r-kz640f6b20dac724.redis.rds.ops.dc-tst-zj96596.com + # 端口,默认为6379 + port: 6379 + # 数据库索引 + database: 9 + # 密码 + password: Kfcx@1234 + # 连接超时时间 + timeout: 10s + lettuce: + pool: + # 连接池中的最小空闲连接 + min-idle: 0 + # 连接池中的最大空闲连接 + max-idle: 8 + # 连接池的最大数据库连接数 + max-active: 8 + # #连接池最大阻塞等待时间(使用负值表示没有限制) + max-wait: -1ms + +# 流水分析平台配置 +lsfx: + api: + + base-url: http://158.234.196.5:82/c4c3 + # 生产环境 + # base-url: http://64.202.32.176/c4c3 + + # 认证配置 + app-id: remote_app + app-secret: dXj6eHRmPv # 见知提供的密钥 + client-id: c2017e8d105c435a96f86373635b6a09 # 测试环境固定值 + + # 接口路径配置 + endpoints: + get-token: /account/common/getToken + upload-file: /watson/api/project/remoteUploadSplitFile + fetch-inner-flow: /watson/api/project/getJZFileOrZjrcuFile + check-parse-status: /watson/api/project/upload/getpendings + get-bank-statement: /watson/api/project/getBSByLogId + # 新增接口 + get-file-upload-status: /watson/api/project/bs/upload + delete-files: /watson/api/project/batchDeleteUploadFile + + # RestTemplate配置 + connection-timeout: 30000 # 连接超时30秒 + read-timeout: 60000 # 读取超时60秒 + + # 连接池配置 + pool: + max-total: 100 # 最大连接数 + default-max-per-route: 20 # 每个路由最大连接数 + +credit-parse: + api: + url: http://192.168.0.111:62320/xfeature-mngs/conversation/htmlEval diff --git a/ruoyi-admin/src/main/resources/application-uat.yml b/ruoyi-admin/src/main/resources/application-uat.yml new file mode 100644 index 00000000..547b70cc --- /dev/null +++ b/ruoyi-admin/src/main/resources/application-uat.yml @@ -0,0 +1,146 @@ +# 开发环境配置 +ruoyi: + # 文件路径 示例( Windows配置D:/ruoyi/uploadPath,Linux配置 /home/ruoyi/uploadPath) + profile: backend/uploadPath + + +# 开发环境配置 +server: + # 服务器的HTTP端口,默认为8080 + port: 62318 + servlet: + # 应用的访问路径 + context-path: / + tomcat: + # tomcat的URI编码 + uri-encoding: UTF-8 + # 连接数满后的排队数,默认为100 + accept-count: 1000 + threads: + # tomcat最大线程数,默认为200 + max: 800 + # Tomcat启动初始化的线程数,默认值10 + min-spare: 100 + + +# 数据源配置 +spring: + datasource: + type: com.alibaba.druid.pool.DruidDataSource + driverClassName: com.mysql.cj.jdbc.Driver + druid: + # 主库数据源 + master: + url: jdbc:mysql://158.234.199.250:3306/ccdi?useUnicode=true&characterEncoding=UTF-8&zeroDateTimeBehavior=convertToNull&useSSL=true&serverTimezone=GMT%2B8 + username: dbicm + password: Kfcx@1234 + # 从库数据源 + slave: + # 从数据源开关/默认关闭 + enabled: false + url: + username: + password: + # 初始连接数 + initialSize: 5 + # 最小连接池数量 + minIdle: 10 + # 最大连接池数量 + maxActive: 20 + # 配置获取连接等待超时的时间 + maxWait: 60000 + # 配置连接超时时间 + connectTimeout: 30000 + # 配置网络超时时间 + socketTimeout: 60000 + # 配置间隔多久才进行一次检测,检测需要关闭的空闲连接,单位是毫秒 + timeBetweenEvictionRunsMillis: 60000 + # 配置一个连接在池中最小生存的时间,单位是毫秒 + minEvictableIdleTimeMillis: 300000 + # 配置一个连接在池中最大生存的时间,单位是毫秒 + maxEvictableIdleTimeMillis: 900000 + # 配置检测连接是否有效 + validationQuery: SELECT 1 FROM DUAL + testWhileIdle: true + testOnBorrow: false + testOnReturn: false + webStatFilter: + enabled: true + statViewServlet: + enabled: true + # 设置白名单,不填则允许所有访问 + allow: + url-pattern: /druid/* + # 控制台管理用户名和密码 + login-username: ruoyi + login-password: 123456 + filter: + stat: + enabled: true + # 慢SQL记录 + log-slow-sql: true + slow-sql-millis: 1000 + merge-sql: true + wall: + config: + multi-statement-allow: true + data: + # redis 配置 + redis: + # 地址 + host: r-kz640f6b20dac724.redis.rds.ops.dc-tst-zj96596.com + # 端口,默认为6379 + port: 6379 + # 数据库索引 + database: 9 + # 密码 + password: Kfcx@1234 + # 连接超时时间 + timeout: 10s + lettuce: + pool: + # 连接池中的最小空闲连接 + min-idle: 0 + # 连接池中的最大空闲连接 + max-idle: 8 + # 连接池的最大数据库连接数 + max-active: 8 + # #连接池最大阻塞等待时间(使用负值表示没有限制) + max-wait: -1ms + +# 流水分析平台配置 +lsfx: + api: + + base-url: http://158.234.196.5:82/c4c3 + # 生产环境 + # base-url: http://64.202.32.176/c4c3 + + # 认证配置 + app-id: remote_app + app-secret: dXj6eHRmPv # 见知提供的密钥 + client-id: c2017e8d105c435a96f86373635b6a09 # 测试环境固定值 + + # 接口路径配置 + endpoints: + get-token: /account/common/getToken + upload-file: /watson/api/project/remoteUploadSplitFile + fetch-inner-flow: /watson/api/project/getJZFileOrZjrcuFile + check-parse-status: /watson/api/project/upload/getpendings + get-bank-statement: /watson/api/project/getBSByLogId + # 新增接口 + get-file-upload-status: /watson/api/project/bs/upload + delete-files: /watson/api/project/batchDeleteUploadFile + + # RestTemplate配置 + connection-timeout: 30000 # 连接超时30秒 + read-timeout: 60000 # 读取超时60秒 + + # 连接池配置 + pool: + max-total: 100 # 最大连接数 + default-max-per-route: 20 # 每个路由最大连接数 + +credit-parse: + api: + url: http://192.168.0.111:62320/xfeature-mngs/conversation/htmlEval diff --git a/ruoyi-admin/src/main/resources/application.yml b/ruoyi-admin/src/main/resources/application.yml index d1bb7290..504c697c 100644 --- a/ruoyi-admin/src/main/resources/application.yml +++ b/ruoyi-admin/src/main/resources/application.yml @@ -23,6 +23,11 @@ logging: "com.ruoyi.ccdi.project.mapper.CcdiBankStatementMapper.insertBatch": info "com.ruoyi.ccdi.project.mapper.CcdiBankTagResultMapper.insertBatch": info +server: + tongweb: + license: + path: classpath:license.dat + # 用户配置 user: password: diff --git a/ruoyi-admin/src/main/resources/license.dat b/ruoyi-admin/src/main/resources/license.dat new file mode 100644 index 00000000..4219b998 --- /dev/null +++ b/ruoyi-admin/src/main/resources/license.dat @@ -0,0 +1 @@ +uc3Y29XJfVtZtZTbmF72t3V405cxamrXBnM0P0vqrrLnJjQ0T0Mt93avL/euwcmvgpWN09qZhbWX25eO9U91ptOrcWNK1XJz6z9waqNC5L40d09ybfrmrDP352Ny76fqyPauv06+ru7f+bTwG99zvHOS8bQvJub/rL3JkoKbfbnZXJmVyVtYwMjPTIjEyQtMsaWMQpnNlNlbkPTX2lTE5EwNsaWOApnNlNlb5cGX3RmVsU9czZQZWFmVhpjcfZGdGVT0yF0Z0LTMDITEwEyLuZFCmVXRl9kYxClPS01ByRXX1Y3b2RmFtRfTUb2ZT12Vi5nVXX1ClRnNpZlcfTnb25mVyVtYuMCPTclRX5FCQVVX0N1VO9DTKYmVD0GlwluZUV1PQpXJk9IYyZVd2FD0K9JZfTWVFd051F4XlcjbWJQpU0tMFZGV19W9ul0atYmPUVk5FVkCWRVV19U9OJTSJQ0X0x0U9VOQLWUWFlTU1lLbSMmSmhkNHlBRrcVdG8kNtUxYCT2RHFTc5lperM2WUFkkvU3M3MzTDBldOlqeTb3YUVGx3VWT1WkaERHhilxOTM0T0l1FrdBS1aUWG4GE5FtaLMyUXZUlz8zM0UnSCs0lDM5RDVFRzJDBzZmOoRkNFdEt6YwNKTkTXA1ZFVXXJT0UlNElD5fTDRTRU5TdU1YdROEL2xUhvV3OLY3bTVmhMpZUJU2QXF2I1VYdxTjWVVm9jZKUPWUMXFHNrFJeJZDU29mM2hKQpUmUVJGIxwwOVSUaXFXQy9JU4cFdlkGJQY4SCYWYjFkJndiaCVFMlNk1QZQTwQWRDJ0th1YMwaHYmEzQrB2aWTTRmpgpOA5dfVkRVd0lPVSUMSUTl9kNFNFTyaWPU0G5mNwaieUdUJ0NiZjNVbnOC90tNYyb2S3djNmh4BidmNlRnBk1PdCShdDYUdS9mNMSiMnYzVml2pSameEY2NStCZtRvU3dloGgvQyU0TmcTlTlMJVYhc3VEp01EpYRwUGNUUWZ5daVxU3blZzg3dnR2UncnR2U4RkU2CkSUc19W5FVTSURVJ0xJ9OXOQ0Q0VG1iU9WFcDYWNktah2M3blbjNlZsZndjOTdXFTFHNQS0SzUE4FVpdqR1dEL2RWgvpMSabVZkJlhRJFRMZUTUJ0NGdwUGbGZitjFE01bZSlRzZHZ4RMUFd2cHhEZqtZbwR2clQUgxFsb0Z1ZFVW5tJvUwWEd2gjVog1eKYUaUdHYK5JUXX1TkVlNJZFUfTET05U5DlDRTVURV9jJi49SmbHR2pU5UFwVUK0ZTBFE5pmd3ZGOEdXppU1VTNEb09UtntCRycUaHUmR1ovVYNGUU1HFy1vVsZXYlBmt1lsNVZDVUNUNyJQeHUXZlF2pzVMVlVjVmNWx2VxWZaUSEh1FlJ0bmRmbENkRV9VWiRVT3R01apMUIMkWjdW8K5JTXX1TkVlNJZFUfTET05U5DlDRTVURV9Ulw49bhMWcW5HdRdZNaK0UEp3AyU0TwY0Wm1md6tDMpN0cDRVBK11aKR0VTZkI3RocKUXRm5Vl6llcpMWaXJXRLZOOaZDOGZ1R0gychWURVJk5JR5VNYXQTl250dGYheFOG1FZog4RFZWMkRmtK1QRCaUclJlNYNFRHSWTFpFUKxYRXX1TkVlNJZFUfTET05U5DlDRTVURV9Hho49T5aTempVMwFhU1b1Vi9HR4YzO1dDNks05HhEQxY2VUZXlMNybyVmVEl3dNlLbYSGbGlWxMVNWUcEZXpVN0w5NVZDVGFUw4NMSUOWbkRjV21QaMbESVhGx1w3MiY1WmJXB6o0NjS1T2tWxjNSeRY0UzV0g2VhR5Z0RWlzkKRMdXX1TkVlNJZFUfTET05U5DlDRTVURV9Hdt49apUHZVNnhlpxQ5MENGNnh1VYN3aDQ2QW5qRqd4K1cXYk9ZdHW4VzeE9XVHB6YmM3Wk1DYwVLdqS1aTNUtjhINicVeUV1JBZRZxTGYWdTVytuepR1QVVXZlNoSVOFdVlVkzRqdPcjOW9HBll6Ota2dHFGV6dtN6c1ekN2UKdwc diff --git a/ruoyi-admin/src/test/java/com/ruoyi/config/TongWebIntegrationConfigurationTest.java b/ruoyi-admin/src/test/java/com/ruoyi/config/TongWebIntegrationConfigurationTest.java new file mode 100644 index 00000000..6764a43f --- /dev/null +++ b/ruoyi-admin/src/test/java/com/ruoyi/config/TongWebIntegrationConfigurationTest.java @@ -0,0 +1,37 @@ +package com.ruoyi.config; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.env.YamlPropertySourceLoader; +import org.springframework.core.env.PropertySource; +import org.springframework.core.io.ClassPathResource; + +import java.io.IOException; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +class TongWebIntegrationConfigurationTest { + + @Test + void shouldExposeTongWebLicenseResourceOnClasspath() { + assertThat(getClass().getClassLoader().getResource("license.dat")) + .as("TongWeb license 文件应随应用资源一起打包") + .isNotNull(); + } + + @Test + void shouldDeclareTongWebLicensePathInApplicationYaml() throws IOException { + YamlPropertySourceLoader loader = new YamlPropertySourceLoader(); + List> propertySources = loader.load("application", new ClassPathResource("application.yml")); + + Object propertyValue = propertySources.stream() + .map(source -> source.getProperty("server.tongweb.license.path")) + .filter(value -> value != null) + .findFirst() + .orElse(null); + + assertThat(propertyValue) + .as("基础配置中应声明 TongWeb license 路径") + .isEqualTo("classpath:license.dat"); + } +} diff --git a/ruoyi-framework/pom.xml b/ruoyi-framework/pom.xml index 3c520806..5b4e4f34 100644 --- a/ruoyi-framework/pom.xml +++ b/ruoyi-framework/pom.xml @@ -59,6 +59,13 @@ ruoyi-system + + + org.springframework.boot + spring-boot-starter-test + test + + - \ No newline at end of file + diff --git a/ruoyi-framework/src/main/java/com/ruoyi/framework/config/RedisConfig.java b/ruoyi-framework/src/main/java/com/ruoyi/framework/config/RedisConfig.java index 3453237e..eaaebddc 100644 --- a/ruoyi-framework/src/main/java/com/ruoyi/framework/config/RedisConfig.java +++ b/ruoyi-framework/src/main/java/com/ruoyi/framework/config/RedisConfig.java @@ -2,9 +2,11 @@ package com.ruoyi.framework.config; import org.springframework.cache.annotation.CachingConfigurerSupport; import org.springframework.cache.annotation.EnableCaching; +import org.springframework.beans.factory.config.BeanPostProcessor; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.data.redis.core.script.DefaultRedisScript; import org.springframework.data.redis.serializer.StringRedisSerializer; @@ -19,6 +21,23 @@ import org.springframework.data.redis.serializer.StringRedisSerializer; @EnableCaching public class RedisConfig extends CachingConfigurerSupport { + @Bean + public static BeanPostProcessor lettuceConnectionFactoryBeanPostProcessor() + { + return new BeanPostProcessor() + { + @Override + public Object postProcessBeforeInitialization(Object bean, String beanName) + { + if (bean instanceof LettuceConnectionFactory lettuceConnectionFactory) + { + lettuceConnectionFactory.setValidateConnection(true); + } + return bean; + } + }; + } + @Bean @SuppressWarnings(value = { "unchecked", "rawtypes" }) public RedisTemplate redisTemplate(RedisConnectionFactory connectionFactory) diff --git a/ruoyi-system/src/main/resources/mapper/system/SysDeptMapper.xml b/ruoyi-system/src/main/resources/mapper/system/SysDeptMapper.xml index cf439f68..eff8d492 100644 --- a/ruoyi-system/src/main/resources/mapper/system/SysDeptMapper.xml +++ b/ruoyi-system/src/main/resources/mapper/system/SysDeptMapper.xml @@ -59,7 +59,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"