@goplusvn/core 0.1.54 → 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/features/README.md +6 -0
- package/features/audit-logs/README.md +18 -0
- package/features/audit-logs/migrations/0001_init.sql +33 -0
- package/features/audit-logs/schema.prisma +20 -0
- package/package.json +5 -1
- package/src/audit/__tests__/audit-context.test.ts +174 -0
- package/src/audit/__tests__/prisma-audit-extension.test.ts +426 -0
- package/src/audit/audit-actor.ts +42 -0
- package/src/audit/audit-context.ts +47 -0
- package/src/audit/entity-audit.ts +71 -0
- package/src/audit/index.ts +29 -10
- package/src/audit/prisma-audit-extension.ts +572 -0
- 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/system/pages/__tests__/system-audit-page.test.tsx +140 -0
- package/src/system/pages/system-audit-page.tsx +532 -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/ui/management/audit-log-page.tsx +12 -207
- package/src/audit/audit-manager.ts +0 -139
- package/src/audit/memory-audit-logger.ts +0 -86
- package/src/audit/types.ts +0 -50
- 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,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
|
+
});
|