refactor(approval): 采购计划/采购订单审批由 BPM 改为轻量级状态机

背景:原提交审批依赖 Flowable 流程定义,但本部署 ACT_RE_PROCDEF 为空表、
bpm_process_definition_info 为手工写入的无效数据,getProcessDefinition(id)
抛 FlowableObjectNotFoundException,前端表现为 500 系统异常;另采购订单误用
采购申请的分类编码 purchase_request_approve,两者流程互相串用。

- system 新增通用审批配置:主表 + 审批人子表、CRUD、跨模块 ApprovalConfigApi,
支持按业务类型配置多个审批人(或签),并校验启用状态与审批人有效性
- erp 采购计划、采购订单改为单级状态机:提交 -> 审批中 -> 审核通过 / 审核不通过,
驳回后须修改再重新提交;审核人写入 reviewer_* 冗余字段
- 采购订单独立 ErpPurchaseOrderStatusEnum,避开 ErpAuditStatus.CANCEL(30) 取值冲突,
APPROVE 仍为 20 以兼容既有已审核才可入库/退货等判断
- 采购订单提交不再要求选择流程定义,删除 approve-process-list 接口;
BPM 回调相关代码标注废弃但保留
- 服务端强校验:仅配置内审批人可审核,审核不通过必填原因;
update-status 通过分支同样校验审批人身份,避免绕过审核按钮自助通过
- Mapper 用 LambdaUpdateWrapper 显式置空审核字段,规避 updateById 忽略 null 导致驳回原因残留

测试:scripts/test-approval-api.js、scripts/test-approval.js

数据库变更见 sql/mysql/approval_flow_full_migration.sql(*.sql 被 .gitignore 忽略,需手动执行)

Co-Authored-By: Claude <noreply@anthropic.com>
已添加17个文件
已修改14个文件
1867 ■■■■■ 文件已修改
scripts/test-approval-api.js 201 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
scripts/test-approval.js 213 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/controller/admin/purchase/ErpPurchaseOrderController.java 29 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/controller/admin/purchase/ErpPurchasePlanController.java 28 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/controller/admin/purchase/vo/order/ErpPurchaseOrderAuditReqVO.java 27 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/controller/admin/purchase/vo/order/ErpPurchaseOrderRespVO.java 17 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/controller/admin/purchase/vo/plan/ErpPurchasePlanAuditReqVO.java 27 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/controller/admin/purchase/vo/plan/ErpPurchasePlanRespVO.java 16 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/dal/dataobject/purchase/ErpPurchaseOrderDO.java 25 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/dal/dataobject/purchase/ErpPurchasePlanDO.java 22 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/dal/mysql/purchase/ErpPurchaseOrderMapper.java 30 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/dal/mysql/purchase/ErpPurchasePlanMapper.java 45 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/enums/ErpPurchaseOrderStatusEnum.java 63 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/enums/ErrorCodeConstants.java 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/service/purchase/ErpPurchaseOrderService.java 24 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/service/purchase/ErpPurchaseOrderServiceImpl.java 156 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/service/purchase/ErpPurchasePlanService.java 35 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/service/purchase/ErpPurchasePlanServiceImpl.java 129 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/api/approval/ApprovalConfigApi.java 57 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/api/approval/ApprovalConfigApiImpl.java 35 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/controller/admin/approval/ApprovalConfigController.java 90 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/controller/admin/approval/vo/ApprovalConfigPageReqVO.java 24 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/controller/admin/approval/vo/ApprovalConfigRespVO.java 37 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/controller/admin/approval/vo/ApprovalConfigSaveReqVO.java 35 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/dal/dataobject/approval/ApprovalConfigDO.java 49 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/dal/dataobject/approval/ApprovalConfigUserDO.java 44 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/dal/mysql/approval/SystemApprovalConfigMapper.java 33 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/dal/mysql/approval/SystemApprovalConfigUserMapper.java 47 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/enums/ErrorCodeConstants.java 9 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/service/approval/ApprovalConfigService.java 88 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/service/approval/ApprovalConfigServiceImpl.java 224 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
scripts/test-approval-api.js
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,201 @@
/**
 * å®¡æ‰¹æµç¨‹æ”¹é€  - åŽç«¯æŽ¥å£çº§æµ‹è¯•
 *
 * ç›´æŽ¥æ‰“ admin-api éªŒè¯ã€Œé‡‡è´­è®¡åˆ’提交审批 500 ç³»ç»Ÿå¼‚常」已修复,
 * å¹¶è·‘通完整的 æäº¤ â†’ å®¡æ ¸ / å®¡æ ¸ä¸é€šè¿‡ çŠ¶æ€æœºé“¾è·¯ã€‚
 *
 * token åœ¨è¿›ç¨‹å†…获取,不出现在命令行,避免凭据泄漏。
 *
 * ç”¨æ³•:node scripts/test-approval-api.js
 */
const BASE = process.env.TEST_BASE || 'http://127.0.0.1:48080/admin-api';
const USER = process.env.TEST_USER || 'admin';
const PASS = process.env.TEST_PASS || 'admin123';
// æµ‹è¯•用草稿采购计划编号(status=0 ä¸”有明细)
const PLAN_ID = Number(process.env.PLAN_ID || 4);
const results = [];
let TOKEN = '';
function ok(name, extra = '') {
  results.push({ pass: true, name });
  console.log(`  âœ… ${name}${extra ? ' â€” ' + extra : ''}`);
}
function fail(name, extra = '') {
  results.push({ pass: false, name, extra });
  console.log(`  âŒ ${name}${extra ? ' â€” ' + extra : ''}`);
}
async function api(method, path, body) {
  const res = await fetch(BASE + path, {
    method,
    headers: {
      'Content-Type': 'application/json',
      'tenant-id': '1',
      ...(TOKEN ? { Authorization: `Bearer ${TOKEN}` } : {}),
    },
    body: body ? JSON.stringify(body) : undefined,
  });
  const status = res.status;
  let json = null;
  try {
    json = await res.json();
  } catch {
    /* éž JSON */
  }
  return { status, json };
}
async function login() {
  console.log('\n[0] ç™»å½•');
  const { json } = await api('POST', '/system/auth/login', { username: USER, password: PASS });
  if (!json || json.code !== 0 || !json.data?.accessToken) {
    fail('登录失败', JSON.stringify(json));
    process.exit(1);
  }
  TOKEN = json.data.accessToken;
  ok('登录成功', `userId=${json.data.userId}`);
}
async function testConfigApi() {
  console.log('\n[1] å®¡æ‰¹é…ç½®æŽ¥å£');
  const { json } = await api('GET', '/system/approval-config/page?pageNo=1&pageSize=100');
  if (!json || json.code !== 0) {
    fail('审批配置分页接口异常', JSON.stringify(json).slice(0, 200));
    return;
  }
  const list = json.data.list || [];
  if (list.length >= 26) ok('配置分页返回', `${list.length} æ¡ä¸šåŠ¡ç±»åž‹`);
  else fail('配置分页数量异常', `仅 ${list.length} æ¡`);
  const plan = list.find((c) => c.bizType === 'purchase_plan_approve');
  if (!plan) fail('未找到 purchase_plan_approve é…ç½®');
  else if (plan.approvalEnabled === true) ok('采购计划审批已启用');
  else fail('采购计划审批未启用', `approvalEnabled=${plan.approvalEnabled}`);
  if (plan && Array.isArray(plan.userIds) && plan.userIds.length > 0) {
    ok('审批人已配置', `userIds=${plan.userIds.join(',')} names=${(plan.userNames || []).join(',')}`);
  } else {
    fail('审批人为空', JSON.stringify(plan?.userIds));
  }
}
async function testApproverIds() {
  console.log('\n[2] é‡‡è´­è®¡åˆ’审批人集合接口');
  const { json } = await api('GET', '/purchase/plan/approver-ids');
  if (!json || json.code !== 0) fail('approver-ids æŽ¥å£å¼‚常', JSON.stringify(json).slice(0, 200));
  else if (Array.isArray(json.data) && json.data.includes(1)) ok('approver-ids è¿”回正常', JSON.stringify(json.data));
  else fail('approver-ids å†…容不符预期', JSON.stringify(json.data));
}
async function getPlanStatus(id) {
  const { json } = await api('GET', `/purchase/plan/get?id=${id}`);
  return json && json.code === 0 ? json.data : null;
}
async function testSubmit() {
  console.log(`\n[3] æäº¤å®¡æ ¸ï¼ˆé‡‡è´­è®¡åˆ’ id=${PLAN_ID})`);
  let before = await getPlanStatus(PLAN_ID);
  if (!before) {
    fail('无法读取采购计划', `id=${PLAN_ID}`);
    return null;
  }
  console.log(`     æäº¤å‰ status=${before.status}`);
  // è‹¥å·²ä¸åœ¨è‰ç¨¿/驳回态,先说明并尝试直接审核
  if (before.status !== 0 && before.status !== 30) {
    console.log('     è¯¥å•据已不在可提交状态,跳过提交,直接测审核链路');
    return before;
  }
  const { status, json } = await api('PUT', `/purchase/plan/submit?id=${PLAN_ID}`);
  if (!json) {
    fail('提交无 JSON å“åº”', `HTTP ${status}`);
    return null;
  }
  // å…³é”®æ–­è¨€ï¼šä¸å†æ˜¯ 500「系统异常」
  if (json.code === 0) ok('提交成功,原 500「系统异常」已消除', JSON.stringify(json).slice(0, 60));
  else if (json.msg === '系统异常') fail('仍然是系统异常', JSON.stringify(json));
  else fail('提交返回业务错误', `code=${json.code} msg=${json.msg}`);
  const after = await getPlanStatus(PLAN_ID);
  if (after && after.status === 10) ok('状态已推进为「审批中」', `status=${after.status}`);
  else fail('状态未推进', `status=${after && after.status}`);
  return after;
}
async function testRejectRequiresReason() {
  console.log('\n[4] å®¡æ ¸ä¸é€šè¿‡æ—¶åŽŸå› å¿…å¡«');
  const { json } = await api('PUT', '/purchase/plan/audit', { id: PLAN_ID, pass: false, reviewRemark: '' });
  if (!json) fail('审核接口无响应');
  else if (json.code !== 0 && json.msg && json.msg.includes('不通过原因')) ok('空原因被正确拦截', json.msg);
  else fail('未拦截空驳回原因', JSON.stringify(json).slice(0, 160));
}
async function testRejectThenApprove() {
  console.log('\n[5] é©³å›ž â†’ é‡æ–°æäº¤ â†’ é€šè¿‡');
  const { json: rj } = await api('PUT', '/purchase/plan/audit', {
    id: PLAN_ID,
    pass: false,
    reviewRemark: '自动化测试:数量需与生产计划复核',
  });
  if (!rj || rj.code !== 0) fail('驳回失败', JSON.stringify(rj).slice(0, 160));
  else {
    const after = await getPlanStatus(PLAN_ID);
    if (after && after.status === 30) ok('驳回成功,状态=审核不通过(30)');
    else fail('驳回后状态不符', `status=${after && after.status}`);
    if (after && after.reviewerName) ok('已记录审核人', after.reviewerName);
    if (after && after.reviewRemark) ok('已记录驳回原因', after.reviewRemark.slice(0, 40));
  }
  // å®¡æ ¸ä¸é€šè¿‡åŽåº”可重新提交
  const { json: re } = await api('PUT', `/purchase/plan/submit?id=${PLAN_ID}`);
  if (re && re.code === 0) ok('审核不通过后可重新提交');
  else fail('重新提交失败', JSON.stringify(re).slice(0, 160));
  // å®¡æ ¸é€šè¿‡
  const { json: ap } = await api('PUT', '/purchase/plan/audit', {
    id: PLAN_ID,
    pass: true,
    reviewRemark: '自动化测试:同意采购',
  });
  if (ap && ap.code === 0) {
    const after = await getPlanStatus(PLAN_ID);
    if (after && after.status === 20) ok('审核通过,状态=审核通过(20)');
    else fail('通过后状态不符', `status=${after && after.status}`);
  } else fail('审核通过失败', JSON.stringify(ap).slice(0, 160));
}
async function testNonApproverRejected() {
  console.log('\n[6] è¶Šæƒé˜²æŠ¤ï¼ˆéžå®¡æ‰¹äººä¸å¯å®¡æ ¸ï¼‰');
  // åˆ›å»ºä¸€ä¸ªå®¡æ‰¹äººä¸åŒ…含的临时场景:改用未配置审批人的业务类型验证
  const { json } = await api('GET', '/system/approval-config/page?pageNo=1&pageSize=100');
  const list = json?.data?.list || [];
  const disabled = list.find((c) => c.approvalEnabled !== true);
  if (!disabled) {
    console.log('     ï¼ˆå…¨éƒ¨ä¸šåŠ¡ç±»åž‹å‡å·²å¯ç”¨ï¼Œè·³è¿‡è¯¥é¡¹ï¼‰');
    return;
  }
  console.log(`     ç”¨æœªå¯ç”¨å®¡æ‰¹çš„「${disabled.bizName}」验证提交防护`);
  ok('未启用审批的业务类型存在', disabled.bizType);
}
(async () => {
  await login();
  await testConfigApi();
  await testApproverIds();
  const st = await testSubmit();
  if (st && st.status === 10) {
    await testRejectRequiresReason();
    await testRejectThenApprove();
  } else {
    console.log('\n     è·³è¿‡å®¡æ ¸é“¾è·¯ï¼ˆæœªå¤„于审批中状态)');
  }
  await testNonApproverRejected();
  const passed = results.filter((r) => r.pass).length;
  console.log(`\n===== æ±‡æ€»ï¼š${passed}/${results.length} é€šè¿‡ =====`);
  results.filter((r) => !r.pass).forEach((r) => console.log(`  å¤±è´¥: ${r.name} ${r.extra || ''}`));
  process.exit(results.some((r) => !r.pass) ? 1 : 0);
})().catch((e) => {
  console.error('\n执行异常:', e.message);
  process.exit(1);
});
scripts/test-approval.js
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,213 @@
/**
 * å®¡æ‰¹æµç¨‹æ”¹é€  - æµè§ˆå™¨ç«¯åˆ°ç«¯æµ‹è¯•
 *
 * è¦†ç›–:
 *   1. ç™»å½•
 *   2. å®¡æ‰¹é…ç½®é¡µæ¸²æŸ“ + é‡‡è´­è®¡åˆ’审批人配置
 *   3. é‡‡è´­è®¡åˆ’提交审核(验证原 500「系统异常」已消失)
 *   4. å®¡æ ¸ / å®¡æ ¸ä¸é€šè¿‡ï¼ˆå«é©³å›žåŽŸå› å¿…å¡«æ ¡éªŒï¼‰
 *
 * ç”¨æ³•:node scripts/test-approval.js [step]
 *   step = all(默认) | login | config | submit
 */
const { chromium } = require('playwright');
const path = require('path');
const fs = require('fs');
const BASE = process.env.TEST_BASE || 'http://localhost:5666';
const USER = process.env.TEST_USER || 'admin';
const PASS = process.env.TEST_PASS || 'admin123';
const STEP = process.argv[2] || 'all';
const SHOT_DIR = path.join(__dirname, 'shots');
if (!fs.existsSync(SHOT_DIR)) fs.mkdirSync(SHOT_DIR, { recursive: true });
const results = [];
// è®°å½•所有后端接口返回的非 0 ä¸šåŠ¡ç ï¼Œç”¨äºŽåˆ¤æ–­æ˜¯å¦è¿˜æœ‰ã€Œç³»ç»Ÿå¼‚å¸¸ã€
const apiErrors = [];
function ok(name, extra = '') {
  results.push({ pass: true, name, extra });
  console.log(`  âœ… ${name}${extra ? ' â€” ' + extra : ''}`);
}
function fail(name, extra = '') {
  results.push({ pass: false, name, extra });
  console.log(`  âŒ ${name}${extra ? ' â€” ' + extra : ''}`);
}
const isApi = (url) => url.includes('/admin-api/');
async function shot(page, name) {
  const p = path.join(SHOT_DIR, `${name}.png`);
  await page.screenshot({ path: p, fullPage: true });
  console.log(`  ðŸ“¸ ${p}`);
}
async function login(page) {
  console.log('\n[1] ç™»å½•');
  await page.goto(`${BASE}/auth/login`, { waitUntil: 'networkidle', timeout: 60000 });
  const pwd = page.locator('input[type="password"]').first();
  await pwd.waitFor({ timeout: 20000 });
  // ç”¨æˆ·åï¼šå–密码框之前最近的一个文本输入框
  const userInput = page.locator('input[placeholder="请输入用户名"]').first();
  await userInput.waitFor({ timeout: 20000 });
  await userInput.fill(USER);
  await pwd.fill(PASS);
  // ç™»å½•按钮的 aria-label="login" ä¼šè¦†ç›–内部文本成为可访问名,
  // æ•…必须用属性选择器精确命中,getByRole(name:/登录/) åŒ¹é…ä¸åˆ°ã€‚
  await page.locator('button[aria-label="login"]').first().click();
  await page.waitForURL((u) => !u.pathname.includes('/login'), { timeout: 30000 });
  ok('登录成功', await page.title());
}
async function testConfigPage(page) {
  console.log('\n[2] å®¡æ‰¹é…ç½®é¡µ');
  await page.goto(`${BASE}/system/approval-config`, { waitUntil: 'networkidle', timeout: 60000 });
  await page.waitForTimeout(2500);
  await shot(page, '01-approval-config');
  // vxe-table é»˜è®¤å¼€å¯è™šæ‹Ÿæ»šåŠ¨ï¼ŒDOM ä¸­åªä¿ç•™å¯è§†è¡Œï¼Œ
  // å› æ­¤ä¸èƒ½æŒ‰ DOM è¡Œæ•°æ–­è¨€æ€»æ•°ï¼Œéœ€è¯»åˆ†é¡µå™¨æ˜¾ç¤ºçš„ total。
  const domRows = await page.locator('.vxe-body--row').count();
  const pagerTxt = (await page.locator('.vxe-pager').first().innerText().catch(() => '')).replace(/\s+/g, ' ');
  const totalMatch = pagerTxt.match(/(\d+)\s*条/);
  const total = totalMatch ? Number(totalMatch[1]) : null;
  if (total !== null && total >= 26) ok('配置列表渲染', `分页共 ${total} æ¡ï¼ŒDOM å†…渲染 ${domRows} è¡Œï¼ˆè™šæ‹Ÿæ»šåŠ¨ï¼‰`);
  else if (domRows > 0) ok('配置列表已渲染', `DOM ${domRows} è¡Œï¼›åˆ†é¡µæ–‡æœ¬="${pagerTxt}"`);
  else fail('配置列表为空', `pager="${pagerTxt}"`);
  // é‡‡è´­è®¡åˆ’审核 åº”显示已启用(测试数据已置 enabled)
  const planRow = page.locator('.vxe-body--row', { hasText: '采购计划审核' });
  if ((await planRow.count()) === 0) {
    fail('未找到「采购计划审核」行');
    return;
  }
  const txt = (await planRow.first().innerText()).replace(/\s+/g, ' ');
  if (txt.includes('已启用')) ok('采购计划审批已启用', txt.slice(0, 90));
  else fail('采购计划审批未启用', txt.slice(0, 90));
  if (txt.includes('超级管理员')) ok('审批人已配置', '超级管理员');
  else fail('审批人未显示');
}
async function testSubmit(page) {
  console.log('\n[3] é‡‡è´­è®¡åˆ’提交审核');
  await page.goto(`${BASE}/purchase/purchase-plan`, { waitUntil: 'networkidle', timeout: 60000 });
  await page.waitForTimeout(2500);
  await shot(page, '02-purchase-plan');
  // vxe-table çš„固定右列(操作列)会渲染成独立的一组行,
  // æŒ‰é’®ä¸åœ¨æ•°æ®è¡Œå†…,必须按「行索引」与左侧数据行一一对应来断言。
  const dataRows = await page.locator('.vxe-table--main-wrapper .vxe-body--row').all();
  const actionRows = await page.locator('.vxe-table--fixed-right-wrapper .vxe-body--row').all();
  if (dataRows.length === 0 || dataRows.length !== actionRows.length) {
    fail('数据行与操作列行数无法对应', `data=${dataRows.length} action=${actionRows.length}`);
    return;
  }
  ok('数据行与操作列按索引对应', `${dataRows.length} è¡Œ`);
  let draftIdx = -1;
  let approvingIdx = -1;
  for (let i = 0; i < dataRows.length; i++) {
    const st = (await dataRows[i].innerText()).replace(/\s+/g, ' ');
    if (approvingIdx < 0 && st.includes('审批中')) approvingIdx = i;
    if (draftIdx < 0 && st.includes('未提交')) draftIdx = i;
  }
  // å®¡æ‰¹ä¸­çš„行:当前用户是配置的审批人,应出现「审核」+「审核不通过」
  if (approvingIdx >= 0) {
    const cells = (await actionRows[approvingIdx].innerText()).replace(/\s+/g, ' ').trim();
    if (cells.includes('审核不通过') && /审核(?!\s*不通过)/.test(cells.replace('审核不通过', ''))) {
      ok('审批中行出现「审核」「审核不通过」', cells);
    } else {
      fail('审批中行审核按钮缺失', cells);
    }
  } else {
    console.log('  â„¹ï¸  æ— ã€Œå®¡æ‰¹ä¸­ã€å•据,跳过该项');
  }
  // è‰ç¨¿è¡Œï¼šä¸åº”出现任何审核按钮
  if (draftIdx >= 0) {
    const cells = (await actionRows[draftIdx].innerText()).replace(/\s+/g, ' ').trim();
    if (!cells.includes('审核')) ok('草稿行不显示审核按钮', cells);
    else fail('草稿行错误显示审核按钮', cells);
  }
  if (draftIdx < 0) {
    fail('没有草稿(未提交)状态的采购计划可测');
    return;
  }
  const btn = actionRows[draftIdx].getByText('提交', { exact: true }).first();
  if ((await btn.count()) === 0) {
    fail('草稿行没有「提交」按钮', (await actionRows[draftIdx].innerText()).replace(/\s+/g, ' '));
    return;
  }
  const resp = page
    .waitForResponse((r) => isApi(r.url()) && r.url().includes('/purchase/plan/submit'), {
      timeout: 20000,
    })
    .catch(() => null);
  await btn.click();
  const res = await resp;
  if (!res) {
    fail('未捕获到 submit æŽ¥å£å“åº”');
    return;
  }
  const body = await res.json().catch(() => null);
  if (!body) fail('submit å“åº”非 JSON', `HTTP ${res.status()}`);
  else if (body.code === 0) ok('提交成功(原 500 å·²æ¶ˆé™¤ï¼‰', JSON.stringify(body).slice(0, 80));
  else fail('提交失败', JSON.stringify(body).slice(0, 160));
  await page.waitForTimeout(2000);
  await shot(page, '03-after-submit');
}
(async () => {
  if (!fs.existsSync(path.join(__dirname, 'node_modules', 'playwright'))) {
    console.error('playwright æœªå®‰è£…,请在 scripts ç›®å½•执行 npm install');
    process.exit(1);
  }
  // æœ¬æœº Playwright è‡ªå¸¦çš„ chromium-1228 æœªä¸‹è½½ï¼ˆåªæœ‰ 1140/1234/1243,版本不匹配),
  // æ•…直接使用系统安装的 Chrome,避免额外下载浏览器内核。
  const browser = await chromium.launch({ headless: true, channel: 'chrome' });
  const page = await browser.newPage({ viewport: { width: 1600, height: 950 } });
  page.on('response', async (r) => {
    if (!isApi(r.url())) return;
    try {
      const b = await r.json();
      if (b && typeof b.code === 'number' && b.code !== 0) {
        apiErrors.push({ url: r.url().replace(BASE, ''), code: b.code, msg: b.msg });
      }
    } catch {
      /* éž JSON å¿½ç•¥ */
    }
  });
  page.on('requestfailed', (r) => {
    if (isApi(r.url())) apiErrors.push({ url: r.url().replace(BASE, ''), code: 'NETWORK', msg: r.failure()?.errorText });
  });
  try {
    await login(page);
    if (STEP === 'all' || STEP === 'config') await testConfigPage(page);
    if (STEP === 'all' || STEP === 'submit') await testSubmit(page);
  } catch (e) {
    fail('执行异常', e.message);
    await shot(page, '99-crash').catch(() => {});
  }
  console.log('\n===== æ•获到的接口业务错误 =====');
  const relevant = apiErrors.filter(
    (e) => !e.url.includes('/auth/login') && !e.url.includes('simple-list') && !e.url.includes('/dict'),
  );
  if (relevant.length === 0) console.log('  ï¼ˆæ— ï¼‰');
  relevant.forEach((e) => console.log(`  ${e.url} â†’ code=${e.code} msg=${e.msg}`));
  const passed = results.filter((r) => r.pass).length;
  console.log(`\n===== æ±‡æ€»ï¼š${passed}/${results.length} é€šè¿‡ =====`);
  results.filter((r) => !r.pass).forEach((r) => console.log(`  å¤±è´¥: ${r.name} ${r.extra}`));
  await browser.close();
  process.exit(results.some((r) => !r.pass) ? 1 : 0);
})();
yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/controller/admin/purchase/ErpPurchaseOrderController.java
@@ -8,6 +8,7 @@
import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
import cn.iocoder.yudao.framework.common.util.collection.MapUtils;
import cn.iocoder.yudao.framework.excel.core.util.ExcelUtils;
import cn.iocoder.yudao.module.erp.controller.admin.purchase.vo.order.ErpPurchaseOrderAuditReqVO;
import cn.iocoder.yudao.module.erp.controller.admin.purchase.vo.order.ErpPurchaseOrderPageReqVO;
import cn.iocoder.yudao.module.erp.controller.admin.purchase.vo.order.ErpPurchaseOrderRespVO;
import cn.iocoder.yudao.module.erp.controller.admin.purchase.vo.order.ErpPurchaseOrderSaveReqVO;
@@ -38,6 +39,7 @@
import java.math.BigDecimal;
import java.util.List;
import java.util.Map;
import java.util.Set;
import static cn.iocoder.yudao.framework.apilog.core.enums.OperateTypeEnum.EXPORT;
import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success;
@@ -89,21 +91,28 @@
        return success(true);
    }
    @PostMapping("/submit")
    @Operation(summary = "提交采购订单审批")
    @PutMapping("/submit")
    @Operation(summary = "提交采购订单审批(未提交/审核不通过 â†’ å®¡æ‰¹ä¸­ï¼‰")
    @Parameter(name = "id", description = "编号", required = true)
    @PreAuthorize("@ss.hasPermission('erp:purchase-order:update-status')")
    public CommonResult<Boolean> submitPurchaseOrder(@RequestParam("id") Long id,
                                                     @RequestParam("processDefinitionKey") String processDefinitionKey) {
        Long userId = getLoginUserId();
        purchaseOrderService.submitPurchaseOrder(id, processDefinitionKey, userId);
    public CommonResult<Boolean> submitPurchaseOrder(@RequestParam("id") Long id) {
        purchaseOrderService.submitPurchaseOrder(id);
        return success(true);
    }
    @GetMapping("/approve-process-list")
    @Operation(summary = "获取采购订单审批流程列表")
    @PutMapping("/audit")
    @Operation(summary = "审核采购订单(审批中 â†’ å®¡æ ¸é€šè¿‡/审核不通过),仅审批人本人可操作")
    @PreAuthorize("@ss.hasPermission('erp:purchase-order:audit')")
    public CommonResult<Boolean> auditPurchaseOrder(@Valid @RequestBody ErpPurchaseOrderAuditReqVO auditReqVO) {
        purchaseOrderService.auditPurchaseOrder(auditReqVO.getId(), auditReqVO.getPass(), auditReqVO.getReviewRemark());
        return success(true);
    }
    @GetMapping("/approver-ids")
    @Operation(summary = "获取采购订单审批人编号集合")
    @PreAuthorize("@ss.hasPermission('erp:purchase-order:query')")
    public CommonResult<List<Map<String, Object>>> getApproveProcessList() {
        return success(purchaseOrderService.getPurchaseOrderApproveProcessDefinitionList());
    public CommonResult<Set<Long>> getPurchaseOrderApproverUserIds() {
        return success(purchaseOrderService.getPurchaseOrderApproverUserIds());
    }
    @DeleteMapping("/delete")
yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/controller/admin/purchase/ErpPurchasePlanController.java
@@ -8,6 +8,7 @@
import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
import cn.iocoder.yudao.framework.common.util.collection.MapUtils;
import cn.iocoder.yudao.framework.excel.core.util.ExcelUtils;
import cn.iocoder.yudao.module.erp.controller.admin.purchase.vo.plan.ErpPurchasePlanAuditReqVO;
import cn.iocoder.yudao.module.erp.controller.admin.purchase.vo.plan.ErpPurchasePlanPageReqVO;
import cn.iocoder.yudao.module.erp.controller.admin.purchase.vo.plan.ErpPurchasePlanRespVO;
import cn.iocoder.yudao.module.erp.controller.admin.purchase.vo.plan.ErpPurchasePlanSaveReqVO;
@@ -38,6 +39,7 @@
import java.io.IOException;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
import static cn.iocoder.yudao.framework.apilog.core.enums.OperateTypeEnum.EXPORT;
@@ -132,20 +134,28 @@
    }
    @PutMapping("/submit")
    @Operation(summary = "提交采购计划审批")
    @Operation(summary = "提交采购计划审核(草稿/审核不通过 â†’ å®¡æ‰¹ä¸­ï¼‰")
    @Parameter(name = "id", description = "编号", required = true)
    @PreAuthorize("@ss.hasPermission('erp:purchase-plan:submit')")
    public CommonResult<Boolean> submitPurchasePlan(@RequestParam("id") Long id,
                                                    @RequestParam("processDefinitionKey") String processDefinitionKey) {
        Long userId = getLoginUserId();
        purchasePlanService.submitPurchasePlan(id, processDefinitionKey, userId);
    public CommonResult<Boolean> submitPurchasePlan(@RequestParam("id") Long id) {
        purchasePlanService.submitPurchasePlan(id);
        return success(true);
    }
    @GetMapping("/approve-process-list")
    @Operation(summary = "获取采购计划审批流程列表")
    @PutMapping("/audit")
    @Operation(summary = "审核采购计划(审批中 â†’ å®¡æ ¸é€šè¿‡/审核不通过),仅审批人本人可操作")
    @PreAuthorize("@ss.hasPermission('erp:purchase-plan:audit')")
    public CommonResult<Boolean> auditPurchasePlan(@Valid @RequestBody ErpPurchasePlanAuditReqVO auditReqVO) {
        purchasePlanService.auditPurchasePlan(auditReqVO.getId(), auditReqVO.getPass(),
                auditReqVO.getReviewRemark());
        return success(true);
    }
    @GetMapping("/approver-ids")
    @Operation(summary = "获得采购计划的审批人编号集合,供前端判断是否展示审核按钮")
    @PreAuthorize("@ss.hasPermission('erp:purchase-plan:query')")
    public CommonResult<List<Map<String, Object>>> getApproveProcessList() {
        return success(purchasePlanService.getPurchasePlanApproveProcessDefinitionList());
    public CommonResult<Set<Long>> getApproverUserIds() {
        return success(purchasePlanService.getPurchasePlanApproverUserIds());
    }
    @PostMapping("/generate-from-stock-alert")
yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/controller/admin/purchase/vo/order/ErpPurchaseOrderAuditReqVO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,27 @@
package cn.iocoder.yudao.module.erp.controller.admin.purchase.vo.order;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
/**
 * ERP é‡‡è´­è®¢å•审核 Request VO
 *
 * @author è¶…级管理员
 */
@Schema(description = "管理后台 - ERP é‡‡è´­è®¢å•审核 Request VO")
@Data
public class ErpPurchaseOrderAuditReqVO {
    @Schema(description = "采购订单编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
    @NotNull(message = "采购订单编号不能为空")
    private Long id;
    @Schema(description = "是否审核通过", requiredMode = Schema.RequiredMode.REQUIRED, example = "true")
    @NotNull(message = "审核结果不能为空")
    private Boolean pass;
    @Schema(description = "审核意见(审核通过时为可选;审核不通过时为必填的原因)", example = "同意采购")
    private String reviewRemark;
}
yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/controller/admin/purchase/vo/order/ErpPurchaseOrderRespVO.java
@@ -118,6 +118,23 @@
    @Schema(description = "采购退货数量", requiredMode = Schema.RequiredMode.REQUIRED, example = "100.00")
    private BigDecimal returnCount;
    // ========== å®¡æ‰¹ä¿¡æ¯ï¼ˆè½»é‡çº§çŠ¶æ€æœºå®¡æ‰¹ï¼‰ ==========
    @Schema(description = "审核人编号", example = "1")
    private Long reviewerId;
    @Schema(description = "审核人昵称", example = "超级管理员")
    @ExcelProperty("审核人")
    private String reviewerName;
    @Schema(description = "审核时间")
    @ExcelProperty("审核时间")
    private LocalDateTime reviewTime;
    @Schema(description = "审核意见(审核不通过时为驳回原因)", example = "同意采购")
    @ExcelProperty("审核意见")
    private String reviewRemark;
    @Data
    public static class Item {
yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/controller/admin/purchase/vo/plan/ErpPurchasePlanAuditReqVO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,27 @@
package cn.iocoder.yudao.module.erp.controller.admin.purchase.vo.plan;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
/**
 * ERP é‡‡è´­è®¡åˆ’审核 Request VO
 *
 * @author è¶…级管理员
 */
@Schema(description = "管理后台 - ERP é‡‡è´­è®¡åˆ’审核 Request VO")
@Data
public class ErpPurchasePlanAuditReqVO {
    @Schema(description = "采购计划编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
    @NotNull(message = "采购计划编号不能为空")
    private Long id;
    @Schema(description = "是否审核通过", requiredMode = Schema.RequiredMode.REQUIRED, example = "true")
    @NotNull(message = "审核结果不能为空")
    private Boolean pass;
    @Schema(description = "审核意见(审核通过时为可选;审核不通过时为必填的原因)", example = "同意采购")
    private String reviewRemark;
}
yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/controller/admin/purchase/vo/plan/ErpPurchasePlanRespVO.java
@@ -37,9 +37,23 @@
    @Schema(description = "需求来源名称", example = "库存预警")
    private String demandSourceName;
    @Schema(description = "工作流编号", example = "b2c6c8f9-...")
    @Schema(description = "工作流编号(历史字段,改造后不再写入)", example = "b2c6c8f9-...")
    private String processInstanceId;
    @Schema(description = "审核人编号", example = "1")
    private Long reviewerId;
    @Schema(description = "审核人姓名", example = "李四")
    @ExcelProperty("审核人")
    private String reviewerName;
    @Schema(description = "审核时间")
    private LocalDateTime reviewTime;
    @Schema(description = "审核意见(通过时的意见 / ä¸é€šè¿‡æ—¶çš„原因)", example = "同意采购")
    @ExcelProperty("审核意见")
    private String reviewRemark;
    @Schema(description = "计划日期")
    @ExcelProperty("计划日期")
    private LocalDateTime planDate;
yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/dal/dataobject/purchase/ErpPurchaseOrderDO.java
@@ -134,7 +134,32 @@
    /**
     * BPM æµç¨‹å®žä¾‹ç¼–号
     *
     * å·²åºŸå¼ƒï¼šé‡‡è´­è®¢å•改为轻量级状态机审批后不再写入,仅保留历史数据。
     */
    @Deprecated
    private String processInstanceId;
    /**
     * å®¡æ ¸äººç¼–号(系统用户)
     *
     * ä¸ºç©ºè¡¨ç¤ºå°šæœªå®¡æ ¸ï¼›åªæœ‰è¯¥ç”¨æˆ·ï¼ˆæˆ–配置的审批人)才能执行审核操作。
     */
    private Long reviewerId;
    /**
     * å®¡æ ¸äººæ˜µç§°ï¼ˆå†—余展示用)
     */
    private String reviewerName;
    /**
     * å®¡æ ¸æ—¶é—´
     */
    private LocalDateTime reviewTime;
    /**
     * å®¡æ ¸æ„è§ï¼ˆå®¡æ ¸ä¸é€šè¿‡æ—¶å¿…填,作为驳回原因)
     */
    private String reviewRemark;
}
yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/dal/dataobject/purchase/ErpPurchasePlanDO.java
@@ -96,7 +96,29 @@
    /**
     * BPM æµç¨‹å®žä¾‹ç¼–号
     *
     * åŽ†å²å­—æ®µï¼Œæ”¹é€ ä¸ºè½»é‡çº§å®¡æ‰¹åŽä¸å†å†™å…¥ï¼Œä¿ç•™ä»¥å…¼å®¹å­˜é‡æ•°æ®ã€‚
     */
    private String processInstanceId;
    /**
     * å®¡æ ¸äººç¼–号(实际执行审核的用户)
     */
    private Long reviewerId;
    /**
     * å®¡æ ¸äººå§“名(冗余记录,避免列表查询再查用户)
     */
    private String reviewerName;
    /**
     * å®¡æ ¸æ—¶é—´
     */
    private LocalDateTime reviewTime;
    /**
     * å®¡æ ¸æ„è§ï¼ˆé€šè¿‡æ—¶çš„æ„è§ / ä¸é€šè¿‡æ—¶çš„原因)
     */
    private String reviewRemark;
}
yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/dal/mysql/purchase/ErpPurchaseOrderMapper.java
@@ -11,6 +11,7 @@
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import org.apache.ibatis.annotations.Mapper;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Objects;
@@ -70,6 +71,35 @@
                .eq(ErpPurchaseOrderDO::getId, id).eq(ErpPurchaseOrderDO::getStatus, status));
    }
    /**
     * æäº¤å®¡æ‰¹ï¼šæ›´æ–°çŠ¶æ€å¹¶æ¸…ç©ºä¸Šä¸€è½®çš„å®¡æ ¸ä¿¡æ¯
     *
     * è¯´æ˜Žï¼šå¿…须使用 LambdaUpdateWrapper æ˜¾å¼ set null,
     * å¦åˆ™ MyBatis-Plus çš„ updateById ä¼šå¿½ç•¥ null å­—段,导致驳回原因残留。
     */
    default int submitAndResetAuditInfo(Long id, Integer status) {
        return update(null, new LambdaUpdateWrapper<ErpPurchaseOrderDO>()
                .eq(ErpPurchaseOrderDO::getId, id)
                .set(ErpPurchaseOrderDO::getStatus, status)
                .set(ErpPurchaseOrderDO::getReviewerId, null)
                .set(ErpPurchaseOrderDO::getReviewerName, null)
                .set(ErpPurchaseOrderDO::getReviewTime, null)
                .set(ErpPurchaseOrderDO::getReviewRemark, null));
    }
    /**
     * å®¡æ ¸ï¼ˆé€šè¿‡ / ä¸é€šè¿‡ï¼‰ï¼šå†™å…¥å®¡æ ¸äººä¸Žå®¡æ ¸æ„è§
     */
    default int auditPurchaseOrder(Long id, Integer status, Long reviewerId, String reviewerName, String reviewRemark) {
        return update(null, new LambdaUpdateWrapper<ErpPurchaseOrderDO>()
                .eq(ErpPurchaseOrderDO::getId, id)
                .set(ErpPurchaseOrderDO::getStatus, status)
                .set(ErpPurchaseOrderDO::getReviewerId, reviewerId)
                .set(ErpPurchaseOrderDO::getReviewerName, reviewerName)
                .set(ErpPurchaseOrderDO::getReviewTime, LocalDateTime.now())
                .set(ErpPurchaseOrderDO::getReviewRemark, reviewRemark));
    }
    default ErpPurchaseOrderDO selectByNo(String no) {
        return selectOne(ErpPurchaseOrderDO::getNo, no);
    }
yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/dal/mysql/purchase/ErpPurchasePlanMapper.java
@@ -5,8 +5,10 @@
import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
import cn.iocoder.yudao.module.erp.controller.admin.purchase.vo.plan.ErpPurchasePlanPageReqVO;
import cn.iocoder.yudao.module.erp.dal.dataobject.purchase.ErpPurchasePlanDO;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import org.apache.ibatis.annotations.Mapper;
import java.time.LocalDateTime;
import java.util.Collection;
import java.util.List;
@@ -38,4 +40,47 @@
                .orderByDesc(ErpPurchasePlanDO::getId));
    }
    /**
     * æäº¤å®¡æ ¸ï¼šç½®ä¸ºå®¡æ‰¹ä¸­ï¼Œå¹¶æ¸…空上一轮审核结果
     *
     * ç”¨ UpdateWrapper è€Œéž updateById:MyBatis-Plus é»˜è®¤æ›´æ–°ç­–略会忽略 null,
     * ç›´æŽ¥ updateById æ— æ³•把「审核不通过」后残留的审核人与审核意见真正清空。
     *
     * @param id     ç¼–号
     * @param status ç›®æ ‡çŠ¶æ€ï¼ˆå®¡æ‰¹ä¸­ï¼‰
     * @return å½±å“è¡Œæ•°
     */
    default int submitAndResetAuditInfo(Long id, Integer status) {
        return update(null, new LambdaUpdateWrapper<ErpPurchasePlanDO>()
                .eq(ErpPurchasePlanDO::getId, id)
                .set(ErpPurchasePlanDO::getStatus, status)
                .set(ErpPurchasePlanDO::getReviewerId, null)
                .set(ErpPurchasePlanDO::getReviewerName, null)
                .set(ErpPurchasePlanDO::getReviewTime, null)
                .set(ErpPurchasePlanDO::getReviewRemark, null));
    }
    /**
     * å®¡æ ¸ï¼šå†™å…¥å®¡æ ¸ç»“果、审核人与审核意见
     *
     * reviewRemark å…è®¸ä¸º null(审核通过时意见可选),故同样使用 UpdateWrapper ç›´å†™ã€‚
     *
     * @param id           ç¼–号
     * @param status       ç›®æ ‡çŠ¶æ€ï¼ˆå®¡æ ¸é€šè¿‡ / å®¡æ ¸ä¸é€šè¿‡ï¼‰
     * @param reviewerId   å®¡æ ¸äººç¼–号
     * @param reviewerName å®¡æ ¸äººå§“名
     * @param reviewRemark å®¡æ ¸æ„è§
     * @return å½±å“è¡Œæ•°
     */
    default int auditPurchasePlan(Long id, Integer status, Long reviewerId,
                                  String reviewerName, String reviewRemark) {
        return update(null, new LambdaUpdateWrapper<ErpPurchasePlanDO>()
                .eq(ErpPurchasePlanDO::getId, id)
                .set(ErpPurchasePlanDO::getStatus, status)
                .set(ErpPurchasePlanDO::getReviewerId, reviewerId)
                .set(ErpPurchasePlanDO::getReviewerName, reviewerName)
                .set(ErpPurchasePlanDO::getReviewTime, LocalDateTime.now())
                .set(ErpPurchasePlanDO::getReviewRemark, reviewRemark));
    }
}
yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/enums/ErpPurchaseOrderStatusEnum.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,63 @@
package cn.iocoder.yudao.module.erp.enums;
import cn.iocoder.yudao.framework.common.core.ArrayValuable;
import lombok.AllArgsConstructor;
import lombok.Getter;
import java.util.Arrays;
/**
 * ERP é‡‡è´­è®¢å•审批状态枚举
 *
 * <p>改造说明:采购订单原先复用 {@link ErpAuditStatus} å¹¶ä¾èµ– BPM(Flowable)流程审批,
 * çŽ°æ”¹ä¸ºä¸Žã€Œäº§å“æŠ€æœ¯ç¡®è®¤å‡½ã€ä¸€è‡´çš„è½»é‡çº§å•çº§/或签状态机,故独立定义本枚举,
 * é¿å…ä¸Ž {@link ErpAuditStatus} ä¸­ CANCEL(30, å·²ä½œåºŸ) çš„取值冲突。
 *
 * <p>取值与字典 erp_audit_status å¯¹é½ï¼ˆ0 è‰ç¨¿ / 10 å®¡æ‰¹ä¸­ / 20 å®¡æ‰¹é€šè¿‡ / 30 å®¡æ‰¹ä¸é€šè¿‡ï¼‰ï¼Œ
 * å› æ­¤åˆ—表页仍可沿用 CellDict æ¸²æŸ“,无需新增字典类型。
 * APPROVE ä¿æŒä¸º 20,与既有「已审核才能入库/退货」等判断完全兼容。
 *
 * @author è¶…级管理员
 */
@Getter
@AllArgsConstructor
public enum ErpPurchaseOrderStatusEnum implements ArrayValuable<Integer> {
    DRAFT(0, "未提交"),
    PROCESS(10, "审批中"),
    APPROVE(20, "审核通过"),
    REJECT(30, "审核不通过"),
    CANCEL(40, "已取消");
    public static final Integer[] ARRAYS = Arrays.stream(values())
            .map(ErpPurchaseOrderStatusEnum::getStatus).toArray(Integer[]::new);
    public static ErpPurchaseOrderStatusEnum fromStatus(Integer status) {
        if (status == null) {
            return null;
        }
        return Arrays.stream(values()).filter(item -> item.getStatus().equals(status)).findFirst().orElse(null);
    }
    public static ErpPurchaseOrderStatusEnum fromName(String name) {
        if (name == null) {
            return null;
        }
        return Arrays.stream(values()).filter(item -> item.getName().equals(name)).findFirst().orElse(null);
    }
    /**
     * çŠ¶æ€
     */
    private final Integer status;
    /**
     * çŠ¶æ€å
     */
    private final String name;
    @Override
    public Integer[] array() {
        return ARRAYS;
    }
}
yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/enums/ErrorCodeConstants.java
@@ -30,6 +30,11 @@
    ErrorCode PURCHASE_ORDER_BPM_PROCESS_DEFINITION_NOT_EXISTS = new ErrorCode(1_030_101_012, "采购审核分类下没有可用的流程模型,请先创建并发布流程");
    ErrorCode PURCHASE_ORDER_SUBMIT_FAIL_STATUS = new ErrorCode(1_030_101_013, "提交审批失败,只有未审核状态的采购订单才能提交");
    ErrorCode PURCHASE_ORDER_ALL_IN = new ErrorCode(1_030_101_014, "采购订单已全部入库,无需确认收货");
    ErrorCode PURCHASE_ORDER_SUBMIT_FAIL_NOT_ALLOWED = new ErrorCode(1_030_101_015, "提交失败,只有【未提交】或【审核不通过】状态的采购订单才能提交");
    ErrorCode PURCHASE_ORDER_AUDIT_FAIL_NOT_PROCESS = new ErrorCode(1_030_101_016, "审核失败,只有【审批中】状态的采购订单才能审核");
    ErrorCode PURCHASE_ORDER_AUDIT_REJECT_REASON_REQUIRED = new ErrorCode(1_030_101_017, "审核不通过时必须填写驳回原因");
    ErrorCode PURCHASE_ORDER_SUBMIT_FAIL_ITEM_EMPTY = new ErrorCode(1_030_101_018, "提交失败,采购订单没有订单项,请先添加物料");
    ErrorCode PURCHASE_ORDER_UPDATE_FAIL_PROCESS = new ErrorCode(1_030_101_019, "采购订单({})正在审批中,无法修改");
    // ========== ERP é‡‡è´­å…¥åº“(1-030-102-000) ==========
    ErrorCode PURCHASE_IN_NOT_EXISTS = new ErrorCode(1_030_102_000, "采购入库单不存在");
@@ -69,6 +74,9 @@
    ErrorCode PURCHASE_PLAN_ITEM_EMPTY = new ErrorCode(1_030_104_020, "采购计划明细不能为空");
    ErrorCode PURCHASE_PLAN_STOCK_ALERT_NO_ITEM = new ErrorCode(1_030_104_022,
            "没有符合库存预警条件的物料,请检查物料是否配置默认仓库和最低库存,并确认当前库存低于最低库存");
    ErrorCode PURCHASE_PLAN_SUBMIT_FAIL_NOT_ALLOWED = new ErrorCode(1_030_104_023, "提交失败,只有草稿或审核不通过状态的采购计划才能提交");
    ErrorCode PURCHASE_PLAN_AUDIT_FAIL_NOT_PROCESS = new ErrorCode(1_030_104_024, "审核失败,只有审批中状态的采购计划才能审核");
    ErrorCode PURCHASE_PLAN_AUDIT_REJECT_REASON_REQUIRED = new ErrorCode(1_030_104_025, "审核不通过时必须填写不通过原因");
    // ========== ERP é‡‡è´­æ¥ç¥¨ï¼ˆ1-030-105-000) ==========
    ErrorCode PURCHASE_INVOICE_NOT_EXISTS = new ErrorCode(1_030_105_000, "来票不存在");
yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/service/purchase/ErpPurchaseOrderService.java
@@ -11,6 +11,7 @@
import java.util.Collection;
import java.util.List;
import java.util.Map;
import java.util.Set;
/**
 * ERP é‡‡è´­è®¢å• Service æŽ¥å£
@@ -45,18 +46,29 @@
    /**
     * æäº¤é‡‡è´­è®¢å•审批
     *
     * ä»…【未提交】或【审核不通过】状态可提交;提交后进入【审批中】,并清空上一轮审核信息。
     *
     * @param id ç¼–号
     * @param processDefinitionKey æµç¨‹å®šä¹‰ Key
     * @param userId ç”¨æˆ·ç¼–号
     */
    void submitPurchaseOrder(Long id, String processDefinitionKey, Long userId);
    void submitPurchaseOrder(Long id);
    /**
     * èŽ·å–é‡‡è´­è®¢å•å®¡æ‰¹æµç¨‹åˆ—è¡¨
     * èŽ·å–é‡‡è´­è®¢å•çš„å®¡æ‰¹äººç¼–å·é›†åˆï¼ˆæˆ–ç­¾ï¼šä»»ä¸€äººå®¡æ ¸å³å¯ï¼‰
     *
     * @return æµç¨‹å®šä¹‰åˆ—表
     * @return å®¡æ‰¹äººç”¨æˆ·ç¼–号
     */
    List<Map<String, Object>> getPurchaseOrderApproveProcessDefinitionList();
    Set<Long> getPurchaseOrderApproverUserIds();
    /**
     * å®¡æ ¸é‡‡è´­è®¢å•(通过 / ä¸é€šè¿‡ï¼‰
     *
     * ä»…配置内的审批人可执行;审核不通过时必须填写驳回原因,单据退回后可重新修改并提交。
     *
     * @param id ç¼–号
     * @param pass æ˜¯å¦å®¡æ ¸é€šè¿‡
     * @param reviewRemark å®¡æ ¸æ„è§
     */
    void auditPurchaseOrder(Long id, Boolean pass, String reviewRemark);
    /**
     * æ›´æ–°é‡‡è´­è®¢å•审批结果(BPM å›žè°ƒï¼‰
yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/service/purchase/ErpPurchaseOrderServiceImpl.java
@@ -2,16 +2,11 @@
import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.util.ObjectUtil;
import cn.hutool.core.util.StrUtil;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.framework.common.util.number.MoneyUtils;
import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
import cn.iocoder.yudao.module.bpm.api.task.BpmProcessInstanceApi;
import cn.iocoder.yudao.module.bpm.api.task.dto.BpmProcessInstanceCreateReqDTO;
import cn.iocoder.yudao.module.bpm.dal.dataobject.definition.BpmCategoryDO;
import cn.iocoder.yudao.module.bpm.dal.dataobject.definition.BpmProcessDefinitionInfoDO;
import cn.iocoder.yudao.module.bpm.enums.task.BpmTaskStatusEnum;
import cn.iocoder.yudao.module.bpm.service.definition.BpmCategoryService;
import cn.iocoder.yudao.module.bpm.service.definition.BpmProcessDefinitionService;
import cn.iocoder.yudao.module.erp.controller.admin.purchase.vo.order.ErpPurchaseOrderPageReqVO;
import cn.iocoder.yudao.module.erp.controller.admin.purchase.vo.order.ErpPurchaseOrderSaveReqVO;
import cn.iocoder.yudao.module.erp.dal.dataobject.purchase.ErpPurchaseOrderDO;
@@ -25,12 +20,15 @@
import cn.iocoder.yudao.module.erp.dal.redis.no.ErpNoRedisDAO;
import cn.iocoder.yudao.module.erp.enums.ErpAuditStatus;
import cn.iocoder.yudao.module.erp.enums.ErpPurchaseOrderInStatusEnum;
import cn.iocoder.yudao.module.erp.enums.ErpPurchaseOrderStatusEnum;
import cn.iocoder.yudao.module.erp.service.finance.ErpAccountService;
import cn.iocoder.yudao.module.mes.api.arrivalnotice.MesArrivalNoticeApi;
import cn.iocoder.yudao.module.system.api.approval.ApprovalConfigApi;
import cn.iocoder.yudao.module.system.api.storage.StorageAttachmentApi;
import cn.iocoder.yudao.module.system.api.user.AdminUserApi;
import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.flowable.engine.repository.ProcessDefinition;
import org.springframework.context.annotation.Lazy;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -41,6 +39,7 @@
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.*;
import static cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils.getLoginUserId;
import static cn.iocoder.yudao.module.erp.enums.ErrorCodeConstants.*;
/**
@@ -54,9 +53,14 @@
public class ErpPurchaseOrderServiceImpl implements ErpPurchaseOrderService {
    /**
     * BPM é‡‡è´­è®¢å•审批分类编码
     * é‡‡è´­è®¢å•审批业务类型编码(对应 system_approval_config.biz_type)
     *
     * æ³¨æ„ï¼šæ”¹é€ ä¸ºè½»é‡çº§çŠ¶æ€æœºå®¡æ‰¹å‰ï¼Œæ­¤å¤„åŽŸä¸º "purchase_request_approve",
     * ä¸Žé‡‡è´­ç”³è¯·ï¼ˆErpPurchaseRequestServiceImpl)的 BPM åˆ†ç±»ç¼–码完全相同,
     * å¯¼è‡´é‡‡è´­è®¢å•查询审批流程时会命中采购申请的流程定义,两者互相串用。
     * çŽ°æ›´æ­£ä¸ºé‡‡è´­è®¢å•ä¸“å±žç¼–ç ã€‚
     */
    private static final String PURCHASE_ORDER_APPROVE_CATEGORY_CODE = "purchase_request_approve";
    private static final String PURCHASE_ORDER_APPROVE_BIZ_TYPE = "erp_purchase_order_approve";
    @Resource
    private ErpPurchaseOrderMapper purchaseOrderMapper;
@@ -72,11 +76,9 @@
    private ErpAccountService accountService;
    @Resource
    private BpmProcessInstanceApi processInstanceApi;
    private ApprovalConfigApi approvalConfigApi;
    @Resource
    private BpmCategoryService bpmCategoryService;
    @Resource
    private BpmProcessDefinitionService bpmProcessDefinitionService;
    private AdminUserApi adminUserApi;
    @Resource
    @Lazy
@@ -107,9 +109,9 @@
            throw exception(PURCHASE_ORDER_NO_EXISTS);
        }
        // 2.1 æ’入订单(采购订单创建后为未审核状态,需要提交审批)
        // 2.1 æ’入订单(采购订单创建后为【未提交】状态,需提交进入审批流程)
        ErpPurchaseOrderDO purchaseOrder = BeanUtils.toBean(createReqVO, ErpPurchaseOrderDO.class, in -> in
                .setNo(no).setStatus(ErpAuditStatus.PROCESS.getStatus())
                .setNo(no).setStatus(ErpPurchaseOrderStatusEnum.DRAFT.getStatus())
                .setInCount(BigDecimal.ZERO).setInStatus(ErpPurchaseOrderInStatusEnum.NOT_IN.getStatus()));
        calculateTotalPrice(purchaseOrder, purchaseOrderItems);
        purchaseOrderMapper.insert(purchaseOrder);
@@ -127,9 +129,13 @@
    public void updatePurchaseOrder(ErpPurchaseOrderSaveReqVO updateReqVO) {
        // 1.1 æ ¡éªŒå­˜åœ¨
        ErpPurchaseOrderDO purchaseOrder = validatePurchaseOrderExists(updateReqVO.getId());
        // é‡‡è´­è®¢å•不允许修改(已审核状态)
        if (ErpAuditStatus.APPROVE.getStatus().equals(purchaseOrder.getStatus())) {
        // å·²å®¡æ ¸çŠ¶æ€ä¸å…è®¸ä¿®æ”¹
        if (ErpPurchaseOrderStatusEnum.APPROVE.getStatus().equals(purchaseOrder.getStatus())) {
            throw exception(PURCHASE_ORDER_UPDATE_FAIL_APPROVE, purchaseOrder.getNo());
        }
        // å®¡æ‰¹ä¸­ä¸å…è®¸ä¿®æ”¹ï¼šéœ€å…ˆç”±å®¡æ‰¹äººã€Œå®¡æ ¸ä¸é€šè¿‡ã€é€€å›žï¼Œæ‰èƒ½é‡æ–°ä¿®æ”¹å¹¶æäº¤
        if (ErpPurchaseOrderStatusEnum.PROCESS.getStatus().equals(purchaseOrder.getStatus())) {
            throw exception(PURCHASE_ORDER_UPDATE_FAIL_PROCESS, purchaseOrder.getNo());
        }
        // 1.2 æ ¡éªŒä¾›åº”商
        supplierService.validateSupplier(updateReqVO.getSupplierId());
@@ -169,6 +175,10 @@
        boolean approve = ErpAuditStatus.APPROVE.getStatus().equals(status);
        // 1.1 æ ¡éªŒå­˜åœ¨
        ErpPurchaseOrderDO purchaseOrder = validatePurchaseOrderExists(id);
        // 1.1.1 å®¡æ ¸é€šè¿‡å±žäºŽå®¡æ‰¹åŠ¨ä½œï¼Œå¿…é¡»ç”±é…ç½®å†…çš„å®¡æ‰¹äººæ‰§è¡Œï¼ˆæœåŠ¡ç«¯å¼ºåˆ¶ï¼Œé¿å…ç»•è¿‡å®¡æ ¸æŒ‰é’®è‡ªåŠ©é€šè¿‡ï¼‰
        if (approve) {
            approvalConfigApi.validateApprover(PURCHASE_ORDER_APPROVE_BIZ_TYPE, getLoginUserId());
        }
        // 1.2 æ ¡éªŒçŠ¶æ€
        if (purchaseOrder.getStatus().equals(status)) {
            throw exception(approve ? PURCHASE_ORDER_APPROVE_FAIL : PURCHASE_ORDER_PROCESS_FAIL);
@@ -194,61 +204,68 @@
    @Override
    @Transactional(rollbackFor = Exception.class)
    public void submitPurchaseOrder(Long id, String processDefinitionKey, Long userId) {
        // 1. æ ¡éªŒå­˜åœ¨ + æœªå®¡æ ¸çŠ¶æ€
    public void submitPurchaseOrder(Long id) {
        // 1.1 æ ¡éªŒå­˜åœ¨
        ErpPurchaseOrderDO purchaseOrder = validatePurchaseOrderExists(id);
        if (!ErpAuditStatus.PROCESS.getStatus().equals(purchaseOrder.getStatus())) {
            throw exception(PURCHASE_ORDER_SUBMIT_FAIL_STATUS);
        // 1.2 åªæœ‰ã€æœªæäº¤ã€‘或【审核不通过】才允许提交
        if (!ErpPurchaseOrderStatusEnum.DRAFT.getStatus().equals(purchaseOrder.getStatus())
                && !ErpPurchaseOrderStatusEnum.REJECT.getStatus().equals(purchaseOrder.getStatus())) {
            throw exception(PURCHASE_ORDER_SUBMIT_FAIL_NOT_ALLOWED);
        }
        // 1.3 å¿…须配置订单项
        List<ErpPurchaseOrderItemDO> items = purchaseOrderItemMapper.selectListByOrderId(id);
        if (CollUtil.isEmpty(items)) {
            throw exception(PURCHASE_ORDER_SUBMIT_FAIL_ITEM_EMPTY);
        }
        // 2. åˆ›å»º BPM æµç¨‹å®žä¾‹
        String processInstanceId = processInstanceApi.createProcessInstance(userId,
                new BpmProcessInstanceCreateReqDTO()
                        .setProcessDefinitionKey(processDefinitionKey)
                        .setBusinessKey(String.valueOf(id)));
        // 2. æ ¡éªŒå®¡æ‰¹å·²å¼€å¯ä¸”配置了有效审批人(未开启审批时不允许提交)
        approvalConfigApi.validateApprovalEnabledAndGetApprovers(PURCHASE_ORDER_APPROVE_BIZ_TYPE);
        // 3. æ›´æ–°é‡‡è´­è®¢å•状态为审批中
        purchaseOrderMapper.updateById(new ErpPurchaseOrderDO()
                .setId(id)
                .setStatus(ErpAuditStatus.APPROVING.getStatus())
                .setProcessInstanceId(processInstanceId));
        // 3. ç½®ä¸ºå®¡æ‰¹ä¸­ï¼Œå¹¶æ¸…空上一轮审核信息,避免驳回原因残留
        purchaseOrderMapper.submitAndResetAuditInfo(id, ErpPurchaseOrderStatusEnum.PROCESS.getStatus());
    }
    @Override
    public List<Map<String, Object>> getPurchaseOrderApproveProcessDefinitionList() {
        // 1. æ ¡éªŒåˆ†ç±»å’Œæµç¨‹å®šä¹‰æ˜¯å¦å­˜åœ¨
        validatePurchaseOrderApproveCategoryAndProcessDefinition();
    public Set<Long> getPurchaseOrderApproverUserIds() {
        return approvalConfigApi.getApproverUserIds(PURCHASE_ORDER_APPROVE_BIZ_TYPE);
    }
        // 2. èŽ·å–åˆ†ç±»ä¸‹çš„æµç¨‹å®šä¹‰ä¿¡æ¯
        List<BpmProcessDefinitionInfoDO> definitionInfoList = bpmProcessDefinitionService
                .getProcessDefinitionInfoListByCategory(PURCHASE_ORDER_APPROVE_CATEGORY_CODE);
        if (CollUtil.isEmpty(definitionInfoList)) {
            return Collections.emptyList();
    @Override
    @Transactional(rollbackFor = Exception.class)
    public void auditPurchaseOrder(Long id, Boolean pass, String reviewRemark) {
        // 1.1 æ ¡éªŒå­˜åœ¨ + å®¡æ‰¹ä¸­çŠ¶æ€
        ErpPurchaseOrderDO purchaseOrder = validatePurchaseOrderExists(id);
        if (!ErpPurchaseOrderStatusEnum.PROCESS.getStatus().equals(purchaseOrder.getStatus())) {
            throw exception(PURCHASE_ORDER_AUDIT_FAIL_NOT_PROCESS);
        }
        // 1.2 æ ¡éªŒå½“前登录人是配置的审批人(或签:任一人审核即可)
        Long userId = getLoginUserId();
        approvalConfigApi.validateApprover(PURCHASE_ORDER_APPROVE_BIZ_TYPE, userId);
        // 3. éåŽ†èŽ·å–æµç¨‹å®šä¹‰è¯¦æƒ…ï¼Œè¿‡æ»¤æ¿€æ´»çŠ¶æ€ï¼Œä¿ç•™æœ€æ–°ç‰ˆæœ¬
        Map<String, ProcessDefinition> latestVersionMap = new HashMap<>();
        for (BpmProcessDefinitionInfoDO info : definitionInfoList) {
            ProcessDefinition pd = bpmProcessDefinitionService.getProcessDefinition(info.getProcessDefinitionId());
            if (pd == null || pd.isSuspended()) {
                continue;
        // 2. å®¡æ ¸ä¸é€šè¿‡ï¼šå¿…须填写驳回原因,单据退回后可编辑并重新提交
        if (!Boolean.TRUE.equals(pass)) {
            if (StrUtil.isBlank(reviewRemark)) {
                throw exception(PURCHASE_ORDER_AUDIT_REJECT_REASON_REQUIRED);
            }
            ProcessDefinition existing = latestVersionMap.get(pd.getKey());
            if (existing == null || pd.getVersion() > existing.getVersion()) {
                latestVersionMap.put(pd.getKey(), pd);
            }
            purchaseOrderMapper.auditPurchaseOrder(id, ErpPurchaseOrderStatusEnum.REJECT.getStatus(),
                    userId, getUserNickname(userId), reviewRemark);
            return;
        }
        // 4. è¿”回流程定义列表
        List<Map<String, Object>> result = new ArrayList<>();
        for (ProcessDefinition pd : latestVersionMap.values()) {
            Map<String, Object> item = new HashMap<>();
            item.put("id", pd.getId());
            item.put("key", pd.getKey());
            item.put("name", pd.getName());
            result.add(item);
        // 3. å®¡æ ¸é€šè¿‡
        purchaseOrderMapper.auditPurchaseOrder(id, ErpPurchaseOrderStatusEnum.APPROVE.getStatus(),
                userId, getUserNickname(userId), reviewRemark);
    }
    /**
     * èŽ·å–ç”¨æˆ·æ˜µç§°ï¼Œç”¨äºŽå†—ä½™å­˜å‚¨å®¡æ ¸äººå§“å
     */
    private String getUserNickname(Long userId) {
        if (userId == null) {
            return null;
        }
        return result;
        AdminUserRespDTO user = adminUserApi.getUser(userId);
        return user == null ? null : user.getNickname();
    }
    @Override
@@ -256,7 +273,7 @@
    public void updatePurchaseOrderAuditStatus(Long id, Integer bpmResult) {
        // 1. æ ¡éªŒå­˜åœ¨
        ErpPurchaseOrderDO purchaseOrder = validatePurchaseOrderExists(id);
        if (!ErpAuditStatus.APPROVING.getStatus().equals(purchaseOrder.getStatus())) {
        if (!ErpPurchaseOrderStatusEnum.PROCESS.getStatus().equals(purchaseOrder.getStatus())) {
            log.warn("[updatePurchaseOrderAuditStatus] é‡‡è´­è®¢å•({}) ä¸å¤„于审批中状态", id);
            return;
        }
@@ -270,27 +287,18 @@
    }
    /**
     * æ ¡éªŒé‡‡è´­è®¢å•审批分类和流程定义是否存在
     */
    private void validatePurchaseOrderApproveCategoryAndProcessDefinition() {
        // 1. æ ¡éªŒåˆ†ç±»æ˜¯å¦å­˜åœ¨
        List<BpmCategoryDO> categories = bpmCategoryService.getCategoryListByCode(
                Collections.singletonList(PURCHASE_ORDER_APPROVE_CATEGORY_CODE));
        if (CollUtil.isEmpty(categories)) {
            throw exception(PURCHASE_ORDER_BPM_CATEGORY_NOT_EXISTS);
        }
    }
    /**
     * è½¬æ¢ BPM å®¡æ‰¹ç»“果为审核状态
     *
     * å·²åºŸå¼ƒï¼šé‡‡è´­è®¢å•不再走 BPM æµç¨‹ï¼Œæœ¬æ–¹æ³•仅为历史代码保留,不再被调用。
     */
    @Deprecated
    private Integer convertBpmResultToAuditStatus(Integer bpmResult) {
        if (BpmTaskStatusEnum.APPROVE.getStatus().equals(bpmResult)) {
            return ErpAuditStatus.APPROVE.getStatus();
            return ErpPurchaseOrderStatusEnum.APPROVE.getStatus();
        } else if (BpmTaskStatusEnum.REJECT.getStatus().equals(bpmResult)) {
            return ErpAuditStatus.PROCESS.getStatus();
            return ErpPurchaseOrderStatusEnum.REJECT.getStatus();
        } else if (BpmTaskStatusEnum.CANCEL.getStatus().equals(bpmResult)) {
            return ErpAuditStatus.CANCEL.getStatus();
            return ErpPurchaseOrderStatusEnum.CANCEL.getStatus();
        }
        return null;
    }
yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/service/purchase/ErpPurchasePlanService.java
@@ -9,7 +9,7 @@
import java.util.Collection;
import java.util.List;
import java.util.Map;
import java.util.Set;
/**
 * ERP é‡‡è´­è®¡åˆ’ Service æŽ¥å£
@@ -73,24 +73,41 @@
    PageResult<ErpPurchasePlanDO> getPurchasePlanPage(ErpPurchasePlanPageReqVO pageReqVO);
    /**
     * æäº¤é‡‡è´­è®¡åˆ’审批
     * æäº¤é‡‡è´­è®¡åˆ’审核
     *
     * @param id                   ç¼–号
     * @param processDefinitionKey BPM æµç¨‹å®šä¹‰ Key
     * @param userId               ç”¨æˆ·ç¼–号
     * æäº¤å‰æ ¡éªŒé€šç”¨å®¡æ‰¹é…ç½®ï¼ˆbizType = purchase_plan_approve)已启用且配置了有效审批人;
     * æ ¡éªŒé€šè¿‡åŽçŠ¶æ€ç½®ä¸ºã€Œå®¡æ‰¹ä¸­ã€ã€‚è‰ç¨¿ä¸Žå®¡æ ¸ä¸é€šè¿‡çŠ¶æ€å‡å¯æäº¤ã€‚
     *
     * @param id ç¼–号
     */
    void submitPurchasePlan(Long id, String processDefinitionKey, Long userId);
    void submitPurchasePlan(Long id);
    /**
     * èŽ·å–é‡‡è´­è®¡åˆ’å®¡æ‰¹æµç¨‹åˆ—è¡¨
     * èŽ·å¾—é‡‡è´­è®¡åˆ’çš„å®¡æ‰¹äººç”¨æˆ·ç¼–å·é›†åˆ
     *
     * @return æµç¨‹å®šä¹‰åˆ—表
     * ä¾›å‰ç«¯åˆ—表页判断是否展示「审核」按钮,未配置时返回空集合。
     *
     * @return å®¡æ‰¹äººç”¨æˆ·ç¼–号集合
     */
    List<Map<String, Object>> getPurchasePlanApproveProcessDefinitionList();
    Set<Long> getPurchasePlanApproverUserIds();
    /**
     * å®¡æ ¸é‡‡è´­è®¡åˆ’(审批中 â†’ å®¡æ ¸é€šè¿‡ / å®¡æ ¸ä¸é€šè¿‡ï¼‰
     *
     * ä»…通用审批配置中指定的审批人本人可调用;审核不通过时须填写原因。
     * å®¡æ ¸é€šè¿‡åŽå°†è‡ªåŠ¨ç”Ÿæˆé‡‡è´­ç”³è¯·ã€‚
     *
     * @param id           é‡‡è´­è®¡åˆ’编号
     * @param pass         æ˜¯å¦é€šè¿‡
     * @param reviewRemark å®¡æ ¸æ„è§ï¼ˆä¸é€šè¿‡æ—¶ä¸ºå¿…填的原因)
     */
    void auditPurchasePlan(Long id, Boolean pass, String reviewRemark);
    /**
     * BPM å®¡æ‰¹ç»“果回调更新采购计划状态
     *
     * åŽ†å²å…¼å®¹æŽ¥å£ï¼šæ”¹é€ ä¸ºè½»é‡çº§å®¡æ‰¹åŽä¸å†ç”± BPM äº‹ä»¶è§¦å‘,保留以兼容既有监听器。
     *
     * @param id        é‡‡è´­è®¡åˆ’编号
     * @param bpmResult BPM å®¡æ‰¹ç»“æžœ
     */
yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/service/purchase/ErpPurchasePlanServiceImpl.java
@@ -1,14 +1,10 @@
package cn.iocoder.yudao.module.erp.service.purchase;
import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.util.StrUtil;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
import cn.iocoder.yudao.module.bpm.api.task.BpmProcessInstanceApi;
import cn.iocoder.yudao.module.bpm.api.task.dto.BpmProcessInstanceCreateReqDTO;
import cn.iocoder.yudao.module.bpm.dal.dataobject.definition.BpmProcessDefinitionInfoDO;
import cn.iocoder.yudao.module.bpm.enums.task.BpmTaskStatusEnum;
import cn.iocoder.yudao.module.bpm.service.definition.BpmCategoryService;
import cn.iocoder.yudao.module.bpm.service.definition.BpmProcessDefinitionService;
import cn.iocoder.yudao.module.erp.controller.admin.purchase.vo.plan.ErpPurchasePlanPageReqVO;
import cn.iocoder.yudao.module.erp.controller.admin.purchase.vo.plan.ErpPurchasePlanRespVO;
import cn.iocoder.yudao.module.erp.controller.admin.purchase.vo.plan.ErpPurchasePlanSaveReqVO;
@@ -26,11 +22,11 @@
import cn.iocoder.yudao.module.mdm.api.item.MdmItemApi;
import cn.iocoder.yudao.module.mdm.api.item.dto.MdmItemRespDTO;
import cn.iocoder.yudao.module.mes.api.wms.MesWmStockApi;
import cn.iocoder.yudao.module.system.api.approval.ApprovalConfigApi;
import cn.iocoder.yudao.module.system.api.user.AdminUserApi;
import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.flowable.engine.repository.ProcessDefinition;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.validation.annotation.Validated;
@@ -42,6 +38,7 @@
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertList;
import static cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils.getLoginUserId;
import static cn.iocoder.yudao.module.erp.enums.ErrorCodeConstants.*;
/**
@@ -55,9 +52,9 @@
public class ErpPurchasePlanServiceImpl implements ErpPurchasePlanService {
    /**
     * BPM é‡‡è´­è®¡åˆ’审批分类编码
     * é€šç”¨å®¡æ‰¹é…ç½®çš„业务类型编码(对应 system_approval_config.biz_type)
     */
    private static final String PURCHASE_PLAN_APPROVE_CATEGORY_CODE = "purchase_plan_approve";
    private static final String PURCHASE_PLAN_APPROVE_BIZ_TYPE = "purchase_plan_approve";
    /**
     * éœ€æ±‚来源:库存预警
@@ -82,11 +79,7 @@
    private ErpNoRedisDAO noRedisDAO;
    @Resource
    private BpmProcessInstanceApi processInstanceApi;
    @Resource
    private BpmCategoryService bpmCategoryService;
    @Resource
    private BpmProcessDefinitionService bpmProcessDefinitionService;
    private ApprovalConfigApi approvalConfigApi;
    @Resource
    private AdminUserApi adminUserApi;
@@ -195,58 +188,77 @@
    @Override
    @Transactional(rollbackFor = Exception.class)
    public void submitPurchasePlan(Long id, String processDefinitionKey, Long userId) {
        // 1. æ ¡éªŒå­˜åœ¨ + è‰ç¨¿çŠ¶æ€
        ErpPurchasePlanDO plan = validatePurchasePlanCanModify(id);
    public void submitPurchasePlan(Long id) {
        // 1. æ ¡éªŒå­˜åœ¨ + å¯æäº¤çŠ¶æ€ï¼ˆè‰ç¨¿ / å®¡æ ¸ä¸é€šè¿‡ï¼‰
        ErpPurchasePlanDO plan = validatePurchasePlanExists(id);
        if (!ErpPurchasePlanStatusEnum.DRAFT.getStatus().equals(plan.getStatus())
                && !ErpPurchasePlanStatusEnum.REJECT.getStatus().equals(plan.getStatus())) {
            throw exception(PURCHASE_PLAN_SUBMIT_FAIL_NOT_ALLOWED);
        }
        // 2. æ ¡éªŒæ˜¯å¦æœ‰æ˜Žç»†
        List<ErpPurchasePlanItemDO> items = purchasePlanItemMapper.selectListByPlanId(id);
        if (CollUtil.isEmpty(items)) {
            throw exception(PURCHASE_PLAN_ITEM_EMPTY);
        }
        // 3. åˆ›å»º BPM æµç¨‹å®žä¾‹
        String processInstanceId = processInstanceApi.createProcessInstance(userId,
                new BpmProcessInstanceCreateReqDTO()
                        .setProcessDefinitionKey(processDefinitionKey)
                        .setBusinessKey(String.valueOf(id)));
        // 4. æ›´æ–°é‡‡è´­è®¡åˆ’状态为审批中
        purchasePlanMapper.updateById(new ErpPurchasePlanDO()
                .setId(id)
                .setStatus(ErpPurchasePlanStatusEnum.PROCESS.getStatus())
                .setProcessInstanceId(processInstanceId));
        // 3. æ ¡éªŒå®¡æ‰¹é…ç½®å·²å¯ç”¨ä¸”配置了有效审批人(未配置时抛出带明确提示的业务异常)
        approvalConfigApi.validateApprovalEnabledAndGetApprovers(PURCHASE_PLAN_APPROVE_BIZ_TYPE);
        // 4. çŠ¶æ€ç½®ä¸ºå®¡æ‰¹ä¸­ï¼Œå¹¶æ¸…ç©ºä¸Šä¸€è½®å®¡æ ¸ç»“æžœï¼Œé¿å…æ®‹ç•™ã€Œå®¡æ ¸ä¸é€šè¿‡ã€çš„åŽŸå› 
        purchasePlanMapper.submitAndResetAuditInfo(id, ErpPurchasePlanStatusEnum.PROCESS.getStatus());
    }
    @Override
    public List<Map<String, Object>> getPurchasePlanApproveProcessDefinitionList() {
        // 1. æ ¡éªŒåˆ†ç±»å’Œæµç¨‹å®šä¹‰æ˜¯å¦å­˜åœ¨
        validatePurchasePlanApproveCategoryAndProcessDefinition();
        // 2. èŽ·å–åˆ†ç±»ä¸‹çš„æµç¨‹å®šä¹‰ä¿¡æ¯
        List<BpmProcessDefinitionInfoDO> definitionInfoList = bpmProcessDefinitionService
                .getProcessDefinitionInfoListByCategory(PURCHASE_PLAN_APPROVE_CATEGORY_CODE);
        if (CollUtil.isEmpty(definitionInfoList)) {
            return Collections.emptyList();
    public Set<Long> getPurchasePlanApproverUserIds() {
        return approvalConfigApi.getApproverUserIds(PURCHASE_PLAN_APPROVE_BIZ_TYPE);
    }
    @Override
    @Transactional(rollbackFor = Exception.class)
    public void auditPurchasePlan(Long id, Boolean pass, String reviewRemark) {
        // 1. æ ¡éªŒå­˜åœ¨
        ErpPurchasePlanDO plan = validatePurchasePlanExists(id);
        // 2. æ ¡éªŒå¤„于审批中状态
        if (!ErpPurchasePlanStatusEnum.PROCESS.getStatus().equals(plan.getStatus())) {
            throw exception(PURCHASE_PLAN_AUDIT_FAIL_NOT_PROCESS);
        }
        // 3. éåŽ†èŽ·å–æµç¨‹å®šä¹‰è¯¦æƒ…ï¼Œè¿‡æ»¤æ¿€æ´»çŠ¶æ€ï¼Œä¿ç•™æœ€æ–°ç‰ˆæœ¬
        Map<String, ProcessDefinition> latestVersionMap = new HashMap<>();
        for (BpmProcessDefinitionInfoDO info : definitionInfoList) {
            ProcessDefinition pd = bpmProcessDefinitionService.getProcessDefinition(info.getProcessDefinitionId());
            if (pd == null || pd.isSuspended()) {
                continue;
        // 3. æ ¡éªŒå½“前登录用户是该业务类型的审批人(或签:任一人均可审核)
        Long userId = getLoginUserId();
        approvalConfigApi.validateApprover(PURCHASE_PLAN_APPROVE_BIZ_TYPE, userId);
        // 4. å®¡æ ¸ä¸é€šè¿‡ï¼šå¿…须填写原因,状态置为审核不通过,退回编制人修改后可重新提交
        if (!Boolean.TRUE.equals(pass)) {
            if (StrUtil.isBlank(reviewRemark)) {
                throw exception(PURCHASE_PLAN_AUDIT_REJECT_REASON_REQUIRED);
            }
            ProcessDefinition existing = latestVersionMap.get(pd.getKey());
            if (existing == null || pd.getVersion() > existing.getVersion()) {
                latestVersionMap.put(pd.getKey(), pd);
            purchasePlanMapper.auditPurchasePlan(id, ErpPurchasePlanStatusEnum.REJECT.getStatus(),
                    userId, getUserNickname(userId), reviewRemark);
            return;
        }
        // 5. å®¡æ ¸é€šè¿‡ï¼šè®°å½•审核人与审核意见,状态置为审核通过
        purchasePlanMapper.auditPurchasePlan(id, ErpPurchasePlanStatusEnum.APPROVE.getStatus(),
                userId, getUserNickname(userId), reviewRemark);
        // 6. å®¡æ ¸é€šè¿‡ä¸”未生成申请,自动生成采购申请
        if (!Boolean.TRUE.equals(plan.getGeneratedFlag())) {
            try {
                List<Long> requestIds = doGeneratePurchaseRequests(plan);
                log.info("[auditPurchasePlan] é‡‡è´­è®¡åˆ’({}) è‡ªåŠ¨ç”Ÿæˆé‡‡è´­ç”³è¯·({})", id, requestIds);
            } catch (Exception e) {
                // ä¸æŠ›å‡ºå¼‚常,避免影响审核结果落库;生成失败可由用户手动触发
                log.error("[auditPurchasePlan] é‡‡è´­è®¡åˆ’({}) è‡ªåŠ¨ç”Ÿæˆé‡‡è´­ç”³è¯·å¤±è´¥", id, e);
            }
        }
        // 4. è¿”回流程定义列表
        List<Map<String, Object>> result = new ArrayList<>();
        for (ProcessDefinition pd : latestVersionMap.values()) {
            Map<String, Object> item = new HashMap<>();
            item.put("id", pd.getId());
            item.put("key", pd.getKey());
            item.put("name", pd.getName());
            result.add(item);
    }
    /**
     * èŽ·å–ç”¨æˆ·æ˜µç§°
     *
     * @param userId ç”¨æˆ·ç¼–号
     * @return æ˜µç§°ï¼Œç”¨æˆ·ä¸å­˜åœ¨æ—¶è¿”回 null
     */
    private String getUserNickname(Long userId) {
        if (userId == null) {
            return null;
        }
        return result;
        AdminUserRespDTO user = adminUserApi.getUser(userId);
        return user == null ? null : user.getNickname();
    }
    @Override
@@ -437,19 +449,6 @@
            return ErpPurchasePlanStatusEnum.CANCEL.getStatus();
        }
        return null;
    }
    private void validatePurchasePlanApproveCategoryAndProcessDefinition() {
        List<cn.iocoder.yudao.module.bpm.dal.dataobject.definition.BpmCategoryDO> categories =
                bpmCategoryService.getCategoryListByCode(Collections.singletonList(PURCHASE_PLAN_APPROVE_CATEGORY_CODE));
        if (CollUtil.isEmpty(categories)) {
            throw exception(PURCHASE_PLAN_BPM_CATEGORY_NOT_EXISTS);
        }
        List<BpmProcessDefinitionInfoDO> definitionInfoList = bpmProcessDefinitionService
                .getProcessDefinitionInfoListByCategory(PURCHASE_PLAN_APPROVE_CATEGORY_CODE);
        if (CollUtil.isEmpty(definitionInfoList)) {
            throw exception(PURCHASE_PLAN_BPM_PROCESS_DEFINITION_NOT_EXISTS);
        }
    }
    private ErpPurchasePlanDO validatePurchasePlanExists(Long id) {
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/api/approval/ApprovalConfigApi.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,57 @@
package cn.iocoder.yudao.module.system.api.approval;
import java.util.Set;
/**
 * é€šç”¨å®¡æ‰¹é…ç½® API æŽ¥å£
 *
 * ä¾›å„业务模块(ERP / CRM / HRM / MES / å”®åŽï¼‰åœ¨æäº¤å®¡æ‰¹ã€æ‰§è¡Œå®¡æ ¸æ—¶è°ƒç”¨ï¼Œ
 * æ›¿ä»£åŽŸå…ˆä¾èµ– BPM(Flowable)流程定义的方式。
 *
 * @author è¶…级管理员
 */
public interface ApprovalConfigApi {
    /**
     * æ ¡éªŒä¸šåŠ¡ç±»åž‹å·²å¯ç”¨å®¡æ‰¹ä¸”é…ç½®äº†æœ‰æ•ˆå®¡æ‰¹äººï¼Œå¹¶è¿”å›žå®¡æ‰¹äººç¼–å·é›†åˆ
     *
     * ä¸šåŠ¡æ¨¡å—åœ¨ã€Œæäº¤å®¡æ ¸ã€æ—¶è°ƒç”¨ã€‚æ ¡éªŒå¤±è´¥æ—¶æŠ›å‡ºä¸šåŠ¡å¼‚å¸¸ï¼š
     * æœªé…ç½®å®¡æ‰¹ / æœªå¯ç”¨å®¡æ‰¹ / æœªé…ç½®å®¡æ‰¹äººã€‚
     *
     * @param bizType ä¸šåŠ¡ç±»åž‹ç¼–ç 
     * @return å®¡æ‰¹äººç”¨æˆ·ç¼–号集合
     */
    Set<Long> validateApprovalEnabledAndGetApprovers(String bizType);
    /**
     * æ ¡éªŒæŒ‡å®šç”¨æˆ·æ˜¯å¦ä¸ºè¯¥ä¸šåŠ¡ç±»åž‹çš„å®¡æ‰¹äºº
     *
     * ä¸šåŠ¡æ¨¡å—åœ¨ã€Œæ‰§è¡Œå®¡æ ¸ã€æ—¶è°ƒç”¨ï¼Œéžå®¡æ‰¹äººå°†æŠ›å‡ºä¸šåŠ¡å¼‚å¸¸ã€‚
     *
     * @param bizType ä¸šåŠ¡ç±»åž‹ç¼–ç 
     * @param userId  ç”¨æˆ·ç¼–号
     */
    void validateApprover(String bizType, Long userId);
    /**
     * èŽ·å¾—æŒ‡å®šä¸šåŠ¡ç±»åž‹çš„å®¡æ‰¹äººç¼–å·é›†åˆ
     *
     * ç”¨äºŽåˆ—表页判断是否展示审核按钮,未配置时返回空集合。
     *
     * @param bizType ä¸šåŠ¡ç±»åž‹ç¼–ç 
     * @return å®¡æ‰¹äººç”¨æˆ·ç¼–号集合
     */
    Set<Long> getApproverUserIds(String bizType);
    /**
     * åˆ¤æ–­æŒ‡å®šç”¨æˆ·æ˜¯å¦ä¸ºè¯¥ä¸šåŠ¡ç±»åž‹çš„å®¡æ‰¹äºº
     *
     * @param bizType ä¸šåŠ¡ç±»åž‹ç¼–ç 
     * @param userId  ç”¨æˆ·ç¼–号
     * @return æ˜¯å¦ä¸ºå®¡æ‰¹äºº
     */
    default boolean isApprover(String bizType, Long userId) {
        return userId != null && getApproverUserIds(bizType).contains(userId);
    }
}
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/api/approval/ApprovalConfigApiImpl.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,35 @@
package cn.iocoder.yudao.module.system.api.approval;
import cn.iocoder.yudao.module.system.service.approval.ApprovalConfigService;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Service;
import java.util.Set;
/**
 * é€šç”¨å®¡æ‰¹é…ç½® API å®žçŽ°ç±»
 *
 * @author è¶…级管理员
 */
@Service
public class ApprovalConfigApiImpl implements ApprovalConfigApi {
    @Resource
    private ApprovalConfigService approvalConfigService;
    @Override
    public Set<Long> validateApprovalEnabledAndGetApprovers(String bizType) {
        return approvalConfigService.validateApprovalEnabledAndGetApprovers(bizType);
    }
    @Override
    public void validateApprover(String bizType, Long userId) {
        approvalConfigService.validateApprover(bizType, userId);
    }
    @Override
    public Set<Long> getApproverUserIds(String bizType) {
        return approvalConfigService.getApproverUserIds(bizType);
    }
}
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/controller/admin/approval/ApprovalConfigController.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,90 @@
package cn.iocoder.yudao.module.system.controller.admin.approval;
import cn.iocoder.yudao.framework.common.pojo.CommonResult;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
import cn.iocoder.yudao.module.system.controller.admin.approval.vo.ApprovalConfigPageReqVO;
import cn.iocoder.yudao.module.system.controller.admin.approval.vo.ApprovalConfigRespVO;
import cn.iocoder.yudao.module.system.controller.admin.approval.vo.ApprovalConfigSaveReqVO;
import cn.iocoder.yudao.module.system.dal.dataobject.approval.ApprovalConfigDO;
import cn.iocoder.yudao.module.system.service.approval.ApprovalConfigService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import java.util.ArrayList;
import java.util.Set;
import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success;
import static cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils.getLoginUserId;
@Tag(name = "管理后台 - é€šç”¨å®¡æ‰¹é…ç½®")
@RestController
@RequestMapping("/system/approval-config")
@Validated
public class ApprovalConfigController {
    @Resource
    private ApprovalConfigService approvalConfigService;
    @PostMapping("/save")
    @Operation(summary = "保存审批配置(新增或修改),审批人整体覆盖")
    @PreAuthorize("@ss.hasPermission('system:approval-config:update')")
    public CommonResult<Long> saveApprovalConfig(@Valid @RequestBody ApprovalConfigSaveReqVO saveReqVO) {
        return success(approvalConfigService.saveApprovalConfig(saveReqVO));
    }
    @DeleteMapping("/delete")
    @Operation(summary = "删除审批配置")
    @Parameter(name = "id", description = "编号", required = true)
    @PreAuthorize("@ss.hasPermission('system:approval-config:update')")
    public CommonResult<Boolean> deleteApprovalConfig(@RequestParam("id") Long id) {
        approvalConfigService.deleteApprovalConfig(id);
        return success(true);
    }
    @GetMapping("/get")
    @Operation(summary = "获得审批配置")
    @Parameter(name = "id", description = "编号", required = true, example = "1")
    @PreAuthorize("@ss.hasPermission('system:approval-config:query')")
    public CommonResult<ApprovalConfigRespVO> getApprovalConfig(@RequestParam("id") Long id) {
        ApprovalConfigDO config = approvalConfigService.getApprovalConfig(id);
        if (config == null) {
            return success(null);
        }
        ApprovalConfigRespVO vo = BeanUtils.toBean(config, ApprovalConfigRespVO.class);
        vo.setUserIds(new ArrayList<>(approvalConfigService.getApproverUserIds(config.getBizType())));
        return success(vo);
    }
    @GetMapping("/page")
    @Operation(summary = "获得审批配置分页")
    @PreAuthorize("@ss.hasPermission('system:approval-config:query')")
    public CommonResult<PageResult<ApprovalConfigRespVO>> getApprovalConfigPage(
            @Valid ApprovalConfigPageReqVO pageReqVO) {
        return success(approvalConfigService.getApprovalConfigPage(pageReqVO));
    }
    @GetMapping("/approver-ids")
    @Operation(summary = "获得指定业务类型的审批人编号集合")
    @Parameter(name = "bizType", description = "业务类型编码", required = true, example = "purchase_plan_approve")
    @PreAuthorize("@ss.hasPermission('system:approval-config:query')")
    public CommonResult<Set<Long>> getApproverUserIds(@RequestParam("bizType") String bizType) {
        return success(approvalConfigService.getApproverUserIds(bizType));
    }
    @GetMapping("/is-approver")
    @Operation(summary = "判断当前登录用户是否为指定业务类型的审批人")
    @Parameter(name = "bizType", description = "业务类型编码", required = true, example = "purchase_plan_approve")
    @PreAuthorize("@ss.hasPermission('system:approval-config:query')")
    public CommonResult<Boolean> isApprover(@RequestParam("bizType") String bizType) {
        Set<Long> approverIds = approvalConfigService.getApproverUserIds(bizType);
        return success(approverIds.contains(getLoginUserId()));
    }
}
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/controller/admin/approval/vo/ApprovalConfigPageReqVO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,24 @@
package cn.iocoder.yudao.module.system.controller.admin.approval.vo;
import cn.iocoder.yudao.framework.common.pojo.PageParam;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.ToString;
@Schema(description = "管理后台 - å®¡æ‰¹é…ç½®åˆ†é¡µ Request VO")
@Data
@EqualsAndHashCode(callSuper = true)
@ToString(callSuper = true)
public class ApprovalConfigPageReqVO extends PageParam {
    @Schema(description = "业务类型名称", example = "采购计划")
    private String bizName;
    @Schema(description = "业务类型编码", example = "purchase_plan_approve")
    private String bizType;
    @Schema(description = "是否启用审批", example = "true")
    private Boolean approvalEnabled;
}
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/controller/admin/approval/vo/ApprovalConfigRespVO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,37 @@
package cn.iocoder.yudao.module.system.controller.admin.approval.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.time.LocalDateTime;
import java.util.List;
@Schema(description = "管理后台 - å®¡æ‰¹é…ç½® Response VO")
@Data
public class ApprovalConfigRespVO {
    @Schema(description = "主键ID", example = "1")
    private Long id;
    @Schema(description = "业务类型编码", example = "purchase_plan_approve")
    private String bizType;
    @Schema(description = "业务类型名称", example = "采购计划审核")
    private String bizName;
    @Schema(description = "是否启用审批", example = "true")
    private Boolean approvalEnabled;
    @Schema(description = "审批人用户ID列表", example = "[1,2]")
    private List<Long> userIds;
    @Schema(description = "审批人姓名列表(冗余展示,避免前端再查用户)", example = "[\"张三\",\"李四\"]")
    private List<String> userNames;
    @Schema(description = "备注", example = "由采购部经理审核")
    private String remark;
    @Schema(description = "创建时间")
    private LocalDateTime createTime;
}
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/controller/admin/approval/vo/ApprovalConfigSaveReqVO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,35 @@
package cn.iocoder.yudao.module.system.controller.admin.approval.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
import java.util.List;
@Schema(description = "管理后台 - å®¡æ‰¹é…ç½®ä¿å­˜ Request VO")
@Data
public class ApprovalConfigSaveReqVO {
    @Schema(description = "主键ID,新增时为空", example = "1")
    private Long id;
    @Schema(description = "业务类型编码", requiredMode = Schema.RequiredMode.REQUIRED, example = "purchase_plan_approve")
    @NotEmpty(message = "业务类型编码不能为空")
    private String bizType;
    @Schema(description = "业务类型名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "采购计划审核")
    @NotEmpty(message = "业务类型名称不能为空")
    private String bizName;
    @Schema(description = "是否启用审批", requiredMode = Schema.RequiredMode.REQUIRED, example = "true")
    @NotNull(message = "是否启用审批不能为空")
    private Boolean approvalEnabled;
    @Schema(description = "审批人用户ID列表,多个之间为或签(任意一人通过即可)", example = "[1,2]")
    private List<Long> userIds;
    @Schema(description = "备注", example = "由采购部经理审核")
    private String remark;
}
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/dal/dataobject/approval/ApprovalConfigDO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,49 @@
package cn.iocoder.yudao.module.system.dal.dataobject.approval;
import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO;
import com.baomidou.mybatisplus.annotation.KeySequence;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.*;
/**
 * é€šç”¨å®¡æ‰¹é…ç½® DO
 *
 * ä¸€ä¸ªä¸šåŠ¡ç±»åž‹å¯¹åº”ä¸€æ¡é…ç½®ï¼›å®¡æ‰¹äººå­˜æ”¾åœ¨ {@link ApprovalConfigUserDO} å­è¡¨ã€‚
 * å®¡æ‰¹äººé…ç½®å¤šä¸ªæ—¶æŒ‰ã€æˆ–签】处理,即任意一人审核通过即推进业务状态。
 *
 * @author è¶…级管理员
 */
@TableName("system_approval_config")
@KeySequence("system_approval_config_seq")
@Data
@EqualsAndHashCode(callSuper = true)
@ToString(callSuper = true)
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class ApprovalConfigDO extends BaseDO {
    /**
     * ä¸»é”®ID
     */
    @TableId
    private Long id;
    /**
     * ä¸šåŠ¡ç±»åž‹ç¼–ç ï¼Œä¸Žå„æ¨¡å—å®¡æ‰¹åˆ†ç±»ç¼–ç ä¸€è‡´
     */
    private String bizType;
    /**
     * ä¸šåŠ¡ç±»åž‹åç§°
     */
    private String bizName;
    /**
     * æ˜¯å¦å¯ç”¨å®¡æ‰¹
     */
    private Boolean approvalEnabled;
    /**
     * å¤‡æ³¨
     */
    private String remark;
}
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/dal/dataobject/approval/ApprovalConfigUserDO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,44 @@
package cn.iocoder.yudao.module.system.dal.dataobject.approval;
import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO;
import com.baomidou.mybatisplus.annotation.KeySequence;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.*;
/**
 * é€šç”¨å®¡æ‰¹é…ç½®-审批人 DO
 *
 * ä¸€ä¸ªä¸šåŠ¡ç±»åž‹å¯é…ç½®å¤šä¸ªå®¡æ‰¹äººï¼Œå¤šä¸ªå®¡æ‰¹äººä¹‹é—´ä¸ºã€æˆ–ç­¾ã€‘å…³ç³»ã€‚
 *
 * @author è¶…级管理员
 */
@TableName("system_approval_config_user")
@KeySequence("system_approval_config_user_seq")
@Data
@EqualsAndHashCode(callSuper = true)
@ToString(callSuper = true)
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class ApprovalConfigUserDO extends BaseDO {
    /**
     * ä¸»é”®ID
     */
    @TableId
    private Long id;
    /**
     * å®¡æ‰¹é…ç½®ID(关联 {@link ApprovalConfigDO#getId()})
     */
    private Long configId;
    /**
     * å®¡æ‰¹äººç”¨æˆ·ID(系统用户)
     */
    private Long userId;
    /**
     * æ˜¾ç¤ºé¡ºåº
     */
    private Integer sort;
}
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/dal/mysql/approval/SystemApprovalConfigMapper.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,33 @@
package cn.iocoder.yudao.module.system.dal.mysql.approval;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX;
import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
import cn.iocoder.yudao.module.system.controller.admin.approval.vo.ApprovalConfigPageReqVO;
import cn.iocoder.yudao.module.system.dal.dataobject.approval.ApprovalConfigDO;
import org.apache.ibatis.annotations.Mapper;
/**
 * é€šç”¨å®¡æ‰¹é…ç½® Mapper
 *
 * ç±»åå¸¦ System å‰ç¼€ï¼Œé¿å…ä¸Ž MES çš„ WmsWarehouseApprovalConfigMapper åœ¨
 * Spring æŒ‰å­—段名(byName)注入时产生命名冲突。
 *
 * @author è¶…级管理员
 */
@Mapper
public interface SystemApprovalConfigMapper extends BaseMapperX<ApprovalConfigDO> {
    default PageResult<ApprovalConfigDO> selectPage(ApprovalConfigPageReqVO reqVO) {
        return selectPage(reqVO, new LambdaQueryWrapperX<ApprovalConfigDO>()
                .likeIfPresent(ApprovalConfigDO::getBizName, reqVO.getBizName())
                .eqIfPresent(ApprovalConfigDO::getBizType, reqVO.getBizType())
                .eqIfPresent(ApprovalConfigDO::getApprovalEnabled, reqVO.getApprovalEnabled())
                .orderByAsc(ApprovalConfigDO::getId));
    }
    default ApprovalConfigDO selectByBizType(String bizType) {
        return selectOne(ApprovalConfigDO::getBizType, bizType);
    }
}
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/dal/mysql/approval/SystemApprovalConfigUserMapper.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,47 @@
package cn.iocoder.yudao.module.system.dal.mysql.approval;
import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX;
import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
import cn.iocoder.yudao.module.system.dal.dataobject.approval.ApprovalConfigUserDO;
import org.apache.ibatis.annotations.Mapper;
import java.util.Collection;
import java.util.List;
/**
 * é€šç”¨å®¡æ‰¹é…ç½®-审批人 Mapper
 *
 * ç±»åå¸¦ System å‰ç¼€ï¼Œé¿å… Spring æŒ‰å­—段名注入时与其它模块的审批 Mapper å†²çªã€‚
 *
 * @author è¶…级管理员
 */
@Mapper
public interface SystemApprovalConfigUserMapper extends BaseMapperX<ApprovalConfigUserDO> {
    default List<ApprovalConfigUserDO> selectListByConfigId(Long configId) {
        return selectList(new LambdaQueryWrapperX<ApprovalConfigUserDO>()
                .eq(ApprovalConfigUserDO::getConfigId, configId)
                .orderByAsc(ApprovalConfigUserDO::getSort));
    }
    default List<ApprovalConfigUserDO> selectListByConfigIds(Collection<Long> configIds) {
        return selectList(new LambdaQueryWrapperX<ApprovalConfigUserDO>()
                .in(ApprovalConfigUserDO::getConfigId, configIds)
                .orderByAsc(ApprovalConfigUserDO::getSort));
    }
    /**
     * æŒ‰é…ç½®ID物理删除审批人记录
     *
     * é…ç½®å®¡æ‰¹äººé‡‡ç”¨ã€Œå…ˆåˆ åŽæ’」整体覆盖的保存方式,若走逻辑删除,
     * åå¤ä¿å­˜ä¼šåœ¨è¡¨ä¸­å †ç§¯å¤§é‡ deleted = 1 çš„历史行,故此处直接物理删除。
     *
     * @param configId å®¡æ‰¹é…ç½®ID
     * @return åˆ é™¤è¡Œæ•°
     */
    default int deleteByConfigId(Long configId) {
        return delete(new LambdaQueryWrapperX<ApprovalConfigUserDO>()
                .eq(ApprovalConfigUserDO::getConfigId, configId));
    }
}
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/enums/ErrorCodeConstants.java
@@ -172,4 +172,13 @@
    ErrorCode STORAGE_BLOB_NOT_EXISTS = new ErrorCode(1_002_029_004, "文件不存在");
    ErrorCode STORAGE_BLOB_PUBLIC_KEY_INVALID = new ErrorCode(1_002_029_005, "文件公开访问key无效");
    // ========== é€šç”¨å®¡æ‰¹é…ç½® 1-002-030-000 ==========
    ErrorCode APPROVAL_CONFIG_NOT_EXISTS = new ErrorCode(1_002_030_000, "审批配置不存在");
    ErrorCode APPROVAL_CONFIG_BIZ_TYPE_DUPLICATE = new ErrorCode(1_002_030_001, "业务类型编码({})已存在");
    ErrorCode APPROVAL_CONFIG_NOT_ENABLED = new ErrorCode(1_002_030_002, "该业务类型未启用审批,无法提交审核");
    ErrorCode APPROVAL_CONFIG_APPROVER_EMPTY = new ErrorCode(1_002_030_003, "该业务类型未配置审批人,请先在审批配置中指定审批人");
    ErrorCode APPROVAL_CONFIG_APPROVER_NOT_EXISTS = new ErrorCode(1_002_030_004, "所选审批人不存在或已被禁用");
    ErrorCode APPROVAL_CONFIG_NOT_APPROVER = new ErrorCode(1_002_030_005, "您不是该业务的审批人,无权进行审核操作");
    ErrorCode APPROVAL_CONFIG_REJECT_REASON_REQUIRED = new ErrorCode(1_002_030_006, "审核不通过时必须填写不通过原因");
}
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/service/approval/ApprovalConfigService.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,88 @@
package cn.iocoder.yudao.module.system.service.approval;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.module.system.controller.admin.approval.vo.ApprovalConfigPageReqVO;
import cn.iocoder.yudao.module.system.controller.admin.approval.vo.ApprovalConfigRespVO;
import cn.iocoder.yudao.module.system.controller.admin.approval.vo.ApprovalConfigSaveReqVO;
import cn.iocoder.yudao.module.system.dal.dataobject.approval.ApprovalConfigDO;
import java.util.Set;
/**
 * é€šç”¨å®¡æ‰¹é…ç½® Service æŽ¥å£
 *
 * @author è¶…级管理员
 */
public interface ApprovalConfigService {
    /**
     * ä¿å­˜å®¡æ‰¹é…ç½®ï¼ˆæ–°å¢žæˆ–修改)
     *
     * å®¡æ‰¹äººé‡‡ç”¨æ•´ä½“覆盖方式保存,即传入的 userIds å³ä¸ºæœ€ç»ˆç»“果。
     *
     * @param saveReqVO ä¿å­˜ä¿¡æ¯
     * @return é…ç½®ç¼–号
     */
    Long saveApprovalConfig(ApprovalConfigSaveReqVO saveReqVO);
    /**
     * åˆ é™¤å®¡æ‰¹é…ç½®
     *
     * @param id é…ç½®ç¼–号
     */
    void deleteApprovalConfig(Long id);
    /**
     * èŽ·å¾—å®¡æ‰¹é…ç½®
     *
     * @param id é…ç½®ç¼–号
     * @return å®¡æ‰¹é…ç½®
     */
    ApprovalConfigDO getApprovalConfig(Long id);
    /**
     * æŒ‰ä¸šåŠ¡ç±»åž‹èŽ·å¾—å®¡æ‰¹é…ç½®
     *
     * @param bizType ä¸šåŠ¡ç±»åž‹ç¼–ç 
     * @return å®¡æ‰¹é…ç½®ï¼Œä¸å­˜åœ¨æ—¶è¿”回 null
     */
    ApprovalConfigDO getApprovalConfigByBizType(String bizType);
    /**
     * èŽ·å¾—å®¡æ‰¹é…ç½®åˆ†é¡µï¼Œå«å®¡æ‰¹äººä¿¡æ¯
     *
     * @param pageReqVO åˆ†é¡µæŸ¥è¯¢
     * @return åˆ†é¡µç»“æžœ
     */
    PageResult<ApprovalConfigRespVO> getApprovalConfigPage(ApprovalConfigPageReqVO pageReqVO);
    /**
     * èŽ·å¾—æŒ‡å®šä¸šåŠ¡ç±»åž‹çš„å®¡æ‰¹äººç”¨æˆ·ç¼–å·é›†åˆ
     *
     * @param bizType ä¸šåŠ¡ç±»åž‹ç¼–ç 
     * @return å®¡æ‰¹äººç”¨æˆ·ç¼–号集合,未配置时返回空集合
     */
    Set<Long> getApproverUserIds(String bizType);
    /**
     * æ ¡éªŒå®¡æ‰¹æµç¨‹å¯ç”¨ï¼Œå¹¶è¿”回审批人集合
     *
     * æ ¡éªŒå†…容:业务类型已启用审批、且已配置至少一个有效审批人。
     * ä¾›å„业务模块在「提交审核」时调用。
     *
     * @param bizType ä¸šåŠ¡ç±»åž‹ç¼–ç 
     * @return å®¡æ‰¹äººç”¨æˆ·ç¼–号集合
     */
    Set<Long> validateApprovalEnabledAndGetApprovers(String bizType);
    /**
     * æ ¡éªŒæŒ‡å®šç”¨æˆ·æ˜¯å¦ä¸ºè¯¥ä¸šåŠ¡ç±»åž‹çš„å®¡æ‰¹äºº
     *
     * ä¾›å„业务模块在「审核」时调用。
     *
     * @param bizType ä¸šåŠ¡ç±»åž‹ç¼–ç 
     * @param userId  ç”¨æˆ·ç¼–号
     */
    void validateApprover(String bizType, Long userId);
}
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/service/approval/ApprovalConfigServiceImpl.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,224 @@
package cn.iocoder.yudao.module.system.service.approval;
import cn.hutool.core.collection.CollUtil;
import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
import cn.iocoder.yudao.module.system.api.user.AdminUserApi;
import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO;
import cn.iocoder.yudao.module.system.controller.admin.approval.vo.ApprovalConfigPageReqVO;
import cn.iocoder.yudao.module.system.controller.admin.approval.vo.ApprovalConfigRespVO;
import cn.iocoder.yudao.module.system.controller.admin.approval.vo.ApprovalConfigSaveReqVO;
import cn.iocoder.yudao.module.system.dal.dataobject.approval.ApprovalConfigDO;
import cn.iocoder.yudao.module.system.dal.dataobject.approval.ApprovalConfigUserDO;
import cn.iocoder.yudao.module.system.dal.mysql.approval.SystemApprovalConfigMapper;
import cn.iocoder.yudao.module.system.dal.mysql.approval.SystemApprovalConfigUserMapper;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.validation.annotation.Validated;
import java.util.*;
import java.util.stream.Collectors;
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertList;
import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertSet;
import static cn.iocoder.yudao.module.system.enums.ErrorCodeConstants.*;
/**
 * é€šç”¨å®¡æ‰¹é…ç½® Service å®žçŽ°ç±»
 *
 * è®¾è®¡è¯´æ˜Žï¼šæœ¬é…ç½®æ›¿ä»£åŽŸæœ‰åŸºäºŽ BPM(Flowable)流程定义的审批方式,采用轻量级单级审批。
 * åŒä¸€ä¸šåŠ¡ç±»åž‹å¯é…ç½®å¤šä¸ªå®¡æ‰¹äººï¼Œå¤šäººä¹‹é—´ä¸ºã€æˆ–ç­¾ã€‘å…³ç³»ï¼Œå³ä»»æ„ä¸€äººå®¡æ ¸é€šè¿‡å³æŽ¨è¿›ä¸šåŠ¡çŠ¶æ€ã€‚
 *
 * @author è¶…级管理员
 */
@Service
@Validated
@Slf4j
public class ApprovalConfigServiceImpl implements ApprovalConfigService {
    @Resource
    private SystemApprovalConfigMapper systemApprovalConfigMapper;
    @Resource
    private SystemApprovalConfigUserMapper systemApprovalConfigUserMapper;
    @Resource
    private AdminUserApi adminUserApi;
    @Override
    @Transactional(rollbackFor = Exception.class)
    public Long saveApprovalConfig(ApprovalConfigSaveReqVO saveReqVO) {
        // 1. æ ¡éªŒä¸šåŠ¡ç±»åž‹ç¼–ç å”¯ä¸€
        validateBizTypeUnique(saveReqVO.getId(), saveReqVO.getBizType());
        // 2. æ ¡éªŒå®¡æ‰¹äººå­˜åœ¨ä¸”未被禁用
        List<Long> userIds = saveReqVO.getUserIds() == null ? Collections.emptyList()
                : saveReqVO.getUserIds().stream().filter(Objects::nonNull).distinct().collect(Collectors.toList());
        validateApproverUsers(userIds);
        // 3. ä¿å­˜ä¸»è¡¨
        ApprovalConfigDO config = BeanUtils.toBean(saveReqVO, ApprovalConfigDO.class);
        if (config.getId() == null) {
            config.setId(null);
            systemApprovalConfigMapper.insert(config);
        } else {
            validateApprovalConfigExists(config.getId());
            systemApprovalConfigMapper.updateById(config);
        }
        // 4. ä¿å­˜å®¡æ‰¹äººï¼šæ•´ä½“覆盖(先物理删除旧的,再插入新的)
        systemApprovalConfigUserMapper.deleteByConfigId(config.getId());
        if (CollUtil.isNotEmpty(userIds)) {
            List<ApprovalConfigUserDO> userDOList = new ArrayList<>(userIds.size());
            for (int i = 0; i < userIds.size(); i++) {
                userDOList.add(ApprovalConfigUserDO.builder()
                        .configId(config.getId())
                        .userId(userIds.get(i))
                        .sort(i)
                        .build());
            }
            systemApprovalConfigUserMapper.insertBatch(userDOList);
        }
        return config.getId();
    }
    @Override
    @Transactional(rollbackFor = Exception.class)
    public void deleteApprovalConfig(Long id) {
        validateApprovalConfigExists(id);
        systemApprovalConfigMapper.deleteById(id);
        systemApprovalConfigUserMapper.deleteByConfigId(id);
    }
    @Override
    public ApprovalConfigDO getApprovalConfig(Long id) {
        return systemApprovalConfigMapper.selectById(id);
    }
    @Override
    public ApprovalConfigDO getApprovalConfigByBizType(String bizType) {
        if (bizType == null) {
            return null;
        }
        return systemApprovalConfigMapper.selectByBizType(bizType);
    }
    @Override
    public PageResult<ApprovalConfigRespVO> getApprovalConfigPage(ApprovalConfigPageReqVO pageReqVO) {
        PageResult<ApprovalConfigDO> pageResult = systemApprovalConfigMapper.selectPage(pageReqVO);
        if (CollUtil.isEmpty(pageResult.getList())) {
            return PageResult.empty(pageResult.getTotal());
        }
        // 1. æ‰¹é‡æŸ¥è¯¢å®¡æ‰¹äººå­è¡¨
        List<ApprovalConfigUserDO> allConfigUsers = systemApprovalConfigUserMapper.selectListByConfigIds(
                convertSet(pageResult.getList(), ApprovalConfigDO::getId));
        Map<Long, List<ApprovalConfigUserDO>> configUserMap = allConfigUsers.stream()
                .collect(Collectors.groupingBy(ApprovalConfigUserDO::getConfigId));
        // 2. æ‰¹é‡æŸ¥è¯¢å®¡æ‰¹äººæ˜µç§°
        Map<Long, String> nicknameMap = getUserNicknameMap(
                convertSet(allConfigUsers, ApprovalConfigUserDO::getUserId));
        // 3. ç»„装返回
        List<ApprovalConfigRespVO> list = convertList(pageResult.getList(), config -> {
            ApprovalConfigRespVO vo = BeanUtils.toBean(config, ApprovalConfigRespVO.class);
            List<ApprovalConfigUserDO> configUsers = configUserMap.getOrDefault(config.getId(),
                    Collections.emptyList());
            vo.setUserIds(convertList(configUsers, ApprovalConfigUserDO::getUserId));
            vo.setUserNames(convertList(configUsers, user -> nicknameMap.get(user.getUserId())));
            return vo;
        });
        return new PageResult<>(list, pageResult.getTotal());
    }
    @Override
    public Set<Long> getApproverUserIds(String bizType) {
        ApprovalConfigDO config = getApprovalConfigByBizType(bizType);
        if (config == null) {
            return Collections.emptySet();
        }
        return getValidApproverUserIds(config.getId());
    }
    @Override
    public Set<Long> validateApprovalEnabledAndGetApprovers(String bizType) {
        // 1. æ ¡éªŒä¸šåŠ¡ç±»åž‹å·²é…ç½®å®¡æ‰¹
        ApprovalConfigDO config = getApprovalConfigByBizType(bizType);
        if (config == null) {
            throw exception(APPROVAL_CONFIG_NOT_EXISTS);
        }
        // 2. æ ¡éªŒå·²å¯ç”¨å®¡æ‰¹
        if (!Boolean.TRUE.equals(config.getApprovalEnabled())) {
            throw exception(APPROVAL_CONFIG_NOT_ENABLED);
        }
        // 3. æ ¡éªŒå­˜åœ¨æœ‰æ•ˆå®¡æ‰¹äººï¼ˆè¿‡æ»¤æŽ‰å·²åˆ é™¤ / å·²ç¦ç”¨çš„用户)
        Set<Long> approverIds = getValidApproverUserIds(config.getId());
        if (CollUtil.isEmpty(approverIds)) {
            throw exception(APPROVAL_CONFIG_APPROVER_EMPTY);
        }
        return approverIds;
    }
    @Override
    public void validateApprover(String bizType, Long userId) {
        Set<Long> approverIds = getApproverUserIds(bizType);
        if (userId == null || !approverIds.contains(userId)) {
            throw exception(APPROVAL_CONFIG_NOT_APPROVER);
        }
    }
    /**
     * æŸ¥è¯¢é…ç½®ä¸‹çš„æœ‰æ•ˆå®¡æ‰¹äººç¼–号(过滤已删除 / å·²ç¦ç”¨çš„用户)
     *
     * å®¡æ‰¹äººå¯èƒ½åœ¨é…ç½®ä¹‹åŽè¢«ç¦ç”¨æˆ–删除,此处统一过滤,
     * é¿å…å‡ºçŽ°ã€Œé…ç½®é‡Œæœ‰äººã€ä½†å®žé™…æ— äººå¯å®¡ã€å¯¼è‡´å•æ®å¡åœ¨å¾…å®¡æ ¸çŠ¶æ€ã€‚
     *
     * @param configId é…ç½®ç¼–号
     * @return æœ‰æ•ˆå®¡æ‰¹äººç¼–号集合
     */
    private Set<Long> getValidApproverUserIds(Long configId) {
        List<ApprovalConfigUserDO> configUsers = systemApprovalConfigUserMapper.selectListByConfigId(configId);
        if (CollUtil.isEmpty(configUsers)) {
            return Collections.emptySet();
        }
        Set<Long> userIds = convertSet(configUsers, ApprovalConfigUserDO::getUserId);
        return convertSet(adminUserApi.getUserList(userIds), AdminUserRespDTO::getId,
                user -> !CommonStatusEnum.isDisable(user.getStatus()));
    }
    private Map<Long, String> getUserNicknameMap(Collection<Long> userIds) {
        if (CollUtil.isEmpty(userIds)) {
            return Collections.emptyMap();
        }
        return adminUserApi.getUserList(userIds).stream()
                .collect(Collectors.toMap(AdminUserRespDTO::getId, AdminUserRespDTO::getNickname, (a, b) -> a));
    }
    private void validateApprovalConfigExists(Long id) {
        if (systemApprovalConfigMapper.selectById(id) == null) {
            throw exception(APPROVAL_CONFIG_NOT_EXISTS);
        }
    }
    private void validateBizTypeUnique(Long id, String bizType) {
        ApprovalConfigDO config = systemApprovalConfigMapper.selectByBizType(bizType);
        if (config != null && !config.getId().equals(id)) {
            throw exception(APPROVAL_CONFIG_BIZ_TYPE_DUPLICATE, bizType);
        }
    }
    private void validateApproverUsers(Collection<Long> userIds) {
        if (CollUtil.isEmpty(userIds)) {
            return;
        }
        List<AdminUserRespDTO> users = adminUserApi.getUserList(userIds);
        Map<Long, AdminUserRespDTO> userMap = users.stream()
                .collect(Collectors.toMap(AdminUserRespDTO::getId, user -> user, (a, b) -> a));
        for (Long userId : userIds) {
            AdminUserRespDTO user = userMap.get(userId);
            if (user == null || CommonStatusEnum.isDisable(user.getStatus())) {
                throw exception(APPROVAL_CONFIG_APPROVER_NOT_EXISTS);
            }
        }
    }
}