@goplusvn/core 0.1.34 → 0.1.35

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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@goplusvn/core",
3
3
  "description": "GoPlusVN Platform Kit - ERP kernel: layout, RBAC, CRUD, multi-tenant, system pages",
4
- "version": "0.1.34",
4
+ "version": "0.1.35",
5
5
  "private": false,
6
6
  "publishConfig": {
7
7
  "registry": "https://registry.npmjs.org",
@@ -0,0 +1,84 @@
1
+ import { describe, expect, it } from "vitest"
2
+
3
+ import { planApproval, planRejection, resolveEffectiveSteps } from "../approval-engine"
4
+ import { ApprovalError, type ApprovalFlow } from "../types"
5
+
6
+ const FLOW: ApprovalFlow = {
7
+ entityType: "test-doc",
8
+ steps: [
9
+ { level: 1, name: "Phê duyệt Lần 1", permission: { resource: "doc", action: "approve_l1" } },
10
+ { level: 2, name: "Phê duyệt Lần 2", permission: { resource: "doc", action: "approve_l2" } },
11
+ {
12
+ level: 3,
13
+ name: "Phê duyệt Giám đốc",
14
+ permission: { resource: "doc", action: "approve_l3" },
15
+ minAmount: 5_000_000,
16
+ },
17
+ ],
18
+ }
19
+
20
+ const allow = () => true
21
+ const denyAll = () => false
22
+
23
+ describe("approval-engine", () => {
24
+ it("duyệt tuần tự n cấp: bước kế + isFinal đúng", async () => {
25
+ const p1 = await planApproval(FLOW, { amount: 10_000_000, completedLevel: 0, terminal: false }, allow)
26
+ expect(p1.step.level).toBe(1)
27
+ expect(p1.isFinal).toBe(false)
28
+
29
+ const p3 = await planApproval(FLOW, { amount: 10_000_000, completedLevel: 2, terminal: false }, allow)
30
+ expect(p3.step.level).toBe(3)
31
+ expect(p3.isFinal).toBe(true)
32
+ })
33
+
34
+ it("điều kiện tiền: dưới ngưỡng bỏ qua cấp có minAmount", async () => {
35
+ const steps = resolveEffectiveSteps(FLOW, 1_000_000)
36
+ expect(steps.map((s) => s.level)).toEqual([1, 2])
37
+
38
+ const p2 = await planApproval(FLOW, { amount: 1_000_000, completedLevel: 1, terminal: false }, allow)
39
+ expect(p2.step.level).toBe(2)
40
+ expect(p2.isFinal).toBe(true) // L2 là bước cuối vì L3 bị lọc
41
+ })
42
+
43
+ it("amount null = áp dụng đủ mọi cấp", () => {
44
+ expect(resolveEffectiveSteps(FLOW, null)).toHaveLength(3)
45
+ })
46
+
47
+ it("thiếu quyền của ĐÚNG bước hiện tại → 403 kèm tên bước", async () => {
48
+ const canOnlyL1 = (_r: string, a: string) => a === "approve_l1"
49
+ await expect(
50
+ planApproval(FLOW, { amount: null, completedLevel: 1, terminal: false }, canOnlyL1)
51
+ ).rejects.toThrowError(/Phê duyệt Lần 2/)
52
+ await expect(
53
+ planApproval(FLOW, { amount: null, completedLevel: 1, terminal: false }, canOnlyL1)
54
+ ).rejects.toMatchObject({ status: 403 })
55
+ })
56
+
57
+ it("chứng từ terminal hoặc đã đủ cấp → ApprovalError", async () => {
58
+ await expect(
59
+ planApproval(FLOW, { amount: null, completedLevel: 0, terminal: true }, allow)
60
+ ).rejects.toThrowError(/đã được xử lý/)
61
+ await expect(
62
+ planApproval(FLOW, { amount: null, completedLevel: 3, terminal: false }, allow)
63
+ ).rejects.toThrowError(/đủ cấp/)
64
+ })
65
+
66
+ it("planRejection: chặn terminal + thiếu quyền", async () => {
67
+ await expect(
68
+ planRejection(
69
+ { amount: null, completedLevel: 1, terminal: true },
70
+ allow,
71
+ { resource: "doc", action: "reject" },
72
+ "Bạn không có quyền Từ chối"
73
+ )
74
+ ).rejects.toThrowError(/đã được xử lý/)
75
+ await expect(
76
+ planRejection(
77
+ { amount: null, completedLevel: 1, terminal: false },
78
+ denyAll,
79
+ { resource: "doc", action: "reject" },
80
+ "Bạn không có quyền Từ chối"
81
+ )
82
+ ).rejects.toThrowError(/không có quyền Từ chối/)
83
+ })
84
+ })
@@ -0,0 +1,80 @@
1
+ import {
2
+ ApprovalError,
3
+ type ApprovalFlow,
4
+ type ApprovalPlan,
5
+ type ApprovalState,
6
+ type ApprovalStep,
7
+ } from "./types"
8
+
9
+ /**
10
+ * APPROVAL ENGINE — máy trạng thái duyệt n cấp dùng chung (Phase 6).
11
+ *
12
+ * Engine chỉ TÍNH TOÁN: bước nào đến lượt, ai được duyệt, có phải bước cuối
13
+ * không. Persistence (đổi status, ghi history, side-effect như tạo phiếu chi)
14
+ * do adapter của từng module thực hiện trong transaction của nó — engine
15
+ * không import Prisma nên promote lên core không kéo theo schema.
16
+ */
17
+
18
+ /** Lọc các bước áp dụng thực tế theo điều kiện số tiền. */
19
+ export function resolveEffectiveSteps(
20
+ flow: ApprovalFlow,
21
+ amount: number | null
22
+ ): ApprovalStep[] {
23
+ const steps = [...flow.steps].sort((a, b) => a.level - b.level)
24
+ if (amount == null) return steps
25
+ return steps.filter((s) => s.minAmount == null || amount >= s.minAmount)
26
+ }
27
+
28
+ /**
29
+ * Xác định bước duyệt kế tiếp + kiểm tra quyền. Ném ApprovalError nếu chứng
30
+ * từ đã kết thúc, đã duyệt đủ cấp, hoặc người duyệt thiếu quyền của bước.
31
+ * `can` được phép async (quyền qua ủy quyền phải tra DB).
32
+ */
33
+ export async function planApproval(
34
+ flow: ApprovalFlow,
35
+ state: ApprovalState,
36
+ can: (resource: string, action: string) => boolean | Promise<boolean>
37
+ ): Promise<ApprovalPlan> {
38
+ if (state.terminal) {
39
+ throw new ApprovalError(
40
+ "Chứng từ này đã được xử lý (Đã duyệt hoặc Bị từ chối)"
41
+ )
42
+ }
43
+ const effectiveSteps = resolveEffectiveSteps(flow, state.amount)
44
+ const step = effectiveSteps[state.completedLevel]
45
+ if (!step) {
46
+ throw new ApprovalError("Chứng từ đã duyệt đủ cấp — không còn bước nào")
47
+ }
48
+ if (!(await can(step.permission.resource, step.permission.action))) {
49
+ throw new ApprovalError(
50
+ step.denyMessage ?? `Bạn không có quyền ${step.name}`,
51
+ 403
52
+ )
53
+ }
54
+ return {
55
+ step,
56
+ isFinal: state.completedLevel === effectiveSteps.length - 1,
57
+ effectiveSteps,
58
+ }
59
+ }
60
+
61
+ /**
62
+ * Kiểm tra điều kiện từ chối: chứng từ chưa kết thúc + có quyền. Trả về
63
+ * void — từ chối được phép ở BẤT KỲ cấp nào đang chờ (khớp hành vi 2 flow
64
+ * hiện có).
65
+ */
66
+ export async function planRejection(
67
+ state: ApprovalState,
68
+ can: (resource: string, action: string) => boolean | Promise<boolean>,
69
+ permission: { resource: string; action: string },
70
+ denyMessage: string
71
+ ): Promise<void> {
72
+ if (state.terminal) {
73
+ throw new ApprovalError(
74
+ "Chứng từ này đã được xử lý (Đã duyệt hoặc Bị từ chối)"
75
+ )
76
+ }
77
+ if (!(await can(permission.resource, permission.action))) {
78
+ throw new ApprovalError(denyMessage, 403)
79
+ }
80
+ }
@@ -0,0 +1,9 @@
1
+ /**
2
+ * APPROVAL ENGINE — máy trạng thái duyệt n cấp dùng chung (@goerp/core/approval).
3
+ *
4
+ * Thuần logic, Prisma-agnostic: consumer tự cung cấp `can(resource, action)`
5
+ * (đồng bộ/bất đồng bộ) + flow. Persistence + delegation + DB-config do app lo.
6
+ * Promote từ vinhhoa (Phase 6, KE_HOACH_ERP_THUC_THU.md).
7
+ */
8
+ export * from "./types"
9
+ export * from "./approval-engine"
@@ -0,0 +1,60 @@
1
+ /**
2
+ * APPROVAL ENGINE — kiểu dữ liệu (Phase 6, KE_HOACH_ERP_THUC_THU.md).
3
+ *
4
+ * Trừu tượng hóa 2 bản duyệt cài riêng (PO + đề nghị thanh toán) thành một
5
+ * máy trạng thái n cấp dùng chung. Engine THUẦN LOGIC — không import Prisma,
6
+ * không side-effect: người gọi (adapter trong module) tự lo persistence
7
+ * trong transaction của mình. Viết kiểu DI để promote lên @goerp/core (M3).
8
+ */
9
+
10
+ export interface ApprovalStep {
11
+ /** Cấp duyệt, 1-based, tăng dần. */
12
+ level: number
13
+ /** Tên hiển thị của cấp ("Kế toán", "Giám đốc", "Phê duyệt Lần 1"...). */
14
+ name: string
15
+ /** Quyền RBAC yêu cầu để duyệt cấp này. */
16
+ permission: { resource: string; action: string }
17
+ /**
18
+ * Điều kiện theo số tiền: bước CHỈ áp dụng khi amount >= minAmount.
19
+ * Bỏ trống = luôn áp dụng. (Ví dụ: dưới 5tr bỏ qua cấp Giám đốc.)
20
+ */
21
+ minAmount?: number
22
+ /** Message khi thiếu quyền — mặc định "Bạn không có quyền <name>". */
23
+ denyMessage?: string
24
+ }
25
+
26
+ export interface ApprovalFlow {
27
+ /** Loại chứng từ ("payment-request", "purchase-order"...). */
28
+ entityType: string
29
+ steps: ApprovalStep[]
30
+ }
31
+
32
+ /** Trạng thái duyệt hiện tại của chứng từ — adapter map từ model riêng. */
33
+ export interface ApprovalState {
34
+ /** Số tiền chứng từ (null = không có điều kiện tiền). */
35
+ amount: number | null
36
+ /** Số CẤP đã duyệt xong (0 = chưa cấp nào; tính theo effective steps). */
37
+ completedLevel: number
38
+ /** Đã kết thúc (approved/rejected) — mọi thao tác duyệt tiếp bị chặn. */
39
+ terminal: boolean
40
+ }
41
+
42
+ export interface ApprovalPlan {
43
+ /** Bước cần duyệt bây giờ. */
44
+ step: ApprovalStep
45
+ /** true nếu đây là bước cuối → sau bước này chứng từ approved. */
46
+ isFinal: boolean
47
+ /** Danh sách bước áp dụng thực tế sau khi lọc điều kiện tiền. */
48
+ effectiveSteps: ApprovalStep[]
49
+ }
50
+
51
+ /** Lỗi nghiệp vụ của engine — message hiển thị thẳng cho user (VN). */
52
+ export class ApprovalError extends Error {
53
+ readonly status: number
54
+
55
+ constructor(message: string, status = 400) {
56
+ super(message)
57
+ this.name = "ApprovalError"
58
+ this.status = status
59
+ }
60
+ }