@goplusvn/core 0.1.87 → 0.1.89
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/CHANGELOG.md +45 -0
- package/package.json +1 -1
- package/src/audit/__tests__/prisma-audit-extension.test.ts +113 -0
- package/src/audit/prisma-audit-extension.ts +41 -13
- package/src/configs/status.ts +67 -0
- package/src/cron/__tests__/cron-single-runner.test.ts +260 -0
- package/src/cron/cron-schedule.ts +113 -20
- package/src/cron/db-cron-manager.ts +90 -7
- package/src/cron/simple-cron-job.ts +17 -6
- package/src/crud/__tests__/status-boolean-display.test.tsx +186 -0
- package/src/crud/components/crud-detail-dialog.tsx +21 -8
- package/src/crud/components/crud-field-renderer.tsx +20 -13
- package/src/crud/components/crud-filter-chips.tsx +14 -39
- package/src/crud/components/crud-filters/datetime-filter.tsx +58 -17
- package/src/crud/components/crud-provider.tsx +10 -2
- package/src/crud/components/crud-table-toolbar.tsx +38 -40
- package/src/crud/components/crud-table.tsx +13 -8
- package/src/crud/lib/coerce.ts +36 -10
- package/src/crud/lib/filter-defaults.test.ts +115 -0
- package/src/crud/lib/filter-defaults.ts +56 -0
- package/src/crud/lib/filter-display.ts +59 -0
- package/src/crud/lib/query-builder.ts +4 -0
- package/src/types/index.ts +16 -9
- package/src/ui/data-display/data-table/data-table-toolbar.tsx +8 -2
- package/src/ui/data-display/data-table/data-table.tsx +32 -4
- package/src/ui/layout/user-dropdown.tsx +30 -23
- package/src/ui/primitives/status-badge.tsx +14 -10
- package/src/ui/shared/status-indicator.tsx +266 -74
- package/src/utils/index.ts +87 -38
- package/src/workspace/__tests__/workspace-delegation.test.ts +29 -11
|
@@ -23,7 +23,11 @@ export interface CronFields {
|
|
|
23
23
|
dowIsWildcard: boolean;
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
-
function parseField(
|
|
26
|
+
function parseField(
|
|
27
|
+
field: string,
|
|
28
|
+
min: number,
|
|
29
|
+
max: number,
|
|
30
|
+
): Set<number> | null {
|
|
27
31
|
const values = new Set<number>();
|
|
28
32
|
for (const part of field.split(",")) {
|
|
29
33
|
const stepMatch = part.match(/^(.+)\/(\d+)$/);
|
|
@@ -79,46 +83,135 @@ export function parseCronExpression(expr: string): CronFields | null {
|
|
|
79
83
|
};
|
|
80
84
|
}
|
|
81
85
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
+
/** Các thành phần lịch của một mốc thời gian, ĐỌC THEO MÚI GIỜ chỉ định. */
|
|
87
|
+
export interface ZonedParts {
|
|
88
|
+
minute: number;
|
|
89
|
+
hour: number;
|
|
90
|
+
dayOfMonth: number;
|
|
91
|
+
month: number;
|
|
92
|
+
dayOfWeek: number;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const DOW_INDEX: Record<string, number> = {
|
|
96
|
+
Sun: 0,
|
|
97
|
+
Mon: 1,
|
|
98
|
+
Tue: 2,
|
|
99
|
+
Wed: 3,
|
|
100
|
+
Thu: 4,
|
|
101
|
+
Fri: 5,
|
|
102
|
+
Sat: 6,
|
|
103
|
+
};
|
|
86
104
|
|
|
87
|
-
|
|
88
|
-
|
|
105
|
+
/**
|
|
106
|
+
* Đọc mốc thời gian theo `timeZone` (bỏ trống = giờ địa phương của tiến trình).
|
|
107
|
+
*
|
|
108
|
+
* Vì sao cần: container goerp khai `TZ=Asia/Ho_Chi_Minh`, nhưng CHỈ CẦN một
|
|
109
|
+
* container cũ còn sống với TZ mặc định UTC là job "30 22 * * *" nổ lúc 05:30
|
|
110
|
+
* sáng VN — đã xảy ra thật và chốt nhầm "không đi làm" cho cả nhà máy. Khai
|
|
111
|
+
* `timezone` cho job thì lịch bám múi giờ NGHIỆP VỤ, không bám cấu hình máy.
|
|
112
|
+
*/
|
|
113
|
+
export function zonedParts(date: Date, timeZone?: string): ZonedParts {
|
|
114
|
+
if (!timeZone) {
|
|
115
|
+
return {
|
|
116
|
+
minute: date.getMinutes(),
|
|
117
|
+
hour: date.getHours(),
|
|
118
|
+
dayOfMonth: date.getDate(),
|
|
119
|
+
month: date.getMonth() + 1,
|
|
120
|
+
dayOfWeek: date.getDay(),
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
const parts = new Intl.DateTimeFormat("en-US", {
|
|
124
|
+
timeZone,
|
|
125
|
+
hourCycle: "h23",
|
|
126
|
+
month: "numeric",
|
|
127
|
+
day: "numeric",
|
|
128
|
+
hour: "numeric",
|
|
129
|
+
minute: "numeric",
|
|
130
|
+
weekday: "short",
|
|
131
|
+
}).formatToParts(date);
|
|
132
|
+
const get = (type: string) =>
|
|
133
|
+
parts.find((p) => p.type === type)?.value ?? "0";
|
|
134
|
+
return {
|
|
135
|
+
minute: Number(get("minute")),
|
|
136
|
+
hour: Number(get("hour")),
|
|
137
|
+
dayOfMonth: Number(get("day")),
|
|
138
|
+
month: Number(get("month")),
|
|
139
|
+
dayOfWeek: DOW_INDEX[get("weekday")] ?? 0,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function cronMatches(
|
|
144
|
+
fields: CronFields,
|
|
145
|
+
date: Date,
|
|
146
|
+
timeZone?: string,
|
|
147
|
+
): boolean {
|
|
148
|
+
const at = zonedParts(date, timeZone);
|
|
149
|
+
if (!fields.minute.has(at.minute)) return false;
|
|
150
|
+
if (!fields.hour.has(at.hour)) return false;
|
|
151
|
+
if (!fields.month.has(at.month)) return false;
|
|
152
|
+
|
|
153
|
+
const domMatch = fields.dayOfMonth.has(at.dayOfMonth);
|
|
154
|
+
const dowMatch = fields.dayOfWeek.has(at.dayOfWeek);
|
|
89
155
|
// POSIX: cả dom lẫn dow bị giới hạn → OR; ngược lại → AND (vế wildcard luôn đúng).
|
|
90
|
-
if (!fields.domIsWildcard && !fields.dowIsWildcard)
|
|
156
|
+
if (!fields.domIsWildcard && !fields.dowIsWildcard)
|
|
157
|
+
return domMatch || dowMatch;
|
|
91
158
|
return domMatch && dowMatch;
|
|
92
159
|
}
|
|
93
160
|
|
|
94
161
|
/**
|
|
95
162
|
* Lần khớp KẾ TIẾP sau `from` (đầu phút, không tính chính `from`).
|
|
96
163
|
* Trả null nếu không có trong 2 năm tới (biểu thức bất khả thi, vd 30/2).
|
|
164
|
+
* `timeZone` bỏ trống = giờ địa phương của tiến trình (hành vi cũ).
|
|
97
165
|
*/
|
|
98
|
-
export function nextCronDate(
|
|
166
|
+
export function nextCronDate(
|
|
167
|
+
fields: CronFields,
|
|
168
|
+
from = new Date(),
|
|
169
|
+
timeZone?: string,
|
|
170
|
+
): Date | null {
|
|
99
171
|
const cursor = new Date(from.getTime());
|
|
100
172
|
cursor.setSeconds(0, 0);
|
|
101
173
|
cursor.setMinutes(cursor.getMinutes() + 1);
|
|
102
174
|
|
|
103
175
|
const LIMIT = 2 * 366 * 24 * 60; // phút
|
|
104
176
|
for (let i = 0; i < LIMIT; i++) {
|
|
105
|
-
if (cronMatches(fields, cursor)) return cursor;
|
|
177
|
+
if (cronMatches(fields, cursor, timeZone)) return cursor;
|
|
178
|
+
|
|
106
179
|
// Nhảy nhanh: sai tháng → đầu tháng sau; sai ngày → đầu ngày sau; sai giờ
|
|
107
180
|
// → đầu giờ sau. Giảm vòng lặp từ hàng trăm nghìn xuống vài trăm.
|
|
108
|
-
|
|
181
|
+
//
|
|
182
|
+
// Có `timeZone` thì KHÔNG dùng setMonth/setHours (chúng đặt theo giờ máy,
|
|
183
|
+
// không phải giờ nghiệp vụ) — nhảy bằng cách CỘNG số phút còn lại tới
|
|
184
|
+
// biên giờ/ngày ĐỌC THEO múi giờ đó. Sai lệch do DST cùng lắm làm vòng
|
|
185
|
+
// lặp chạy thêm vài nhịp phút, không làm sai kết quả.
|
|
186
|
+
const at = zonedParts(cursor, timeZone);
|
|
187
|
+
const dayOk =
|
|
188
|
+
!fields.domIsWildcard && !fields.dowIsWildcard
|
|
189
|
+
? fields.dayOfMonth.has(at.dayOfMonth) ||
|
|
190
|
+
fields.dayOfWeek.has(at.dayOfWeek)
|
|
191
|
+
: fields.dayOfMonth.has(at.dayOfMonth) &&
|
|
192
|
+
fields.dayOfWeek.has(at.dayOfWeek);
|
|
193
|
+
|
|
194
|
+
if (timeZone) {
|
|
195
|
+
if (!fields.month.has(at.month) || !dayOk) {
|
|
196
|
+
// Sang đầu ngày kế tiếp theo múi giờ nghiệp vụ.
|
|
197
|
+
cursor.setMinutes(
|
|
198
|
+
cursor.getMinutes() + (24 * 60 - (at.hour * 60 + at.minute)),
|
|
199
|
+
);
|
|
200
|
+
} else if (!fields.hour.has(at.hour)) {
|
|
201
|
+
cursor.setMinutes(cursor.getMinutes() + (60 - at.minute));
|
|
202
|
+
} else {
|
|
203
|
+
cursor.setMinutes(cursor.getMinutes() + 1);
|
|
204
|
+
}
|
|
205
|
+
continue;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
if (!fields.month.has(at.month)) {
|
|
109
209
|
cursor.setMonth(cursor.getMonth() + 1, 1);
|
|
110
210
|
cursor.setHours(0, 0, 0, 0);
|
|
111
|
-
} else if (
|
|
112
|
-
!(fields.domIsWildcard && fields.dowIsWildcard) &&
|
|
113
|
-
!(
|
|
114
|
-
(!fields.domIsWildcard && !fields.dowIsWildcard
|
|
115
|
-
? fields.dayOfMonth.has(cursor.getDate()) || fields.dayOfWeek.has(cursor.getDay())
|
|
116
|
-
: fields.dayOfMonth.has(cursor.getDate()) && fields.dayOfWeek.has(cursor.getDay()))
|
|
117
|
-
)
|
|
118
|
-
) {
|
|
211
|
+
} else if (!(fields.domIsWildcard && fields.dowIsWildcard) && !dayOk) {
|
|
119
212
|
cursor.setDate(cursor.getDate() + 1);
|
|
120
213
|
cursor.setHours(0, 0, 0, 0);
|
|
121
|
-
} else if (!fields.hour.has(
|
|
214
|
+
} else if (!fields.hour.has(at.hour)) {
|
|
122
215
|
cursor.setHours(cursor.getHours() + 1, 0, 0, 0);
|
|
123
216
|
} else {
|
|
124
217
|
cursor.setMinutes(cursor.getMinutes() + 1);
|
|
@@ -1,10 +1,6 @@
|
|
|
1
1
|
import { SimpleCronJob } from "./simple-cron-job";
|
|
2
2
|
|
|
3
|
-
import type {
|
|
4
|
-
CronJob,
|
|
5
|
-
CronJobManager,
|
|
6
|
-
CronJobOptions,
|
|
7
|
-
} from "./types";
|
|
3
|
+
import type { CronJob, CronJobManager, CronJobOptions } from "./types";
|
|
8
4
|
|
|
9
5
|
/**
|
|
10
6
|
* Cron CÓ TRẠNG THÁI TRONG DB — engine dùng chung mọi app goerp (bảng
|
|
@@ -51,6 +47,12 @@ export interface CronDb {
|
|
|
51
47
|
systemJob?: {
|
|
52
48
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
53
49
|
count(args?: any): Promise<number>;
|
|
50
|
+
/**
|
|
51
|
+
* Optional: có thì bật GIÀNH LƯỢT CHẠY (một tiến trình / một nhịp). Thiếu
|
|
52
|
+
* thì manager chạy như cũ — app cũ không vỡ.
|
|
53
|
+
*/
|
|
54
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
55
|
+
updateMany?(args: any): Promise<{ count: number }>;
|
|
54
56
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
55
57
|
findUnique(args: any): Promise<any>;
|
|
56
58
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
@@ -110,12 +112,41 @@ export function getJobExecutionContext(): JobExecutionContext | null {
|
|
|
110
112
|
return currentExecutionContext;
|
|
111
113
|
}
|
|
112
114
|
|
|
115
|
+
/**
|
|
116
|
+
* GIÀNH LƯỢT CHẠY — chặn job chạy nhiều lần cùng một nhịp.
|
|
117
|
+
*
|
|
118
|
+
* Cron ở đây là timer TRONG TIẾN TRÌNH: mỗi tiến trình boot lên là tự đăng ký
|
|
119
|
+
* đủ bộ job. Hai container (hoặc `next dev` chạy song song bản deploy, hoặc
|
|
120
|
+
* một container cũ chưa bị gỡ) ⇒ MỌI job chạy hai lần mỗi nhịp. Đã gây hậu
|
|
121
|
+
* quả thật trên prod: hai dòng `job_execution_logs` cho mỗi nhịp, và job chốt
|
|
122
|
+
* ngày chạy lượt thứ hai ghi đè dữ liệu của lượt đầu.
|
|
123
|
+
*
|
|
124
|
+
* Cách chốt: trước khi chạy, mỗi tiến trình cố CẬP NHẬT CÓ ĐIỀU KIỆN dòng
|
|
125
|
+
* `system_jobs` — chỉ ăn khi `lastRun` còn cũ hơn nhịp hiện tại. Postgres bảo
|
|
126
|
+
* đảm một câu UPDATE có điều kiện là nguyên tử, nên đúng MỘT tiến trình nhận
|
|
127
|
+
* được `count = 1`; các tiến trình khác thấy 0 và bỏ nhịp. Không cần bảng
|
|
128
|
+
* khoá riêng, không cần leader election.
|
|
129
|
+
*
|
|
130
|
+
* Tắt bằng `GOERP_CRON_SINGLE_RUNNER=off` (chạy nhiều node CỐ Ý, hiếm).
|
|
131
|
+
*
|
|
132
|
+
* LƯU Ý: cái này chống trùng theo NHỊP, không sửa được tiến trình chạy SAI
|
|
133
|
+
* MÚI GIỜ — container UTC nổ job "30 22" vào một nhịp hoàn toàn khác nên vẫn
|
|
134
|
+
* giành được lượt. Vế đó do `timezone` của job lo (xem cron-schedule.ts).
|
|
135
|
+
*/
|
|
136
|
+
function singleRunnerEnabled(): boolean {
|
|
137
|
+
return (process.env.GOERP_CRON_SINGLE_RUNNER ?? "").toLowerCase() !== "off";
|
|
138
|
+
}
|
|
139
|
+
|
|
113
140
|
export class DbCronJobManager implements CronJobManager {
|
|
114
141
|
private jobs = new Map<string, CronJob>();
|
|
115
142
|
/** onTick GỐC của app — giữ để updateJob dựng lại job với cronTime mới. */
|
|
116
143
|
private jobCallbacks = new Map<string, () => void | Promise<void>>();
|
|
117
|
-
/** onTick ĐÃ BỌC (ghi log) —
|
|
144
|
+
/** onTick ĐÃ BỌC (ghi log) — bản cho LỊCH, có giành lượt chạy. */
|
|
118
145
|
private wrappedCallbacks = new Map<string, () => void | Promise<void>>();
|
|
146
|
+
/** Bản cho nút "Chạy ngay": vẫn ghi log nhưng KHÔNG giành lượt. */
|
|
147
|
+
private manualCallbacks = new Map<string, () => void | Promise<void>>();
|
|
148
|
+
/** Múi giờ nghiệp vụ đã khai của từng job — giữ qua updateJob. */
|
|
149
|
+
private jobTimezones = new Map<string, string | undefined>();
|
|
119
150
|
private isInitialized = false;
|
|
120
151
|
|
|
121
152
|
async init(): Promise<void> {
|
|
@@ -146,12 +177,21 @@ export class DbCronJobManager implements CronJobManager {
|
|
|
146
177
|
private wrapOnTick(
|
|
147
178
|
name: string,
|
|
148
179
|
onTick: () => void | Promise<void>,
|
|
180
|
+
/** false cho lượt chạy TAY: người bấm thì phải chạy, không giành lượt. */
|
|
181
|
+
claimTick = true,
|
|
149
182
|
): () => Promise<void> {
|
|
150
183
|
return async () => {
|
|
151
184
|
const { db } = requireConfig();
|
|
152
185
|
const startedAt = new Date();
|
|
153
186
|
let logId: string | null = null;
|
|
154
187
|
|
|
188
|
+
if (claimTick && !(await this.claimTick(name, startedAt))) {
|
|
189
|
+
log().info(
|
|
190
|
+
`Bỏ nhịp job ${name}: tiến trình khác đã nhận lượt này (single-runner)`,
|
|
191
|
+
);
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
|
|
155
195
|
if (db.jobExecutionLog) {
|
|
156
196
|
try {
|
|
157
197
|
const entry = await db.jobExecutionLog.create({
|
|
@@ -202,6 +242,36 @@ export class DbCronJobManager implements CronJobManager {
|
|
|
202
242
|
};
|
|
203
243
|
}
|
|
204
244
|
|
|
245
|
+
/**
|
|
246
|
+
* Nhận lượt chạy của nhịp hiện tại chưa? Xem docblock `singleRunnerEnabled`.
|
|
247
|
+
* Trả `true` (cho chạy) ở mọi trường hợp không chắc — cron là hạ tầng nền,
|
|
248
|
+
* thà chạy trùng còn hơn im lặng không chạy gì.
|
|
249
|
+
*/
|
|
250
|
+
private async claimTick(name: string, startedAt: Date): Promise<boolean> {
|
|
251
|
+
if (!singleRunnerEnabled()) return true;
|
|
252
|
+
const { db } = requireConfig();
|
|
253
|
+
if (!db.systemJob?.updateMany) return true;
|
|
254
|
+
|
|
255
|
+
// Mốc nhịp = đầu phút. Hai tiến trình cùng nổ trong một phút sẽ tính ra
|
|
256
|
+
// CÙNG một mốc, nên chỉ một câu UPDATE khớp điều kiện.
|
|
257
|
+
const bucket = new Date(startedAt);
|
|
258
|
+
bucket.setSeconds(0, 0);
|
|
259
|
+
|
|
260
|
+
try {
|
|
261
|
+
const result = await db.systemJob.updateMany({
|
|
262
|
+
where: {
|
|
263
|
+
name,
|
|
264
|
+
OR: [{ lastRun: null }, { lastRun: { lt: bucket } }],
|
|
265
|
+
},
|
|
266
|
+
data: { lastRun: bucket, status: "running" },
|
|
267
|
+
});
|
|
268
|
+
return (result?.count ?? 1) > 0;
|
|
269
|
+
} catch (e) {
|
|
270
|
+
log().error(`Giành lượt chạy job ${name} lỗi — vẫn cho chạy`, e);
|
|
271
|
+
return true;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
205
275
|
private async closeExecutionLog(
|
|
206
276
|
logId: string | null,
|
|
207
277
|
name: string,
|
|
@@ -239,6 +309,10 @@ export class DbCronJobManager implements CronJobManager {
|
|
|
239
309
|
this.jobCallbacks.set(options.name, options.onTick);
|
|
240
310
|
|
|
241
311
|
const wrappedOnTick = this.wrapOnTick(options.name, options.onTick);
|
|
312
|
+
this.manualCallbacks.set(
|
|
313
|
+
options.name,
|
|
314
|
+
this.wrapOnTick(options.name, options.onTick, false),
|
|
315
|
+
);
|
|
242
316
|
const wrappedOptions: CronJobOptions = {
|
|
243
317
|
...options,
|
|
244
318
|
onTick: wrappedOnTick,
|
|
@@ -247,6 +321,7 @@ export class DbCronJobManager implements CronJobManager {
|
|
|
247
321
|
const job = new SimpleCronJob(wrappedOptions);
|
|
248
322
|
this.jobs.set(options.name, job);
|
|
249
323
|
this.wrappedCallbacks.set(options.name, wrappedOnTick);
|
|
324
|
+
this.jobTimezones.set(options.name, options.timezone);
|
|
250
325
|
|
|
251
326
|
// Đồng bộ DB bất đồng bộ: DB quyết định job có chạy hay không.
|
|
252
327
|
void this.syncJobWithDb(wrappedOptions, job).catch((e) =>
|
|
@@ -340,6 +415,7 @@ export class DbCronJobManager implements CronJobManager {
|
|
|
340
415
|
this.jobs.delete(name);
|
|
341
416
|
this.jobCallbacks.delete(name);
|
|
342
417
|
this.wrappedCallbacks.delete(name);
|
|
418
|
+
this.manualCallbacks.delete(name);
|
|
343
419
|
}
|
|
344
420
|
|
|
345
421
|
getJob(name: string): CronJob | undefined {
|
|
@@ -399,7 +475,8 @@ export class DbCronJobManager implements CronJobManager {
|
|
|
399
475
|
|
|
400
476
|
/** Chạy tay (fire-and-forget) — vẫn đi qua bản BỌC nên có execution log. */
|
|
401
477
|
async runJobNow(name: string): Promise<boolean> {
|
|
402
|
-
const callback =
|
|
478
|
+
const callback =
|
|
479
|
+
this.manualCallbacks.get(name) ?? this.wrappedCallbacks.get(name);
|
|
403
480
|
if (!callback) return false;
|
|
404
481
|
void Promise.resolve(callback()).catch((e) =>
|
|
405
482
|
log().error(`Chạy tay job ${name} lỗi`, e),
|
|
@@ -442,12 +519,18 @@ export class DbCronJobManager implements CronJobManager {
|
|
|
442
519
|
cronTime: newCronTime,
|
|
443
520
|
onTick: wrappedOnTick,
|
|
444
521
|
start: finalEnabled ?? false,
|
|
522
|
+
// Đổi lịch KHÔNG được làm rơi múi giờ nghiệp vụ đã khai lúc đăng ký.
|
|
523
|
+
timezone: this.jobTimezones.get(name),
|
|
445
524
|
};
|
|
446
525
|
|
|
447
526
|
const newJob = new SimpleCronJob(newJobOptions);
|
|
448
527
|
this.jobs.set(name, newJob);
|
|
449
528
|
this.jobCallbacks.set(name, originalOnTick);
|
|
450
529
|
this.wrappedCallbacks.set(name, wrappedOnTick);
|
|
530
|
+
this.manualCallbacks.set(
|
|
531
|
+
name,
|
|
532
|
+
this.wrapOnTick(name, originalOnTick, false),
|
|
533
|
+
);
|
|
451
534
|
|
|
452
535
|
await this.syncJobWithDb(newJobOptions, newJob, enabled);
|
|
453
536
|
log().info(`Đổi lịch job ${name} → ${newCronTime}`);
|
|
@@ -32,11 +32,18 @@ class SimpleCronJob implements CronJob {
|
|
|
32
32
|
/** Chế độ khoảng đơn giản ("5m"…); null nghĩa là chạy theo cron thật. */
|
|
33
33
|
private intervalMs: number | null = null;
|
|
34
34
|
private cronFields: CronFields | null = null;
|
|
35
|
+
/**
|
|
36
|
+
* Múi giờ NGHIỆP VỤ của lịch. Bỏ trống = giờ địa phương tiến trình (hành vi
|
|
37
|
+
* cũ). Khai ra thì "30 22 * * *" là 22:30 ở múi giờ đó, dù container đang
|
|
38
|
+
* chạy UTC — chính là cách chặn hẳn lỗi job hằng ngày nổ lệch 7 tiếng.
|
|
39
|
+
*/
|
|
40
|
+
private timezone?: string;
|
|
35
41
|
|
|
36
42
|
constructor(options: CronJobOptions) {
|
|
37
43
|
this.name = options.name;
|
|
38
44
|
this.cronTime = options.cronTime;
|
|
39
45
|
this.onTick = options.onTick;
|
|
46
|
+
this.timezone = options.timezone;
|
|
40
47
|
|
|
41
48
|
const simpleMatch = options.cronTime.match(/^(\d+)([smhd])$/);
|
|
42
49
|
if (simpleMatch) {
|
|
@@ -73,7 +80,7 @@ class SimpleCronJob implements CronJob {
|
|
|
73
80
|
/** Hẹn giờ tới lần khớp cron kế tiếp; chạy xong tự hẹn tiếp. */
|
|
74
81
|
private scheduleNextCron(): void {
|
|
75
82
|
if (!this.running || !this.cronFields) return;
|
|
76
|
-
const next = nextCronDate(this.cronFields);
|
|
83
|
+
const next = nextCronDate(this.cronFields, new Date(), this.timezone);
|
|
77
84
|
if (!next) {
|
|
78
85
|
logger.warn(
|
|
79
86
|
`Cron job "${this.name}": biểu thức "${this.cronTime}" không có lần chạy nào trong 2 năm tới — dừng.`,
|
|
@@ -88,10 +95,13 @@ class SimpleCronJob implements CronJob {
|
|
|
88
95
|
this.timerId = setTimeout(() => this.scheduleNextCron(), MAX_CHUNK);
|
|
89
96
|
return;
|
|
90
97
|
}
|
|
91
|
-
this.timerId = setTimeout(
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
98
|
+
this.timerId = setTimeout(
|
|
99
|
+
async () => {
|
|
100
|
+
await this.fire();
|
|
101
|
+
this.scheduleNextCron();
|
|
102
|
+
},
|
|
103
|
+
Math.max(0, delay),
|
|
104
|
+
);
|
|
95
105
|
}
|
|
96
106
|
|
|
97
107
|
start(): void {
|
|
@@ -131,7 +141,8 @@ class SimpleCronJob implements CronJob {
|
|
|
131
141
|
|
|
132
142
|
nextDate(): Date | null {
|
|
133
143
|
if (!this.running) return null;
|
|
134
|
-
if (this.cronFields)
|
|
144
|
+
if (this.cronFields)
|
|
145
|
+
return nextCronDate(this.cronFields, new Date(), this.timezone);
|
|
135
146
|
return new Date(Date.now() + this.intervalMs!);
|
|
136
147
|
}
|
|
137
148
|
}
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HỒI QUY — Ô "Trạng thái" với dữ liệu boolean.
|
|
3
|
+
*
|
|
4
|
+
* Cùng một field khai `type: "switch"` + cặp option chữ
|
|
5
|
+
* (`Hoạt động` / `Tạm ngưng`), nhưng CỘT trong DB mỗi app một kiểu:
|
|
6
|
+
*
|
|
7
|
+
* - text → "active" / "inactive" (spartronics, phần lớn bảng)
|
|
8
|
+
* - boolean → true / false (`is_active`, `enabled`)
|
|
9
|
+
* - smallint → 1 / 0
|
|
10
|
+
*
|
|
11
|
+
* Trước bản này chỗ nào cũng so TUYỆT ĐỐI (`optValue === value`) và form thì
|
|
12
|
+
* so cứng `value === "active"`, nên với cột boolean:
|
|
13
|
+
*
|
|
14
|
+
* - bảng danh sách hiện chữ "true"/"false" trong chip xám;
|
|
15
|
+
* - `getStatusMeta(true)` ném TypeError (`true.toLowerCase is not a function`);
|
|
16
|
+
* - hộp thoại Sửa hiện công tắc TẮT cho bản ghi đang BẬT.
|
|
17
|
+
*
|
|
18
|
+
* Bộ test này khoá cả ba đường: chuẩn hoá giá trị, chip hiển thị, và cặp
|
|
19
|
+
* bật/tắt mà công tắc form đọc/ghi.
|
|
20
|
+
*/
|
|
21
|
+
import { describe, it, expect } from "vitest";
|
|
22
|
+
import { render, screen } from "@testing-library/react";
|
|
23
|
+
|
|
24
|
+
import {
|
|
25
|
+
normalizeStatusValue,
|
|
26
|
+
isTruthyStatus,
|
|
27
|
+
matchesOptionValue,
|
|
28
|
+
} from "../../configs/status";
|
|
29
|
+
import { getStatusMeta } from "../../ui/shared/status-indicator";
|
|
30
|
+
import { StatusBadge } from "../../ui/primitives/status-badge";
|
|
31
|
+
import { coerceFieldValue, resolveSwitchPair } from "../lib/coerce";
|
|
32
|
+
import type { FieldConfig } from "../../types";
|
|
33
|
+
|
|
34
|
+
const statusField = (over: Partial<FieldConfig> = {}): FieldConfig => ({
|
|
35
|
+
name: "status",
|
|
36
|
+
label: "Trạng thái",
|
|
37
|
+
type: "switch",
|
|
38
|
+
options: [
|
|
39
|
+
{ label: "Hoạt động", value: "active" },
|
|
40
|
+
{ label: "Tạm ngưng", value: "inactive" },
|
|
41
|
+
],
|
|
42
|
+
...over,
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
describe("normalizeStatusValue — mọi biến thể về một key", () => {
|
|
46
|
+
it("boolean → active/inactive", () => {
|
|
47
|
+
expect(normalizeStatusValue(true)).toBe("active");
|
|
48
|
+
expect(normalizeStatusValue(false)).toBe("inactive");
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("số 1/0 (cột smallint, BigInt của Prisma) → active/inactive", () => {
|
|
52
|
+
expect(normalizeStatusValue(1)).toBe("active");
|
|
53
|
+
expect(normalizeStatusValue(0)).toBe("inactive");
|
|
54
|
+
expect(normalizeStatusValue(BigInt(1))).toBe("active");
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("chuỗi đi qua form/URL → active/inactive", () => {
|
|
58
|
+
for (const v of ["true", "1", "on", "yes", "ACTIVE", " Active "]) {
|
|
59
|
+
expect(normalizeStatusValue(v)).toBe("active");
|
|
60
|
+
}
|
|
61
|
+
for (const v of ["false", "0", "off", "no", "INACTIVE"]) {
|
|
62
|
+
expect(normalizeStatusValue(v)).toBe("inactive");
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("trạng thái KHÁC bật/tắt giữ nguyên key để tra bảng màu", () => {
|
|
67
|
+
expect(normalizeStatusValue("pending")).toBe("pending");
|
|
68
|
+
expect(normalizeStatusValue("Partially Paid")).toBe("partially_paid");
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("rỗng → null (không quy bừa về inactive)", () => {
|
|
72
|
+
expect(normalizeStatusValue(null)).toBeNull();
|
|
73
|
+
expect(normalizeStatusValue(undefined)).toBeNull();
|
|
74
|
+
expect(normalizeStatusValue("")).toBeNull();
|
|
75
|
+
expect(normalizeStatusValue(" ")).toBeNull();
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("isTruthyStatus dùng chung một bảng", () => {
|
|
79
|
+
expect(isTruthyStatus(true)).toBe(true);
|
|
80
|
+
expect(isTruthyStatus("active")).toBe(true);
|
|
81
|
+
expect(isTruthyStatus(0)).toBe(false);
|
|
82
|
+
expect(isTruthyStatus("pending")).toBe(false);
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
describe("matchesOptionValue — giá trị dòng khớp option đã khai", () => {
|
|
87
|
+
it("boolean khớp option chữ", () => {
|
|
88
|
+
expect(matchesOptionValue("active", true)).toBe(true);
|
|
89
|
+
expect(matchesOptionValue("inactive", false)).toBe(true);
|
|
90
|
+
expect(matchesOptionValue("active", false)).toBe(false);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("số khớp option chuỗi và ngược lại", () => {
|
|
94
|
+
expect(matchesOptionValue("1", 1)).toBe(true);
|
|
95
|
+
expect(matchesOptionValue(1, "1")).toBe(true);
|
|
96
|
+
expect(matchesOptionValue(true, "active")).toBe(true);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("hai giá trị lạ KHÔNG khớp bừa vì cùng chuẩn hoá ra null", () => {
|
|
100
|
+
expect(matchesOptionValue("", null)).toBe(false);
|
|
101
|
+
expect(matchesOptionValue(null, undefined)).toBe(false);
|
|
102
|
+
expect(matchesOptionValue({}, [])).toBe(false);
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
describe("getStatusMeta — nhận cả giá trị không phải chuỗi", () => {
|
|
107
|
+
it("boolean true → Hoạt động, không ném lỗi", () => {
|
|
108
|
+
expect(() => getStatusMeta(true)).not.toThrow();
|
|
109
|
+
expect(getStatusMeta(true).label).toBe("Hoạt động");
|
|
110
|
+
expect(getStatusMeta(true).badgeVariant).toBe("success");
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("boolean false → Ngừng hoạt động", () => {
|
|
114
|
+
expect(getStatusMeta(false).label).toBe("Ngừng hoạt động");
|
|
115
|
+
expect(getStatusMeta(false).badgeVariant).toBe("danger");
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it("key lạ báo matched=false để nơi gọi tự quyết nhãn", () => {
|
|
119
|
+
expect(getStatusMeta("khong_co_trong_bang").matched).toBe(false);
|
|
120
|
+
expect(getStatusMeta("active").matched).toBe(true);
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
describe("StatusBadge — chip trạng thái", () => {
|
|
125
|
+
it("giá trị boolean KHÔNG được hiện ra chữ true/false", () => {
|
|
126
|
+
render(<StatusBadge status={true} />);
|
|
127
|
+
expect(screen.getByText("Hoạt động")).toBeTruthy();
|
|
128
|
+
expect(screen.queryByText("true")).toBeNull();
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it("boolean false → nhãn tiếng Việt", () => {
|
|
132
|
+
render(<StatusBadge status={false} />);
|
|
133
|
+
expect(screen.getByText("Ngừng hoạt động")).toBeTruthy();
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it("nhãn do nơi gọi truyền vào vẫn thắng", () => {
|
|
137
|
+
render(<StatusBadge status={true} label="Đang bật" />);
|
|
138
|
+
expect(screen.getByText("Đang bật")).toBeTruthy();
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
describe("resolveSwitchPair — cặp bật/tắt của công tắc form", () => {
|
|
143
|
+
it("có cặp option → ghi đúng giá trị cột chữ", () => {
|
|
144
|
+
expect(resolveSwitchPair(statusField())).toEqual({
|
|
145
|
+
on: "active",
|
|
146
|
+
off: "inactive",
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it("không khai option → cột Boolean thật", () => {
|
|
151
|
+
expect(resolveSwitchPair(statusField({ options: undefined }))).toEqual({
|
|
152
|
+
on: true,
|
|
153
|
+
off: false,
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it("khai valueType boolean → ghi true/false dù option là chữ", () => {
|
|
158
|
+
expect(resolveSwitchPair(statusField({ valueType: "boolean" }))).toEqual({
|
|
159
|
+
on: true,
|
|
160
|
+
off: false,
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it("công tắc BẬT cho bản ghi boolean true dù option khai chữ", () => {
|
|
165
|
+
const { on } = resolveSwitchPair(statusField());
|
|
166
|
+
expect(matchesOptionValue(on, true)).toBe(true);
|
|
167
|
+
expect(matchesOptionValue(on, "active")).toBe(true);
|
|
168
|
+
expect(matchesOptionValue(on, false)).toBe(false);
|
|
169
|
+
});
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
describe("coerceFieldValue — lưu xuống đúng kiểu cột", () => {
|
|
173
|
+
it("valueType boolean: chuỗi 'active' của form → true", () => {
|
|
174
|
+
const f = statusField({ valueType: "boolean" });
|
|
175
|
+
expect(coerceFieldValue(f, "active")).toBe(true);
|
|
176
|
+
expect(coerceFieldValue(f, "inactive")).toBe(false);
|
|
177
|
+
expect(coerceFieldValue(f, true)).toBe(true);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it("cột chữ: boolean lọt vào vẫn ra đúng option, không thành 'true'", () => {
|
|
181
|
+
const f = statusField();
|
|
182
|
+
expect(coerceFieldValue(f, true)).toBe("active");
|
|
183
|
+
expect(coerceFieldValue(f, false)).toBe("inactive");
|
|
184
|
+
expect(coerceFieldValue(f, "active")).toBe("active");
|
|
185
|
+
});
|
|
186
|
+
});
|
|
@@ -31,6 +31,8 @@ import {
|
|
|
31
31
|
isFieldVisibleInDetail,
|
|
32
32
|
sortFieldsByOrder,
|
|
33
33
|
} from "../lib/crud-utils";
|
|
34
|
+
import { matchesOptionValue } from "../../utils";
|
|
35
|
+
import { humanizeDictKey } from "../lib/translate-config";
|
|
34
36
|
import { formatFieldValue } from "../lib/field-formatter";
|
|
35
37
|
import { dataLoader } from "../lib/data-loader";
|
|
36
38
|
|
|
@@ -111,14 +113,23 @@ function renderFieldValue(
|
|
|
111
113
|
/>
|
|
112
114
|
);
|
|
113
115
|
}
|
|
116
|
+
// SO LỎNG như ở bảng: `true` phải khớp option `"active"` (cột Boolean),
|
|
117
|
+
// `1` phải khớp `"1"` (cột smallint).
|
|
114
118
|
const option = field.options?.find((opt) => {
|
|
115
119
|
const optValue = typeof opt === "object" ? opt.value : opt;
|
|
116
|
-
return optValue
|
|
120
|
+
return matchesOptionValue(optValue, value);
|
|
117
121
|
});
|
|
118
|
-
const label =
|
|
119
|
-
(typeof option === "object" ? option.label : option) ?? String(value);
|
|
120
122
|
if (isEmpty) return <span className="text-muted-foreground">—</span>;
|
|
121
|
-
|
|
123
|
+
const rawLabel = typeof option === "object" ? option.label : option;
|
|
124
|
+
// Không khớp option nào thì ĐỪNG ép nhãn "true"/"false" — để StatusBadge
|
|
125
|
+
// tự tra nhãn tiếng Việt theo taxonomy chung.
|
|
126
|
+
const label =
|
|
127
|
+
typeof rawLabel === "string" && rawLabel !== ""
|
|
128
|
+
? rawLabel.startsWith("crud.")
|
|
129
|
+
? humanizeDictKey(rawLabel)
|
|
130
|
+
: rawLabel
|
|
131
|
+
: undefined;
|
|
132
|
+
return <StatusBadge status={value} label={label} />;
|
|
122
133
|
}
|
|
123
134
|
|
|
124
135
|
if (isEmpty) {
|
|
@@ -138,7 +149,11 @@ function renderFieldValue(
|
|
|
138
149
|
return (
|
|
139
150
|
<div className="flex flex-wrap gap-1">
|
|
140
151
|
{labels.map((l, i) => (
|
|
141
|
-
<Badge
|
|
152
|
+
<Badge
|
|
153
|
+
key={`${l}-${i}`}
|
|
154
|
+
variant="secondary"
|
|
155
|
+
className="font-normal"
|
|
156
|
+
>
|
|
142
157
|
{l}
|
|
143
158
|
</Badge>
|
|
144
159
|
))}
|
|
@@ -193,9 +208,7 @@ function renderFieldValue(
|
|
|
193
208
|
field.type === "json" && typeof value === "object"
|
|
194
209
|
? JSON.stringify(value, null, 2)
|
|
195
210
|
: formatFieldValue(value, field) || String(value);
|
|
196
|
-
return
|
|
197
|
-
<span className="whitespace-pre-wrap break-words">{text}</span>
|
|
198
|
-
);
|
|
211
|
+
return <span className="whitespace-pre-wrap break-words">{text}</span>;
|
|
199
212
|
}
|
|
200
213
|
|
|
201
214
|
return formatFieldValue(value, field) || String(value);
|