@goplusvn/core 0.1.55 → 0.1.56
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/PLATFORM.md +44 -0
- package/package.json +3 -1
- package/src/crud/components/crud-import-dialog.tsx +23 -421
- package/src/crud/components/crud-page.tsx +7 -12
- package/src/import/__tests__/import-dialog.test.tsx +141 -0
- package/src/import/__tests__/import-engine.test.ts +235 -0
- package/src/import/import-dialog.tsx +570 -0
- package/src/import/import-engine.ts +357 -0
- package/src/import/index.ts +39 -0
- package/src/import/types.ts +73 -0
- package/src/import/use-import.ts +156 -0
- package/src/ui/filters/__tests__/advanced-filter-builder.test.tsx +194 -0
- package/src/ui/filters/advanced-filter-builder.tsx +380 -0
- package/src/ui/filters/index.ts +7 -0
- package/src/ui/index.tsx +1 -0
- package/src/crud/crud-filters/checkbox-filter.tsx +0 -87
- package/src/crud/crud-filters/datetime-filter.tsx +0 -82
- package/src/crud/crud-filters/filter-builder.tsx +0 -64
- package/src/crud/crud-filters/index.tsx +0 -78
- package/src/crud/crud-filters/radio-filter.tsx +0 -79
- package/src/crud/crud-filters/select-filter.tsx +0 -148
- package/src/crud/crud-filters/text-filter.tsx +0 -81
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hộp thoại nhập hợp nhất. Ba điều dễ vỡ:
|
|
3
|
+
* - cổng quyền phải nằm SAU mọi hook (bản CrudImportDialog cũ đặt trước nên
|
|
4
|
+
* khi quyền đổi false → true React ném "Rendered more hooks…"),
|
|
5
|
+
* - hai hợp đồng server (harness workbook và CRUD theo EntityConfig) phải quy
|
|
6
|
+
* về một mô hình hiển thị,
|
|
7
|
+
* - 202 nghĩa là nhập nền: đóng dialog, KHÔNG hiện báo cáo "0 dòng".
|
|
8
|
+
*/
|
|
9
|
+
import { render, screen, waitFor } from "@testing-library/react";
|
|
10
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
11
|
+
|
|
12
|
+
import { ImportDialog, normalizeImportResult } from "../import-dialog";
|
|
13
|
+
|
|
14
|
+
const toastSuccess = vi.fn();
|
|
15
|
+
vi.mock("sonner", () => ({
|
|
16
|
+
toast: {
|
|
17
|
+
success: (...args: unknown[]) => toastSuccess(...args),
|
|
18
|
+
error: vi.fn(),
|
|
19
|
+
},
|
|
20
|
+
}));
|
|
21
|
+
|
|
22
|
+
afterEach(() => {
|
|
23
|
+
vi.restoreAllMocks();
|
|
24
|
+
toastSuccess.mockClear();
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
describe("normalizeImportResult", () => {
|
|
28
|
+
it("đọc được hợp đồng harness workbook", () => {
|
|
29
|
+
expect(
|
|
30
|
+
normalizeImportResult({
|
|
31
|
+
success: true,
|
|
32
|
+
totalRows: 10,
|
|
33
|
+
importedCount: 8,
|
|
34
|
+
skippedCount: 2,
|
|
35
|
+
errors: [{ row: 3, column: "Số lượng", message: "phải là số" }],
|
|
36
|
+
warnings: [{ row: 4, message: "trùng mã" }],
|
|
37
|
+
}),
|
|
38
|
+
).toEqual({
|
|
39
|
+
success: true,
|
|
40
|
+
totalRows: 10,
|
|
41
|
+
importedCount: 8,
|
|
42
|
+
skippedCount: 2,
|
|
43
|
+
errors: [{ row: 3, label: "Số lượng", message: "phải là số" }],
|
|
44
|
+
warnings: [{ row: 4, message: "trùng mã" }],
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("đọc được hợp đồng CRUD (imported/failed/field) và tự suy totalRows", () => {
|
|
49
|
+
const r = normalizeImportResult({
|
|
50
|
+
success: false,
|
|
51
|
+
imported: 3,
|
|
52
|
+
failed: 1,
|
|
53
|
+
errors: [{ row: 2, field: "email", message: "sai định dạng" }],
|
|
54
|
+
});
|
|
55
|
+
expect(r.importedCount).toBe(3);
|
|
56
|
+
expect(r.skippedCount).toBe(1);
|
|
57
|
+
expect(r.totalRows).toBe(4);
|
|
58
|
+
expect(r.errors[0].label).toBe("email");
|
|
59
|
+
expect(r.warnings).toBeUndefined();
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("lỗi không kèm cột lẫn field thì nhãn để trống, không bịa", () => {
|
|
63
|
+
const r = normalizeImportResult({
|
|
64
|
+
success: false,
|
|
65
|
+
errors: [{ row: 0, message: "File rỗng" }],
|
|
66
|
+
});
|
|
67
|
+
expect(r.errors[0]).toEqual({
|
|
68
|
+
row: 0,
|
|
69
|
+
label: undefined,
|
|
70
|
+
message: "File rỗng",
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("chịu được phản hồi rỗng/rác mà không ném", () => {
|
|
75
|
+
expect(normalizeImportResult(null)).toMatchObject({
|
|
76
|
+
success: false,
|
|
77
|
+
importedCount: 0,
|
|
78
|
+
errors: [],
|
|
79
|
+
});
|
|
80
|
+
expect(normalizeImportResult({ errors: "x" }).errors).toEqual([]);
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
describe("ImportDialog", () => {
|
|
85
|
+
it("không có quyền thì không render gì, và BẬT quyền giữa chừng không vỡ hook", () => {
|
|
86
|
+
const { rerender, container } = render(
|
|
87
|
+
<ImportDialog apiUrl="/api/x/import" canImport={false} />,
|
|
88
|
+
);
|
|
89
|
+
expect(container.innerHTML).toBe("");
|
|
90
|
+
|
|
91
|
+
// Chính là ca làm vỡ bản cũ (early return đứng trước useMemo/useCallback).
|
|
92
|
+
rerender(<ImportDialog apiUrl="/api/x/import" canImport />);
|
|
93
|
+
expect(screen.getByRole("button", { name: "Import" })).toBeTruthy();
|
|
94
|
+
|
|
95
|
+
rerender(<ImportDialog apiUrl="/api/x/import" canImport={false} />);
|
|
96
|
+
expect(container.innerHTML).toBe("");
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("chế độ điều khiển ngoài: mở sẵn, không render nút kích hoạt", () => {
|
|
100
|
+
render(
|
|
101
|
+
<ImportDialog apiUrl="/api/x/import" open onOpenChange={() => {}} />,
|
|
102
|
+
);
|
|
103
|
+
expect(screen.getByText("Import dữ liệu")).toBeTruthy();
|
|
104
|
+
expect(screen.getByText("Bước 1: Tải file mẫu")).toBeTruthy();
|
|
105
|
+
|
|
106
|
+
// Chỉ còn nút gửi trong footer (đang tắt vì chưa chọn file) — không có nút mở.
|
|
107
|
+
const importButtons = screen.getAllByRole("button", { name: "Import" });
|
|
108
|
+
expect(importButtons).toHaveLength(1);
|
|
109
|
+
expect((importButtons[0] as HTMLButtonElement).disabled).toBe(true);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("tải mẫu từ server lấy tên file theo content-disposition", async () => {
|
|
113
|
+
const createUrl = vi
|
|
114
|
+
.spyOn(URL, "createObjectURL")
|
|
115
|
+
.mockReturnValue("blob:x");
|
|
116
|
+
vi.spyOn(URL, "revokeObjectURL").mockImplementation(() => {});
|
|
117
|
+
global.fetch = vi.fn(
|
|
118
|
+
async () =>
|
|
119
|
+
new Response(new Blob(["x"]), {
|
|
120
|
+
headers: {
|
|
121
|
+
"content-disposition": 'attachment; filename="mau-don-mua.xlsx"',
|
|
122
|
+
},
|
|
123
|
+
}),
|
|
124
|
+
) as unknown as typeof fetch;
|
|
125
|
+
|
|
126
|
+
render(
|
|
127
|
+
<ImportDialog
|
|
128
|
+
apiUrl="/api/purchase-orders/import"
|
|
129
|
+
templateApiUrl="/api/purchase-orders/import/template"
|
|
130
|
+
open
|
|
131
|
+
onOpenChange={() => {}}
|
|
132
|
+
/>,
|
|
133
|
+
);
|
|
134
|
+
screen.getByText("Tải mẫu XLSX").click();
|
|
135
|
+
|
|
136
|
+
await waitFor(() => expect(createUrl).toHaveBeenCalled());
|
|
137
|
+
expect(String((global.fetch as any).mock.calls[0][0])).toContain(
|
|
138
|
+
"/import/template",
|
|
139
|
+
);
|
|
140
|
+
});
|
|
141
|
+
});
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Harness nhập workbook. Tập trung vào chỗ dữ liệu thật hay làm vỡ: người dùng
|
|
3
|
+
* dán từ Excel bản địa nên số là "1.234.567,89" và ngày là "31/07/2026", còn
|
|
4
|
+
* tên cột thì lệch hoa/thường và thừa khoảng trắng.
|
|
5
|
+
*/
|
|
6
|
+
import * as XLSX from "xlsx";
|
|
7
|
+
import { describe, expect, it } from "vitest";
|
|
8
|
+
|
|
9
|
+
import {
|
|
10
|
+
buildValidationReport,
|
|
11
|
+
findSheet,
|
|
12
|
+
getHeaders,
|
|
13
|
+
normalizeRow,
|
|
14
|
+
parseExcelBuffer,
|
|
15
|
+
parseFlexibleDate,
|
|
16
|
+
parseVietnameseNumber,
|
|
17
|
+
validateHeaders,
|
|
18
|
+
} from "../import-engine";
|
|
19
|
+
import type { ImportColumnDef } from "../types";
|
|
20
|
+
|
|
21
|
+
const COLUMNS: ImportColumnDef[] = [
|
|
22
|
+
{ excelHeader: "Mã hàng", fieldKey: "code", type: "string", required: true },
|
|
23
|
+
{ excelHeader: "Số lượng", fieldKey: "qty", type: "number", required: true },
|
|
24
|
+
{ excelHeader: "Ngày", fieldKey: "date", type: "date" },
|
|
25
|
+
{
|
|
26
|
+
excelHeader: "Loại",
|
|
27
|
+
fieldKey: "kind",
|
|
28
|
+
type: "enum",
|
|
29
|
+
enumValues: ["gold", "silver"],
|
|
30
|
+
defaultValue: "gold",
|
|
31
|
+
},
|
|
32
|
+
];
|
|
33
|
+
|
|
34
|
+
describe("parseVietnameseNumber", () => {
|
|
35
|
+
it("đọc được cách viết Việt Nam và cách viết Anh", () => {
|
|
36
|
+
expect(parseVietnameseNumber("1.234.567,89")).toBe(1234567.89);
|
|
37
|
+
expect(parseVietnameseNumber("1234,56")).toBe(1234.56);
|
|
38
|
+
expect(parseVietnameseNumber("1234.56")).toBe(1234.56);
|
|
39
|
+
expect(parseVietnameseNumber(" 42 ")).toBe(42);
|
|
40
|
+
expect(parseVietnameseNumber(42)).toBe(42);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("trả NaN cho thứ không phải số — caller phân biệt được với 0", () => {
|
|
44
|
+
expect(parseVietnameseNumber("abc")).toBeNaN();
|
|
45
|
+
expect(parseVietnameseNumber(null)).toBeNaN();
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
describe("parseFlexibleDate", () => {
|
|
50
|
+
it("đọc dd/MM/yyyy theo lối Việt Nam, KHÔNG phải MM/dd", () => {
|
|
51
|
+
const d = parseFlexibleDate("03/07/2026")!;
|
|
52
|
+
expect(d.getDate()).toBe(3);
|
|
53
|
+
expect(d.getMonth()).toBe(6); // tháng 7
|
|
54
|
+
expect(d.getFullYear()).toBe(2026);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("đọc yyyy-MM-dd và số serial của Excel", () => {
|
|
58
|
+
const iso = parseFlexibleDate("2026-07-31")!;
|
|
59
|
+
expect(iso.getMonth()).toBe(6);
|
|
60
|
+
expect(iso.getDate()).toBe(31);
|
|
61
|
+
// 45000 = 2023-03-15 tính từ mốc 1899-12-30
|
|
62
|
+
const serial = parseFlexibleDate(45000)!;
|
|
63
|
+
expect(serial.getFullYear()).toBe(2023);
|
|
64
|
+
expect(serial.getMonth()).toBe(2);
|
|
65
|
+
expect(serial.getDate()).toBe(15);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("trả null cho rác thay vì Invalid Date", () => {
|
|
69
|
+
expect(parseFlexibleDate("hôm qua")).toBeNull();
|
|
70
|
+
expect(parseFlexibleDate(new Date("x"))).toBeNull();
|
|
71
|
+
expect(parseFlexibleDate({})).toBeNull();
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("từ chối ngày không tồn tại thay vì cuộn sang tháng sau", () => {
|
|
75
|
+
// 31/06 và 29/02/2026 là lỗi gõ; cuộn ngầm sẽ đổi ngày chứng từ.
|
|
76
|
+
expect(parseFlexibleDate("31/06/2026")).toBeNull();
|
|
77
|
+
expect(parseFlexibleDate("29/02/2026")).toBeNull();
|
|
78
|
+
expect(parseFlexibleDate("32/13/2026")).toBeNull();
|
|
79
|
+
expect(parseFlexibleDate("2026-06-31")).toBeNull();
|
|
80
|
+
expect(parseFlexibleDate("29/02/2024")).not.toBeNull(); // năm nhuận thì hợp lệ
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
describe("validateHeaders", () => {
|
|
85
|
+
it("bỏ qua lệch hoa/thường và khoảng trắng thừa", () => {
|
|
86
|
+
expect(validateHeaders([" mã HÀNG ", "Số lượng"], COLUMNS)).toEqual([]);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("chỉ báo thiếu cột BẮT BUỘC", () => {
|
|
90
|
+
const errors = validateHeaders(["Mã hàng"], COLUMNS);
|
|
91
|
+
expect(errors).toHaveLength(1);
|
|
92
|
+
expect(errors[0].column).toBe("Số lượng");
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
describe("normalizeRow", () => {
|
|
97
|
+
it("ánh xạ header lệch chuẩn, ép kiểu và trim", () => {
|
|
98
|
+
const { data, errors } = normalizeRow(
|
|
99
|
+
{ " MÃ hàng ": " SP01 ", "Số lượng": "1.500,5", Ngày: "31/07/2026" },
|
|
100
|
+
COLUMNS,
|
|
101
|
+
1,
|
|
102
|
+
);
|
|
103
|
+
expect(errors).toEqual([]);
|
|
104
|
+
expect(data.code).toBe("SP01");
|
|
105
|
+
expect(data.qty).toBe(1500.5);
|
|
106
|
+
expect((data.date as Date).getDate()).toBe(31);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("ô trống bắt buộc thì báo lỗi, ô trống tuỳ chọn thì lấy mặc định", () => {
|
|
110
|
+
const { data, errors } = normalizeRow({ "Mã hàng": "SP01" }, COLUMNS, 2);
|
|
111
|
+
expect(errors.map((e) => e.column)).toEqual(["Số lượng"]);
|
|
112
|
+
expect(data.qty).toBeNull();
|
|
113
|
+
expect(data.kind).toBe("gold"); // defaultValue
|
|
114
|
+
expect(data.date).toBeNull();
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("số/ngày sai vẫn ghi giá trị an toàn để caller không phải kiểm null khắp nơi", () => {
|
|
118
|
+
const { data, errors } = normalizeRow(
|
|
119
|
+
{ "Mã hàng": "SP01", "Số lượng": "abc", Ngày: "32/13/2026" },
|
|
120
|
+
COLUMNS,
|
|
121
|
+
3,
|
|
122
|
+
);
|
|
123
|
+
expect(errors).toHaveLength(2);
|
|
124
|
+
expect(data.qty).toBe(0);
|
|
125
|
+
expect(data.date).toBeNull();
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("enum khớp không phân biệt hoa/thường, trả về đúng giá trị chuẩn", () => {
|
|
129
|
+
expect(
|
|
130
|
+
normalizeRow(
|
|
131
|
+
{ "Mã hàng": "A", "Số lượng": "1", Loại: "SILVER" },
|
|
132
|
+
COLUMNS,
|
|
133
|
+
4,
|
|
134
|
+
).data.kind,
|
|
135
|
+
).toBe("silver");
|
|
136
|
+
|
|
137
|
+
const bad = normalizeRow(
|
|
138
|
+
{ "Mã hàng": "A", "Số lượng": "1", Loại: "bạch kim" },
|
|
139
|
+
COLUMNS,
|
|
140
|
+
5,
|
|
141
|
+
);
|
|
142
|
+
expect(bad.errors).toHaveLength(1);
|
|
143
|
+
expect(bad.data.kind).toBe("gold"); // rơi về mặc định
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("chạy validate riêng của cột", () => {
|
|
147
|
+
const cols: ImportColumnDef[] = [
|
|
148
|
+
{
|
|
149
|
+
excelHeader: "Số lượng",
|
|
150
|
+
fieldKey: "qty",
|
|
151
|
+
type: "number",
|
|
152
|
+
validate: (v, row) =>
|
|
153
|
+
(v as number) > 0
|
|
154
|
+
? null
|
|
155
|
+
: { row, column: "Số lượng", value: v, message: "Phải dương" },
|
|
156
|
+
},
|
|
157
|
+
];
|
|
158
|
+
expect(normalizeRow({ "Số lượng": "-3" }, cols, 7).errors[0].message).toBe(
|
|
159
|
+
"Phải dương",
|
|
160
|
+
);
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
describe("buildValidationReport", () => {
|
|
165
|
+
const base = {
|
|
166
|
+
totalRows: 10,
|
|
167
|
+
importedCount: 8,
|
|
168
|
+
skippedCount: 2,
|
|
169
|
+
warnings: [],
|
|
170
|
+
importedIds: ["a", "b"],
|
|
171
|
+
dryRun: false,
|
|
172
|
+
};
|
|
173
|
+
const oneError = [{ row: 1, column: "x", value: null, message: "sai" }];
|
|
174
|
+
|
|
175
|
+
it("'full' hỏng vì một lỗi, 'partial' thì không", () => {
|
|
176
|
+
expect(
|
|
177
|
+
buildValidationReport({ ...base, mode: "full", errors: oneError })
|
|
178
|
+
.success,
|
|
179
|
+
).toBe(false);
|
|
180
|
+
expect(
|
|
181
|
+
buildValidationReport({ ...base, mode: "partial", errors: oneError })
|
|
182
|
+
.success,
|
|
183
|
+
).toBe(true);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it("chạy thử không được khoe đã nhập gì", () => {
|
|
187
|
+
const r = buildValidationReport({
|
|
188
|
+
...base,
|
|
189
|
+
mode: "partial",
|
|
190
|
+
errors: [],
|
|
191
|
+
dryRun: true,
|
|
192
|
+
});
|
|
193
|
+
expect(r.success).toBe(true);
|
|
194
|
+
expect(r.importedCount).toBe(0);
|
|
195
|
+
expect(r.importedIds).toEqual([]);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it("chạy thử có lỗi thì KHÔNG thành công, kể cả chế độ partial", () => {
|
|
199
|
+
expect(
|
|
200
|
+
buildValidationReport({
|
|
201
|
+
...base,
|
|
202
|
+
mode: "partial",
|
|
203
|
+
errors: oneError,
|
|
204
|
+
dryRun: true,
|
|
205
|
+
}).success,
|
|
206
|
+
).toBe(false);
|
|
207
|
+
});
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
describe("parseExcelBuffer + tiện ích sheet", () => {
|
|
211
|
+
function workbookBuffer(): ArrayBuffer {
|
|
212
|
+
const wb = XLSX.utils.book_new();
|
|
213
|
+
const ws = XLSX.utils.aoa_to_sheet([
|
|
214
|
+
["Mã hàng", "Số lượng"],
|
|
215
|
+
["SP01", 3],
|
|
216
|
+
["SP02", ""],
|
|
217
|
+
]);
|
|
218
|
+
XLSX.utils.book_append_sheet(wb, ws, "Dữ liệu");
|
|
219
|
+
return XLSX.write(wb, { type: "array", bookType: "xlsx" }) as ArrayBuffer;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
it("đọc buffer thật thành dòng, ô trống là chuỗi rỗng chứ không undefined", () => {
|
|
223
|
+
const parsed = parseExcelBuffer(workbookBuffer());
|
|
224
|
+
const rows = findSheet(parsed, " dữ LIỆU ")!;
|
|
225
|
+
expect(rows).toHaveLength(2);
|
|
226
|
+
expect(rows[0]["Mã hàng"]).toBe("SP01");
|
|
227
|
+
expect(rows[1]["Số lượng"]).toBe("");
|
|
228
|
+
expect(getHeaders(rows)).toEqual(["Mã hàng", "Số lượng"]);
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it("findSheet trả null khi không có sheet; getHeaders chịu được mảng rỗng", () => {
|
|
232
|
+
expect(findSheet(parseExcelBuffer(workbookBuffer()), "Sheet2")).toBeNull();
|
|
233
|
+
expect(getHeaders([])).toEqual([]);
|
|
234
|
+
});
|
|
235
|
+
});
|