@goplusvn/core 0.1.50 → 0.1.51
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.
|
|
4
|
+
"version": "0.1.51",
|
|
5
5
|
"private": false,
|
|
6
6
|
"publishConfig": {
|
|
7
7
|
"registry": "https://registry.npmjs.org",
|
|
@@ -43,6 +43,8 @@
|
|
|
43
43
|
"./assets/*": "./src/assets/*",
|
|
44
44
|
"./styles/*": "./src/styles/*",
|
|
45
45
|
"./auth/api-handler": "./src/auth/api-handler.ts",
|
|
46
|
+
"./tasks": "./src/tasks/index.ts",
|
|
47
|
+
"./tasks/ui": "./src/tasks/ui/task-list-client.tsx",
|
|
46
48
|
"./auth/proxy-gate": "./src/auth/proxy-gate.ts",
|
|
47
49
|
"./rbac/route-handlers": "./src/rbac/route-handlers.ts",
|
|
48
50
|
"./rbac/permissions-version": "./src/rbac/permissions-version.ts",
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
configureTaskRunner,
|
|
5
|
+
enqueueTask,
|
|
6
|
+
readTaskFile,
|
|
7
|
+
reclaimStaleTasks,
|
|
8
|
+
registerTaskHandler,
|
|
9
|
+
saveTaskFile,
|
|
10
|
+
type TaskDb,
|
|
11
|
+
type TaskNotifyInput,
|
|
12
|
+
} from "../task-runner";
|
|
13
|
+
|
|
14
|
+
function makeDb(overrides: Partial<Record<string, unknown>> = {}) {
|
|
15
|
+
const rows = new Map<string, Record<string, unknown>>();
|
|
16
|
+
let seq = 0;
|
|
17
|
+
const db = {
|
|
18
|
+
backgroundTask: {
|
|
19
|
+
create: vi.fn(async ({ data }: { data: Record<string, unknown> }) => {
|
|
20
|
+
const id = `t${++seq}`;
|
|
21
|
+
const row = { id, status: "pending", ...data };
|
|
22
|
+
rows.set(id, row);
|
|
23
|
+
return row;
|
|
24
|
+
}),
|
|
25
|
+
update: vi.fn(
|
|
26
|
+
async ({
|
|
27
|
+
where,
|
|
28
|
+
data,
|
|
29
|
+
}: {
|
|
30
|
+
where: { id: string };
|
|
31
|
+
data: Record<string, unknown>;
|
|
32
|
+
}) => {
|
|
33
|
+
const row = rows.get(where.id);
|
|
34
|
+
if (row) Object.assign(row, data);
|
|
35
|
+
return row;
|
|
36
|
+
},
|
|
37
|
+
),
|
|
38
|
+
updateMany: vi.fn(
|
|
39
|
+
async ({
|
|
40
|
+
where,
|
|
41
|
+
data,
|
|
42
|
+
}: {
|
|
43
|
+
where: Record<string, unknown>;
|
|
44
|
+
data: Record<string, unknown>;
|
|
45
|
+
}) => {
|
|
46
|
+
let count = 0;
|
|
47
|
+
for (const row of rows.values()) {
|
|
48
|
+
const statusCond = where.status as
|
|
49
|
+
| string
|
|
50
|
+
| { in: string[] }
|
|
51
|
+
| undefined;
|
|
52
|
+
const statusOk =
|
|
53
|
+
statusCond === undefined ||
|
|
54
|
+
(typeof statusCond === "string"
|
|
55
|
+
? row.status === statusCond
|
|
56
|
+
: (statusCond.in as string[]).includes(row.status as string));
|
|
57
|
+
const idOk = where.id === undefined || row.id === where.id;
|
|
58
|
+
if (statusOk && idOk) {
|
|
59
|
+
Object.assign(row, data);
|
|
60
|
+
count++;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return { count };
|
|
64
|
+
},
|
|
65
|
+
),
|
|
66
|
+
findUnique: vi.fn(async ({ where }: { where: { id: string } }) => {
|
|
67
|
+
return rows.get(where.id) ?? null;
|
|
68
|
+
}),
|
|
69
|
+
...overrides,
|
|
70
|
+
},
|
|
71
|
+
__rows: rows,
|
|
72
|
+
};
|
|
73
|
+
return db as unknown as TaskDb & { __rows: Map<string, Record<string, unknown>> };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const flush = () => new Promise((r) => setTimeout(r, 10));
|
|
77
|
+
|
|
78
|
+
describe("task-runner", () => {
|
|
79
|
+
let notifications: TaskNotifyInput[];
|
|
80
|
+
|
|
81
|
+
beforeEach(() => {
|
|
82
|
+
notifications = [];
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("chưa configure → enqueue throw với hướng dẫn", async () => {
|
|
86
|
+
// Reset config bằng cách configure với db hợp lệ SAU test này — ở đây
|
|
87
|
+
// module có thể đã được configure bởi test khác nên chỉ chạy khi chưa có.
|
|
88
|
+
// (Thứ tự an toàn: test này đứng đầu file, config module-level còn null.)
|
|
89
|
+
await expect(
|
|
90
|
+
enqueueTask({ type: "x", title: "x", createdBy: "u1" }),
|
|
91
|
+
).rejects.toThrow(/configureTaskRunner/);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("enqueue → handler chạy, success + progress 100 + notify có link tải", async () => {
|
|
95
|
+
const db = makeDb();
|
|
96
|
+
configureTaskRunner({
|
|
97
|
+
db,
|
|
98
|
+
notify: async (n) => {
|
|
99
|
+
notifications.push(n);
|
|
100
|
+
},
|
|
101
|
+
});
|
|
102
|
+
registerTaskHandler("demo:ok", async ({ setProgress }) => {
|
|
103
|
+
await setProgress(50);
|
|
104
|
+
return { fileKey: "local:a.xlsx", fileName: "a.xlsx", rowCount: 3 };
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
const task = await enqueueTask({
|
|
108
|
+
type: "demo:ok",
|
|
109
|
+
title: "Xuất demo",
|
|
110
|
+
createdBy: "u1",
|
|
111
|
+
});
|
|
112
|
+
await flush();
|
|
113
|
+
|
|
114
|
+
const row = db.__rows.get(task.id)!;
|
|
115
|
+
expect(row.status).toBe("success");
|
|
116
|
+
expect(row.progress).toBe(100);
|
|
117
|
+
expect(notifications).toHaveLength(1);
|
|
118
|
+
expect(notifications[0].type).toBe("success");
|
|
119
|
+
expect(notifications[0].url).toBe(`/api/tasks/${task.id}/download`);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("handler throw → status error + notify thất bại", async () => {
|
|
123
|
+
const db = makeDb();
|
|
124
|
+
configureTaskRunner({
|
|
125
|
+
db,
|
|
126
|
+
notify: async (n) => {
|
|
127
|
+
notifications.push(n);
|
|
128
|
+
},
|
|
129
|
+
});
|
|
130
|
+
registerTaskHandler("demo:boom", async () => {
|
|
131
|
+
throw new Error("nổ có chủ đích");
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
const task = await enqueueTask({
|
|
135
|
+
type: "demo:boom",
|
|
136
|
+
title: "Nổ",
|
|
137
|
+
createdBy: "u1",
|
|
138
|
+
});
|
|
139
|
+
await flush();
|
|
140
|
+
|
|
141
|
+
const row = db.__rows.get(task.id)!;
|
|
142
|
+
expect(row.status).toBe("error");
|
|
143
|
+
expect(row.error).toBe("nổ có chủ đích");
|
|
144
|
+
expect(notifications[0].type).toBe("error");
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it("kết quả chỉ có errorFileKey (import lỗi) → notify warning + link ?file=error", async () => {
|
|
148
|
+
const db = makeDb();
|
|
149
|
+
configureTaskRunner({
|
|
150
|
+
db,
|
|
151
|
+
notify: async (n) => {
|
|
152
|
+
notifications.push(n);
|
|
153
|
+
},
|
|
154
|
+
});
|
|
155
|
+
registerTaskHandler("demo:import-err", async () => ({
|
|
156
|
+
errorFileKey: "local:loi.xlsx",
|
|
157
|
+
summary: "3 dòng lỗi",
|
|
158
|
+
}));
|
|
159
|
+
|
|
160
|
+
const task = await enqueueTask({
|
|
161
|
+
type: "demo:import-err",
|
|
162
|
+
title: "Nhập demo",
|
|
163
|
+
createdBy: "u1",
|
|
164
|
+
});
|
|
165
|
+
await flush();
|
|
166
|
+
|
|
167
|
+
expect(notifications[0].type).toBe("warning");
|
|
168
|
+
expect(notifications[0].url).toBe(
|
|
169
|
+
`/api/tasks/${task.id}/download?file=error`,
|
|
170
|
+
);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it("type chưa đăng ký → enqueue throw ngay (không tạo row)", async () => {
|
|
174
|
+
const db = makeDb();
|
|
175
|
+
configureTaskRunner({ db });
|
|
176
|
+
await expect(
|
|
177
|
+
enqueueTask({ type: "demo:missing", title: "x", createdBy: "u1" }),
|
|
178
|
+
).rejects.toThrow(/chưa đăng ký/);
|
|
179
|
+
expect(db.__rows.size).toBe(0);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it("reclaimStaleTasks: pending/running mồ côi → error", async () => {
|
|
183
|
+
const db = makeDb();
|
|
184
|
+
configureTaskRunner({ db });
|
|
185
|
+
db.__rows.set("stale1", { id: "stale1", status: "running" });
|
|
186
|
+
db.__rows.set("stale2", { id: "stale2", status: "pending" });
|
|
187
|
+
db.__rows.set("done", { id: "done", status: "success" });
|
|
188
|
+
|
|
189
|
+
await reclaimStaleTasks();
|
|
190
|
+
|
|
191
|
+
expect(db.__rows.get("stale1")!.status).toBe("error");
|
|
192
|
+
expect(db.__rows.get("stale2")!.status).toBe("error");
|
|
193
|
+
expect(db.__rows.get("done")!.status).toBe("success");
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it("storage seam: có storage thì save/read đi qua storage", async () => {
|
|
197
|
+
const store = new Map<string, Buffer>();
|
|
198
|
+
configureTaskRunner({
|
|
199
|
+
db: makeDb(),
|
|
200
|
+
storage: {
|
|
201
|
+
save: async (buf, key) => {
|
|
202
|
+
store.set(`s3:${key}`, buf);
|
|
203
|
+
return `s3:${key}`;
|
|
204
|
+
},
|
|
205
|
+
read: async (key) => store.get(key)!,
|
|
206
|
+
},
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
const key = await saveTaskFile(Buffer.from("abc"), "x/y.xlsx", "app/x");
|
|
210
|
+
expect(key).toBe("s3:x/y.xlsx");
|
|
211
|
+
expect((await readTaskFile(key)).toString()).toBe("abc");
|
|
212
|
+
});
|
|
213
|
+
});
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// Trung tâm tác vụ nền — engine dùng chung. Bảng đi kèm ship qua
|
|
2
|
+
// `goerp-features sync` (feature background-tasks). App wiring mẫu: vinhhoa
|
|
3
|
+
// src/server/tasks (configureTaskRunner + handlers + routes /api/tasks).
|
|
4
|
+
export {
|
|
5
|
+
configureTaskRunner,
|
|
6
|
+
enqueueTask,
|
|
7
|
+
readTaskFile,
|
|
8
|
+
reclaimStaleTasks,
|
|
9
|
+
registerTaskHandler,
|
|
10
|
+
saveTaskFile,
|
|
11
|
+
type EnqueueInput,
|
|
12
|
+
type TaskContext,
|
|
13
|
+
type TaskDb,
|
|
14
|
+
type TaskFileResult,
|
|
15
|
+
type TaskHandler,
|
|
16
|
+
type TaskNotifyInput,
|
|
17
|
+
type TaskRecord,
|
|
18
|
+
type TaskStorage,
|
|
19
|
+
} from "./task-runner";
|
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
import { mkdir, readFile, writeFile } from "fs/promises";
|
|
2
|
+
import { dirname, join } from "path";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Trung tâm tác vụ nền — engine dùng chung cho mọi app goerp (bảng
|
|
6
|
+
* `background_tasks` ship qua `goerp-features sync`, feature background-tasks).
|
|
7
|
+
*
|
|
8
|
+
* Hàng đợi trong DB, worker IN-PROCESS: enqueue xong là `void runTask(id)`
|
|
9
|
+
* ngay trong container đang phục vụ request (deploy 1 container — không cần
|
|
10
|
+
* queue ngoài). Vòng đời: pending → running (CAS updateMany, chống chạy đôi)
|
|
11
|
+
* → success | error. Server restart giữa chừng → `reclaimStaleTasks()` (gọi
|
|
12
|
+
* từ instrumentation) đánh dấu error để user chạy lại.
|
|
13
|
+
*
|
|
14
|
+
* App cắm phụ thuộc qua `configureTaskRunner` (cùng khuôn
|
|
15
|
+
* `configureSettingsService`): db (Prisma client có model BackgroundTask),
|
|
16
|
+
* notify (chuông/push — tuỳ chọn), storage (S3/MinIO — tuỳ chọn, mặc định
|
|
17
|
+
* thư mục local PRIVATE, KHÔNG để trong public/).
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
export interface TaskFileResult {
|
|
21
|
+
fileKey?: string;
|
|
22
|
+
fileName?: string;
|
|
23
|
+
contentType?: string;
|
|
24
|
+
rowCount?: number;
|
|
25
|
+
errorFileKey?: string;
|
|
26
|
+
errorFileName?: string;
|
|
27
|
+
summary?: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface TaskContext {
|
|
31
|
+
taskId: string;
|
|
32
|
+
params: unknown;
|
|
33
|
+
/** Cập nhật % tiến độ (0–100) — throttle sẵn, gọi thoải mái theo chunk. */
|
|
34
|
+
setProgress: (percent: number) => Promise<void>;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export type TaskHandler = (ctx: TaskContext) => Promise<TaskFileResult>;
|
|
38
|
+
|
|
39
|
+
export interface TaskRecord {
|
|
40
|
+
id: string;
|
|
41
|
+
type: string;
|
|
42
|
+
title: string;
|
|
43
|
+
status: string;
|
|
44
|
+
params: unknown;
|
|
45
|
+
createdBy: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Delegate Prisma tối thiểu — structural, args nới thành `any` CÓ CHỦ ĐÍCH:
|
|
50
|
+
* client Prisma sinh ra của app có kiểu args HẸP hơn (contravariance) nên
|
|
51
|
+
* khai chặt ở đây sẽ không assignable (cùng bài học SettingsDb).
|
|
52
|
+
*/
|
|
53
|
+
export interface TaskDb {
|
|
54
|
+
backgroundTask: {
|
|
55
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
56
|
+
create(args: any): Promise<TaskRecord>;
|
|
57
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
58
|
+
update(args: any): Promise<unknown>;
|
|
59
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
60
|
+
updateMany(args: any): Promise<{ count: number }>;
|
|
61
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
62
|
+
findUnique(args: any): Promise<TaskRecord | null>;
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface TaskNotifyInput {
|
|
67
|
+
userIds: string[];
|
|
68
|
+
type: "success" | "error" | "warning" | "info";
|
|
69
|
+
category: string;
|
|
70
|
+
title: string;
|
|
71
|
+
content: string;
|
|
72
|
+
url?: string;
|
|
73
|
+
resourceType?: string;
|
|
74
|
+
resourceId?: string;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export interface TaskStorage {
|
|
78
|
+
/** Trả key đã lưu, hoặc `null` = "để core fallback local" (vd S3 đang tắt). */
|
|
79
|
+
save(buffer: Buffer, key: string, contentType: string): Promise<string | null>;
|
|
80
|
+
/** Chỉ được gọi cho key KHÔNG phải `local:` — key local core tự đọc. */
|
|
81
|
+
read(fileKey: string): Promise<Buffer>;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
interface TaskRunnerConfig {
|
|
85
|
+
db: TaskDb;
|
|
86
|
+
/** Báo chuông/push khi task xong-lỗi — bỏ trống thì im lặng. */
|
|
87
|
+
notify?: (input: TaskNotifyInput) => Promise<unknown>;
|
|
88
|
+
/** Kho file kết quả — bỏ trống dùng thư mục local `localDir`. */
|
|
89
|
+
storage?: TaskStorage;
|
|
90
|
+
/** Thư mục fallback local (mặc định <cwd>/storage/task-files). */
|
|
91
|
+
localDir?: string;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
let config: TaskRunnerConfig | null = null;
|
|
95
|
+
|
|
96
|
+
export function configureTaskRunner(next: TaskRunnerConfig): void {
|
|
97
|
+
config = next;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function requireConfig(): TaskRunnerConfig {
|
|
101
|
+
if (!config) {
|
|
102
|
+
throw new Error(
|
|
103
|
+
"[tasks] chưa configureTaskRunner({ db, notify?, storage? }) — gọi 1 lần lúc khởi tạo app (cạnh configureSettingsService).",
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
return config;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const registry = new Map<string, TaskHandler>();
|
|
110
|
+
|
|
111
|
+
export function registerTaskHandler(type: string, handler: TaskHandler): void {
|
|
112
|
+
registry.set(type, handler);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export interface EnqueueInput {
|
|
116
|
+
type: string;
|
|
117
|
+
title: string;
|
|
118
|
+
params?: unknown;
|
|
119
|
+
createdBy: string;
|
|
120
|
+
branchId?: string | null;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Tạo task + chạy ngay trong process (fire-and-forget). AUTHORIZE TRƯỚC KHI
|
|
125
|
+
* GỌI: worker chạy ngoài request nên không còn session — mọi giới hạn quyền
|
|
126
|
+
* (scope chi nhánh, quyền xem giá vốn…) phải được snapshot vào `params`.
|
|
127
|
+
*/
|
|
128
|
+
export async function enqueueTask(input: EnqueueInput): Promise<TaskRecord> {
|
|
129
|
+
const { db } = requireConfig();
|
|
130
|
+
if (!registry.has(input.type)) {
|
|
131
|
+
throw new Error(`[tasks] type "${input.type}" chưa đăng ký handler`);
|
|
132
|
+
}
|
|
133
|
+
const task = await db.backgroundTask.create({
|
|
134
|
+
data: {
|
|
135
|
+
type: input.type,
|
|
136
|
+
title: input.title,
|
|
137
|
+
params: input.params ?? undefined,
|
|
138
|
+
createdBy: input.createdBy,
|
|
139
|
+
branchId: input.branchId ?? null,
|
|
140
|
+
},
|
|
141
|
+
});
|
|
142
|
+
void runTask(task.id);
|
|
143
|
+
return task;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
async function runTask(id: string): Promise<void> {
|
|
147
|
+
const { db } = requireConfig();
|
|
148
|
+
// CAS pending→running: chỉ một luồng thắng (chống double-run khi retry).
|
|
149
|
+
const claimed = await db.backgroundTask.updateMany({
|
|
150
|
+
where: { id, status: "pending" },
|
|
151
|
+
data: { status: "running", startedAt: new Date() },
|
|
152
|
+
});
|
|
153
|
+
if (claimed.count === 0) return;
|
|
154
|
+
|
|
155
|
+
const task = await db.backgroundTask.findUnique({ where: { id } });
|
|
156
|
+
if (!task) return;
|
|
157
|
+
const handler = registry.get(task.type);
|
|
158
|
+
|
|
159
|
+
try {
|
|
160
|
+
if (!handler) {
|
|
161
|
+
throw new Error(`[tasks] type "${task.type}" chưa đăng ký handler`);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
let lastWrite = 0;
|
|
165
|
+
const setProgress = async (percent: number) => {
|
|
166
|
+
const now = Date.now();
|
|
167
|
+
if (now - lastWrite < 500) return; // throttle ghi DB
|
|
168
|
+
lastWrite = now;
|
|
169
|
+
await db.backgroundTask.update({
|
|
170
|
+
where: { id },
|
|
171
|
+
data: { progress: Math.max(0, Math.min(99, Math.round(percent))) },
|
|
172
|
+
});
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
const result = await handler({
|
|
176
|
+
taskId: id,
|
|
177
|
+
params: task.params,
|
|
178
|
+
setProgress,
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
await db.backgroundTask.update({
|
|
182
|
+
where: { id },
|
|
183
|
+
data: {
|
|
184
|
+
status: "success",
|
|
185
|
+
progress: 100,
|
|
186
|
+
result: result as unknown as Record<string, unknown>,
|
|
187
|
+
finishedAt: new Date(),
|
|
188
|
+
},
|
|
189
|
+
});
|
|
190
|
+
await notifyTaskDone(task.createdBy, id, task.title, result);
|
|
191
|
+
} catch (error) {
|
|
192
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
193
|
+
console.error(`[tasks] ${task.type} (${id}) lỗi:`, error);
|
|
194
|
+
await db.backgroundTask
|
|
195
|
+
.update({
|
|
196
|
+
where: { id },
|
|
197
|
+
data: {
|
|
198
|
+
status: "error",
|
|
199
|
+
error: message || "Tác vụ thất bại",
|
|
200
|
+
finishedAt: new Date(),
|
|
201
|
+
},
|
|
202
|
+
})
|
|
203
|
+
.catch(() => {});
|
|
204
|
+
await notifyTaskFailed(task.createdBy, task.title, message);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Gọi 1 lần khi server boot (instrumentation): task còn "running"/"pending"
|
|
210
|
+
* là mồ côi của process trước (restart giữa chừng) → error để user chạy lại.
|
|
211
|
+
*/
|
|
212
|
+
export async function reclaimStaleTasks(): Promise<void> {
|
|
213
|
+
const { db } = requireConfig();
|
|
214
|
+
const { count } = await db.backgroundTask.updateMany({
|
|
215
|
+
where: { status: { in: ["pending", "running"] } },
|
|
216
|
+
data: {
|
|
217
|
+
status: "error",
|
|
218
|
+
error: "Server khởi động lại giữa chừng — vui lòng chạy lại tác vụ.",
|
|
219
|
+
finishedAt: new Date(),
|
|
220
|
+
},
|
|
221
|
+
});
|
|
222
|
+
if (count > 0) {
|
|
223
|
+
console.warn(`[tasks] reclaim ${count} task mồ côi sau restart`);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// ─── Thông báo khi xong (qua seam notify — app cắm chuông/push) ───
|
|
228
|
+
|
|
229
|
+
async function notifyTaskDone(
|
|
230
|
+
userId: string,
|
|
231
|
+
taskId: string,
|
|
232
|
+
title: string,
|
|
233
|
+
result: TaskFileResult,
|
|
234
|
+
): Promise<void> {
|
|
235
|
+
const { notify } = requireConfig();
|
|
236
|
+
if (!notify) return;
|
|
237
|
+
// Task chạy XONG nhưng nghiệp vụ fail từng dòng (import) → warning + link
|
|
238
|
+
// tải file lỗi để sửa-rồi-nạp-lại.
|
|
239
|
+
const hasErrorFile = !result.fileKey && !!result.errorFileKey;
|
|
240
|
+
await notify({
|
|
241
|
+
userIds: [userId],
|
|
242
|
+
type: hasErrorFile ? "warning" : "success",
|
|
243
|
+
category: "task",
|
|
244
|
+
title: hasErrorFile ? `Cần sửa file: ${title}` : `Hoàn tất: ${title}`,
|
|
245
|
+
content: result.fileKey
|
|
246
|
+
? `${result.summary ?? `${result.rowCount ?? ""} dòng`} — bấm để tải file.`.trim()
|
|
247
|
+
: hasErrorFile
|
|
248
|
+
? `${result.summary ?? "Có dòng lỗi."} Bấm để tải file lỗi từng dòng.`
|
|
249
|
+
: (result.summary ?? "Tác vụ đã chạy xong."),
|
|
250
|
+
url: result.fileKey
|
|
251
|
+
? `/api/tasks/${taskId}/download`
|
|
252
|
+
: hasErrorFile
|
|
253
|
+
? `/api/tasks/${taskId}/download?file=error`
|
|
254
|
+
: undefined,
|
|
255
|
+
resourceType: "background_task",
|
|
256
|
+
resourceId: taskId,
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
async function notifyTaskFailed(
|
|
261
|
+
userId: string,
|
|
262
|
+
title: string,
|
|
263
|
+
message?: string,
|
|
264
|
+
): Promise<void> {
|
|
265
|
+
const { notify } = requireConfig();
|
|
266
|
+
if (!notify) return;
|
|
267
|
+
await notify({
|
|
268
|
+
userIds: [userId],
|
|
269
|
+
type: "error",
|
|
270
|
+
category: "task",
|
|
271
|
+
title: `Thất bại: ${title}`,
|
|
272
|
+
content: message || "Tác vụ nền gặp lỗi — thử chạy lại.",
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// ─── Lưu/đọc file kết quả (storage seam; fallback thư mục PRIVATE local) ───
|
|
277
|
+
|
|
278
|
+
function localDir(): string {
|
|
279
|
+
return requireConfig().localDir ?? join(process.cwd(), "storage", "task-files");
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
export async function saveTaskFile(
|
|
283
|
+
buffer: Buffer,
|
|
284
|
+
key: string,
|
|
285
|
+
contentType: string,
|
|
286
|
+
): Promise<string> {
|
|
287
|
+
const { storage } = requireConfig();
|
|
288
|
+
if (storage) {
|
|
289
|
+
const saved = await storage.save(buffer, key, contentType);
|
|
290
|
+
if (saved) return saved;
|
|
291
|
+
}
|
|
292
|
+
const filepath = join(localDir(), key);
|
|
293
|
+
await mkdir(dirname(filepath), { recursive: true });
|
|
294
|
+
await writeFile(filepath, buffer);
|
|
295
|
+
return `local:${key}`;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
export async function readTaskFile(fileKey: string): Promise<Buffer> {
|
|
299
|
+
const { storage } = requireConfig();
|
|
300
|
+
if (fileKey.startsWith("local:")) {
|
|
301
|
+
return readFile(join(localDir(), fileKey.slice("local:".length)));
|
|
302
|
+
}
|
|
303
|
+
if (!storage) {
|
|
304
|
+
throw new Error(`[tasks] fileKey "${fileKey}" cần storage seam (S3/MinIO) nhưng chưa cấu hình.`);
|
|
305
|
+
}
|
|
306
|
+
return storage.read(fileKey);
|
|
307
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { format } from "date-fns";
|
|
4
|
+
import { Download, FileWarning, ListChecks } from "lucide-react";
|
|
5
|
+
import useSWR from "swr";
|
|
6
|
+
|
|
7
|
+
import { getStatusMeta } from "../../ui/shared/status-indicator";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Trang "Tác vụ nền" của CHÍNH user — dùng chung mọi app goerp: list 50 task,
|
|
11
|
+
* poll 3s khi còn task chạy, progress bar, nút tải file kết quả / file lỗi.
|
|
12
|
+
* App chỉ cần page server-shell (gate session) render component này; API
|
|
13
|
+
* chuẩn: GET /api/tasks + GET /api/tasks/[id]/download (xem app mẫu vinhhoa).
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
interface TaskRow {
|
|
17
|
+
id: string;
|
|
18
|
+
type: string;
|
|
19
|
+
title: string;
|
|
20
|
+
status: "pending" | "running" | "success" | "error";
|
|
21
|
+
progress: number;
|
|
22
|
+
result?: {
|
|
23
|
+
fileKey?: string;
|
|
24
|
+
fileName?: string;
|
|
25
|
+
errorFileKey?: string;
|
|
26
|
+
errorFileName?: string;
|
|
27
|
+
summary?: string;
|
|
28
|
+
} | null;
|
|
29
|
+
error?: string | null;
|
|
30
|
+
createdAt: string;
|
|
31
|
+
finishedAt?: string | null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const fetcher = (url: string) => fetch(url).then((r) => r.json());
|
|
35
|
+
|
|
36
|
+
const STATUS_LABEL: Record<TaskRow["status"], string> = {
|
|
37
|
+
pending: "Chờ chạy",
|
|
38
|
+
running: "Đang chạy",
|
|
39
|
+
success: "Hoàn tất",
|
|
40
|
+
error: "Lỗi",
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export function TaskListClient({ apiUrl = "/api/tasks" }: { apiUrl?: string }) {
|
|
44
|
+
const { data } = useSWR<{ tasks: TaskRow[] }>(`${apiUrl}?take=50`, fetcher, {
|
|
45
|
+
// Còn task đang chạy → poll nhanh để progress nhảy; yên ắng thì thôi.
|
|
46
|
+
refreshInterval: (latest) =>
|
|
47
|
+
latest?.tasks?.some(
|
|
48
|
+
(t) => t.status === "running" || t.status === "pending",
|
|
49
|
+
)
|
|
50
|
+
? 3000
|
|
51
|
+
: 0,
|
|
52
|
+
});
|
|
53
|
+
const tasks = data?.tasks ?? [];
|
|
54
|
+
|
|
55
|
+
return (
|
|
56
|
+
<div className="mx-auto max-w-3xl space-y-3 p-4">
|
|
57
|
+
<div className="flex items-center gap-2">
|
|
58
|
+
<ListChecks className="h-5 w-5 text-muted-foreground" />
|
|
59
|
+
<h1 className="text-base font-bold text-foreground">Tác vụ nền</h1>
|
|
60
|
+
<span className="text-xs text-muted-foreground">
|
|
61
|
+
export/import lớn chạy nền — xong sẽ báo qua chuông thông báo
|
|
62
|
+
</span>
|
|
63
|
+
</div>
|
|
64
|
+
|
|
65
|
+
{tasks.length === 0 ? (
|
|
66
|
+
<p className="rounded-xl border border-border bg-card px-4 py-10 text-center text-sm text-muted-foreground">
|
|
67
|
+
Chưa có tác vụ nào. Xuất/Nhập Excel dữ liệu lớn sẽ hiện ở đây.
|
|
68
|
+
</p>
|
|
69
|
+
) : (
|
|
70
|
+
<div className="divide-y divide-border overflow-hidden rounded-xl border border-border bg-card">
|
|
71
|
+
{tasks.map((task) => {
|
|
72
|
+
const meta = getStatusMeta(
|
|
73
|
+
task.status === "success"
|
|
74
|
+
? "completed"
|
|
75
|
+
: task.status === "error"
|
|
76
|
+
? "cancelled"
|
|
77
|
+
: "pending",
|
|
78
|
+
STATUS_LABEL[task.status],
|
|
79
|
+
);
|
|
80
|
+
return (
|
|
81
|
+
<div key={task.id} className="flex items-center gap-3 px-4 py-3">
|
|
82
|
+
<span
|
|
83
|
+
className={`h-2 w-2 shrink-0 rounded-full ${meta.dotClass}`}
|
|
84
|
+
/>
|
|
85
|
+
<div className="min-w-0 flex-1">
|
|
86
|
+
<p className="truncate text-sm font-medium text-foreground">
|
|
87
|
+
{task.title}
|
|
88
|
+
</p>
|
|
89
|
+
<p className="mt-0.5 truncate text-xs text-muted-foreground">
|
|
90
|
+
{format(new Date(task.createdAt), "dd/MM HH:mm")} ·{" "}
|
|
91
|
+
{task.status === "error"
|
|
92
|
+
? task.error || "Tác vụ thất bại"
|
|
93
|
+
: (task.result?.summary ?? meta.label)}
|
|
94
|
+
</p>
|
|
95
|
+
{(task.status === "running" ||
|
|
96
|
+
task.status === "pending") && (
|
|
97
|
+
<div className="mt-1.5 h-1.5 w-full overflow-hidden rounded-full bg-muted">
|
|
98
|
+
<div
|
|
99
|
+
className="h-full rounded-full bg-primary transition-all"
|
|
100
|
+
style={{ width: `${Math.max(task.progress, 4)}%` }}
|
|
101
|
+
/>
|
|
102
|
+
</div>
|
|
103
|
+
)}
|
|
104
|
+
</div>
|
|
105
|
+
{task.status === "success" && task.result?.fileKey && (
|
|
106
|
+
<a
|
|
107
|
+
href={`${apiUrl}/${task.id}/download`}
|
|
108
|
+
className="inline-flex shrink-0 items-center gap-1.5 rounded-md border border-border bg-card px-2.5 py-1.5 text-xs font-medium text-foreground hover:bg-muted"
|
|
109
|
+
>
|
|
110
|
+
<Download className="h-3.5 w-3.5" /> Tải file
|
|
111
|
+
</a>
|
|
112
|
+
)}
|
|
113
|
+
{task.status === "success" && task.result?.errorFileKey && (
|
|
114
|
+
<a
|
|
115
|
+
href={`${apiUrl}/${task.id}/download?file=error`}
|
|
116
|
+
className="inline-flex shrink-0 items-center gap-1.5 rounded-md border border-amber-200 bg-amber-50 px-2.5 py-1.5 text-xs font-medium text-amber-700 hover:bg-amber-100 dark:border-amber-900 dark:bg-amber-950 dark:text-amber-300"
|
|
117
|
+
>
|
|
118
|
+
<FileWarning className="h-3.5 w-3.5" /> File lỗi
|
|
119
|
+
</a>
|
|
120
|
+
)}
|
|
121
|
+
</div>
|
|
122
|
+
);
|
|
123
|
+
})}
|
|
124
|
+
</div>
|
|
125
|
+
)}
|
|
126
|
+
</div>
|
|
127
|
+
);
|
|
128
|
+
}
|