@goplusvn/core 0.1.35 → 0.1.37
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,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
|
+
}
|
|
@@ -8,6 +8,7 @@ import type { LocaleType } from "../../types";
|
|
|
8
8
|
|
|
9
9
|
import { ensureLocalizedPathname, cn } from "../../utils";
|
|
10
10
|
import { LanguageDropdown } from "../layout";
|
|
11
|
+
import { useOptionalTenant } from "../../providers/tenant-provider";
|
|
11
12
|
|
|
12
13
|
import type { StaticImageData } from "next/image";
|
|
13
14
|
|
|
@@ -32,6 +33,9 @@ export function Auth({
|
|
|
32
33
|
}: AuthProps) {
|
|
33
34
|
const params = useParams();
|
|
34
35
|
const locale = (params.lang as LocaleType) || "vi";
|
|
36
|
+
// Branding của tenant (nếu app mount TenantProvider). Fallback về logo/tên mặc
|
|
37
|
+
// định để các app chưa cấu hình branding giữ nguyên hành vi cũ.
|
|
38
|
+
const branding = useOptionalTenant()?.branding;
|
|
35
39
|
|
|
36
40
|
return (
|
|
37
41
|
<section
|
|
@@ -47,14 +51,25 @@ export function Auth({
|
|
|
47
51
|
href={ensureLocalizedPathname("/", locale)}
|
|
48
52
|
className="flex items-center gap-2 text-foreground font-black"
|
|
49
53
|
>
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
54
|
+
{branding?.logo ? (
|
|
55
|
+
<Image
|
|
56
|
+
src={branding.logo}
|
|
57
|
+
alt={branding.companyName || "Logo"}
|
|
58
|
+
height={24}
|
|
59
|
+
width={24}
|
|
60
|
+
className="object-contain"
|
|
61
|
+
unoptimized
|
|
62
|
+
/>
|
|
63
|
+
) : (
|
|
64
|
+
<Image
|
|
65
|
+
src="/images/icons/shadboard.svg"
|
|
66
|
+
alt="Logo"
|
|
67
|
+
height={24}
|
|
68
|
+
width={24}
|
|
69
|
+
className="dark:invert"
|
|
70
|
+
/>
|
|
71
|
+
)}
|
|
72
|
+
<span>{branding?.companyName || "GoERP"}</span>
|
|
58
73
|
</Link>
|
|
59
74
|
<LanguageDropdown dictionary={dictionary} />
|
|
60
75
|
</div>
|