@yss123/cage-cli 0.3.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (107) hide show
  1. package/dist/approval-tree.d.ts +88 -0
  2. package/dist/approval-tree.js +18 -0
  3. package/dist/approval-tree.js.map +1 -0
  4. package/dist/capabilities.js +16 -1
  5. package/dist/capabilities.js.map +1 -1
  6. package/dist/graph-approvals.d.ts +11 -1
  7. package/dist/graph-approvals.js +18 -5
  8. package/dist/graph-approvals.js.map +1 -1
  9. package/dist/graph-relations.js +10 -8
  10. package/dist/graph-relations.js.map +1 -1
  11. package/dist/graph.js +2 -0
  12. package/dist/graph.js.map +1 -1
  13. package/dist/ontology.js +8 -5
  14. package/dist/ontology.js.map +1 -1
  15. package/dist/sync.js +1 -1
  16. package/graph-ui/approval-flow.js +28 -8
  17. package/graph-ui/approval-parallel.js +110 -0
  18. package/graph-ui/approval-tree.js +17 -0
  19. package/graph-ui/business.css +26 -0
  20. package/graph-ui/business.js +30 -13
  21. package/graph-ui/graph-approvals.js +18 -5
  22. package/graph-ui/graph-relations.js +10 -8
  23. package/package.json +6 -6
  24. package/templates/app/AGENTS.md +2 -2
  25. package/templates/app/README.md +5 -4
  26. package/templates/app/apps/api/package.json +2 -2
  27. package/templates/app/apps/web/package.json +1 -1
  28. package/templates/app/apps/web/src/modules/approval/components/task-operation-dialog.tsx +61 -0
  29. package/templates/app/apps/web/src/modules/approval/hooks/use-approval-tasks.ts +6 -9
  30. package/templates/app/apps/web/src/modules/approval/pages/approval-tasks-page.tsx +81 -49
  31. package/templates/app/apps/web/src/modules/approval/pages/approval-tasks.css +18 -0
  32. package/templates/app/apps/web/src/modules/purchase/components/approval-history.tsx +39 -4
  33. package/templates/app/apps/web/src/modules/purchase/components/order-reviewers.tsx +38 -0
  34. package/templates/app/apps/web/src/modules/purchase/components/purchase-order-form.tsx +9 -2
  35. package/templates/app/apps/web/src/modules/purchase/pages/purchase-intelligence.css +2 -0
  36. package/templates/app/apps/web/src/modules/purchase/pages/purchase-order-detail-page.tsx +2 -0
  37. package/templates/app/apps/web/src/modules/purchase/pages/purchase-orders-page.tsx +2 -0
  38. package/templates/app/apps/web/src/shared/lib/error-message.ts +6 -1
  39. package/templates/app/apps/web/src/shared/ui/approval-member-picker.css +10 -0
  40. package/templates/app/apps/web/src/shared/ui/approval-member-picker.tsx +51 -0
  41. package/templates/app/cage.config.json +14 -0
  42. package/templates/app/db/migrations/0004_draft.sql +27 -0
  43. package/templates/app/docs/ai/README.md +7 -2
  44. package/templates/app/docs/ai/approvals.md +260 -0
  45. package/templates/app/docs/ai/primitives.md +1 -1
  46. package/templates/app/docs/ai/purchase-example.md +19 -5
  47. package/templates/app/docs/ai/recipes.md +1 -1
  48. package/templates/app/docs/errors/CAGE-AI-001.md +2 -2
  49. package/templates/app/docs/errors/CAGE-APPR-001.md +12 -9
  50. package/templates/app/docs/errors/CAGE-APPR-005.md +4 -4
  51. package/templates/app/docs/errors/CAGE-APPR-006.md +24 -9
  52. package/templates/app/docs/errors/CAGE-APPR-007.md +8 -6
  53. package/templates/app/docs/errors/CAGE-EVENT-001.md +1 -1
  54. package/templates/app/docs/errors/CAGE-REG-001.md +1 -1
  55. package/templates/app/docs/errors/README.md +2 -2
  56. package/templates/app/package.json +3 -3
  57. package/templates/app/packages/contracts/package.json +1 -1
  58. package/templates/app/packages/contracts/src/aggregates/approval-member/index.ts +70 -0
  59. package/templates/app/packages/contracts/src/aggregates/purchase-order/index.ts +10 -2
  60. package/templates/app/packages/contracts/src/index.ts +9 -0
  61. package/templates/app/packages/domain/package.json +5 -5
  62. package/templates/app/packages/domain/src/modules/purchase/approval-members.ts +54 -0
  63. package/templates/app/packages/domain/src/modules/purchase/approvals.ts +19 -8
  64. package/templates/app/packages/domain/src/modules/purchase/index.ts +2 -1
  65. package/templates/app/packages/domain/src/modules/purchase/intelligence.test.ts +169 -7
  66. package/templates/app/packages/domain/src/modules/purchase/purchase-order.ts +56 -5
  67. package/templates/blank/AGENTS.md +2 -2
  68. package/templates/blank/apps/api/package.json +1 -1
  69. package/templates/blank/apps/web/package.json +1 -1
  70. package/templates/blank/docs/ai/README.md +7 -2
  71. package/templates/blank/docs/ai/approvals.md +260 -0
  72. package/templates/blank/docs/ai/primitives.md +1 -1
  73. package/templates/blank/docs/ai/purchase-example.md +19 -5
  74. package/templates/blank/docs/ai/recipes.md +1 -1
  75. package/templates/blank/docs/errors/CAGE-AI-001.md +2 -2
  76. package/templates/blank/docs/errors/CAGE-APPR-001.md +12 -9
  77. package/templates/blank/docs/errors/CAGE-APPR-005.md +4 -4
  78. package/templates/blank/docs/errors/CAGE-APPR-006.md +24 -9
  79. package/templates/blank/docs/errors/CAGE-APPR-007.md +8 -6
  80. package/templates/blank/docs/errors/CAGE-EVENT-001.md +1 -1
  81. package/templates/blank/docs/errors/CAGE-REG-001.md +1 -1
  82. package/templates/blank/docs/errors/README.md +2 -2
  83. package/templates/blank/docs/examples/purchase-admin/apps/web/src/modules/approval/components/task-operation-dialog.tsx +61 -0
  84. package/templates/blank/docs/examples/purchase-admin/apps/web/src/modules/approval/hooks/use-approval-tasks.ts +6 -9
  85. package/templates/blank/docs/examples/purchase-admin/apps/web/src/modules/approval/pages/approval-tasks-page.tsx +81 -49
  86. package/templates/blank/docs/examples/purchase-admin/apps/web/src/modules/approval/pages/approval-tasks.css +18 -0
  87. package/templates/blank/docs/examples/purchase-admin/apps/web/src/modules/purchase/components/approval-history.tsx +39 -4
  88. package/templates/blank/docs/examples/purchase-admin/apps/web/src/modules/purchase/components/order-reviewers.tsx +38 -0
  89. package/templates/blank/docs/examples/purchase-admin/apps/web/src/modules/purchase/components/purchase-order-form.tsx +9 -2
  90. package/templates/blank/docs/examples/purchase-admin/apps/web/src/modules/purchase/pages/purchase-intelligence.css +2 -0
  91. package/templates/blank/docs/examples/purchase-admin/apps/web/src/modules/purchase/pages/purchase-order-detail-page.tsx +2 -0
  92. package/templates/blank/docs/examples/purchase-admin/apps/web/src/modules/purchase/pages/purchase-orders-page.tsx +2 -0
  93. package/templates/blank/docs/examples/purchase-admin/apps/web/src/shared/lib/error-message.ts +6 -1
  94. package/templates/blank/docs/examples/purchase-admin/apps/web/src/shared/ui/approval-member-picker.css +10 -0
  95. package/templates/blank/docs/examples/purchase-admin/apps/web/src/shared/ui/approval-member-picker.tsx +51 -0
  96. package/templates/blank/docs/examples/purchase-admin/cage.config.json +14 -0
  97. package/templates/blank/docs/examples/purchase-admin/packages/contracts/src/aggregates/approval-member/index.ts +70 -0
  98. package/templates/blank/docs/examples/purchase-admin/packages/contracts/src/aggregates/purchase-order/index.ts +10 -2
  99. package/templates/blank/docs/examples/purchase-admin/packages/contracts/src/index.ts +9 -0
  100. package/templates/blank/docs/examples/purchase-admin/packages/domain/src/modules/purchase/approval-members.ts +54 -0
  101. package/templates/blank/docs/examples/purchase-admin/packages/domain/src/modules/purchase/approvals.ts +19 -8
  102. package/templates/blank/docs/examples/purchase-admin/packages/domain/src/modules/purchase/index.ts +2 -1
  103. package/templates/blank/docs/examples/purchase-admin/packages/domain/src/modules/purchase/intelligence.test.ts +169 -7
  104. package/templates/blank/docs/examples/purchase-admin/packages/domain/src/modules/purchase/purchase-order.ts +56 -5
  105. package/templates/blank/package.json +3 -3
  106. package/templates/blank/packages/contracts/package.json +1 -1
  107. package/templates/blank/packages/domain/package.json +5 -5
@@ -34,14 +34,14 @@ route('采购审批', nodes)
34
34
 
35
35
  以下路径相对框架源码仓库,不是要求业务项目创建这些文件。当前项目先修业务声明、配置或授权;明确属于框架缺口时反馈错误码和脱敏上下文。
36
36
 
37
- - `packages/approval/src/route.ts:126`
37
+ - `packages/approval/src/route.ts:176`
38
38
 
39
39
  实现片段(位置随框架版本更新):
40
40
 
41
41
  ```ts
42
- export function route(process: string, nodes: RouteNode[]): Route {
42
+ export function route(process: string, nodes: RouteNode[], options: { reminders?: ApprovalReminderPolicy } = {}): Route {
43
43
  if (!/^[a-z][a-z0-9-]*$/.test(process)) throw declError('CAGE-APPR-005', `流程名 ${process} 必须是小写短横线`);
44
44
  if (nodes.length === 0) throw declError('CAGE-APPR-006', `流程 ${process} 至少需要一个步骤`);
45
- const fields = new Set<string>();
46
- for (const n of nodes) {
45
+ const inspection = inspectRoute(nodes);
46
+ const fields = new Set<string>(inspection.fields);
47
47
  ```
@@ -1,4 +1,4 @@
1
- # CAGE-APPR-006:流程没有步骤
1
+ # CAGE-APPR-006:审批步骤配置无效
2
2
 
3
3
  [返回错误码册](README.md) · [业务排错](../ai/troubleshooting.md)
4
4
 
@@ -8,11 +8,13 @@
8
8
 
9
9
  阶段:声明、图规则或 worker。
10
10
 
11
- route 接收了空步骤数组。
11
+ route 没有步骤、展开后为空,或人工 actions/assignment、候选池与 all 组合、AI 步骤必填配置或预算不合法。
12
12
 
13
13
  ## 修复步骤
14
14
 
15
15
  1. 声明至少一个实际审批步骤,并检查条件后能否形成预期流程。
16
+ 2. candidate 只支持 any;逐人会签用默认 direct。actions 只接受 transfer/delegate/addSign 布尔值。
17
+ 3. AI 步骤需模型、要求、上下文字段、接管规则及正整数预算,并绑定 AI 运行时、退回命令和自动角色。
16
18
 
17
19
  ## 正例
18
20
 
@@ -35,12 +37,25 @@ route('po-approval', [])
35
37
  以下路径相对框架源码仓库,不是要求业务项目创建这些文件。当前项目先修业务声明、配置或授权;明确属于框架缺口时反馈错误码和脱敏上下文。
36
38
 
37
39
  - `packages/approval/src/bridge.ts:16`
38
- - `packages/approval/src/engine.ts:70`
39
- - `packages/approval/src/route.ts:66`
40
- - `packages/approval/src/route.ts:68`
41
- - `packages/approval/src/route.ts:127`
42
- - `packages/approval/src/route.ts:154`
43
- - `packages/approval/src/route.ts:156`
40
+ - `packages/approval/src/deadlines.ts:34`
41
+ - `packages/approval/src/deadlines.ts:41`
42
+ - `packages/approval/src/engine.ts:75`
43
+ - `packages/approval/src/execution.ts:40`
44
+ - `packages/approval/src/execution.ts:42`
45
+ - `packages/approval/src/execution.ts:47`
46
+ - `packages/approval/src/execution.ts:49`
47
+ - `packages/approval/src/execution.ts:54`
48
+ - `packages/approval/src/execution.ts:55`
49
+ - `packages/approval/src/execution.ts:88`
50
+ - `packages/approval/src/operations.ts:375`
51
+ - `packages/approval/src/route.ts:109`
52
+ - `packages/approval/src/route.ts:111`
53
+ - `packages/approval/src/route.ts:115`
54
+ - `packages/approval/src/route.ts:117`
55
+ - `packages/approval/src/route.ts:177`
56
+ - `packages/approval/src/route.ts:213`
57
+ - `packages/approval/src/route.ts:224`
58
+ - `packages/approval/src/route.ts:226`
44
59
 
45
60
  实现片段(位置随框架版本更新):
46
61
 
@@ -49,5 +64,5 @@ route('po-approval', [])
49
64
  if (!cmd) throw new Error('[CAGE-APPR-006] 审批未绑定退回修改命令');
50
65
  let ctx: Ctx;
51
66
  if (r.lastApprover) {
52
- const user = await k.contextFor(r.lastApprover, r.tenantId);
67
+ // 待办决定已持有事务连接时复用它;同实例并发排队不能耗尽池后让锁持有者再等新连接。
53
68
  ```
@@ -8,14 +8,15 @@
8
8
 
9
9
  阶段:声明、图规则或 worker。
10
10
 
11
- worker 启动或推进审批步骤时,选人器没有找到人。
11
+ worker 启动或推进审批步骤时选人器没有找到人,或字段选人/候选节点中存在非当前租户启用账号。
12
12
 
13
13
  ## 修复步骤
14
14
 
15
15
  1. 先看本项目 approvals.ts:deptLeader 读取单据部门的负责人;role 使用角色 code;user 使用实际用户 UUID。
16
16
  2. 在 /admin 设置单据所属部门的负责人,或创建对应 code 的角色并分配实际用户。给用户经理角色,并不等于设置了部门负责人。
17
- 3. 再核对审批人的结论能力、数据范围、ceiling 额度和职责分离。选中审批人不等于允许完成审批。
18
- 4. 若 OrderSubmitted 已入死信,修复后在 /admin 按 outboxId 重放,确认待办生成;原提交已成功时不重复提交或 SQL 改回草稿。
17
+ 3. field 使用提交时保存的 routable 人员字段;检查 UUID 对应账号是否全部在当前租户且 active。不能删掉不合格人员后默认为审批通过;字段快照错误需按业务撤回/重提修正。
18
+ 4. 再核对审批人的结论能力、数据范围、ceiling 额度和职责分离。选中审批人不等于允许完成审批。
19
+ 5. 若 OrderSubmitted 已入死信,修复后在 /admin 按 outboxId 重放,确认待办生成;原提交已成功时不重复提交或 SQL 改回草稿。
19
20
 
20
21
  ## 正例
21
22
 
@@ -37,7 +38,8 @@ deptLeader('deptId'):给该部门设置负责人;role('finance'):给实际
37
38
 
38
39
  以下路径相对框架源码仓库,不是要求业务项目创建这些文件。当前项目先修业务声明、配置或授权;明确属于框架缺口时反馈错误码和脱敏上下文。
39
40
 
40
- - `packages/approval/src/engine.ts:81`
41
+ - `packages/approval/src/engine.ts:87`
42
+ - `packages/approval/src/engine.ts:94`
41
43
 
42
44
  实现片段(位置随框架版本更新):
43
45
 
@@ -45,6 +47,6 @@ deptLeader('deptId'):给该部门设置负责人;role('finance'):给实际
45
47
  if (who.length === 0) {
46
48
  throw new Error(`[CAGE-APPR-007] 流程 ${this.route.process} 第 ${index + 1} 步「${title}」找不到审批人,请在 /admin 配置部门负责人或角色`);
47
49
  }
48
- for (const assignee of new Set(who)) {
49
- await tx.insertInto('flow.approval_task').values({ instance_id: instanceId, tenant_id: tenantId, step: index, title, assignee }).execute();
50
+ if (selector.kind === 'field' || assignment === 'candidate') {
51
+ // 新字段/候选声明不允许创建指向停用或其他租户账号的票;整批拒绝,不静默减少会签人数。
50
52
  ```
@@ -34,7 +34,7 @@ emit(OrderCancelled, (o, _i, prev) => ({ orderId: o.id, previousStatus: prev.sta
34
34
 
35
35
  以下路径相对框架源码仓库,不是要求业务项目创建这些文件。当前项目先修业务声明、配置或授权;明确属于框架缺口时反馈错误码和脱敏上下文。
36
36
 
37
- - `packages/kernel/src/kernel.ts:456`
37
+ - `packages/kernel/src/kernel.ts:461`
38
38
 
39
39
  实现片段(位置随框架版本更新):
40
40
 
@@ -34,7 +34,7 @@ export * from './inventory.js'; export * from './purchase-order.js';
34
34
 
35
35
  以下路径相对框架源码仓库,不是要求业务项目创建这些文件。当前项目先修业务声明、配置或授权;明确属于框架缺口时反馈错误码和脱敏上下文。
36
36
 
37
- - `packages/approval/src/route.ts:161`
37
+ - `packages/approval/src/route.ts:231`
38
38
  - `packages/kernel/src/kernel.ts:161`
39
39
  - `packages/kernel/src/registry.ts:209`
40
40
  - `packages/kernel/src/registry.ts:215`
@@ -44,12 +44,12 @@
44
44
 
45
45
  | 诊断码 | 含义 |
46
46
  | --- | --- |
47
- | [CAGE-APPR-001](CAGE-APPR-001.md) | 可路由字段不存在 |
47
+ | [CAGE-APPR-001](CAGE-APPR-001.md) | 审批字段声明无效 |
48
48
  | [CAGE-APPR-002](CAGE-APPR-002.md) | 审批结论落到其他聚合 |
49
49
  | [CAGE-APPR-003](CAGE-APPR-003.md) | 审批结论未允许流程调用 |
50
50
  | [CAGE-APPR-004](CAGE-APPR-004.md) | 路由没有对应审批绑定 |
51
51
  | [CAGE-APPR-005](CAGE-APPR-005.md) | 流程名称格式错误 |
52
- | [CAGE-APPR-006](CAGE-APPR-006.md) | 流程没有步骤 |
52
+ | [CAGE-APPR-006](CAGE-APPR-006.md) | 审批步骤配置无效 |
53
53
  | [CAGE-APPR-007](CAGE-APPR-007.md) | 找不到审批人 |
54
54
  | [CAGE-APPR-010](CAGE-APPR-010.md) | 流程专用命令没有调用者 |
55
55
 
@@ -0,0 +1,61 @@
1
+ /**
2
+ * 人工待办的转办、委派和三种加签操作表单,由审批收件箱按当前任务快照打开。
3
+ * 选择来自业务协作名录,提交携带任务 revision 和必填原因;权限/同节点重复责任由引擎重查。
4
+ * 失败保留选人和原因;成功刷新真实任务并关闭。等待/受托任务不从页面打开本表单,不在前端改流程状态。
5
+ */
6
+ import { useState, type FormEvent } from 'react';
7
+ import { client } from '@app/contracts';
8
+ import type { ApprovalTask, ApprovalTaskAction } from '@yss123/cage-client';
9
+ import { ApprovalMemberPicker } from '../../../shared/ui/approval-member-picker';
10
+ import { errorMessage } from '../../../shared/lib/error-message';
11
+ import { useQueryClient } from '@tanstack/react-query';
12
+
13
+ /** 页面允许发起的人员管理操作,归还/领取/释放在任务行直接处理。 */
14
+ export type PersonnelOperation = 'transfer' | 'delegate' | 'parallel' | 'before' | 'after';
15
+ /** 业务名称与引擎 placement 对应;不引入额外执行步骤或暗示自动授予权限。 */
16
+ const labels = { transfer: '转办审批', delegate: '委派协助', parallel: '并行加签', before: '前加签', after: '后加签' } as const;
17
+ const descriptions = {
18
+ transfer: '将这项审批责任交给另一位人员,你将不再处理此任务。',
19
+ delegate: '请他人核对材料,受托人归还后仍由你决定。',
20
+ parallel: '你与新增人员可以同时处理,所有追加意见都是必签。',
21
+ before: '先请新增人员签署,全部完成后这项任务才回到你手中。',
22
+ after: '你先签署,随后激活新增人员的必签任务。',
23
+ };
24
+
25
+ /**
26
+ * task 与 operation 在打开时固定;任务变化以服务端冲突响应为准,不自动改用新版本重试。
27
+ * close 返回当前收件箱,成功通过查询缓存刷新;无效输入禁用提交,Esc/取消不产生写入。
28
+ */
29
+ export function TaskOperationDialog({ task, operation, close }: { task: ApprovalTask; operation: PersonnelOperation; close: () => void }) {
30
+ const [userIds, setUserIds] = useState<string[]>([]), [reason, setReason] = useState('');
31
+ const [busy, setBusy] = useState(false), [error, setError] = useState('');
32
+ const cache = useQueryClient();
33
+ const single = operation === 'transfer' || operation === 'delegate';
34
+ /**
35
+ * 将人员选择映射为对应动作;每次携带打开弹窗时的 revision,旧任务冲突时保留输入供用户核对。
36
+ * 只在服务端确认成功后刷新并关闭,不能把选中人员或提交请求本身视为已经完成交接。
37
+ */
38
+ async function submit(event: FormEvent) {
39
+ event.preventDefault();
40
+ if (busy || !userIds.length || !reason.trim()) return;
41
+ setBusy(true); setError('');
42
+ const input: ApprovalTaskAction = single
43
+ ? { action: operation, targetUserId: userIds[0], reason, expectedRevision: task.revision }
44
+ : { action: 'addSign', placement: operation, userIds, reason, expectedRevision: task.revision };
45
+ const result = await client.approvalTaskAction(task.id, input);
46
+ setBusy(false);
47
+ if (!result.ok) { setError(errorMessage(result)); return; }
48
+ await cache.invalidateQueries(); close();
49
+ }
50
+ return <div className="modal-backdrop"><section className="modal approval-operation-dialog" role="dialog" aria-modal="true" aria-labelledby="approval-operation-title"
51
+ onKeyDown={event => { if (event.key === 'Escape' && !busy) close(); }}>
52
+ <div className="section-head"><div><h2 id="approval-operation-title">{labels[operation]}</h2><p className="muted">{task.title}</p></div>
53
+ <button type="button" disabled={busy} onClick={close} aria-label="关闭操作">×</button></div>
54
+ <form onSubmit={submit}><p>{descriptions[operation]}</p>
55
+ <ApprovalMemberPicker value={userIds} onChange={setUserIds} max={single ? 1 : 20} exclude={[task.assigneeId, task.ownerId].filter((id): id is string => !!id)} disabled={busy} />
56
+ <label>操作原因<textarea autoFocus rows={3} maxLength={1000} required value={reason} disabled={busy} onChange={event => setReason(event.target.value)} placeholder="说明业务原因,供单据参与者查看" /></label>
57
+ {error && <p className="error" role="alert">{error}</p>}
58
+ <div className="actions"><button type="button" disabled={busy} onClick={close}>取消</button><button className="primary" disabled={busy || !userIds.length || !reason.trim()}>{busy ? '提交中…' : `确认${labels[operation]}`}</button></div>
59
+ </form>
60
+ </section></div>;
61
+ }
@@ -1,29 +1,26 @@
1
1
  /**
2
- * 当前账号审批待办的共享查询入口,供审批页及 app 的数量组合复用同一缓存键。
2
+ * 当前账号本人、候选或等待加签任务的共享查询入口,默认待办供 app 数量组合复用。
3
3
  * 任务分配与实际决策授权均来自服务端,不从角色名称在前端自行推导。
4
4
  */
5
5
  import { useQuery } from '@tanstack/react-query';
6
6
  import { client } from '@app/contracts';
7
7
  import { errorMessage } from '../../../shared/lib/error-message';
8
8
 
9
- /**
10
- * 待办列表展示所需字段:id 是任务标识,businessKey 是业务记录键,title 为任务标题,createdAt 为创建时间字符串。
11
- */
12
- type Task = { id: string; businessKey: string; title: string; createdAt: string };
13
-
14
9
  /**
15
10
  * 审批是框架通用接口,不属于某个聚合的生成方法,因此请求封装在审批模块内。
16
11
  * 待办页面和导航角标复用同一缓存键,调用方不需要了解接口地址或响应形状。
17
12
  */
18
13
  /**
19
14
  * 在 React Query 中读取审批待办,非 ok 结果转为查询错误。
15
+ * view 默认 pending,与导航角标共享;candidates/waiting 使用独立缓存,不混入可决定的任务数量。
20
16
  * 返回查询状态及 refetch;空数组表示当前结果没有待办,未加载或失败不等同于零条。
21
17
  */
22
- export function useApprovalTasks() {
18
+ export function useApprovalTasks(view: 'pending' | 'candidates' | 'waiting' = 'pending') {
23
19
  return useQuery({
24
- queryKey: ['tasks'],
20
+ queryKey: ['tasks', view],
25
21
  queryFn: async () => {
26
- const result = await client.get<Task[]>('/api/approval/tasks');
22
+ // 使用框架任务协议保留 revision,避免页面决定丢失任务流转后的并发校验。
23
+ const result = await (view === 'candidates' ? client.approvalCandidateTasks() : client.approvalTasks(view));
27
24
  if (!result.ok) throw new Error(errorMessage(result));
28
25
  return result.data;
29
26
  },
@@ -1,72 +1,104 @@
1
1
  /**
2
- * 个人审批待办页,展示服务端分配的任务并调用标准审批接口提交结论。
3
- * 通过 app 注入业务详情链接,避免审批模块直接依赖采购页面文件。
2
+ * 审批收件箱使用 URL 的 view 切换本人、可领取和等待加签任务,刷新/前进后退保留视图。
3
+ * 通过 app 注入详情链接,界面调用客户端完成领取、释放、人员流转与决定;候选身份和版本仍由服务端校验。
4
+ * 原因是行内临时草稿,动作成功后由 useCommand 失效查询并重读;waiting 仅供查看,不显示决定入口。
4
5
  */
5
- // 审批待办独立成页;结论调用框架审批接口,领域约束仍在后端执行。
6
+ import { useState } from 'react';
6
7
  import { client } from '@app/contracts';
7
- import { Link } from 'react-router-dom';
8
- import { useCommand } from '../../../shared/hooks/use-command';
8
+ import type { ApprovalTask } from '@yss123/cage-client';
9
+ import { Link, useSearchParams } from 'react-router-dom';
10
+ import { useCommand, type RunCommand } from '../../../shared/hooks/use-command';
9
11
  import { Notice } from '../../../shared/ui/notice';
10
12
  import { PageHeading } from '../../../shared/ui/page-heading';
11
13
  import { useApprovalTasks } from '../hooks/use-approval-tasks';
12
14
  import { AssistantPage } from '../../../shared/ui/assistant-scope';
15
+ import './approval-tasks.css';
16
+ import { TaskOperationDialog, type PersonnelOperation } from '../components/task-operation-dialog';
17
+
18
+ /** 三种任务视图进入查询参数;只有 pending 会计入原有导航待办数。 */
19
+ const views = { pending: '待我处理', candidates: '可领取', waiting: '等待加签' } as const;
13
20
 
14
21
  /**
15
- * 查询待办并区分加载、失败与已清空状态;按钮使用 useCommand 共享忙碌及消息反馈。
16
- * @param orderUrl 由 app 将 businessKey 转为采购详情 URL 的函数。
17
- * 通过/驳回/退回对应不同后端流程,页面不直接更新单据状态;成功后刷新查询取得真实任务变化。
22
+ * 为一项任务展示顺序、意见和合法阶段入口。busy 来自整页,提交时防止同页重复操作。
23
+ * 领取/释放/委派归还必须人工填写原因;决定意见可选。每次都提交当前 revision,失败保留草稿供核对。
24
+ * @param task 服务端安全摘要;assigneeId 为空表示尚未领取,不可直接批准。
25
+ * @param orderUrl app 组合的详情 URL 生成器,不访问其他业务模块内部。
26
+ * @param run 统一结果/缓存处理;此组件不乐观修改任务或赋予权限。
27
+ */
28
+ function TaskRow({ task, orderUrl, busy, run }: { task: ApprovalTask; orderUrl: (id: string) => string; busy: boolean; run: RunCommand }) {
29
+ const [reason, setReason] = useState('');
30
+ const [operation, setOperation] = useState<PersonnelOperation | null>(null);
31
+ const waiting = task.status === 'waiting';
32
+ const candidate = task.assigneeId === null;
33
+ const delegated = task.delegation === 'pending';
34
+ const manage = (action: 'claim' | 'unclaim' | 'resolve', success: string) => run(
35
+ () => client.approvalTaskAction(task.id, { action, expectedRevision: task.revision, reason }), success);
36
+ const position = task.signPosition === 'before' ? '前签' : task.signPosition === 'after' ? '后签' : task.taskKind === 'additional' ? '并行加签' : null;
37
+ return (
38
+ <div className="task">
39
+ <div className="approval-task-summary">
40
+ <h3>{task.title}{position && <small className="muted"> · {position}</small>}</h3>
41
+ {!!task.branchPath?.length && <p className="muted">{task.branchPath.map(part => `${part.parallel} · ${part.branch}`).join(' / ')}</p>}
42
+ <p className="mono muted">PO-{task.businessKey.slice(0, 8).toUpperCase()}</p>
43
+ <p className="muted">金额 ¥{String(task.vars.amount ?? '—')}{task.vars.needsSpecialistReview === true ? ' · 含专业会签' : ''}</p>
44
+ <p className="muted">{waiting ? '前置签署完成后恢复处理' : candidate ? '领取后由你负责处理' : delegated ? '受托处理中,请先归还原审批人' : '请核对单据后提交意见'}</p>
45
+ </div>
46
+ <div className="approval-task-controls">
47
+ <div className="actions">
48
+ <Link className="route-link" to={orderUrl(task.businessKey)}>查看采购单 ↗</Link>
49
+ {!waiting && <input aria-label={`${task.title}处理意见`} placeholder={candidate || delegated ? '填写操作原因(必填)' : '审批意见;释放任务时必填'}
50
+ maxLength={1000} value={reason} disabled={busy} onChange={event => setReason(event.target.value)} />}
51
+ </div>
52
+ {!waiting && <div className="actions">
53
+ {candidate ? <button className="primary" disabled={busy || !reason.trim()} onClick={() => void manage('claim', '已领取,请在待我处理中继续')}>领取任务</button>
54
+ : delegated ? <button disabled={busy || !reason.trim()} onClick={() => void manage('resolve', '已归还原审批人')}>归还审批人</button>
55
+ : <>
56
+ {task.assignment === 'candidate' && <button disabled={busy || !reason.trim()} onClick={() => void manage('unclaim', '已释放到候选池')}>释放任务</button>}
57
+ <button disabled={busy} onClick={() => void run(() => client.decideApprovalTask(task.id, 'reject', { expectedRevision: task.revision, comment: reason }), '已驳回')}>驳回</button>
58
+ <button disabled={busy} onClick={() => void run(() => client.decideApprovalTask(task.id, 'return', { expectedRevision: task.revision, comment: reason }), '已退回修改')}>退回修改</button>
59
+ <button disabled={busy} className="primary" onClick={() => void run(() => client.decideApprovalTask(task.id, 'approve', { expectedRevision: task.revision, comment: reason }), '已通过审批')}>通过审批</button>
60
+ {task.actions.transfer && <button disabled={busy} onClick={() => setOperation('transfer')}>转办</button>}
61
+ {task.actions.delegate && <button disabled={busy} onClick={() => setOperation('delegate')}>委派</button>}
62
+ {task.actions.addSign && task.taskKind === 'original' && task.signPositions.map(position => <button key={position} disabled={busy}
63
+ onClick={() => setOperation(position)}>{position === 'before' ? '前加签' : position === 'after' ? '后加签' : '并行加签'}</button>)}
64
+ </>}
65
+ </div>}
66
+ </div>
67
+ {operation && <TaskOperationDialog task={task} operation={operation} close={() => setOperation(null)} />}
68
+ </div>
69
+ );
70
+ }
71
+
72
+ /**
73
+ * 读取当前视图并区分加载、错误与空结果;URL 中未知 view 按默认待办读取,不制造无效请求。
74
+ * @param orderUrl 由 app 将业务主键转为采购详情地址;组件只改变 view 参数,保留其他 URL 参数。
75
+ * 人员操作使用业务协作名录,不读取完整组织用户;动作政策与签序来自实例快照,不按最新声明猜测在途权限。
18
76
  */
19
77
  export function ApprovalTasksPage({ orderUrl }: { orderUrl: (id: string) => string }) {
20
- const tasks = useApprovalTasks();
78
+ const [params, setParams] = useSearchParams();
79
+ const selected = params.get('view');
80
+ const view = selected === 'candidates' || selected === 'waiting' ? selected : 'pending';
81
+ const tasks = useApprovalTasks(view);
21
82
  const { run, busy, message, clearMessage } = useCommand();
22
83
  return (
23
84
  <>
24
85
  <AssistantPage context={{ page: { id: 'approval-inbox', title: '审批待办', kind: 'list', route: '/approvals' },
25
- visibleContent: [{ id: 'pending-tasks', text: tasks.data ? `当前待处理审批 ${tasks.data.length} 项,以重新查询的待办为准。` : '审批待办正在读取或暂时不可用。' }],
86
+ visibleContent: [{ id: 'visible-tasks', text: tasks.data ? `${views[view]}当前显示 ${tasks.data.length} 项,以重新查询为准。` : '审批任务正在读取或暂时不可用。' }],
26
87
  }} suggestions={['查看当前待我处理的审批', '通过、驳回和退回修改有什么区别?']} />
27
- <PageHeading eyebrow="APPROVAL INBOX" title="审批待办" description="处理分配给你的审批任务。" />
88
+ <PageHeading eyebrow="APPROVAL INBOX" title="审批待办" description="领取、处理和跟进你的审批任务。" />
28
89
  {message && <Notice onClose={clearMessage}>{message}</Notice>}
29
- <section className="panel">
90
+ <section className="panel approval-inbox">
30
91
  <div className="section-head">
31
- <h2>待你处理</h2>
32
- <button onClick={() => void tasks.refetch()}>刷新</button>
92
+ <div className="actions" aria-label="审批任务视图">
93
+ {Object.entries(views).map(([key, label]) => <button key={key} disabled={busy} aria-pressed={key === view} className={key === view ? 'primary' : ''}
94
+ onClick={() => setParams(previous => { const next = new URLSearchParams(previous); if (key === 'pending') next.delete('view'); else next.set('view', key); return next; })}>{label}</button>)}
95
+ </div>
96
+ <button disabled={busy || tasks.isFetching} onClick={() => void tasks.refetch()}>刷新</button>
33
97
  </div>
34
98
  {tasks.isError && <p className="error">{tasks.error.message}</p>}
35
- {tasks.isPending && <p className="empty">正在读取待办…</p>}
36
- {tasks.data?.map((task) => (
37
- <div className="task" key={task.id}>
38
- <div>
39
- <h3>{task.title}</h3>
40
- <p className="mono muted">PO-{task.businessKey.slice(0, 8).toUpperCase()}</p>
41
- </div>
42
- <div className="actions">
43
- {/* 单据的页面地址由 app 组合层提供,审批模块不依赖采购页面内部文件。 */}
44
- <Link className="route-link" to={orderUrl(task.businessKey)}>
45
- 查看采购单 ↗
46
- </Link>
47
- <button
48
- disabled={busy}
49
- onClick={() => run(() => client.post(`/api/approval/tasks/${task.id}/reject`), '已驳回')}
50
- >
51
- 驳回
52
- </button>
53
- <button disabled={busy} onClick={() => run(() => client.post(`/api/approval/tasks/${task.id}/return`), '已退回修改')}>退回修改</button>
54
- <button
55
- disabled={busy}
56
- className="primary"
57
- onClick={() => run(() => client.post(`/api/approval/tasks/${task.id}/approve`), '已通过审批')}
58
- >
59
- 通过审批
60
- </button>
61
- </div>
62
- </div>
63
- ))}
64
- {tasks.data?.length === 0 && (
65
- <div className="empty">
66
- <h3>待办已清空</h3>
67
- <p>新任务分配给你后会显示在这里。</p>
68
- </div>
69
- )}
99
+ {tasks.isPending && <p className="empty">正在读取任务…</p>}
100
+ {tasks.data?.map(task => <TaskRow key={`${task.id}:${task.revision}`} task={task} orderUrl={orderUrl} busy={busy} run={run} />)}
101
+ {tasks.data?.length === 0 && <div className="empty"><h3>当前没有{views[view]}的任务</h3><p>任务分配或签署顺序变化后,可刷新查看。</p></div>}
70
102
  </section>
71
103
  </>
72
104
  );
@@ -0,0 +1,18 @@
1
+ /**
2
+ * 审批收件箱局部布局,沿用工作台配色与按钮;将意见、详情及提交分行,避免窄屏压缩链接。
3
+ * 只作用于审批模块,桌面保留事项/操作两栏,空间不足自动换行;等待阶段由组件省略操作控件。
4
+ */
5
+ .approval-inbox .task { flex-wrap: wrap; gap: 20px; }
6
+ .approval-task-summary { flex: 1 1 240px; }
7
+ .approval-task-controls { display: grid; gap: 12px; flex: 0 1 420px; }
8
+ .approval-task-controls .actions { align-items: center; flex-wrap: wrap; }
9
+ .approval-task-controls .route-link { white-space: nowrap; }
10
+ .approval-task-controls input { min-width: 180px; flex: 1; }
11
+ .approval-operation-dialog { width: min(760px, calc(100vw - 32px)); max-height: calc(100vh - 48px); overflow-y: auto; }
12
+ .approval-operation-dialog form { padding: 24px; }
13
+ .approval-operation-dialog textarea { width: 100%; }
14
+ .approval-inbox .section-head { gap: 16px; flex-wrap: wrap; }
15
+ @media (max-width: 660px) {
16
+ .approval-task-controls { flex-basis: 100%; }
17
+ .approval-inbox .task { padding: 20px; }
18
+ }
@@ -1,9 +1,15 @@
1
1
  /**
2
2
  * 采购单审批轨迹展示,按单据与版本读取服务端整理后的轮次和步骤。
3
- * AI 与人工状态使用同一记录来源;受限详情不显示理由,不能根据缺少细节推断未执行。
3
+ * AI 与人工状态使用同一记录来源;并行节点按服务端分支路径展示,序号仅定位节点、不代表串行先后。
4
+ * 受限详情不显示理由,不能根据缺少细节推断未执行;该组件只消费框架历史,不实现审批调度。
4
5
  */
5
- import { useQuery } from '@tanstack/react-query';
6
+ import { useInfiniteQuery, useQuery } from '@tanstack/react-query';
6
7
  import { client } from '@yss123/cage-client';
8
+ import { useApprovalMembers } from '../../../shared/ui/approval-member-picker';
9
+
10
+ /** 人工操作与引擎自动激活都来自已提交历史;未知动作保留原值,不能伪装成通过。 */
11
+ const actionLabels: Record<string, string> = { transfer: '转办', delegate: '委派协助', resolve: '归还审批人', addSign: '增加签署人', claim: '领取任务', unclaim: '释放任务', approved: '通过审批', rejected: '驳回', returned: '退回修改', cancelled: '取消流程', activated: '激活等待任务', skipped: '结束其余待办', advanced: '推进审批分支', urged: '申请人催办', reminded: '流程时限提醒' };
12
+ const positionLabels: Record<string, string> = { before: '前加签', after: '后加签', parallel: '并行加签' };
7
13
 
8
14
  /**
9
15
  * 运行、步骤与人工待办状态的展示映射,未知状态仍由页面按实际值处理。
@@ -19,14 +25,43 @@ export function ApprovalHistory({ id, version, pending }: { id: string; version:
19
25
  if (!result.ok) throw new Error(`审批记录读取失败:${result.code}`);
20
26
  return result.data;
21
27
  }, refetchInterval: pending ? 1500 : false });
28
+ const members = useApprovalMembers(1, true);
29
+ // 跨轮次动作按服务端游标递增分页;轮询重读已经打开的页,避免只更新首屏或丢失后续意见。
30
+ const actions = useInfiniteQuery({ queryKey: ['approval-task-history', id], initialPageParam: undefined as string | undefined,
31
+ queryFn: async ({ pageParam }) => {
32
+ const result = await client.approvalTaskHistory('po-approval', 'PurchaseOrder', id, { after: pageParam, limit: 50 });
33
+ if (!result.ok) throw new Error(`操作记录读取失败:${result.code}`);
34
+ return result.data;
35
+ }, getNextPageParam: last => last.nextCursor ?? undefined, refetchInterval: pending ? 1500 : false });
36
+ const person = (userId: string | null) => userId ? members.data?.items.find(member => member.userId === userId)?.displayName ?? userId.slice(0, 8) : '系统';
22
37
  return <section className="panel approval-history"><div className="section-head"><h2>审批记录</h2><span className="muted">退回后重新提交,从第一步重新审核</span></div>
23
38
  {history.isPending && <p className="muted">正在读取审核记录…</p>}
24
39
  {history.isError && <p role="alert">{history.error.message}</p>}
25
- {history.data?.length === 0 && <p className="muted">提交后先进入 AI 材料预审,再按流程交由人工审批。</p>}
40
+ {history.data?.length === 0 && <p className="muted">提交后按已配置流程审批,实际环节和并行分支将在此显示。</p>}
26
41
  {history.data?.map((round, index) => <div className="review-round" key={round.id}><h3>第 {history.data.length - index} 轮 · {status[round.status] ?? round.status}</h3>
27
- {round.steps.map(step => <div className="review-step" key={step.index}><strong>{step.index + 1}. {step.kind === 'ai' ? 'AI 材料预审' : '人工审批'} · {status[step.status] ?? step.status}</strong>
42
+ {(round.currentSteps?.length ?? 0) > 1 && <p className="muted">当前有 {round.currentSteps!.length} 个审批环节并行办理,全部完成后才继续。</p>}
43
+ {round.steps.map(step => <div className="review-step" key={step.index}><strong>{step.index + 1}. {step.title ?? (step.kind === 'ai' ? 'AI 材料预审' : '人工审批')} · {status[step.status] ?? step.status}</strong>
44
+ {!!step.branchPath?.length && <p className="muted">{step.branchPath.map(part => `${part.parallel} · ${part.branch}`).join(' / ')}</p>}
28
45
  {step.detailRestricted ? <p>当前权限无法查看审核解释。</p> : <>{step.decision && <p>{step.decision.reason}</p>}{step.decision?.suggestions.map((text, i) => <p className="review-suggestion" key={i}>{text}</p>)}</>}
29
46
  </div>)}
30
47
  </div>)}
48
+ <div className="review-actions"><h3>人员流转与处理意见</h3>
49
+ {actions.isPending && <p role="status">正在读取操作记录…</p>}
50
+ {actions.isError && <p role="alert">{actions.error.message} <button onClick={() => void actions.refetch()}>重试</button></p>}
51
+ {actions.data?.pages.flatMap(page => page.items).map(item => {
52
+ const placement = typeof item.details.placement === 'string' ? positionLabels[item.details.placement] : undefined;
53
+ const targets = Array.isArray(item.details.userIds) ? item.details.userIds.filter((value): value is string => typeof value === 'string') : [];
54
+ const to = typeof item.details.to === 'string' ? item.details.to : null;
55
+ const roundIndex = history.data?.findIndex(round => round.id === item.instanceId) ?? -1;
56
+ return <article className="review-step" key={item.id}>
57
+ <strong>{person(item.actorId)} · {placement ?? actionLabels[item.action] ?? item.action}</strong>
58
+ <small className="muted">{roundIndex >= 0 ? `第 ${history.data!.length - roundIndex} 轮 · ` : ''}{new Date(item.createdAt).toLocaleString('zh-CN')}</small>
59
+ {targets.length > 0 ? <p>签署人:{targets.map(person).join('、')}</p> : to && ['transfer', 'delegate', 'resolve'].includes(item.action) ? <p>交给:{person(to)}</p> : null}
60
+ {item.reason && <p>{item.reason}</p>}
61
+ </article>;
62
+ })}
63
+ {actions.data?.pages[0].items.length === 0 && <p className="muted">尚无人工操作记录。</p>}
64
+ {actions.hasNextPage && <button disabled={actions.isFetchingNextPage} onClick={() => void actions.fetchNextPage()}>加载后续记录</button>}
65
+ </div>
31
66
  </section>;
32
67
  }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * 采购详情中的专业复核配置,申请人在草稿或退回后选择实际会签人。
3
+ * 配置通过 setReviewers 命令保存,提交后只读;页面不能更改已经生成的审批实例快照。
4
+ * 名录只用于展示与选人,服务端重查有效名录、提单人隔离、状态、版本及命令权限。
5
+ */
6
+ import { useState } from 'react';
7
+ import { can, purchaseOrder, type PurchaseOrder } from '@app/contracts';
8
+ import { useCommand } from '../../../shared/hooks/use-command';
9
+ import { ApprovalMemberPicker, useApprovalMembers } from '../../../shared/ui/approval-member-picker';
10
+ import { Notice } from '../../../shared/ui/notice';
11
+
12
+ /**
13
+ * order 为安全读取的最新单据;null 草稿表示未编辑,避免后台刷新覆盖用户尚未保存的选人。
14
+ * 保存携带当前业务版本,成功清空本地草稿并刷新缓存;失败保留选择和提示,不重试写入。
15
+ * 名录首页外或已不可读人员保留 UUID,不能把查不到名称解释为已删除或无需签署。
16
+ */
17
+ export function OrderReviewers({ order }: { order: PurchaseOrder }) {
18
+ const [draft, setDraft] = useState<{ enabled: boolean; ids: string[] } | null>(null);
19
+ const members = useApprovalMembers(1, true);
20
+ const { run, busy, message, clearMessage } = useCommand();
21
+ const editable = order.status === 'draft' && can('PurchaseOrder.setReviewers');
22
+ const selection = draft ?? { enabled: order.needsSpecialistReview ?? false, ids: order.reviewerIds ?? [] };
23
+ const names = (order.reviewerIds ?? []).map(id => members.data?.items.find(member => member.userId === id)?.displayName ?? id);
24
+ return <section className="purpose-editor">
25
+ <h2>审批安排{draft && editable && <small className="muted"> · 尚未保存</small>}</h2>
26
+ <p className="muted">{selection.enabled ? '需要所选专业人员全部签署。' : '本单不要求专业人员会签。'}具体流转与其他审批环节以提交后的流程记录为准。</p>
27
+ {message && <Notice onClose={clearMessage}>{message}</Notice>}
28
+ {editable ? <>
29
+ <label className="review-checkbox"><input type="checkbox" checked={selection.enabled} disabled={busy}
30
+ onChange={event => setDraft({ ...selection, enabled: event.target.checked })} />需要专业会签(技术、质量等)</label>
31
+ {selection.enabled && <ApprovalMemberPicker value={selection.ids} onChange={ids => setDraft({ ...selection, ids })} exclude={[order.createdBy]} disabled={busy} />}
32
+ <div className="actions"><span className="muted">退回后可重新安排,重提将开启新一轮审批。</span>
33
+ <button disabled={busy || draft === null || (selection.enabled && !selection.ids.length)} onClick={async () => {
34
+ if (await run(() => purchaseOrder.setReviewers(order.id, { needsSpecialistReview: selection.enabled, reviewerIds: selection.enabled ? selection.ids : [] }, order.version), '审批安排已保存')) setDraft(null);
35
+ }}>保存审批安排</button></div>
36
+ </> : <p>{order.needsSpecialistReview ? `专业会签:${names.join('、')}` : '无需专业会签'}</p>}
37
+ </section>;
38
+ }
@@ -4,10 +4,11 @@
4
4
  */
5
5
  // 只处理采购草稿输入;状态迁移、创建人和权限仍由领域命令决定。
6
6
  import { useState, type FormEvent } from 'react';
7
- import { purchaseOrder, PurchaseOrderFileFields } from '@app/contracts';
7
+ import { purchaseOrder, PurchaseOrderFileFields, currentUser } from '@app/contracts';
8
8
  import type { RunCommand } from '../../../shared/hooks/use-command';
9
9
  import { AttachmentUpload } from '../../../shared/ui/attachment-upload';
10
10
  import './purchase-order-form.css';
11
+ import { ApprovalMemberPicker } from '../../../shared/ui/approval-member-picker';
11
12
 
12
13
  /**
13
14
  * 在弹窗中维护附件列表、上传状态和提交状态。
@@ -18,6 +19,8 @@ export function PurchaseOrderForm({ close, run }: { close: () => void; run: RunC
18
19
  const [busy, setBusy] = useState(false);
19
20
  const [attachments, setAttachments] = useState<string[]>([]);
20
21
  const [uploading, setUploading] = useState(false);
22
+ const [needsSpecialistReview, setNeedsSpecialistReview] = useState(false);
23
+ const [reviewerIds, setReviewerIds] = useState<string[]>([]);
21
24
  /**
22
25
  * 读取 FormData 并构造创建输入:金额保留字符串,数量转数值,可选空文本转 undefined。
23
26
  * 等待 run 返回后解除提交态,只有成功才关闭;依赖 run 返回结果,不额外捕获任意抛出异常。
@@ -35,6 +38,7 @@ export function PurchaseOrderForm({ close, run }: { close: () => void; run: RunC
35
38
  reason: String(data.get('reason')) || undefined,
36
39
  items: [{ sku: String(data.get('sku')), qty: Number(data.get('qty')) }],
37
40
  attachments,
41
+ needsSpecialistReview, reviewerIds: needsSpecialistReview ? reviewerIds : [],
38
42
  }),
39
43
  '采购单已保存为草稿',
40
44
  );
@@ -71,8 +75,11 @@ export function PurchaseOrderForm({ close, run }: { close: () => void; run: RunC
71
75
  </label>
72
76
  <p className="muted">保存后可在列表提交审批,创建人和部门会自动填写。</p>
73
77
  <label>采购用途与必要性<textarea name="reason" rows={3} maxLength={2000} placeholder="说明具体用途;提交后先进行 AI 材料预审" /></label>
78
+ <label className="review-checkbox"><input type="checkbox" checked={needsSpecialistReview} onChange={event => setNeedsSpecialistReview(event.target.checked)} />需要专业会签(技术、质量等)</label>
79
+ {needsSpecialistReview && <ApprovalMemberPicker value={reviewerIds} onChange={setReviewerIds} exclude={currentUser() ? [currentUser()!.id] : []} disabled={busy} />}
80
+ <p className="muted">{needsSpecialistReview ? '所选专业人员需要全部签署。' : '本次不要求专业会签。'}提交后可在审批记录查看实际环节和办理进度。</p>
74
81
  <AttachmentUpload command="PurchaseOrder.create" field="attachments" policy={PurchaseOrderFileFields.attachments} value={attachments} onChange={setAttachments} onBusyChange={setUploading} />
75
- <button className="primary" disabled={busy || uploading}>
82
+ <button className="primary" disabled={busy || uploading || (needsSpecialistReview && reviewerIds.length === 0)}>
76
83
  {busy ? '保存中…' : '保存草稿'}
77
84
  </button>
78
85
  </form>
@@ -13,3 +13,5 @@
13
13
  .review-step { margin: 14px 0; border-left: 2px solid #bbd0bd; padding: 4px 0 4px 18px; font-size: 13px; line-height: 1.8; }
14
14
  .review-step p { margin: 6px 0; }
15
15
  .review-suggestion { color: #956128; }
16
+ .review-actions { margin-top: 24px; border-top: 1px solid var(--line, #e4e8df); padding-top: 12px; }
17
+ .review-actions small { display: block; }
@@ -14,6 +14,7 @@ import { currency, statusLabels } from '../lib/presentation';
14
14
  import { OrderAttachments } from '../components/order-attachments';
15
15
  import { PurchaseAssistantContext } from '../components/purchase-assistant-context';
16
16
  import { ApprovalHistory } from '../components/approval-history';
17
+ import { OrderReviewers } from '../components/order-reviewers';
17
18
  import './purchase-intelligence.css';
18
19
 
19
20
  const list = '/procurement/orders';
@@ -122,6 +123,7 @@ function OrderDetail({ id }: { id: string }) {
122
123
  }}>保存用途</button></div>
123
124
  </> : <p>{order.reason || '尚未填写用途'}</p>}
124
125
  </div>
126
+ <OrderReviewers order={order} />
125
127
  <OrderAttachments order={order} />
126
128
  <div className="section-head">
127
129
  <h2>采购物料</h2>
@@ -170,6 +170,8 @@ export function PurchaseOrdersPage({ pendingTasks }: { pendingTasks?: number })
170
170
  >
171
171
  PO-{order.id.slice(0, 8).toUpperCase()}
172
172
  </Link>
173
+ {/* 用途摘要帮助采购员辨认单据;完整材料仍在详情页按安全读权限展示。 */}
174
+ {order.reason && <small title={order.reason}>{order.reason.length > 48 ? `${order.reason.slice(0, 48)}…` : order.reason}</small>}
173
175
  <small>{new Date(order.createdAt).toLocaleString('zh-CN')}</small>
174
176
  </td>
175
177
  <td>{order.items.map((i) => `${i.sku} × ${i.qty}`).join('、')}</td>