@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
@@ -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>
@@ -14,10 +14,15 @@ export function errorMessage(r: Extract<CageResult<unknown, string>, { ok: false
14
14
  const labels: Record<string, string> = {
15
15
  FORBIDDEN: '你没有执行此操作的权限',
16
16
  INVALID_INPUT: '请检查填写内容',
17
- CONFLICT: '单据已更新,请刷新后重试',
17
+ CONFLICT: '数据或任务已更新,请刷新后重试',
18
18
  UNAUTHENTICATED: '登录已过期',
19
19
  CONSTRAINT_VIOLATED: '未满足审批条件,请检查审批人或额度',
20
20
  STOCK_SHORT: '库存不足',
21
+ REVIEWERS_REQUIRED: '请为专业会签选择 1–5 位协作人',
22
+ REVIEWERS_NOT_REQUIRED: '取消专业会签时请清空复核人员',
23
+ DUPLICATE_REVIEWERS: '专业会签人员不能重复',
24
+ REVIEWER_IS_APPLICANT: '申请人不能担任本单专业会签人',
25
+ REVIEWER_UNAVAILABLE: '所选人员已不可用,请重新核对协作名录',
21
26
  NETWORK: '连接失败,请稍后重试',
22
27
  };
23
28
  return `${labels[r.domainCode ?? r.code] ?? r.domainCode ?? r.code}${r.requestId ? ` · 请求 ${r.requestId.slice(0, 8)}` : ''}`;
@@ -0,0 +1,10 @@
1
+ /** 协作名录的复选行布局供采购和审批模块复用;不覆盖其他表单,窄屏单列保持可点击标签。 */
2
+ .approval-member-picker { border: 1px solid #dbe3d9; border-radius: 8px; padding: 14px; margin: 14px 0; min-width: 0; }
3
+ .approval-member-picker legend { font-size: 12px; padding: 0 6px; font-weight: 600; }
4
+ .approval-member-options { display: grid; grid-template-columns: repeat(auto-fit, minmax(210px, 1fr)); gap: 8px; margin: 12px 0; max-height: 250px; overflow-y: auto; }
5
+ .approval-member-options label { display: flex; align-items: center; gap: 10px; padding: 10px; border: 1px solid #e5e9e2; border-radius: 6px; cursor: pointer; }
6
+ .approval-member-options input[type='checkbox'] { width: 16px; height: 16px; flex: 0 0 auto; }
7
+ .approval-member-options small { display: block; margin-top: 3px; color: #647365; }
8
+ /* 开关与选人复选框保留原生尺寸,避免通用表单的 width:100% 让标签错位。 */
9
+ label.review-checkbox { display: flex; align-items: center; gap: 10px; }
10
+ label.review-checkbox input { width: auto; flex: none; }
@@ -0,0 +1,51 @@
1
+ /**
2
+ * 采购参与者共用的审批协作名录选择器,由采购表单和审批管理弹窗消费。
3
+ * 仅通过生成契约读取 ApprovalMember 安全视图,不读取完整 User 或凭账号名猜测权限。
4
+ * 目录是公开协作身份,不保证被选者对当前单据有审批权限;实际提交由服务端复核,空/失败不能伪装成可选名单。
5
+ */
6
+ import { useState } from 'react';
7
+ import { useQuery } from '@tanstack/react-query';
8
+ import { approvalMember } from '@app/contracts';
9
+ import { errorMessage } from '../lib/error-message';
10
+ import './approval-member-picker.css';
11
+
12
+ /**
13
+ * 在有名录读取权限的界面加载当前页,可按显示名/职责就地筛选;最多读取 200 项,不声称返回完整组织。
14
+ * page 进入查询缓存键,退出账号时由应用会话层清理;retired 默认不用于选人。
15
+ */
16
+ export function useApprovalMembers(page = 1, includeRetired = false) {
17
+ return useQuery({ queryKey: ['approval-members', page, includeRetired], queryFn: async () => {
18
+ const result = await approvalMember.list({ page, pageSize: 200, ...(includeRetired ? {} : { status: 'active' }) });
19
+ if (!result.ok) throw new Error(errorMessage(result));
20
+ return result.data;
21
+ } });
22
+ }
23
+
24
+ /**
25
+ * 单选/多选均输出真实 userId,value 与 onChange 由父表单持有,exclude 只改善体验,不替代服务端重复票检查。
26
+ * max 限制本次选择数量,已选项始终可取消;翻页保留已选 ID,页面展示本次已选数量。
27
+ * disabled 用于在父级提交时冻结编辑;刷新与失败保留用户选择,不自动选择或发起写操作。
28
+ */
29
+ export function ApprovalMemberPicker({ value, onChange, max = 5, exclude = [], disabled = false }: {
30
+ value: string[]; onChange: (ids: string[]) => void; max?: number; exclude?: string[]; disabled?: boolean;
31
+ }) {
32
+ const [search, setSearch] = useState(''), [page, setPage] = useState(1);
33
+ const query = useApprovalMembers(page);
34
+ const entries = query.data?.items.filter(member => !exclude.includes(member.userId) &&
35
+ `${member.displayName} ${member.specialty}`.includes(search.trim())) ?? [];
36
+ return <fieldset className="approval-member-picker" disabled={disabled}>
37
+ <legend>选择协作人员 · 已选 {value.length}/{max}</legend>
38
+ <input aria-label="筛选协作人员" placeholder="按姓名或职责筛选当前页" value={search} onChange={event => setSearch(event.target.value)} />
39
+ {query.isPending && <p role="status">正在读取协作名录…</p>}
40
+ {query.isError && <p role="alert">{query.error.message} <button type="button" onClick={() => void query.refetch()}>重试</button></p>}
41
+ <div className="approval-member-options">{entries.map(member => <label key={member.id}>
42
+ <input type="checkbox" checked={value.includes(member.userId)} disabled={disabled || (!value.includes(member.userId) && value.length >= max && max !== 1)}
43
+ onChange={event => onChange(event.target.checked ? max === 1 ? [member.userId] : [...value, member.userId] : value.filter(id => id !== member.userId))} />
44
+ <span><strong>{member.displayName}</strong><small>{member.specialty}</small></span>
45
+ </label>)}</div>
46
+ {query.data && entries.length === 0 && <p className="muted">当前页没有匹配人员,请调整筛选或联系名录维护人。</p>}
47
+ {(query.data?.total ?? 0) > 200 && <div className="actions"><button type="button" disabled={page === 1} onClick={() => setPage(page - 1)}>上一页</button>
48
+ <span>第 {page} 页</span><button type="button" disabled={page * 200 >= query.data!.total} onClick={() => setPage(page + 1)}>下一页</button></div>}
49
+ <small className="muted">名录只提供选人信息,处理权限、数据范围和最终额度仍以服务端校验为准。</small>
50
+ </fieldset>;
51
+ }
@@ -9,6 +9,7 @@
9
9
  "module:purchase": "采购管理",
10
10
  "module:inventory": "库存管理",
11
11
  "aggregate:PurchaseOrder": "采购单",
12
+ "aggregate:ApprovalMember": "采购审批协作名录",
12
13
  "aggregate:Inventory": "库存台账",
13
14
  "aggregate:InventoryLock": "库存锁",
14
15
  "aggregate:Dept": "部门",
@@ -16,8 +17,21 @@
16
17
  "aggregate:Role": "角色",
17
18
  "command:PurchaseOrder.create": "新建采购单",
18
19
  "command:PurchaseOrder.explainPurpose": "补充采购用途",
20
+ "command:PurchaseOrder.setReviewers": "设置专业复核人员",
19
21
  "command:PurchaseOrder.returnForChanges": "退回修改",
20
22
  "field:PurchaseOrder.reason": "采购用途",
23
+ "field:PurchaseOrder.needsSpecialistReview": "需要专业复核",
24
+ "field:PurchaseOrder.reviewerIds": "专业复核人员",
25
+ "command:ApprovalMember.create": "登记采购协作人",
26
+ "command:ApprovalMember.update": "修改协作人公开资料",
27
+ "command:ApprovalMember.retire": "退役采购协作人",
28
+ "field:ApprovalMember.userId": "协作账号",
29
+ "field:ApprovalMember.displayName": "公开业务姓名",
30
+ "field:ApprovalMember.specialty": "专业领域",
31
+ "state:ApprovalMember.active": "可选协作人",
32
+ "state:ApprovalMember.retired": "已退役协作人",
33
+ "capability:ApprovalMember.read": "查看采购协作名录",
34
+ "capability:ApprovalMember.export": "导出采购协作名录",
21
35
  "capability:Sys.ai.use": "使用智能助理",
22
36
  "capability:Sys.ai.models.manage": "管理 AI 模型",
23
37
  "command:PurchaseOrder.setAttachments": "修改采购附件",
@@ -0,0 +1,27 @@
1
+ -- 迁移草稿:由 cage gen 生成,人审后提交;已发布的迁移不要修改
2
+ -- 破坏性变更按「先扩展再收缩」分多次发布
3
+
4
+ -- 新表:来自聚合 ApprovalMember
5
+ CREATE TABLE IF NOT EXISTS "core"."approval_member" (
6
+ "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
7
+ "tenant_id" uuid NOT NULL,
8
+ "user_id" uuid NOT NULL REFERENCES "sys"."user"(id),
9
+ "display_name" text NOT NULL,
10
+ "specialty" text NOT NULL,
11
+ "status" text NOT NULL,
12
+ "version" integer NOT NULL DEFAULT 1,
13
+ "created_at" timestamptz NOT NULL DEFAULT now(),
14
+ "updated_at" timestamptz NOT NULL DEFAULT now(),
15
+ CONSTRAINT "approval_member_status_check" CHECK (status IN ('active', 'retired'))
16
+ );
17
+ CREATE UNIQUE INDEX IF NOT EXISTS "approval_member_user_id_uq" ON "core"."approval_member" (tenant_id, "user_id");
18
+ CREATE INDEX IF NOT EXISTS "approval_member_status_idx" ON "core"."approval_member" ("tenant_id", "status");
19
+ CREATE INDEX IF NOT EXISTS "approval_member_user_id_idx" ON "core"."approval_member" ("user_id");
20
+
21
+ ALTER TABLE "core"."purchase_order" ADD COLUMN IF NOT EXISTS "needs_specialist_review" boolean;
22
+
23
+ ALTER TABLE "core"."purchase_order" ADD COLUMN IF NOT EXISTS "reviewer_ids" jsonb;
24
+
25
+ /* cage:schema
26
+ {"version":1,"tables":[{"schema":"sys","name":"dept","aggregate":"Dept","columns":[{"name":"id","type":"uuid","notNull":true},{"name":"tenant_id","type":"uuid","notNull":true},{"name":"name","type":"text","notNull":true},{"name":"code","type":"text","notNull":false},{"name":"parent_id","type":"uuid","notNull":false,"references":"sys.dept"},{"name":"ancestors","type":"text","notNull":true},{"name":"leader_id","type":"uuid","notNull":false},{"name":"status","type":"text","notNull":true},{"name":"version","type":"integer","notNull":true},{"name":"created_at","type":"timestamptz","notNull":true},{"name":"updated_at","type":"timestamptz","notNull":true}],"statusValues":["active","archived"],"indexes":[{"name":"dept_status_idx","columns":["tenant_id","status"]},{"name":"dept_parent_id_idx","columns":["parent_id"]}]},{"schema":"sys","name":"user","aggregate":"User","columns":[{"name":"id","type":"uuid","notNull":true},{"name":"tenant_id","type":"uuid","notNull":true},{"name":"username","type":"text","notNull":true,"unique":true},{"name":"name","type":"text","notNull":true},{"name":"dept_id","type":"uuid","notNull":true,"references":"sys.dept"},{"name":"role_ids","type":"jsonb","notNull":true},{"name":"phone","type":"text","notNull":false},{"name":"email","type":"text","notNull":false},{"name":"sso_subject","type":"text","notNull":false},{"name":"password_hash","type":"text","notNull":false},{"name":"status","type":"text","notNull":true},{"name":"version","type":"integer","notNull":true},{"name":"created_at","type":"timestamptz","notNull":true},{"name":"updated_at","type":"timestamptz","notNull":true}],"statusValues":["active","disabled"],"indexes":[{"name":"user_tenant_dept_id_idx","columns":["tenant_id","dept_id"]},{"name":"user_status_idx","columns":["tenant_id","status"]}]},{"schema":"core","name":"approval_member","aggregate":"ApprovalMember","columns":[{"name":"id","type":"uuid","notNull":true},{"name":"tenant_id","type":"uuid","notNull":true},{"name":"user_id","type":"uuid","notNull":true,"references":"sys.user","unique":true},{"name":"display_name","type":"text","notNull":true},{"name":"specialty","type":"text","notNull":true},{"name":"status","type":"text","notNull":true},{"name":"version","type":"integer","notNull":true},{"name":"created_at","type":"timestamptz","notNull":true},{"name":"updated_at","type":"timestamptz","notNull":true}],"statusValues":["active","retired"],"indexes":[{"name":"approval_member_status_idx","columns":["tenant_id","status"]},{"name":"approval_member_user_id_idx","columns":["user_id"]}]},{"schema":"core","name":"inventory","aggregate":"Inventory","columns":[{"name":"id","type":"uuid","notNull":true},{"name":"tenant_id","type":"uuid","notNull":true},{"name":"sku","type":"text","notNull":true,"unique":true},{"name":"available","type":"integer","notNull":true},{"name":"status","type":"text","notNull":true},{"name":"version","type":"integer","notNull":true},{"name":"created_at","type":"timestamptz","notNull":true},{"name":"updated_at","type":"timestamptz","notNull":true}],"statusValues":["active","deleted"],"indexes":[{"name":"inventory_status_idx","columns":["tenant_id","status"]}]},{"schema":"core","name":"inventory_lock","aggregate":"InventoryLock","columns":[{"name":"id","type":"uuid","notNull":true},{"name":"tenant_id","type":"uuid","notNull":true},{"name":"order_id","type":"uuid","notNull":true},{"name":"items","type":"jsonb","notNull":true},{"name":"status","type":"text","notNull":true},{"name":"version","type":"integer","notNull":true},{"name":"created_at","type":"timestamptz","notNull":true},{"name":"updated_at","type":"timestamptz","notNull":true}],"statusValues":["held","released"],"indexes":[{"name":"inventory_lock_status_idx","columns":["tenant_id","status"]}]},{"schema":"core","name":"purchase_order","aggregate":"PurchaseOrder","columns":[{"name":"id","type":"uuid","notNull":true},{"name":"tenant_id","type":"uuid","notNull":true},{"name":"dept_id","type":"uuid","notNull":true,"references":"sys.dept"},{"name":"created_by","type":"uuid","notNull":true,"references":"sys.user"},{"name":"supplier_phone","type":"text","notNull":false},{"name":"amount","type":"numeric(18,2)","notNull":true},{"name":"reason","type":"text","notNull":false},{"name":"needs_specialist_review","type":"boolean","notNull":false},{"name":"reviewer_ids","type":"jsonb","notNull":false},{"name":"items","type":"jsonb","notNull":true},{"name":"erp_no","type":"text","notNull":false},{"name":"attachments","type":"jsonb","notNull":true},{"name":"status","type":"text","notNull":true},{"name":"version","type":"integer","notNull":true},{"name":"created_at","type":"timestamptz","notNull":true},{"name":"updated_at","type":"timestamptz","notNull":true}],"statusValues":["draft","pending_approval","approved","received","rejected","cancelled"],"indexes":[{"name":"purchase_order_tenant_dept_id_idx","columns":["tenant_id","dept_id"]},{"name":"purchase_order_tenant_created_by_idx","columns":["tenant_id","created_by"]},{"name":"purchase_order_status_idx","columns":["tenant_id","status"]}]},{"schema":"sys","name":"role","aggregate":"Role","columns":[{"name":"id","type":"uuid","notNull":true},{"name":"tenant_id","type":"uuid","notNull":true},{"name":"code","type":"text","notNull":true,"unique":true},{"name":"name","type":"text","notNull":true},{"name":"capabilities","type":"jsonb","notNull":true},{"name":"scopes","type":"jsonb","notNull":true},{"name":"status","type":"text","notNull":true},{"name":"version","type":"integer","notNull":true},{"name":"created_at","type":"timestamptz","notNull":true},{"name":"updated_at","type":"timestamptz","notNull":true}],"statusValues":["active","archived"],"indexes":[{"name":"role_status_idx","columns":["tenant_id","status"]}]}]}
27
+ */
@@ -29,6 +29,7 @@
29
29
  | 开发前 | [本体查询与 AI 上下文](ontology-context.md) | 按需求查哪些代码?为什么关联?候选、证据与子图边界怎么判断? |
30
30
  | 开发与排错 | [能力发现与状态诊断](diagnostics.md) | 公开 API 有什么?注册/契约/进程哪里过期?如何查执行时间线和授权拒绝? |
31
31
  | 按需 | [对象关系](relations.md) | 如何在 fields 中声明一对一、一对多、多对多,修改关联并分页读取? |
32
+ | 复杂审批 | [审批流转、并行与流程时效](approvals.md) | 如何声明 AND 并行与汇合,按字段选人、候选领取、转办/委派/加签,并使用框架节点时限、站内提醒、催办和已读? |
32
33
  | 前端必读 | [业务路由与浏览器验收](routing.md) | 列表/详情如何获得独立 URL?登录回跳、404、/admin 和部署回退怎样验收? |
33
34
  | 原生 AI | [智能助理与 AI 审批](intelligence.md) | 如何启用后端、配置三态审核、生成工具、传入页面快照和接入 SDK? |
34
35
  | 4 | [标准采购范例导读](purchase-example.md) | 一次提交如何进入审批、锁库存、推 ERP、释放资源? |
@@ -55,7 +56,9 @@
55
56
  | 增加操作按钮 | 领域命令 + `apps/web/src/modules/<业务>` | 状态迁移、调用身份、错误展示、提交幂等键 |
56
57
  | 增加列表、详情或新页面 | 模块 pages + app 路由表 | URL、查询条件、深链接、刷新、前进后退、登录回跳;见[路由指南](routing.md) |
57
58
  | 增加报表 | 所属领域模块 `queries.ts` | `reads`、`safe.*`、参数化、调用人 read 权限 |
58
- | 修改审批顺序/条件/选人 | 所属领域模块 `approvals.ts` | `routable`、角色/负责人数据、结论命令约束 |
59
+ | 修改审批顺序/条件/并行/选人 | 所属领域模块 `approvals.ts` | `parallel/branch` 的 AND 汇合与完整 currentSteps、`routable`、`approver.field` 的 UUID/数组、角色/负责人数据、同租户 active 用户及结论命令约束;见[任务流转](approvals.md) |
60
+ | 候选领取、转办、委派和前后加签 | 人工 `step` 的 `assignment/actions` + 客户端任务接口 | 候选规则与权限实时检查、waiting 依赖、任务 revision、旧实例快照、系统迁移;Admin/采购已有领取、释放、归还,采购还提供协作名录选人、转办/委派/三种加签及分页人工历史 |
61
+ | 节点时限、流程提醒与申请人催办 | `step.sla`、`route.reminders` + 导出的 `approvalOperations()` | 自然分钟、逐节点开启计时、同实例共享催办冷却;并行催办携带 expectedSteps;沿用框架 Admin 申请/提醒页或 SDK,见[流程时效](approvals.md) |
59
62
  | AI 预审、退回修改、转人工 | 现有 `step` 的 `kind: 'ai'` + `approval` 绑定 | 策略、取证字段/查询、`automatedBy` 角色、结论命令及 `handoff`;见[智能化指南](intelligence.md) |
60
63
  | 业务助理、随页悬浮窗 | domain 导出 `ai()`;页面接 `useAssistant` 与 `useAiPageContext` | 业务权限、页面快照、流式 Markdown、写入确认;界面由应用实现 |
61
64
  | 模型动态配置、Responses、性格 | `managedModel` 与框架 Admin 页面 | 部署加密密钥、管理权限、实际网关能力、性格仅影响表达 |
@@ -86,7 +89,9 @@
86
89
  | 本体抽取、中文检索/节点/路径/AI 上下文、图规则、图谱、生成、影响分析与门禁 | 上下文注明证据、源码和覆盖边界;名称与测试匹配是候选线索,不能证明任意 JS 正确;跳过用例仍需补足 |
87
90
  | 有界集合 reactionEach、显式步骤 order | 稳定唯一键排序后串行调用目标命令;同事务整体回滚,异步链按 outbox 恢复;不是并行写表接口 |
88
91
  | capabilities、doctor、timeline、explain、snapshot | 实际运行诊断需要受控会话与权限;未取得的证据为 unknown,授权解释不执行 handler |
89
- | 代码审批路由与适配接口 | 顺序步骤、条件步骤、步骤内任一/全部审批;没有通用可视化流程设计器、任意 JS 选人、转签/加签 API |
92
+ | 代码审批路由与任务流转 | 顺序/条件、`parallel/branch` 结构化 AND 分支与汇合、any/all、字段选人、候选领取/释放、转办、单层委派归还、前后/并行必签和动作历史;候选池仅支持 any,追加票全部必签。条件和依赖随提交冻结,currentStep 仅为最小活动索引;尚无任意连线/循环/OR 网关、岗位/代理规则、通用恢复、任意 JS 选人或在途迁移,见[任务流转](approvals.md) |
93
+ | 审批时限、站内提醒与催办 | 人工节点按实际开启时间计算自然分钟 SLA,worker 持久投递临期/超时提醒;申请人可按完整活动集合催办,同实例共享冷却,接收人可标已读。旧快照不自动获得政策;尚无工作日历、自动升级、抄送或邮件/短信通知 |
94
+ | 审批界面与 SDK | Admin/采购待办可领取、释放、归还受托任务并查看等待加签;采购另有协作名录选人器、转办/委派/三种加签及分页人工历史,view 查询参数保留视图。启用运营后通用 Admin 提供我的申请、催办和流程提醒;页面仍需应用启用、真实授权与浏览器验收。AI 助理目前只查询本人待办及提交决定,无人员管理写工具 |
90
95
  | 原生 AI 审批与领域助理 | 现有 `step` 支持 `kind: 'ai'`;三态审核、自动工具、会话及偏好记忆可复用;需显式启用、授权并做模型质量验收 |
91
96
  | Admin 模型与性格、助理 SDK | 动态模型连接和性格管理;Chat Completions / Responses 兼容性以实际服务为准;SDK 提供流式状态与页面快照,悬浮窗和 Markdown 由应用实现 |
92
97
  | 连接器 fake、出站、入站验签、同步记录 | fake 不等于真实 ERP 联调,也不等于完整请求录制/回放平台 |