@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,4 +1,8 @@
1
- // 模型决策用固定脚本,回归实际采购声明如何消费框架 AI 节点、命令和审批引擎。
1
+ /**
2
+ * 以实际采购声明、应用迁移、真实 PostgreSQL 和 worker 回归 AI 预审到人工 OA 的完整业务链。
3
+ * 只把模型生成器换为固定工具裁决,采购路由、协作名录、结论命令及库存反应仍使用应用实现。
4
+ * 断言任务等待、会签完成和最终库存占用的区别;不连接应用数据库,也不把测试成功当作浏览器验收。
5
+ */
2
6
  import { beforeAll, afterAll, expect, it } from 'vitest';
3
7
  import { randomUUID } from 'node:crypto';
4
8
  import { fileURLToPath } from 'node:url';
@@ -12,27 +16,64 @@ let decision: 'pass' | 'return' | 'manual' = 'return';
12
16
  /** 保留真实工具调用协议,只替换模型生成器;领域声明、审批引擎、数据库和 worker 仍使用实际实现。 */
13
17
  const intelligence = ai({ models: { main: { spec: { kind: 'test-only' }, generate: async (): Promise<ModelResult> => ({ text: '', calls: [{ id: randomUUID(), name: 'review_decide',
14
18
  arguments: { decision, reason: '按当前用途审查', suggestions: decision === 'return' ? ['补充具体使用场景'] : [], evidence: [{ source: 'subject', detail: 'reason 字段' }] } }] }) } } });
15
- let env: TestEnv, buyer: TestUser, reviewer: TestUser;
19
+ let env: TestEnv, buyer: TestUser, reviewer: TestUser, specialistA: TestUser, specialistB: TestUser, financeA: TestUser, financeB: TestUser;
16
20
  /**
17
21
  * 显式建立人工负责人和自动审查角色,证明声明本身不授予 AI 执行权限。
18
22
  * 自动角色只获 read/returnForChanges,不授 approve;通过 AI 节点后必须继续走人工经理结论。
19
23
  */
20
24
  beforeAll(async () => {
21
25
  env = await createTestEnv({ domain: { ...purchase, intelligence }, migrations: readMigrations(fileURLToPath(new URL('../../../../../db/migrations/', import.meta.url))) });
22
- buyer = await env.user({ caps: ['PurchaseOrder.read', 'PurchaseOrder.create', 'PurchaseOrder.explainPurpose', 'PurchaseOrder.submit'] });
26
+ buyer = await env.user({ caps: ['PurchaseOrder.read', 'PurchaseOrder.create', 'PurchaseOrder.explainPurpose', 'PurchaseOrder.submit', 'PurchaseOrder.setReviewers', 'ApprovalMember.read'],
27
+ scopes: { ApprovalMember: 'all' } });
23
28
  reviewer = await env.user({ caps: ['PurchaseOrder.read', 'PurchaseOrder.approve', 'PurchaseOrder.returnForChanges'], scopes: { PurchaseOrder: 'dept' }, params: { 'PurchaseOrder.approve': { ceiling: 50000 } } });
24
29
  await env.kernel.execute('Dept.update', { id: env.rootDeptId, leaderId: reviewer.id }, env.system());
25
30
  await env.kernel.execute('Inventory.create', { sku: 'AI-01', available: 100 }, env.system());
26
31
  const role = await env.kernel.execute('Role.create', { code: 'purchase_ai_reviewer', name: '材料预审角色' }, env.system());
27
32
  for (const capability of ['PurchaseOrder.read', 'PurchaseOrder.returnForChanges']) await env.kernel.execute('Role.grantCapability', { id: role.id, capability }, env.system());
28
33
  await env.kernel.execute('Role.setScope', { id: role.id, aggregate: 'PurchaseOrder', combinator: 'all' }, env.system());
34
+ specialistA = await collaborator('设备技术复核'); specialistB = await collaborator('采购合规复核');
35
+ financeA = await collaborator('财务共享受理甲'); financeB = await collaborator('财务共享受理乙');
36
+ const financeRole = await env.kernel.execute('Role.create', { code: 'finance', name: '采购财务候选人员' }, env.system());
37
+ for (const user of [financeA, financeB]) await env.kernel.execute('User.assignRoles', { id: user.id, roleIds: [user.roleId, financeRole.id] }, env.system());
29
38
  });
30
39
  afterAll(() => env?.close());
31
- /** 创建材料不足的固定单据并消费当前事件,让每个测试从真实 AI 节点的处理结果开始。 */
32
- async function submit() {
33
- const order = await env.kernel.execute('PurchaseOrder.create', { amount: '1200.00', reason: '用途待补充', items: [{ sku: 'AI-01', qty: 2 }] }, await buyer.ctx());
40
+
41
+ /**
42
+ * 通过组织和业务命令登记同部门协作人;10 万元额度只供本隔离库验证大额流程,名录本身不授予能力。
43
+ * 专业和财务共享这些真实审批能力,财务候选身份由额外的 finance 角色单独赋予。
44
+ */
45
+ async function collaborator(specialty: string) {
46
+ const user = await env.user({ caps: ['PurchaseOrder.read', 'PurchaseOrder.approve', 'PurchaseOrder.reject', 'PurchaseOrder.returnForChanges'],
47
+ scopes: { PurchaseOrder: 'dept' }, params: { 'PurchaseOrder.approve': { ceiling: 100000 } } });
48
+ await env.kernel.execute('ApprovalMember.create', { userId: user.id, displayName: specialty, specialty }, env.system());
49
+ return user;
50
+ }
51
+
52
+ /** 创建固定物料草稿;金额按元传输,专业名单仍交真实 create/submit 命令校验。 */
53
+ async function draft(options: { amount?: string; needsSpecialistReview?: boolean; reviewerIds?: string[] } = {}) {
54
+ return env.kernel.execute('PurchaseOrder.create', { amount: '1200.00', reason: '用途待补充', items: [{ sku: 'AI-01', qty: 2 }], ...options }, await buyer.ctx());
55
+ }
56
+
57
+ /** 提交真实采购单并消费当前事件;固定模型输出使每例从明确 AI 结果开始,不替换人工审批路由。 */
58
+ async function submit(options: { amount?: string; needsSpecialistReview?: boolean; reviewerIds?: string[] } = {}) {
59
+ const order = await draft(options);
34
60
  await env.kernel.execute('PurchaseOrder.submit', { id: order.id }, await buyer.ctx()); await env.drain(); return order;
35
61
  }
62
+
63
+ /** 从操作者本人待办重新取得当前 revision,避免复用动作发生前的旧页面版本。 */
64
+ async function taskOf(user: TestUser, orderId: string, status = 'pending') {
65
+ const task = (await purchase.poRoute.engine(env.kernel).tasksOf(user.id, env.tenantId, status)).find(item => item.businessKey === orderId);
66
+ expect(task).toBeDefined(); return task!;
67
+ }
68
+
69
+ /** 通过真实待办批准当前节点,最终节点才会执行采购领域命令和同事务库存占用。 */
70
+ async function approveBy(user: TestUser, orderId: string) {
71
+ const task = await taskOf(user, orderId);
72
+ await purchase.poRoute.engine(env.kernel).decide(task.id, user.id, env.tenantId, 'approved', undefined, task.revision);
73
+ }
74
+
75
+ /** 以采购员安全读侧核对业务状态,不从任务通过的局部结果推断整张单据已经批准。 */
76
+ async function orderStatus(orderId: string) { return (await env.kernel.read.get(purchase.PurchaseOrder, orderId, await buyer.ctx())).status; }
36
77
  /** 退回不覆盖旧审批证据;重新提交产生新轮次,历史保留上次建议且最新轮次继续经理待办。 */
37
78
  it('AI 退回修改后保存用途并重新提交,形成新轮次且先重新经过 AI 节点', async () => {
38
79
  decision = 'return'; const order = await submit();
@@ -49,7 +90,7 @@ it('AI 通过只推进到经理节点,经理通过后才执行批准命令及
49
90
  decision = 'pass'; const order = await submit(), engine = purchase.poRoute.engine(env.kernel);
50
91
  expect((await env.kernel.read.get(purchase.PurchaseOrder, order.id, await buyer.ctx())).status).toBe('pending_approval');
51
92
  const task = (await engine.tasksOf(reviewer.id, env.tenantId)).find(t => t.businessKey === order.id)!;
52
- await engine.decide(task.id, reviewer.id, env.tenantId, 'approved');
93
+ await engine.decide(task.id, reviewer.id, env.tenantId, 'approved', undefined, task.revision);
53
94
  expect((await env.kernel.read.get(purchase.PurchaseOrder, order.id, await buyer.ctx())).status).toBe('approved');
54
95
  expect((await env.query('select status from core.inventory_lock where id = $1', [order.id]))[0].status).toBe('held');
55
96
  });
@@ -60,3 +101,124 @@ it('AI 转人工保留在本节点,人工可退回修改', async () => {
60
101
  await engine.decide(task.id, reviewer.id, env.tenantId, 'returned', '请补充审批材料');
61
102
  expect((await env.kernel.read.get(purchase.PurchaseOrder, order.id, await buyer.ctx())).status).toBe('draft');
62
103
  });
104
+
105
+ /** 草稿允许暂存,但编辑和提交都拒绝不完整、自审、重复或未登记名单,失败不产生审批实例。 */
106
+ it.each(['无人', '重复', '本人', '未登记', '关闭仍有名单'] as const)('专业复核名单%s时,设置和提交均拒绝且保留草稿', async boundary => {
107
+ const reviewerIds = boundary === '无人' ? [] : boundary === '重复' ? [specialistA.id, specialistA.id]
108
+ : boundary === '本人' ? [buyer.id] : boundary === '未登记' ? [randomUUID()] : [specialistA.id];
109
+ const needsSpecialistReview = boundary !== '关闭仍有名单';
110
+ const domainCode = { 无人: 'REVIEWERS_REQUIRED', 重复: 'DUPLICATE_REVIEWERS', 本人: 'REVIEWER_IS_APPLICANT', 未登记: 'REVIEWER_UNAVAILABLE', 关闭仍有名单: 'REVIEWERS_NOT_REQUIRED' }[boundary];
111
+ const order = await draft({ needsSpecialistReview, reviewerIds });
112
+ expect(await env.callAs(await buyer.ctx(), 'PurchaseOrder.setReviewers', { id: order.id, needsSpecialistReview, reviewerIds })).toMatchObject({ ok: false, domainCode });
113
+ expect(await env.callAs(await buyer.ctx(), 'PurchaseOrder.submit', { id: order.id })).toMatchObject({ ok: false, domainCode });
114
+ expect(await orderStatus(order.id)).toBe('draft');
115
+ expect(await env.query('select id from flow.approval_instance where business_key=$1', [order.id])).toHaveLength(0);
116
+ });
117
+
118
+ /** 名录通过业务 read 开放,用户无需 User.read;提交会重查设置后退役的成员,防止旧草稿带入新流程。 */
119
+ it('采购员无需系统用户读权限即可配置协作人,但提交拒绝已退役名单', async () => {
120
+ const person = await env.user({ caps: [] });
121
+ const member = await env.kernel.execute('ApprovalMember.create', { userId: person.id, displayName: '临时合同复核', specialty: '合同条款' }, env.system());
122
+ expect((await env.kernel.read.get(purchase.ApprovalMember, member.id, await buyer.ctx())).displayName).toBe('临时合同复核');
123
+ const order = await draft();
124
+ await env.kernel.execute('PurchaseOrder.setReviewers', { id: order.id, needsSpecialistReview: true, reviewerIds: [person.id] }, await buyer.ctx());
125
+ await env.kernel.execute('ApprovalMember.retire', { id: member.id }, env.system());
126
+ expect(await env.callAs(await buyer.ctx(), 'PurchaseOrder.submit', { id: order.id })).toMatchObject({ ok: false, domainCode: 'REVIEWER_UNAVAILABLE' });
127
+ expect(await orderStatus(order.id)).toBe('draft');
128
+ });
129
+
130
+ /** 两位字段选人均须完成;第一票不能提前触发批准命令和库存锁,最终签署者仍走真实领域约束。 */
131
+ it('经理后开启所选专业人员 all 会签,全部通过才批准并锁库存', async () => {
132
+ decision = 'pass'; const order = await submit({ needsSpecialistReview: true, reviewerIds: [specialistA.id, specialistB.id] });
133
+ await approveBy(reviewer, order.id);
134
+ const a = await taskOf(specialistA, order.id), b = await taskOf(specialistB, order.id);
135
+ expect(a.step).toBe(2); expect(b.step).toBe(2); expect(a.id).not.toBe(b.id);
136
+ expect(a.actions).toEqual({ transfer: true, delegate: true, addSign: true });
137
+ // 历史标题来自当前实例的步骤快照,帮助详情区分部门审核与专业会签;不读取或展示 AI 策略全文。
138
+ const history = await purchase.poRoute.engine(env.kernel).history('PurchaseOrder', order.id, await buyer.ctx());
139
+ expect(history[0].steps.map(step => step.title)).toEqual(['AI 材料预审', '部门经理审批', '专业人员会签']);
140
+ await approveBy(specialistA, order.id);
141
+ expect(await orderStatus(order.id)).toBe('pending_approval');
142
+ expect(await env.query('select id from core.inventory_lock where id=$1', [order.id])).toHaveLength(0);
143
+ await approveBy(specialistB, order.id);
144
+ expect(await orderStatus(order.id)).toBe('approved');
145
+ expect((await env.query('select status from core.inventory_lock where id=$1', [order.id]))[0].status).toBe('held');
146
+ });
147
+
148
+ /** gt 阈值必须保持严格大于;恰好 5 万元时经理的同额 ceiling 足够,不创建财务候选任务。 */
149
+ it('恰好 50000 元经理通过后直接批准,不产生财务候选待办', async () => {
150
+ decision = 'pass'; const order = await submit({ amount: '50000.00' });
151
+ await approveBy(reviewer, order.id);
152
+ expect(await orderStatus(order.id)).toBe('approved');
153
+ expect((await purchase.poRoute.engine(env.kernel).candidateTasks(financeA.id, env.tenantId)).some(task => task.businessKey === order.id)).toBe(false);
154
+ });
155
+
156
+ /** 同一大额采购仅建一个候选任务,领取/释放保留 revision,另一财务领取并批准后才产生库存锁。 */
157
+ it('超过 50000 元进入财务共享候选池,释放后另一人领取并完成真实采购审批', async () => {
158
+ decision = 'pass'; const order = await submit({ amount: '50000.01' }), engine = purchase.poRoute.engine(env.kernel);
159
+ await approveBy(reviewer, order.id);
160
+ const a = (await engine.candidateTasks(financeA.id, env.tenantId)).find(task => task.businessKey === order.id)!;
161
+ const b = (await engine.candidateTasks(financeB.id, env.tenantId)).find(task => task.businessKey === order.id)!;
162
+ expect(a.id).toBe(b.id); expect(a.assignment).toBe('candidate'); expect(a.assigneeId).toBeNull();
163
+ await expect(engine.decide(a.id, financeA.id, env.tenantId, 'approved', undefined, a.revision)).rejects.toMatchObject({ code: 'NOT_FOUND' });
164
+ await engine.act(a.id, financeA.id, env.tenantId, { action: 'claim', expectedRevision: a.revision, reason: '财务甲领取大额采购' });
165
+ const claimed = await taskOf(financeA, order.id);
166
+ await engine.act(a.id, financeA.id, env.tenantId, { action: 'unclaim', expectedRevision: claimed.revision, reason: '转回共享池由在岗财务受理' });
167
+ await expect(engine.act(b.id, financeB.id, env.tenantId, { action: 'claim', expectedRevision: b.revision, reason: '旧列表尝试领取' })).rejects.toMatchObject({ code: 'CONFLICT' });
168
+ const available = (await engine.candidateTasks(financeB.id, env.tenantId)).find(task => task.businessKey === order.id)!;
169
+ await engine.act(available.id, financeB.id, env.tenantId, { action: 'claim', expectedRevision: available.revision, reason: '财务乙刷新后领取' });
170
+ expect(await orderStatus(order.id)).toBe('pending_approval');
171
+ await approveBy(financeB, order.id);
172
+ expect(await orderStatus(order.id)).toBe('approved');
173
+ expect((await env.query('select status from core.inventory_lock where id=$1', [order.id]))[0].status).toBe('held');
174
+ const history = await engine.taskHistory('PurchaseOrder', order.id, await buyer.ctx(), { limit: 100 });
175
+ expect(history.items.map(entry => entry.action)).toEqual(['approved', 'claim', 'unclaim', 'claim', 'approved']);
176
+ });
177
+
178
+ /** 转办在本例经理节点真实开放,原人不能用旧待办决定;最终采购命令使用接手人的身份。 */
179
+ it('经理将采购审核转办给协作人,由接手人完成批准并留下转办和决定记录', async () => {
180
+ decision = 'pass'; const order = await submit(), engine = purchase.poRoute.engine(env.kernel), task = await taskOf(reviewer, order.id);
181
+ await engine.act(task.id, reviewer.id, env.tenantId, { action: 'transfer', targetUserId: specialistA.id, expectedRevision: task.revision, reason: '设备细节交专业人员审核' });
182
+ await expect(engine.decide(task.id, reviewer.id, env.tenantId, 'approved', undefined, task.revision)).rejects.toMatchObject({ code: 'NOT_FOUND' });
183
+ await approveBy(specialistA, order.id);
184
+ expect(await orderStatus(order.id)).toBe('approved');
185
+ const history = await engine.taskHistory('PurchaseOrder', order.id, await buyer.ctx());
186
+ expect(history.items.map(entry => entry.action)).toEqual(['transfer', 'approved']);
187
+ expect(await env.audit({ aggregateId: order.id, command: 'PurchaseOrder.approve', kind: 'write' })).toMatchObject([{ on_behalf_of: specialistA.id }]);
188
+ });
189
+
190
+ /** 委派用于协助核验,接手人只能完成归还;原经理作最终决定,避免把委派误作转办。 */
191
+ it('经理委派采购材料核查,协作人归还后仍由经理正式批准', async () => {
192
+ decision = 'pass'; const order = await submit(), engine = purchase.poRoute.engine(env.kernel), task = await taskOf(reviewer, order.id);
193
+ await engine.act(task.id, reviewer.id, env.tenantId, { action: 'delegate', targetUserId: specialistA.id, expectedRevision: task.revision, reason: '请核对设备技术材料' });
194
+ const delegated = await taskOf(specialistA, order.id);
195
+ expect(delegated).toMatchObject({ ownerId: reviewer.id, delegation: 'pending' });
196
+ await expect(engine.decide(delegated.id, specialistA.id, env.tenantId, 'approved', undefined, delegated.revision)).rejects.toMatchObject({ code: 'INVALID_TRANSITION' });
197
+ await engine.act(delegated.id, specialistA.id, env.tenantId, { action: 'resolve', expectedRevision: delegated.revision, reason: '已核对技术材料并归还' });
198
+ expect(await orderStatus(order.id)).toBe('pending_approval');
199
+ await approveBy(reviewer, order.id);
200
+ expect(await orderStatus(order.id)).toBe('approved');
201
+ const history = await engine.taskHistory('PurchaseOrder', order.id, await buyer.ctx());
202
+ expect(history.items.map(entry => entry.action)).toEqual(['delegate', 'resolve', 'approved']);
203
+ });
204
+
205
+ /** 三种加签共用真实采购单:首票后都不能建锁,前/后置的等待状态必须按指定签序开放。 */
206
+ it.each(['before', 'parallel', 'after'] as const)('经理节点 %s 加签要求原审批与附加意见都完成才批准采购', async placement => {
207
+ decision = 'pass'; const order = await submit(), engine = purchase.poRoute.engine(env.kernel), task = await taskOf(reviewer, order.id);
208
+ await engine.act(task.id, reviewer.id, env.tenantId, { action: 'addSign', userIds: [specialistA.id], placement, expectedRevision: task.revision, reason: '采购设备补充专业意见' });
209
+ if (placement === 'before') {
210
+ expect((await taskOf(reviewer, order.id, 'waiting')).id).toBe(task.id);
211
+ await approveBy(specialistA, order.id);
212
+ } else {
213
+ if (placement === 'after') {
214
+ const waiting = await taskOf(specialistA, order.id, 'waiting');
215
+ await expect(engine.decide(waiting.id, specialistA.id, env.tenantId, 'approved', undefined, waiting.revision)).rejects.toMatchObject({ code: 'INVALID_TRANSITION' });
216
+ }
217
+ await approveBy(reviewer, order.id);
218
+ }
219
+ expect(await orderStatus(order.id)).toBe('pending_approval');
220
+ expect(await env.query('select id from core.inventory_lock where id=$1', [order.id])).toHaveLength(0);
221
+ await approveBy(placement === 'before' ? reviewer : specialistA, order.id);
222
+ expect(await orderStatus(order.id)).toBe('approved');
223
+ expect((await env.query('select status from core.inventory_lock where id=$1', [order.id]))[0].status).toBe('held');
224
+ });
@@ -1,7 +1,11 @@
1
- // 采购单声明集中列出状态、范围、不变量、命令和事件。
2
- // handler 只读声明的依赖;数据库写入、版本检查、审计由内核完成。
1
+ /**
2
+ * 采购单声明集中列出申请材料、专业复核名单、状态、范围、命令和事件,由 API、worker 和生成器统一消费。
3
+ * 草稿可编辑专业复核名单,提交重新核对协作名录;handler 仅读取声明依赖,数据库写入、版本与审计由内核完成。
4
+ * 审批结论仍以 process 命令执行职责分离、额度和库存检查;选入名录或路由不授予权限。
5
+ */
3
6
  import {
4
7
  aggregate,
8
+ bool,
5
9
  ceiling,
6
10
  command,
7
11
  emit,
@@ -17,9 +21,11 @@ import {
17
21
  sod,
18
22
  states,
19
23
  text,
24
+ type ReadRepo,
20
25
  } from '@yss123/cage-kernel';
21
26
  import { z } from 'zod';
22
27
  import { Inventory } from '../inventory/index.js';
28
+ import { ApprovalMember } from './approval-members.js';
23
29
 
24
30
  /**
25
31
  * 采购单生命周期:草稿提交后等待审批,通过后等待外部收货;退回修改重新进入草稿。
@@ -46,6 +52,10 @@ export const PurchaseOrder = aggregate('PurchaseOrder', {
46
52
  amount: money(),
47
53
  /** 可为空的用途说明;创建可先存草稿,AI 预审会判断是否足以支撑审批。 */
48
54
  reason: text(2000).optional(),
55
+ /** 是否增加专业会签;旧记录 null/缺省按 false,不改变已经启动的审批实例。 */
56
+ needsSpecialistReview: bool().optional(),
57
+ /** 提交快照的专业复核用户 UUID;旧记录可为空,启用复核时必须为 1–5 个不同且非创建人的有效名录用户。 */
58
+ reviewerIds: list(id()).optional(),
49
59
  /** 申请明细快照:sku 为物料编码,qty 为整数件数;输入额外要求每行数量为正。 */
50
60
  items: list({ sku: text(64), qty: int() }),
51
61
  /** ERP 成功响应后回填的外部单号,尚未同步时为空;不作为采购单生命周期状态。 */
@@ -76,6 +86,22 @@ export const OrderReceived = event('OrderReceived', { orderId: id() });
76
86
  /** 命令输入比持久化 int 原语更具体:至少一行且每行数量为正,避免零数量申请进入审批。 */
77
87
  const itemsInput = z.array(z.object({ sku: z.string(), qty: z.number().int().positive() })).min(1);
78
88
 
89
+ /**
90
+ * 在设置和提交时核对专业复核名单,防止界面被绕过、名录退役或重复账号减少会签人数。
91
+ * disabled/范围/批准能力仍由审批运行时处理;此处只按当前命令声明的名录读依赖判断业务成员 active。
92
+ * 关闭专业复核必须传空名单;旧记录 null 等价 false/[]。最多五人使逐人查询有明确上限。
93
+ */
94
+ async function validateReviewers(needsReview: boolean | null | undefined, ids: string[] | null | undefined, creator: string, members: ReadRepo<typeof ApprovalMember>) {
95
+ const selected = ids ?? [];
96
+ if (!needsReview) return selected.length ? fail('REVIEWERS_NOT_REQUIRED', '未启用专业复核时请清空复核人员') : undefined;
97
+ if (selected.length < 1 || selected.length > 5) return fail('REVIEWERS_REQUIRED', '专业复核需选择 1–5 位协作人');
98
+ if (new Set(selected).size !== selected.length) return fail('DUPLICATE_REVIEWERS', '同一人不能重复承担专业会签');
99
+ if (selected.includes(creator)) return fail('REVIEWER_IS_APPLICANT', '提单人不能担任本单专业复核人');
100
+ for (const userId of selected) {
101
+ if (!await members.findOne({ userId, status: 'active' })) return fail('REVIEWER_UNAVAILABLE', '所选专业复核人不在有效协作名录中');
102
+ }
103
+ }
104
+
79
105
  /**
80
106
  * 保存草稿;金额格式、字段长度和不变量由内核随后统一校验,handler 无额外副作用。
81
107
  * deptId 可指定但仍须满足创建范围;attachments 为已完成上传 ID,省略时初始化为空集合。
@@ -89,10 +115,28 @@ export const create = command(PurchaseOrder, {
89
115
  items: itemsInput,
90
116
  supplierPhone: z.string().optional(),
91
117
  reason: z.string().max(2000).optional(),
118
+ needsSpecialistReview: z.boolean().optional(),
119
+ reviewerIds: z.array(z.string().uuid()).max(5).optional(),
92
120
  deptId: z.string().uuid().optional(),
93
121
  attachments: z.array(z.string().uuid()).max(10).optional(),
94
122
  }),
95
- handler: (o) => o,
123
+ // 草稿允许先保存未选完的复核需求;提交才要求完整名单,新记录统一 false/[],旧记录兼容 null。
124
+ handler: (o) => ({ ...o, needsSpecialistReview: o.needsSpecialistReview ?? false, reviewerIds: o.reviewerIds ?? [] }),
125
+ });
126
+
127
+ /**
128
+ * 在 draft 中调整本单专业复核需求和名单;需要 PurchaseOrder.setReviewers,不能直接修改待审批快照。
129
+ * 输入保存前核对 1–5 位不同且非提单人的 active 名录成员;关闭时用 false/[],失败整次回滚。
130
+ * 提交还会重新查询名录,以拒绝保存后退役的成员;名录不授予对单据的读取、批准能力或额度。
131
+ */
132
+ export const setReviewers = command(PurchaseOrder, {
133
+ name: 'setReviewers', description: '设置采购单专业复核需求和人员', from: 'draft', to: null,
134
+ input: z.object({ id: z.string().uuid(), needsSpecialistReview: z.boolean(), reviewerIds: z.array(z.string().uuid()).max(5) }),
135
+ reads: { members: ApprovalMember },
136
+ handler: async (draft, input, { members }) => {
137
+ const invalid = await validateReviewers(input.needsSpecialistReview, input.reviewerIds, draft.createdBy, members);
138
+ return invalid ?? { needsSpecialistReview: input.needsSpecialistReview, reviewerIds: input.reviewerIds };
139
+ },
96
140
  });
97
141
 
98
142
  // 退回后的说明只在草稿状态修改,保留提交时的版本校验和原始审计。
@@ -119,12 +163,19 @@ export const setAttachments = command(PurchaseOrder, {
119
163
  handler: (_draft, input) => ({ attachments: input.attachments }),
120
164
  });
121
165
 
122
- /** 提交只改变状态并记录 OrderSubmitted;审批实例由事件消费启动,HTTP 成功不代表审批已完成。 */
166
+ /**
167
+ * 提交前重查专业复核名单,避免草稿保存后退役的成员进入新流程;缺省旧字段按无需复核处理。
168
+ * 成功只改变状态并记录 OrderSubmitted,实例由 worker 消费后创建;HTTP 成功不表示审批完成。
169
+ */
123
170
  export const submit = command(PurchaseOrder, {
124
171
  from: 'draft',
125
172
  to: 'pending_approval',
126
173
  emits: [OrderSubmitted],
127
- handler: (o) => (o.items.length === 0 ? fail('NO_ITEMS') : o),
174
+ reads: { members: ApprovalMember },
175
+ handler: async (o, _input, { members }) => {
176
+ if (o.items.length === 0) return fail('NO_ITEMS');
177
+ return await validateReviewers(o.needsSpecialistReview, o.reviewerIds, o.createdBy, members) ?? o;
178
+ },
128
179
  });
129
180
 
130
181
  /**
@@ -11,9 +11,9 @@
11
11
  "build:app": "pnpm --filter @app/domain build && pnpm --filter @app/api build && pnpm --filter @app/web build"
12
12
  },
13
13
  "devDependencies": {
14
- "@yss123/cage-cli": "0.3.0",
15
- "@yss123/cage-testkit": "0.3.0",
16
- "@yss123/cage-lint": "0.3.0",
14
+ "@yss123/cage-cli": "0.3.1",
15
+ "@yss123/cage-testkit": "0.3.1",
16
+ "@yss123/cage-lint": "0.3.1",
17
17
  "@types/node": "^24.3.0",
18
18
  "typescript": "~5.9.3",
19
19
  "vitest": "^4.1.12",
@@ -10,7 +10,7 @@
10
10
  "typecheck": "tsc -p tsconfig.json --noEmit"
11
11
  },
12
12
  "dependencies": {
13
- "@yss123/cage-client": "0.3.0",
13
+ "@yss123/cage-client": "0.3.1",
14
14
  "zod": "^4.1.5"
15
15
  }
16
16
  }
@@ -14,11 +14,11 @@
14
14
  "typecheck": "tsc -p tsconfig.json --noEmit"
15
15
  },
16
16
  "dependencies": {
17
- "@yss123/cage-kernel": "0.3.0",
18
- "@yss123/cage-admin": "0.3.0",
19
- "@yss123/cage-approval": "0.3.0",
20
- "@yss123/cage-connectors": "0.3.0",
21
- "@yss123/cage-ai": "0.3.0",
17
+ "@yss123/cage-kernel": "0.3.1",
18
+ "@yss123/cage-admin": "0.3.1",
19
+ "@yss123/cage-approval": "0.3.1",
20
+ "@yss123/cage-connectors": "0.3.1",
21
+ "@yss123/cage-ai": "0.3.1",
22
22
  "zod": "^4.1.5"
23
23
  }
24
24
  }