@goplusvn/core 0.1.35 → 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
@@ -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.35",
4
+ "version": "0.1.36",
5
5
  "private": false,
6
6
  "publishConfig": {
7
7
  "registry": "https://registry.npmjs.org",
@@ -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,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
+ }