From e347fd6e8b523b75e7eb28627ba3597cf9e40f6a Mon Sep 17 00:00:00 2001
From: liu <2021943741@qq.com>
Date: 星期三, 16 九月 2026 14:26:40 +0800
Subject: [PATCH] refactor(approval): 采购计划/采购订单审批由 BPM 改为轻量级状态机

---
 scripts/test-approval.js                                                                                                      |  213 ++++++
 yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/enums/ErrorCodeConstants.java                                      |    8 
 yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/dal/dataobject/approval/ApprovalConfigUserDO.java            |   44 +
 yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/service/approval/ApprovalConfigService.java                  |   88 ++
 yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/controller/admin/purchase/ErpPurchasePlanController.java           |   28 
 yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/controller/admin/purchase/vo/plan/ErpPurchasePlanAuditReqVO.java   |   27 
 yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/service/purchase/ErpPurchasePlanServiceImpl.java                   |  129 ++--
 yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/dal/dataobject/purchase/ErpPurchasePlanDO.java                     |   22 
 yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/controller/admin/approval/vo/ApprovalConfigPageReqVO.java    |   24 
 yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/controller/admin/approval/vo/ApprovalConfigSaveReqVO.java    |   35 +
 yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/api/approval/ApprovalConfigApi.java                          |   57 +
 yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/controller/admin/purchase/vo/order/ErpPurchaseOrderRespVO.java     |   17 
 yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/controller/admin/purchase/vo/plan/ErpPurchasePlanRespVO.java       |   16 
 yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/controller/admin/purchase/vo/order/ErpPurchaseOrderAuditReqVO.java |   27 
 yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/controller/admin/approval/vo/ApprovalConfigRespVO.java       |   37 +
 yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/dal/dataobject/approval/ApprovalConfigDO.java                |   49 +
 yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/service/purchase/ErpPurchaseOrderServiceImpl.java                  |  156 ++--
 yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/api/approval/ApprovalConfigApiImpl.java                      |   35 +
 yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/dal/mysql/approval/SystemApprovalConfigMapper.java           |   33 +
 yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/controller/admin/approval/ApprovalConfigController.java      |   90 ++
 yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/dal/mysql/purchase/ErpPurchasePlanMapper.java                      |   45 +
 yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/service/purchase/ErpPurchasePlanService.java                       |   35 
 yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/enums/ErpPurchaseOrderStatusEnum.java                              |   63 +
 yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/dal/mysql/purchase/ErpPurchaseOrderMapper.java                     |   30 
 yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/service/approval/ApprovalConfigServiceImpl.java              |  224 +++++++
 scripts/test-approval-api.js                                                                                                  |  201 ++++++
 yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/service/purchase/ErpPurchaseOrderService.java                      |   24 
 yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/enums/ErrorCodeConstants.java                                |    9 
 yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/controller/admin/purchase/ErpPurchaseOrderController.java          |   29 
 yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/dal/dataobject/purchase/ErpPurchaseOrderDO.java                    |   25 
 yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/dal/mysql/approval/SystemApprovalConfigUserMapper.java       |   47 +
 31 files changed, 1,693 insertions(+), 174 deletions(-)

diff --git a/scripts/test-approval-api.js b/scripts/test-approval-api.js
new file mode 100644
index 0000000..a37c29d
--- /dev/null
+++ b/scripts/test-approval-api.js
@@ -0,0 +1,201 @@
+/**
+ * 瀹℃壒娴佺▼鏀归�� - 鍚庣鎺ュ彛绾ф祴璇�
+ *
+ * 鐩存帴鎵� admin-api 楠岃瘉銆岄噰璐鍒掓彁浜ゅ鎵� 500 绯荤粺寮傚父銆嶅凡淇锛�
+ * 骞惰窇閫氬畬鏁寸殑 鎻愪氦 鈫� 瀹℃牳 / 瀹℃牳涓嶉�氳繃 鐘舵�佹満閾捐矾銆�
+ *
+ * token 鍦ㄨ繘绋嬪唴鑾峰彇锛屼笉鍑虹幇鍦ㄥ懡浠よ锛岄伩鍏嶅嚟鎹硠婕忋��
+ *
+ * 鐢ㄦ硶锛歯ode 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 杩斿洖姝e父', 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('绌哄師鍥犺姝g‘鎷︽埅', 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);
+});
diff --git a/scripts/test-approval.js b/scripts/test-approval.js
new file mode 100644
index 0000000..e9b898d
--- /dev/null
+++ b/scripts/test-approval.js
@@ -0,0 +1,213 @@
+/**
+ * 瀹℃壒娴佺▼鏀归�� - 娴忚鍣ㄧ鍒扮娴嬭瘯
+ *
+ * 瑕嗙洊锛�
+ *   1. 鐧诲綍
+ *   2. 瀹℃壒閰嶇疆椤垫覆鏌� + 閲囪喘璁″垝瀹℃壒浜洪厤缃�
+ *   3. 閲囪喘璁″垝鎻愪氦瀹℃牳锛堥獙璇佸師 500銆岀郴缁熷紓甯搞�嶅凡娑堝け锛�
+ *   4. 瀹℃牳 / 瀹℃牳涓嶉�氳繃锛堝惈椹冲洖鍘熷洜蹇呭~鏍¢獙锛�
+ *
+ * 鐢ㄦ硶锛歯ode 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 = [];
+// 璁板綍鎵�鏈夊悗绔帴鍙h繑鍥炵殑闈� 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);
+})();
diff --git a/yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/controller/admin/purchase/ErpPurchaseOrderController.java b/yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/controller/admin/purchase/ErpPurchaseOrderController.java
index 82ea65b..781490b 100644
--- a/yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/controller/admin/purchase/ErpPurchaseOrderController.java
+++ b/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")
diff --git a/yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/controller/admin/purchase/ErpPurchasePlanController.java b/yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/controller/admin/purchase/ErpPurchasePlanController.java
index a4ce778..63121f6 100644
--- a/yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/controller/admin/purchase/ErpPurchasePlanController.java
+++ b/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")
diff --git a/yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/controller/admin/purchase/vo/order/ErpPurchaseOrderAuditReqVO.java b/yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/controller/admin/purchase/vo/order/ErpPurchaseOrderAuditReqVO.java
new file mode 100644
index 0000000..86a793f
--- /dev/null
+++ b/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;
+
+}
diff --git a/yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/controller/admin/purchase/vo/order/ErpPurchaseOrderRespVO.java b/yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/controller/admin/purchase/vo/order/ErpPurchaseOrderRespVO.java
index cd3e609..25e4e2e 100644
--- a/yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/controller/admin/purchase/vo/order/ErpPurchaseOrderRespVO.java
+++ b/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 {
 
diff --git a/yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/controller/admin/purchase/vo/plan/ErpPurchasePlanAuditReqVO.java b/yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/controller/admin/purchase/vo/plan/ErpPurchasePlanAuditReqVO.java
new file mode 100644
index 0000000..ea7902c
--- /dev/null
+++ b/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;
+
+}
diff --git a/yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/controller/admin/purchase/vo/plan/ErpPurchasePlanRespVO.java b/yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/controller/admin/purchase/vo/plan/ErpPurchasePlanRespVO.java
index 9950d04..0ab1550 100644
--- a/yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/controller/admin/purchase/vo/plan/ErpPurchasePlanRespVO.java
+++ b/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;
diff --git a/yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/dal/dataobject/purchase/ErpPurchaseOrderDO.java b/yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/dal/dataobject/purchase/ErpPurchaseOrderDO.java
index 0f9b877..685be61 100644
--- a/yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/dal/dataobject/purchase/ErpPurchaseOrderDO.java
+++ b/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;
+
 }
\ No newline at end of file
diff --git a/yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/dal/dataobject/purchase/ErpPurchasePlanDO.java b/yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/dal/dataobject/purchase/ErpPurchasePlanDO.java
index 72c6d97..17b13a7 100644
--- a/yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/dal/dataobject/purchase/ErpPurchasePlanDO.java
+++ b/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;
+
 }
diff --git a/yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/dal/mysql/purchase/ErpPurchaseOrderMapper.java b/yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/dal/mysql/purchase/ErpPurchaseOrderMapper.java
index 9f7aca0..00d4025 100644
--- a/yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/dal/mysql/purchase/ErpPurchaseOrderMapper.java
+++ b/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);
     }
diff --git a/yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/dal/mysql/purchase/ErpPurchasePlanMapper.java b/yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/dal/mysql/purchase/ErpPurchasePlanMapper.java
index 473577e..361f95f 100644
--- a/yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/dal/mysql/purchase/ErpPurchasePlanMapper.java
+++ b/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锛歁yBatis-Plus 榛樿鏇存柊绛栫暐浼氬拷鐣� null锛�
+     * 鐩存帴 updateById 鏃犳硶鎶娿�屽鏍镐笉閫氳繃銆嶅悗娈嬬暀鐨勫鏍镐汉涓庡鏍告剰瑙佺湡姝f竻绌恒��
+     *
+     * @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));
+    }
+
 }
diff --git a/yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/enums/ErpPurchaseOrderStatusEnum.java b/yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/enums/ErpPurchaseOrderStatusEnum.java
new file mode 100644
index 0000000..37cdba8
--- /dev/null
+++ b/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锛團lowable锛夋祦绋嬪鎵癸紝
+ * 鐜版敼涓轰笌銆屼骇鍝佹妧鏈‘璁ゅ嚱銆嶄竴鑷寸殑杞婚噺绾у崟绾�/鎴栫鐘舵�佹満锛屾晠鐙珛瀹氫箟鏈灇涓撅紝
+ * 閬垮厤涓� {@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;
+    }
+
+}
diff --git a/yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/enums/ErrorCodeConstants.java b/yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/enums/ErrorCodeConstants.java
index 91c56d0..20399ad 100644
--- a/yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/enums/ErrorCodeConstants.java
+++ b/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, "閲囪喘璁㈠崟({})姝e湪瀹℃壒涓紝鏃犳硶淇敼");
 
     // ========== 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, "鏉ョエ涓嶅瓨鍦�");
diff --git a/yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/service/purchase/ErpPurchaseOrderService.java b/yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/service/purchase/ErpPurchaseOrderService.java
index 120cbd2..c1bfc4f 100644
--- a/yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/service/purchase/ErpPurchaseOrderService.java
+++ b/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);
 
     /**
      * 鏇存柊閲囪喘璁㈠崟瀹℃壒缁撴灉锛圔PM 鍥炶皟锛�
diff --git a/yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/service/purchase/ErpPurchaseOrderServiceImpl.java b/yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/service/purchase/ErpPurchaseOrderServiceImpl.java
index 083095f..0015037 100644
--- a/yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/service/purchase/ErpPurchaseOrderServiceImpl.java
+++ b/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 鍒嗙被缂栫爜瀹屽叏鐩稿悓锛�
+     * 瀵艰嚧閲囪喘璁㈠崟鏌ヨ瀹℃壒娴佺▼鏃朵細鍛戒腑閲囪喘鐢宠鐨勬祦绋嬪畾涔夛紝涓よ�呬簰鐩镐覆鐢ㄣ��
+     * 鐜版洿姝d负閲囪喘璁㈠崟涓撳睘缂栫爜銆�
      */
-    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 娴佺▼锛屾湰鏂规硶浠呬负鍘嗗彶浠g爜淇濈暀锛屼笉鍐嶈璋冪敤銆�
      */
+    @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;
     }
diff --git a/yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/service/purchase/ErpPurchasePlanService.java b/yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/service/purchase/ErpPurchasePlanService.java
index 2189c51..4241ac6 100644
--- a/yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/service/purchase/ErpPurchasePlanService.java
+++ b/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               鐢ㄦ埛缂栧彿
+     * 鎻愪氦鍓嶆牎楠岄�氱敤瀹℃壒閰嶇疆锛坆izType = 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 瀹℃壒缁撴灉
      */
diff --git a/yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/service/purchase/ErpPurchasePlanServiceImpl.java b/yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/service/purchase/ErpPurchasePlanServiceImpl.java
index 255118d..c0bcb87 100644
--- a/yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/service/purchase/ErpPurchasePlanServiceImpl.java
+++ b/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) {
diff --git a/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/api/approval/ApprovalConfigApi.java b/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/api/approval/ApprovalConfigApi.java
new file mode 100644
index 0000000..1aa6230
--- /dev/null
+++ b/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 鎺ュ彛
+ *
+ * 渚涘悇涓氬姟妯″潡锛圗RP / CRM / HRM / MES / 鍞悗锛夊湪鎻愪氦瀹℃壒銆佹墽琛屽鏍告椂璋冪敤锛�
+ * 鏇夸唬鍘熷厛渚濊禆 BPM锛團lowable锛夋祦绋嬪畾涔夌殑鏂瑰紡銆�
+ *
+ * @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);
+    }
+
+}
diff --git a/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/api/approval/ApprovalConfigApiImpl.java b/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/api/approval/ApprovalConfigApiImpl.java
new file mode 100644
index 0000000..b0c176e
--- /dev/null
+++ b/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);
+    }
+
+}
diff --git a/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/controller/admin/approval/ApprovalConfigController.java b/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/controller/admin/approval/ApprovalConfigController.java
new file mode 100644
index 0000000..96a1ece
--- /dev/null
+++ b/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()));
+    }
+
+}
diff --git a/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/controller/admin/approval/vo/ApprovalConfigPageReqVO.java b/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/controller/admin/approval/vo/ApprovalConfigPageReqVO.java
new file mode 100644
index 0000000..d180f6a
--- /dev/null
+++ b/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;
+
+}
diff --git a/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/controller/admin/approval/vo/ApprovalConfigRespVO.java b/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/controller/admin/approval/vo/ApprovalConfigRespVO.java
new file mode 100644
index 0000000..59bde9e
--- /dev/null
+++ b/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 = "瀹℃壒浜虹敤鎴稩D鍒楄〃", 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;
+
+}
diff --git a/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/controller/admin/approval/vo/ApprovalConfigSaveReqVO.java b/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/controller/admin/approval/vo/ApprovalConfigSaveReqVO.java
new file mode 100644
index 0000000..ac4df4b
--- /dev/null
+++ b/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 = "瀹℃壒浜虹敤鎴稩D鍒楄〃锛屽涓箣闂翠负鎴栫锛堜换鎰忎竴浜洪�氳繃鍗冲彲锛�", example = "[1,2]")
+    private List<Long> userIds;
+
+    @Schema(description = "澶囨敞", example = "鐢遍噰璐儴缁忕悊瀹℃牳")
+    private String remark;
+
+}
diff --git a/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/dal/dataobject/approval/ApprovalConfigDO.java b/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/dal/dataobject/approval/ApprovalConfigDO.java
new file mode 100644
index 0000000..5146bdf
--- /dev/null
+++ b/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;
+
+}
diff --git a/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/dal/dataobject/approval/ApprovalConfigUserDO.java b/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/dal/dataobject/approval/ApprovalConfigUserDO.java
new file mode 100644
index 0000000..244a272
--- /dev/null
+++ b/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;
+    /**
+     * 瀹℃壒浜虹敤鎴稩D锛堢郴缁熺敤鎴凤級
+     */
+    private Long userId;
+    /**
+     * 鏄剧ず椤哄簭
+     */
+    private Integer sort;
+
+}
diff --git a/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/dal/mysql/approval/SystemApprovalConfigMapper.java b/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/dal/mysql/approval/SystemApprovalConfigMapper.java
new file mode 100644
index 0000000..0a1833e
--- /dev/null
+++ b/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 鎸夊瓧娈靛悕锛坆yName锛夋敞鍏ユ椂浜х敓鍛藉悕鍐茬獊銆�
+ *
+ * @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);
+    }
+
+}
diff --git a/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/dal/mysql/approval/SystemApprovalConfigUserMapper.java b/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/dal/mysql/approval/SystemApprovalConfigUserMapper.java
new file mode 100644
index 0000000..3c66842
--- /dev/null
+++ b/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));
+    }
+
+    /**
+     * 鎸夐厤缃甀D鐗╃悊鍒犻櫎瀹℃壒浜鸿褰�
+     *
+     * 閰嶇疆瀹℃壒浜洪噰鐢ㄣ�屽厛鍒犲悗鎻掋�嶆暣浣撹鐩栫殑淇濆瓨鏂瑰紡锛岃嫢璧伴�昏緫鍒犻櫎锛�
+     * 鍙嶅淇濆瓨浼氬湪琛ㄤ腑鍫嗙Н澶ч噺 deleted = 1 鐨勫巻鍙茶锛屾晠姝ゅ鐩存帴鐗╃悊鍒犻櫎銆�
+     *
+     * @param configId 瀹℃壒閰嶇疆ID
+     * @return 鍒犻櫎琛屾暟
+     */
+    default int deleteByConfigId(Long configId) {
+        return delete(new LambdaQueryWrapperX<ApprovalConfigUserDO>()
+                .eq(ApprovalConfigUserDO::getConfigId, configId));
+    }
+
+}
diff --git a/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/enums/ErrorCodeConstants.java b/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/enums/ErrorCodeConstants.java
index 86aeeec..3dc2a70 100644
--- a/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/enums/ErrorCodeConstants.java
+++ b/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, "瀹℃牳涓嶉�氳繃鏃跺繀椤诲~鍐欎笉閫氳繃鍘熷洜");
+
 }
diff --git a/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/service/approval/ApprovalConfigService.java b/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/service/approval/ApprovalConfigService.java
new file mode 100644
index 0000000..ad2a189
--- /dev/null
+++ b/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);
+
+}
diff --git a/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/service/approval/ApprovalConfigServiceImpl.java b/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/service/approval/ApprovalConfigServiceImpl.java
new file mode 100644
index 0000000..66a701f
--- /dev/null
+++ b/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锛團lowable锛夋祦绋嬪畾涔夌殑瀹℃壒鏂瑰紡锛岄噰鐢ㄨ交閲忕骇鍗曠骇瀹℃壒銆�
+ * 鍚屼竴涓氬姟绫诲瀷鍙厤缃涓鎵逛汉锛屽浜轰箣闂翠负銆愭垨绛俱�戝叧绯伙紝鍗充换鎰忎竴浜哄鏍搁�氳繃鍗虫帹杩涗笟鍔$姸鎬併��
+ *
+ * @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);
+            }
+        }
+    }
+
+}

--
Gitblit v1.9.3