@goplusvn/core 0.1.34 → 0.1.36
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
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Kỳ kế toán tháng + khóa sổ — GUARD dùng chung (@goerp/core/accounting-period).
|
|
3
|
+
*
|
|
4
|
+
* Promote từ vinhhoa (Phase 1 ERP). Prisma-agnostic: consumer truyền `tx`
|
|
5
|
+
* (structural PeriodClient có `accountingPeriod.findUnique`). Quy ước: KHÔNG có
|
|
6
|
+
* dòng AccountingPeriod = kỳ MỞ; chỉ dòng status="closed" là đóng. Tháng/năm
|
|
7
|
+
* theo lịch VIỆT NAM (UTC+7) tính inline bằng Intl — không phụ thuộc date-utils.
|
|
8
|
+
*
|
|
9
|
+
* Consumer schema PHẢI có model `AccountingPeriod { year Int, month Int,
|
|
10
|
+
* status String, @@unique([year, month], name: "year_month") }`.
|
|
11
|
+
*
|
|
12
|
+
* closePeriod/reopenPeriod/listPeriods KHÔNG ở đây — chúng gắn nghiệp vụ GL
|
|
13
|
+
* (kết chuyển 911) đặc thù từng app; giữ ở consumer.
|
|
14
|
+
*/
|
|
15
|
+
import { AppError } from "../errors/app-error"
|
|
16
|
+
|
|
17
|
+
/** Client tối thiểu để đọc kỳ kế toán — Prisma delegate của consumer thoả. */
|
|
18
|
+
export interface PeriodClient {
|
|
19
|
+
accountingPeriod: {
|
|
20
|
+
findUnique(args: {
|
|
21
|
+
where: { year_month: { year: number; month: number } }
|
|
22
|
+
select: { status: true }
|
|
23
|
+
}): Promise<{ status: string | null } | null>
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const VN_YM_FMT = new Intl.DateTimeFormat("en-CA", {
|
|
28
|
+
timeZone: "Asia/Ho_Chi_Minh",
|
|
29
|
+
year: "numeric",
|
|
30
|
+
month: "2-digit",
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
/** Năm/tháng theo lịch VN (UTC+7) của một mốc thời gian. */
|
|
34
|
+
export function vnYearMonth(date: Date | string): {
|
|
35
|
+
year: number
|
|
36
|
+
month: number
|
|
37
|
+
} {
|
|
38
|
+
const d = typeof date === "string" ? new Date(date) : date
|
|
39
|
+
// en-CA → "yyyy-MM"
|
|
40
|
+
const parts = VN_YM_FMT.format(d)
|
|
41
|
+
return {
|
|
42
|
+
year: parseInt(parts.slice(0, 4), 10),
|
|
43
|
+
month: parseInt(parts.slice(5, 7), 10),
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export async function isPeriodClosed(
|
|
48
|
+
date: Date | string,
|
|
49
|
+
tx: PeriodClient
|
|
50
|
+
): Promise<boolean> {
|
|
51
|
+
const { year, month } = vnYearMonth(date)
|
|
52
|
+
const row = await tx.accountingPeriod.findUnique({
|
|
53
|
+
where: { year_month: { year, month } },
|
|
54
|
+
select: { status: true },
|
|
55
|
+
})
|
|
56
|
+
return row?.status === "closed"
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Ném AppError 423 nếu ngày nghiệp vụ rơi vào kỳ đã khóa sổ.
|
|
61
|
+
* `label` mô tả thao tác để thông báo lỗi tự giải thích (vd "sửa phiếu thu").
|
|
62
|
+
*/
|
|
63
|
+
export async function assertPeriodOpen(
|
|
64
|
+
date: Date | string,
|
|
65
|
+
options: { tx: PeriodClient; label?: string }
|
|
66
|
+
): Promise<void> {
|
|
67
|
+
if (await isPeriodClosed(date, options.tx)) {
|
|
68
|
+
const { year, month } = vnYearMonth(date)
|
|
69
|
+
const period = `${String(month).padStart(2, "0")}/${year}`
|
|
70
|
+
throw new AppError({
|
|
71
|
+
code: "ACCOUNTING_PERIOD_CLOSED",
|
|
72
|
+
message: `Accounting period ${period} is closed`,
|
|
73
|
+
userMessage: `Kỳ kế toán ${period} đã khóa sổ — không thể ${
|
|
74
|
+
options.label ?? "ghi nhận/chỉnh sửa chứng từ trong kỳ này"
|
|
75
|
+
}. Liên hệ quản trị để mở lại kỳ nếu thật sự cần.`,
|
|
76
|
+
statusCode: 423,
|
|
77
|
+
context: { year, month },
|
|
78
|
+
})
|
|
79
|
+
}
|
|
80
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sinh số chứng từ tập trung — hợp nhất ~12 bản copy-paste của pattern
|
|
3
|
+
* "advisory lock + MAX sequence trong prefix + 1" rải khắp services.
|
|
4
|
+
*
|
|
5
|
+
* Nguyên tắc (đúc từ bug thực tế, xem docs/KE_HOACH_ERP_THUC_THU.md Phase 1):
|
|
6
|
+
* - Lấy MAX số thứ tự hiện có rồi +1 — KHÔNG `count + 1` (trùng số sau khi xoá
|
|
7
|
+
* phiếu ở giữa, hoặc khi nhiều chi nhánh chung một chuỗi đếm).
|
|
8
|
+
* - `pg_advisory_xact_lock` theo prefix để chống đua đa instance; lock chỉ nhả
|
|
9
|
+
* khi transaction commit ⇒ BẮT BUỘC gọi bên trong transaction chứa lệnh
|
|
10
|
+
* create (sinh số ngoài tx là lock nhả ngay sau SELECT → trùng số).
|
|
11
|
+
* - Tên lock của từng loại phiếu GIỮ NGUYÊN giá trị lịch sử (option `lockName`/
|
|
12
|
+
* `lockKey`) — đổi tên lock là mất serialize giữa bản cũ/mới lúc rolling
|
|
13
|
+
* deploy, hai bản cùng sinh một số.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Executor tối thiểu để chạy advisory lock — Prisma-agnostic. Prisma
|
|
18
|
+
* TransactionClient của consumer thoả structural type này ($executeRaw là
|
|
19
|
+
* tagged-template trả Promise<number>).
|
|
20
|
+
*/
|
|
21
|
+
export interface SqlExecutor {
|
|
22
|
+
$executeRaw(query: TemplateStringsArray, ...values: unknown[]): Promise<number>
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Delegate tối thiểu của bảng chứa số phiếu (vd `tx.receipt`). */
|
|
26
|
+
interface NumberedDelegate {
|
|
27
|
+
findFirst(args: {
|
|
28
|
+
where: Record<string, unknown>
|
|
29
|
+
orderBy: Record<string, "desc">
|
|
30
|
+
select: Record<string, boolean>
|
|
31
|
+
}): Promise<Record<string, unknown> | null>
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface NextDocumentNumberOptions {
|
|
35
|
+
/** Delegate của bảng chứa số phiếu — PHẢI lấy từ `tx` truyền vào, không từ `db`. */
|
|
36
|
+
delegate: NumberedDelegate
|
|
37
|
+
/** Field chứa số phiếu, vd "receiptNo". */
|
|
38
|
+
field: string
|
|
39
|
+
/** Toàn bộ phần đứng trước số thứ tự (thường gồm ngày), vd "PT-260711". */
|
|
40
|
+
prefix: string
|
|
41
|
+
/** Ký tự nối prefix ↔ số thứ tự. Mặc định "-"; dùng "" cho HD2607110001/PAY00000001. */
|
|
42
|
+
separator?: string
|
|
43
|
+
/** Số chữ số của phần thứ tự. Mặc định 4. */
|
|
44
|
+
pad?: number
|
|
45
|
+
/** Tên khóa advisory lock (mặc định = prefix). Giữ tên lịch sử của từng loại phiếu. */
|
|
46
|
+
lockName?: string
|
|
47
|
+
/** Override khóa lock bằng số cứng (Payment dùng 1002 từ trước). */
|
|
48
|
+
lockKey?: number
|
|
49
|
+
/** Điều kiện bổ sung merge vào filter của field (vd Payment: `{ not: { contains: "-" } }`). */
|
|
50
|
+
fieldWhereExtra?: Record<string, unknown>
|
|
51
|
+
/** Điều kiện bổ sung ở mức where (vd phiếu mở ca: `{ type: "opening" }`). */
|
|
52
|
+
whereExtra?: Record<string, unknown>
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Hash tên lock → int (char-code sum — giữ nguyên thuật toán lịch sử). */
|
|
56
|
+
export function advisoryLockKey(name: string): number {
|
|
57
|
+
return name.split("").reduce((acc, ch) => acc + ch.charCodeAt(0), 0)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export async function nextDocumentNumber(
|
|
61
|
+
tx: SqlExecutor,
|
|
62
|
+
options: NextDocumentNumberOptions
|
|
63
|
+
): Promise<string> {
|
|
64
|
+
const { delegate, field, prefix, separator = "-", pad = 4 } = options
|
|
65
|
+
const lockKey = options.lockKey ?? advisoryLockKey(options.lockName ?? prefix)
|
|
66
|
+
|
|
67
|
+
await tx.$executeRaw`SELECT pg_advisory_xact_lock(${lockKey})`
|
|
68
|
+
|
|
69
|
+
const startsWith = `${prefix}${separator}`
|
|
70
|
+
const last = await delegate.findFirst({
|
|
71
|
+
where: {
|
|
72
|
+
[field]: { startsWith, ...(options.fieldWhereExtra ?? {}) },
|
|
73
|
+
...(options.whereExtra ?? {}),
|
|
74
|
+
},
|
|
75
|
+
orderBy: { [field]: "desc" },
|
|
76
|
+
select: { [field]: true },
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
let sequence = 1
|
|
80
|
+
const lastValue = last?.[field]
|
|
81
|
+
if (typeof lastValue === "string" && lastValue.length > startsWith.length) {
|
|
82
|
+
const parsed = parseInt(lastValue.slice(startsWith.length), 10)
|
|
83
|
+
if (!isNaN(parsed)) sequence = parsed + 1
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return `${startsWith}${String(sequence).padStart(pad, "0")}`
|
|
87
|
+
}
|