@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.
- package/dist/approval-tree.d.ts +88 -0
- package/dist/approval-tree.js +18 -0
- package/dist/approval-tree.js.map +1 -0
- package/dist/capabilities.js +16 -1
- package/dist/capabilities.js.map +1 -1
- package/dist/graph-approvals.d.ts +11 -1
- package/dist/graph-approvals.js +18 -5
- package/dist/graph-approvals.js.map +1 -1
- package/dist/graph-relations.js +10 -8
- package/dist/graph-relations.js.map +1 -1
- package/dist/graph.js +2 -0
- package/dist/graph.js.map +1 -1
- package/dist/ontology.js +8 -5
- package/dist/ontology.js.map +1 -1
- package/dist/sync.js +1 -1
- package/graph-ui/approval-flow.js +28 -8
- package/graph-ui/approval-parallel.js +110 -0
- package/graph-ui/approval-tree.js +17 -0
- package/graph-ui/business.css +26 -0
- package/graph-ui/business.js +30 -13
- package/graph-ui/graph-approvals.js +18 -5
- package/graph-ui/graph-relations.js +10 -8
- package/package.json +6 -6
- package/templates/app/AGENTS.md +2 -2
- package/templates/app/README.md +5 -4
- package/templates/app/apps/api/package.json +2 -2
- package/templates/app/apps/web/package.json +1 -1
- package/templates/app/apps/web/src/modules/approval/components/task-operation-dialog.tsx +61 -0
- package/templates/app/apps/web/src/modules/approval/hooks/use-approval-tasks.ts +6 -9
- package/templates/app/apps/web/src/modules/approval/pages/approval-tasks-page.tsx +81 -49
- package/templates/app/apps/web/src/modules/approval/pages/approval-tasks.css +18 -0
- package/templates/app/apps/web/src/modules/purchase/components/approval-history.tsx +39 -4
- package/templates/app/apps/web/src/modules/purchase/components/order-reviewers.tsx +38 -0
- package/templates/app/apps/web/src/modules/purchase/components/purchase-order-form.tsx +9 -2
- package/templates/app/apps/web/src/modules/purchase/pages/purchase-intelligence.css +2 -0
- package/templates/app/apps/web/src/modules/purchase/pages/purchase-order-detail-page.tsx +2 -0
- package/templates/app/apps/web/src/modules/purchase/pages/purchase-orders-page.tsx +2 -0
- package/templates/app/apps/web/src/shared/lib/error-message.ts +6 -1
- package/templates/app/apps/web/src/shared/ui/approval-member-picker.css +10 -0
- package/templates/app/apps/web/src/shared/ui/approval-member-picker.tsx +51 -0
- package/templates/app/cage.config.json +14 -0
- package/templates/app/db/migrations/0004_draft.sql +27 -0
- package/templates/app/docs/ai/README.md +7 -2
- package/templates/app/docs/ai/approvals.md +260 -0
- package/templates/app/docs/ai/primitives.md +1 -1
- package/templates/app/docs/ai/purchase-example.md +19 -5
- package/templates/app/docs/ai/recipes.md +1 -1
- package/templates/app/docs/errors/CAGE-AI-001.md +2 -2
- package/templates/app/docs/errors/CAGE-APPR-001.md +12 -9
- package/templates/app/docs/errors/CAGE-APPR-005.md +4 -4
- package/templates/app/docs/errors/CAGE-APPR-006.md +24 -9
- package/templates/app/docs/errors/CAGE-APPR-007.md +8 -6
- package/templates/app/docs/errors/CAGE-EVENT-001.md +1 -1
- package/templates/app/docs/errors/CAGE-REG-001.md +1 -1
- package/templates/app/docs/errors/README.md +2 -2
- package/templates/app/package.json +3 -3
- package/templates/app/packages/contracts/package.json +1 -1
- package/templates/app/packages/contracts/src/aggregates/approval-member/index.ts +70 -0
- package/templates/app/packages/contracts/src/aggregates/purchase-order/index.ts +10 -2
- package/templates/app/packages/contracts/src/index.ts +9 -0
- package/templates/app/packages/domain/package.json +5 -5
- package/templates/app/packages/domain/src/modules/purchase/approval-members.ts +54 -0
- package/templates/app/packages/domain/src/modules/purchase/approvals.ts +19 -8
- package/templates/app/packages/domain/src/modules/purchase/index.ts +2 -1
- package/templates/app/packages/domain/src/modules/purchase/intelligence.test.ts +169 -7
- package/templates/app/packages/domain/src/modules/purchase/purchase-order.ts +56 -5
- package/templates/blank/AGENTS.md +2 -2
- package/templates/blank/apps/api/package.json +1 -1
- package/templates/blank/apps/web/package.json +1 -1
- package/templates/blank/docs/ai/README.md +7 -2
- package/templates/blank/docs/ai/approvals.md +260 -0
- package/templates/blank/docs/ai/primitives.md +1 -1
- package/templates/blank/docs/ai/purchase-example.md +19 -5
- package/templates/blank/docs/ai/recipes.md +1 -1
- package/templates/blank/docs/errors/CAGE-AI-001.md +2 -2
- package/templates/blank/docs/errors/CAGE-APPR-001.md +12 -9
- package/templates/blank/docs/errors/CAGE-APPR-005.md +4 -4
- package/templates/blank/docs/errors/CAGE-APPR-006.md +24 -9
- package/templates/blank/docs/errors/CAGE-APPR-007.md +8 -6
- package/templates/blank/docs/errors/CAGE-EVENT-001.md +1 -1
- package/templates/blank/docs/errors/CAGE-REG-001.md +1 -1
- package/templates/blank/docs/errors/README.md +2 -2
- package/templates/blank/docs/examples/purchase-admin/apps/web/src/modules/approval/components/task-operation-dialog.tsx +61 -0
- package/templates/blank/docs/examples/purchase-admin/apps/web/src/modules/approval/hooks/use-approval-tasks.ts +6 -9
- package/templates/blank/docs/examples/purchase-admin/apps/web/src/modules/approval/pages/approval-tasks-page.tsx +81 -49
- package/templates/blank/docs/examples/purchase-admin/apps/web/src/modules/approval/pages/approval-tasks.css +18 -0
- package/templates/blank/docs/examples/purchase-admin/apps/web/src/modules/purchase/components/approval-history.tsx +39 -4
- package/templates/blank/docs/examples/purchase-admin/apps/web/src/modules/purchase/components/order-reviewers.tsx +38 -0
- package/templates/blank/docs/examples/purchase-admin/apps/web/src/modules/purchase/components/purchase-order-form.tsx +9 -2
- package/templates/blank/docs/examples/purchase-admin/apps/web/src/modules/purchase/pages/purchase-intelligence.css +2 -0
- package/templates/blank/docs/examples/purchase-admin/apps/web/src/modules/purchase/pages/purchase-order-detail-page.tsx +2 -0
- package/templates/blank/docs/examples/purchase-admin/apps/web/src/modules/purchase/pages/purchase-orders-page.tsx +2 -0
- package/templates/blank/docs/examples/purchase-admin/apps/web/src/shared/lib/error-message.ts +6 -1
- package/templates/blank/docs/examples/purchase-admin/apps/web/src/shared/ui/approval-member-picker.css +10 -0
- package/templates/blank/docs/examples/purchase-admin/apps/web/src/shared/ui/approval-member-picker.tsx +51 -0
- package/templates/blank/docs/examples/purchase-admin/cage.config.json +14 -0
- package/templates/blank/docs/examples/purchase-admin/packages/contracts/src/aggregates/approval-member/index.ts +70 -0
- package/templates/blank/docs/examples/purchase-admin/packages/contracts/src/aggregates/purchase-order/index.ts +10 -2
- package/templates/blank/docs/examples/purchase-admin/packages/contracts/src/index.ts +9 -0
- package/templates/blank/docs/examples/purchase-admin/packages/domain/src/modules/purchase/approval-members.ts +54 -0
- package/templates/blank/docs/examples/purchase-admin/packages/domain/src/modules/purchase/approvals.ts +19 -8
- package/templates/blank/docs/examples/purchase-admin/packages/domain/src/modules/purchase/index.ts +2 -1
- package/templates/blank/docs/examples/purchase-admin/packages/domain/src/modules/purchase/intelligence.test.ts +169 -7
- package/templates/blank/docs/examples/purchase-admin/packages/domain/src/modules/purchase/purchase-order.ts +56 -5
- package/templates/blank/package.json +3 -3
- package/templates/blank/packages/contracts/package.json +1 -1
- package/templates/blank/packages/domain/package.json +5 -5
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// 生成物:由 cage gen 从 domain 声明生成,不要手改。
|
|
2
|
+
/* eslint-disable */
|
|
3
|
+
|
|
4
|
+
import { z } from 'zod';
|
|
5
|
+
import { client, useCageList, useCageQuery, type TransferTask, type RequestOptions } from '@yss123/cage-client';
|
|
6
|
+
|
|
7
|
+
/** 采购审批协作名录:读侧快照(敏感字段是按权限掩码后的值) */
|
|
8
|
+
export const ApprovalMemberSchema = z.object({
|
|
9
|
+
id: z.string().uuid(),
|
|
10
|
+
userId: z.string().uuid(),
|
|
11
|
+
displayName: z.string(),
|
|
12
|
+
specialty: z.string(),
|
|
13
|
+
status: z.enum(["active", "retired"]),
|
|
14
|
+
version: z.number().int(),
|
|
15
|
+
createdAt: z.string(),
|
|
16
|
+
updatedAt: z.string(),
|
|
17
|
+
});
|
|
18
|
+
export type ApprovalMember = z.infer<typeof ApprovalMemberSchema>;
|
|
19
|
+
export type ApprovalMemberStatus = ApprovalMember['status'];
|
|
20
|
+
export const ApprovalMemberStates = ['active', 'retired'] as const;
|
|
21
|
+
|
|
22
|
+
/** 命令输入:和后端命令的输入校验完全一致 */
|
|
23
|
+
export const ApprovalMemberInputs = {
|
|
24
|
+
create: z.object({ userId: z.string().uuid().regex(new RegExp("^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$")), displayName: z.unknown(), specialty: z.unknown() }),
|
|
25
|
+
retire: z.object({ id: z.string().uuid().regex(new RegExp("^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$")) }),
|
|
26
|
+
update: z.object({ id: z.string().uuid().regex(new RegExp("^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$")), displayName: z.unknown(), specialty: z.unknown() }),
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/** ApprovalMember.create 可能返回的错误码 */
|
|
30
|
+
export type ApprovalMemberCreateError = 'CONFLICT' | 'CONSTRAINT_VIOLATED' | 'DOMAIN_ERROR' | 'FORBIDDEN' | 'INVALID_INPUT';
|
|
31
|
+
/** ApprovalMember.retire 可能返回的错误码 */
|
|
32
|
+
export type ApprovalMemberRetireError = 'CONFLICT' | 'DOMAIN_ERROR' | 'FORBIDDEN' | 'INVALID_INPUT' | 'INVALID_TRANSITION' | 'NOT_FOUND';
|
|
33
|
+
/** ApprovalMember.update 可能返回的错误码 */
|
|
34
|
+
export type ApprovalMemberUpdateError = 'CONFLICT' | 'DOMAIN_ERROR' | 'FORBIDDEN' | 'INVALID_INPUT' | 'INVALID_TRANSITION' | 'NOT_FOUND';
|
|
35
|
+
|
|
36
|
+
export type ApprovalMemberSortKey = 'createdAt' | 'updatedAt' | 'userId' | 'displayName' | 'specialty';
|
|
37
|
+
export interface ApprovalMemberListParams {
|
|
38
|
+
page?: number;
|
|
39
|
+
pageSize?: number;
|
|
40
|
+
/** 排序:字段名,前面加 - 表示倒序 */
|
|
41
|
+
sort?: ApprovalMemberSortKey | `-${ApprovalMemberSortKey}`;
|
|
42
|
+
/** 可以逗号分隔多个状态 */
|
|
43
|
+
status?: string;
|
|
44
|
+
createdAfter?: string;
|
|
45
|
+
createdBefore?: string;
|
|
46
|
+
userId?: string | number | boolean;
|
|
47
|
+
displayName?: string | number | boolean;
|
|
48
|
+
specialty?: string | number | boolean;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** 调用方法:每个命令一个;client 自动生成并附带幂等键和 CSRF 令牌 */
|
|
52
|
+
export const approvalMember = {
|
|
53
|
+
/** 登记采购审批协作人 */
|
|
54
|
+
create: (input: z.input<typeof ApprovalMemberInputs.create>, options: RequestOptions = {}) => client.post<ApprovalMember, ApprovalMemberCreateError>('/api/approval-member/create', input, options),
|
|
55
|
+
/** 停用采购审批协作名录成员 */
|
|
56
|
+
retire: (id: string, expectedVersion?: number, options: RequestOptions = {}) => client.post<ApprovalMember, ApprovalMemberRetireError>(`/api/approval-member/${encodeURIComponent(id)}/retire`, { expectedVersion }, options),
|
|
57
|
+
/** 修改采购协作人的公开资料 */
|
|
58
|
+
update: (id: string, input: Omit<z.input<typeof ApprovalMemberInputs.update>, 'id'>, expectedVersion?: number, options: RequestOptions = {}) => client.post<ApprovalMember, ApprovalMemberUpdateError>(`/api/approval-member/${encodeURIComponent(id)}/update`, { ...input, expectedVersion }, options),
|
|
59
|
+
/** 异步导出 CSV:返回任务,使用 client.transfer(id) 查状态,完成后下载。需要 ApprovalMember.export 能力。 */
|
|
60
|
+
export: (params: ApprovalMemberListParams = {}, options: RequestOptions = {}) => client.post<TransferTask, 'CONFLICT'>('/api/approval-member/export', params, options),
|
|
61
|
+
get: (id: string, options: RequestOptions = {}) => client.get<ApprovalMember>(`/api/approval-member/${encodeURIComponent(id)}`, undefined, options),
|
|
62
|
+
list: (params: ApprovalMemberListParams = {}, options: RequestOptions = {}) => client.get<{ items: ApprovalMember[]; total: number; page: number; pageSize: number }>('/api/approval-member', params as Record<string, unknown>, options),
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
/** 详情查询 hook:响应也用 schema 校验;范围外返回 NOT_FOUND */
|
|
66
|
+
export const useApprovalMember = (id: string) => useCageQuery(['approval-member', id], `/api/approval-member/${encodeURIComponent(id)}`, ApprovalMemberSchema, { enabled: !!id });
|
|
67
|
+
/** 列表查询 hook:走读侧账号与安全视图 */
|
|
68
|
+
export const useApprovalMemberList = (params: ApprovalMemberListParams = {}) => useCageList(['approval-member'], '/api/approval-member', params as Record<string, unknown>, ApprovalMemberSchema);
|
|
69
|
+
/** 查询缓存键前缀:命令成功后让它失效 */
|
|
70
|
+
export const approvalMemberKeys = { all: ['approval-member'] as const, one: (id: string) => ['approval-member', id] as const };
|
|
@@ -12,6 +12,8 @@ export const PurchaseOrderSchema = z.object({
|
|
|
12
12
|
supplierPhone: z.string().nullable().optional(),
|
|
13
13
|
amount: z.string(),
|
|
14
14
|
reason: z.string().nullable().optional(),
|
|
15
|
+
needsSpecialistReview: z.boolean().nullable().optional(),
|
|
16
|
+
reviewerIds: z.array(z.string().uuid()).nullable().optional(),
|
|
15
17
|
items: z.array(z.object({ sku: z.string(), qty: z.number().int() })),
|
|
16
18
|
erpNo: z.string().nullable().optional(),
|
|
17
19
|
attachments: z.array(z.string().uuid()).max(10),
|
|
@@ -29,9 +31,10 @@ export const PurchaseOrderFileFields = {"attachments":{"maxBytes":2147483648,"ac
|
|
|
29
31
|
/** 命令输入:和后端命令的输入校验完全一致 */
|
|
30
32
|
export const PurchaseOrderInputs = {
|
|
31
33
|
cancel: z.object({ id: z.string().uuid().regex(new RegExp("^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$")) }),
|
|
32
|
-
create: z.object({ amount: z.string(), items: z.array(z.object({ sku: z.string(), qty: z.number().int().max(9007199254740991).gt(0) })).min(1), supplierPhone: z.string().optional(), reason: z.string().max(2000).optional(), deptId: z.string().uuid().regex(new RegExp("^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$")).optional(), attachments: z.array(z.string().uuid().regex(new RegExp("^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$"))).max(10).optional() }),
|
|
34
|
+
create: z.object({ amount: z.string(), items: z.array(z.object({ sku: z.string(), qty: z.number().int().max(9007199254740991).gt(0) })).min(1), supplierPhone: z.string().optional(), reason: z.string().max(2000).optional(), needsSpecialistReview: z.boolean().optional(), reviewerIds: z.array(z.string().uuid().regex(new RegExp("^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$"))).max(5).optional(), deptId: z.string().uuid().regex(new RegExp("^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$")).optional(), attachments: z.array(z.string().uuid().regex(new RegExp("^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$"))).max(10).optional() }),
|
|
33
35
|
explainPurpose: z.object({ id: z.string().uuid().regex(new RegExp("^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$")), reason: z.unknown() }),
|
|
34
36
|
setAttachments: z.object({ id: z.string().uuid().regex(new RegExp("^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$")), attachments: z.array(z.string().uuid().regex(new RegExp("^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$"))).max(10) }),
|
|
37
|
+
setReviewers: z.object({ id: z.string().uuid().regex(new RegExp("^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$")), needsSpecialistReview: z.boolean(), reviewerIds: z.array(z.string().uuid().regex(new RegExp("^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$"))).max(5) }),
|
|
35
38
|
submit: z.object({ id: z.string().uuid().regex(new RegExp("^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$")) }),
|
|
36
39
|
};
|
|
37
40
|
|
|
@@ -43,11 +46,13 @@ export type PurchaseOrderCreateError = 'CONFLICT' | 'CONSTRAINT_VIOLATED' | 'DOM
|
|
|
43
46
|
export type PurchaseOrderExplainPurposeError = 'CONFLICT' | 'DOMAIN_ERROR' | 'FORBIDDEN' | 'INVALID_INPUT' | 'INVALID_TRANSITION' | 'INVARIANT_VIOLATED' | 'NOT_FOUND';
|
|
44
47
|
/** PurchaseOrder.setAttachments 可能返回的错误码 */
|
|
45
48
|
export type PurchaseOrderSetAttachmentsError = 'CONFLICT' | 'DOMAIN_ERROR' | 'FORBIDDEN' | 'INVALID_INPUT' | 'INVALID_TRANSITION' | 'INVARIANT_VIOLATED' | 'NOT_FOUND';
|
|
49
|
+
/** PurchaseOrder.setReviewers 可能返回的错误码 */
|
|
50
|
+
export type PurchaseOrderSetReviewersError = 'CONFLICT' | 'DOMAIN_ERROR' | 'FORBIDDEN' | 'INVALID_INPUT' | 'INVALID_TRANSITION' | 'INVARIANT_VIOLATED' | 'NOT_FOUND';
|
|
46
51
|
/** PurchaseOrder.submit 可能返回的错误码;业务码(domainCode):NO_ITEMS */
|
|
47
52
|
export type PurchaseOrderSubmitError = 'CONFLICT' | 'DOMAIN_ERROR' | 'FORBIDDEN' | 'INVALID_INPUT' | 'INVALID_TRANSITION' | 'INVARIANT_VIOLATED' | 'NOT_FOUND';
|
|
48
53
|
export type PurchaseOrderSubmitDomainCode = 'NO_ITEMS';
|
|
49
54
|
|
|
50
|
-
export type PurchaseOrderSortKey = 'createdAt' | 'updatedAt' | 'deptId' | 'createdBy' | 'supplierPhone' | 'amount' | 'reason' | 'erpNo';
|
|
55
|
+
export type PurchaseOrderSortKey = 'createdAt' | 'updatedAt' | 'deptId' | 'createdBy' | 'supplierPhone' | 'amount' | 'reason' | 'needsSpecialistReview' | 'erpNo';
|
|
51
56
|
export interface PurchaseOrderListParams {
|
|
52
57
|
page?: number;
|
|
53
58
|
pageSize?: number;
|
|
@@ -61,6 +66,7 @@ export interface PurchaseOrderListParams {
|
|
|
61
66
|
createdBy?: string | number | boolean;
|
|
62
67
|
amount?: string | number | boolean;
|
|
63
68
|
reason?: string | number | boolean;
|
|
69
|
+
needsSpecialistReview?: string | number | boolean;
|
|
64
70
|
erpNo?: string | number | boolean;
|
|
65
71
|
}
|
|
66
72
|
|
|
@@ -71,6 +77,8 @@ export const purchaseOrder = {
|
|
|
71
77
|
/** 补充采购用途与必要性,保存后可以重新提交审核 */
|
|
72
78
|
explainPurpose: (id: string, input: Omit<z.input<typeof PurchaseOrderInputs.explainPurpose>, 'id'>, expectedVersion?: number, options: RequestOptions = {}) => client.post<PurchaseOrder, PurchaseOrderExplainPurposeError>(`/api/purchase-order/${encodeURIComponent(id)}/explain-purpose`, { ...input, expectedVersion }, options),
|
|
73
79
|
setAttachments: (id: string, input: Omit<z.input<typeof PurchaseOrderInputs.setAttachments>, 'id'>, expectedVersion?: number, options: RequestOptions = {}) => client.post<PurchaseOrder, PurchaseOrderSetAttachmentsError>(`/api/purchase-order/${encodeURIComponent(id)}/set-attachments`, { ...input, expectedVersion }, options),
|
|
80
|
+
/** 设置采购单专业复核需求和人员 */
|
|
81
|
+
setReviewers: (id: string, input: Omit<z.input<typeof PurchaseOrderInputs.setReviewers>, 'id'>, expectedVersion?: number, options: RequestOptions = {}) => client.post<PurchaseOrder, PurchaseOrderSetReviewersError>(`/api/purchase-order/${encodeURIComponent(id)}/set-reviewers`, { ...input, expectedVersion }, options),
|
|
74
82
|
submit: (id: string, expectedVersion?: number, options: RequestOptions = {}) => client.post<PurchaseOrder, PurchaseOrderSubmitError>(`/api/purchase-order/${encodeURIComponent(id)}/submit`, { expectedVersion }, options),
|
|
75
83
|
/** 异步导出 CSV:返回任务,使用 client.transfer(id) 查状态,完成后下载。需要 PurchaseOrder.export 能力。 */
|
|
76
84
|
export: (params: PurchaseOrderListParams = {}, options: RequestOptions = {}) => client.post<TransferTask, 'CONFLICT'>('/api/purchase-order/export', params, options),
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// 生成物:由 cage gen 从 domain 声明生成,不要手改。
|
|
2
2
|
/* eslint-disable */
|
|
3
3
|
|
|
4
|
+
export * from './aggregates/approval-member';
|
|
4
5
|
export * from './aggregates/dept';
|
|
5
6
|
export * from './aggregates/inventory';
|
|
6
7
|
export * from './aggregates/inventory-lock';
|
|
@@ -9,6 +10,7 @@ export * from './aggregates/role';
|
|
|
9
10
|
export * from './aggregates/user';
|
|
10
11
|
export * from './queries';
|
|
11
12
|
import { can as baseCan, useCan as baseUseCan } from '@yss123/cage-client';
|
|
13
|
+
import { ApprovalMemberInputs } from './aggregates/approval-member';
|
|
12
14
|
import { DeptInputs } from './aggregates/dept';
|
|
13
15
|
import { InventoryInputs } from './aggregates/inventory';
|
|
14
16
|
import { InventoryLockInputs } from './aggregates/inventory-lock';
|
|
@@ -19,6 +21,11 @@ export { client, configureClient, loadMe, currentUser, CageQueryError, CageComma
|
|
|
19
21
|
|
|
20
22
|
/** 能力目录(由代码派生):写错能力点名会编译失败 */
|
|
21
23
|
export const capabilities = [
|
|
24
|
+
'ApprovalMember.create',
|
|
25
|
+
'ApprovalMember.export',
|
|
26
|
+
'ApprovalMember.read',
|
|
27
|
+
'ApprovalMember.retire',
|
|
28
|
+
'ApprovalMember.update',
|
|
22
29
|
'Dept.archive',
|
|
23
30
|
'Dept.create',
|
|
24
31
|
'Dept.export',
|
|
@@ -42,6 +49,7 @@ export const capabilities = [
|
|
|
42
49
|
'PurchaseOrder.reject',
|
|
43
50
|
'PurchaseOrder.returnForChanges',
|
|
44
51
|
'PurchaseOrder.setAttachments',
|
|
52
|
+
'PurchaseOrder.setReviewers',
|
|
45
53
|
'PurchaseOrder.submit',
|
|
46
54
|
'PurchaseOrder.unmask.phone',
|
|
47
55
|
'Role.archive',
|
|
@@ -78,6 +86,7 @@ export const useCan = (capability: Capability): boolean => baseUseCan(capability
|
|
|
78
86
|
|
|
79
87
|
/** 各聚合的命令输入 schema:按聚合名、命令名访问,可直接供表单库校验 */
|
|
80
88
|
export const schemas = {
|
|
89
|
+
ApprovalMember: ApprovalMemberInputs,
|
|
81
90
|
Dept: DeptInputs,
|
|
82
91
|
Inventory: InventoryInputs,
|
|
83
92
|
InventoryLock: InventoryLockInputs,
|
|
@@ -14,11 +14,11 @@
|
|
|
14
14
|
"typecheck": "tsc -p tsconfig.json --noEmit"
|
|
15
15
|
},
|
|
16
16
|
"dependencies": {
|
|
17
|
-
"@yss123/cage-kernel": "0.3.
|
|
18
|
-
"@yss123/cage-admin": "0.3.
|
|
19
|
-
"@yss123/cage-approval": "0.3.
|
|
20
|
-
"@yss123/cage-connectors": "0.3.
|
|
21
|
-
"@yss123/cage-ai": "0.3.
|
|
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
|
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 采购协作名录:由业务管理员登记可参与采购复核的账号、公开业务姓名和专业领域,供草稿选人及任务操作使用。
|
|
3
|
+
* 聚合通过标准命令维护,浏览器读取 safe.approval_member;名录与 sys.user 的私密资料、角色授权分离。
|
|
4
|
+
* 名录在同租户内向具 ApprovalMember.read 且显式获 all 范围的采购参与者公开;加入名录不授予审批权限、范围或额度。
|
|
5
|
+
*/
|
|
6
|
+
import { aggregate, command, ref, scope, states, text } from '@yss123/cage-kernel';
|
|
7
|
+
import { z } from 'zod';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* 一位账号对应一个采购协作身份,显示姓名是管理员明确登记的业务公开值,不读取 User.name 明文。
|
|
11
|
+
* active 可供新申请选择,retired 保留旧单据的人员证据;退役不取消已经承担的审批责任或撤销真实角色。
|
|
12
|
+
*/
|
|
13
|
+
export const ApprovalMember = aggregate('ApprovalMember', {
|
|
14
|
+
description: '采购审批协作名录',
|
|
15
|
+
fields: {
|
|
16
|
+
/** 当前租户的真实用户引用;租户内唯一,禁止用多条名录为同一账号制造多个会签名额。 */
|
|
17
|
+
userId: ref('User').unique(),
|
|
18
|
+
/** 对采购协作者公开的展示姓名;与登录姓名独立维护,不包含电话、邮箱或其他个人资料。 */
|
|
19
|
+
displayName: text(80),
|
|
20
|
+
/** 供选人判断的专业领域或岗位说明,例如设备技术、合同审查;不是角色编码或权限表达式。 */
|
|
21
|
+
specialty: text(120),
|
|
22
|
+
},
|
|
23
|
+
states: states('active', 'retired', { terminal: ['retired'] }),
|
|
24
|
+
// 名录没有部门或创建人字段,因此采购参与者必须由管理员显式授予 ApprovalMember 的 all 范围。
|
|
25
|
+
scope: scope.none(),
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* 登记采购协作人,要求 ApprovalMember.create;真实账号引用和租户约束由内核处理。
|
|
30
|
+
* 只保存公开业务名录,不改 User 的角色或密码,不代表所选用户可以批准任何采购单。
|
|
31
|
+
*/
|
|
32
|
+
export const createApprovalMember = command(ApprovalMember, {
|
|
33
|
+
name: 'create', description: '登记采购审批协作人', from: 'new', to: 'active',
|
|
34
|
+
input: z.object({ userId: z.string().uuid(), displayName: z.string().trim().min(1).max(80), specialty: z.string().trim().min(1).max(120) }),
|
|
35
|
+
handler: draft => draft,
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* 仅更新有效成员的展示姓名与专业说明,版本、范围和审计由内核维护。
|
|
40
|
+
* 不接受 userId,避免已被采购单引用的协作身份悄悄换人;需要另一个账号时登记新成员。
|
|
41
|
+
*/
|
|
42
|
+
export const updateApprovalMember = command(ApprovalMember, {
|
|
43
|
+
name: 'update', description: '修改采购协作人的公开资料', from: 'active', to: null,
|
|
44
|
+
input: z.object({ id: z.string().uuid(), displayName: z.string().trim().min(1).max(80), specialty: z.string().trim().min(1).max(120) }),
|
|
45
|
+
handler: (_draft, input) => ({ displayName: input.displayName, specialty: input.specialty }),
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* 退役成员使后续设置/提交专业复核名单拒绝此人,保留记录供历史展示。
|
|
50
|
+
* 此动作不停用登录账号、不收回角色,也不修改在途流程快照;在途处理仍按当前真实权限核验。
|
|
51
|
+
*/
|
|
52
|
+
export const retireApprovalMember = command(ApprovalMember, {
|
|
53
|
+
name: 'retire', description: '停用采购审批协作名录成员', from: 'active', to: 'retired', handler: draft => draft,
|
|
54
|
+
});
|
|
@@ -1,10 +1,14 @@
|
|
|
1
|
-
|
|
1
|
+
/**
|
|
2
|
+
* 采购实际审批路由:AI 预审、部门审核、可选专业会签和大额财务候选受理,共用一个采购流程实例。
|
|
3
|
+
* 人工节点显式开放转办、委派和前后/并行加签;步骤/政策随提交保存,旧实例保持自己的快照。
|
|
4
|
+
* 路由只决定任务分配与签序,最终结论仍执行领域命令的职责分离、额度与库存约束。
|
|
5
|
+
*/
|
|
2
6
|
import { approval } from '@yss123/cage-kernel';
|
|
3
|
-
import { approver, gt, route, step, when } from '@yss123/cage-approval';
|
|
7
|
+
import { approver, eq, gt, route, step, when } from '@yss123/cage-approval';
|
|
4
8
|
import { PurchaseOrder, OrderSubmitted, OrderCancelled, approve, reject, returnForChanges } from './purchase-order.js';
|
|
5
9
|
|
|
6
10
|
/**
|
|
7
|
-
* 把提交/取消事件与审批生命周期关联;routable
|
|
11
|
+
* 把提交/取消事件与审批生命周期关联;routable 仅开放金额、部门和专业复核需求/名单。
|
|
8
12
|
* 通过、驳回、退回分别调用已有 process 命令,领域状态与最终审批结论由引擎在事务内落实。
|
|
9
13
|
* automatedBy 是自动结论执行角色代码,必须由部署管理员显式配置能力和范围,声明不自动授予权限。
|
|
10
14
|
*/
|
|
@@ -12,7 +16,7 @@ export const poApproval = approval(PurchaseOrder, {
|
|
|
12
16
|
process: 'po-approval',
|
|
13
17
|
start: OrderSubmitted,
|
|
14
18
|
cancelOn: [OrderCancelled],
|
|
15
|
-
routable: ['amount', 'deptId'],
|
|
19
|
+
routable: ['amount', 'deptId', 'needsSpecialistReview', 'reviewerIds'],
|
|
16
20
|
onApproved: approve,
|
|
17
21
|
onRejected: reject,
|
|
18
22
|
onReturned: returnForChanges,
|
|
@@ -20,10 +24,12 @@ export const poApproval = approval(PurchaseOrder, {
|
|
|
20
24
|
});
|
|
21
25
|
|
|
22
26
|
/**
|
|
23
|
-
* 顺序为 AI
|
|
27
|
+
* 顺序为 AI 材料预审、部门经理、按单据指定人员的专业会签、条件财务候选受理。
|
|
28
|
+
* needsSpecialistReview=true 才增加专业会签,名单来自提交快照且全部人必须通过;金额严格大于 50000 元才增加财务节点。
|
|
24
29
|
* AI 只读取 reason/amount/items,pass 仅推进到经理,return 回到草稿,manual 在当前节点交部门负责人。
|
|
25
30
|
* 模型失败同样转人工,最多四轮/五次工具调用/120 秒;不以失败兜底为自动批准。
|
|
26
|
-
*
|
|
31
|
+
* 财务共享一项未分配任务,finance 当前成员须先领取;所有人工动作仍需真实命令能力、范围、额度和职责分离。
|
|
32
|
+
* AI 人工接管保持原节点规则;经理、专业和财务才开放三项管理动作,新实例自动保存允许的加签位置。
|
|
27
33
|
*/
|
|
28
34
|
export const poRoute = route('po-approval', [
|
|
29
35
|
step('AI 材料预审', {
|
|
@@ -34,6 +40,11 @@ export const poRoute = route('po-approval', [
|
|
|
34
40
|
limits: { maxSteps: 4, maxToolCalls: 5, timeoutMs: 120_000 },
|
|
35
41
|
onFailure: 'manual',
|
|
36
42
|
}),
|
|
37
|
-
step('部门经理审批', approver.deptLeader('deptId')),
|
|
38
|
-
when(
|
|
43
|
+
step('部门经理审批', approver.deptLeader('deptId'), { actions: { transfer: true, delegate: true, addSign: true } }),
|
|
44
|
+
when(eq('needsSpecialistReview', true), step('专业人员会签', approver.field('reviewerIds'), {
|
|
45
|
+
mode: 'all', actions: { transfer: true, delegate: true, addSign: true },
|
|
46
|
+
})),
|
|
47
|
+
when(gt('amount', 50000), step('财务候选受理', approver.role('finance'), {
|
|
48
|
+
assignment: 'candidate', mode: 'any', actions: { transfer: true, delegate: true, addSign: true },
|
|
49
|
+
})),
|
|
39
50
|
]);
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
// 采购模块的公开声明;其他模块通过本入口依赖它,不穿透内部文件。
|
|
2
2
|
// index 只转导出,避免在组装入口里隐藏业务行为或产生初始化副作用。
|
|
3
|
-
//
|
|
3
|
+
// 主对象、协作名录、审批、只读统计和定时任务统一由此公开;测试文件不导出,运行时不注册测试夹具。
|
|
4
4
|
export * from './purchase-order.js';
|
|
5
|
+
export * from './approval-members.js';
|
|
5
6
|
export * from './approvals.js';
|
|
6
7
|
export * from './queries.js';
|
|
7
8
|
export * from './jobs.js';
|
|
@@ -1,4 +1,8 @@
|
|
|
1
|
-
|
|
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
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
+
});
|