@goplusvn/core 0.1.89 → 0.1.90

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 CHANGED
@@ -1,3 +1,31 @@
1
+ ## 0.1.90 — `between` phủ hết ngày cuối, và một bảng toán tử duy nhất
2
+
3
+ Tiếp bản 0.1.89. `buildListQuery` **chép lại** bảng toán tử thay vì gọi
4
+ `conditionForOperator` — và bản chép thiếu `between`. Hệ quả nối nhau:
5
+
6
+ - Trước 0.1.89: mảng `[từ, đến]` rơi xuống nhánh mặc định rồi đi thẳng vào
7
+ Prisma dạng array ⇒ nổ ở tầng driver. Bộ lọc khoảng ngày của app khai đúng
8
+ `type: "datetime"` + `operator: "between"` hỏng sẵn mà không ai báo.
9
+ - 0.1.89 thêm `between` nhưng dịch cận trên thành `lte`: bộ chọn ngày chỉ chở
10
+ ngày trần, `lte: 31/08` là chặn ở **00:00** ngày cuối ⇒ ngày cuối bị cắt gần
11
+ hết. Chọn 01/08–31/08 thì đơn tạo lúc 9h sáng 31/08 rơi ra ngoài. Không có
12
+ exception nào, chỉ thiếu dữ liệu — khó phát hiện hơn hẳn lỗi ở trên.
13
+
14
+ **Đổi**
15
+
16
+ - `crud/lib/filter-tree.ts` — `conditionForOperator` thêm `case "between"`,
17
+ dựng khoảng **nửa mở** `[từ, đến+1 ngày)` khi cận trên là ngày trần (chuỗi
18
+ `YYYY-MM-DD`, hoặc `Date` đúng nửa đêm UTC do `coerceFieldValue` sinh ra).
19
+ Mốc có giờ thật giữ nguyên `lte`; khoảng số vẫn là khoảng đóng.
20
+ KHÔNG cộng offset múi giờ ở đây — cận dưới cũng đang hiểu theo nửa đêm UTC,
21
+ thêm offset vào một đầu thì hai đầu lệch nhau. App cần biên theo lịch địa
22
+ phương thì quy đổi trước khi gửi.
23
+ - `crud/lib/query-builder.ts` — bỏ chuỗi `if/else` chép tay, gọi thẳng
24
+ `conditionForOperator`. Filter phẳng và `filterTree` từ nay không thể lệch
25
+ bảng toán tử nữa (có test đối chiếu từng toán tử giữa hai đường).
26
+
27
+ **Quy tắc rút ra**: comment ghi "dùng chung" mà code vẫn chép tay thì chỗ chép
28
+ sẽ tụt lại — hợp nhất thật, đừng ghi chú suông.
1
29
  ## 0.1.89 — Bộ lọc CRUD: `defaultValue` sống lại, chip vẽ một lần và đọc được
2
30
 
3
31
  Trang danh sách theo ngày (nghỉ phép, tăng ca, đăng ký suất ăn) mở ra là nạp
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.89",
4
+ "version": "0.1.90",
5
5
  "private": false,
6
6
  "publishConfig": {
7
7
  "registry": "https://registry.npmjs.org",
@@ -0,0 +1,123 @@
1
+ /**
2
+ * BỘ LỌC KHOẢNG NGÀY — `between`.
3
+ *
4
+ * Hai lỗi nối nhau, cùng một gốc: `buildListQuery` chép lại bảng toán tử thay
5
+ * vì gọi `conditionForOperator`, và bản chép thiếu `between`.
6
+ *
7
+ * 1. Trước 0.1.89: mảng `[từ, đến]` rơi xuống nhánh mặc định, đi thẳng vào
8
+ * Prisma dạng array ⇒ nổ ở tầng driver. Hai bộ lọc khoảng ngày của vinhhoa
9
+ * (orders.createdAt, purchaseOrders.orderDate) hỏng sẵn mà không ai báo.
10
+ * 2. 0.1.89 dịch thành `lte` ⇒ chặn ở 00:00 ngày cuối, NGÀY CUỐI BỊ CẮT gần
11
+ * hết. Đây mới là kiểu hỏng nguy hiểm: không exception, chỉ thiếu dữ liệu.
12
+ *
13
+ * Bản vá: khoảng nửa mở `[từ, đến+1 ngày)`, và hai đường (filter phẳng /
14
+ * `filterTree`) dùng CHUNG một bảng toán tử để không lệch nhau lần nữa.
15
+ */
16
+ import { describe, expect, it } from "vitest";
17
+
18
+ import type { EntityConfig, FilterOperator } from "../../types";
19
+
20
+ import { buildListQuery } from "./query-builder";
21
+ import { conditionForOperator } from "./filter-tree";
22
+
23
+ const config = {
24
+ name: "orders",
25
+ label: "Đơn",
26
+ pluralLabel: "Đơn",
27
+ fields: [
28
+ { name: "createdAt", label: "Ngày tạo", type: "datetime" },
29
+ { name: "total", label: "Tổng", type: "number" },
30
+ ],
31
+ } as unknown as EntityConfig;
32
+
33
+ function whereFor(value: unknown) {
34
+ return buildListQuery({
35
+ entity: "orders",
36
+ config,
37
+ params: {
38
+ page: 1,
39
+ pageSize: 10,
40
+ filters: [{ name: "createdAt", value, operator: "between" }],
41
+ },
42
+ }).where.createdAt;
43
+ }
44
+
45
+ describe("between — cận trên phải phủ hết ngày cuối", () => {
46
+ it("ngày trần dạng chuỗi ➝ nửa mở tới nửa đêm hôm sau", () => {
47
+ expect(whereFor(["2026-08-01", "2026-08-31"])).toEqual({
48
+ gte: "2026-08-01",
49
+ lt: new Date("2026-09-01T00:00:00.000Z"),
50
+ });
51
+ });
52
+
53
+ it("Date nửa đêm UTC (đã qua coerceFieldValue) cũng được nới", () => {
54
+ const where = whereFor([
55
+ new Date("2026-08-01T00:00:00.000Z"),
56
+ new Date("2026-08-31T00:00:00.000Z"),
57
+ ]);
58
+
59
+ expect((where as { lt: Date }).lt.toISOString()).toBe(
60
+ "2026-09-01T00:00:00.000Z",
61
+ );
62
+ });
63
+
64
+ it("bản ghi lúc 9h sáng ngày cuối nằm TRONG khoảng", () => {
65
+ const { lt } = whereFor(["2026-08-01", "2026-08-31"]) as { lt: Date };
66
+ expect(new Date("2026-08-31T09:00:00.000Z").getTime()).toBeLessThan(
67
+ lt.getTime(),
68
+ );
69
+ });
70
+
71
+ it("mốc CÓ giờ thật thì giữ nguyên lte, không tự nới thêm một ngày", () => {
72
+ const end = new Date("2026-08-31T12:30:00.000Z");
73
+ expect(whereFor(["2026-08-01T00:00:00.000Z", end])).toEqual({
74
+ gte: "2026-08-01T00:00:00.000Z",
75
+ lte: end,
76
+ });
77
+ });
78
+
79
+ it("mảng không đủ hai mốc ➝ trả nguyên giá trị, không dựng khoảng méo", () => {
80
+ expect(conditionForOperator("between", ["2026-08-01"])).toEqual([
81
+ "2026-08-01",
82
+ ]);
83
+ });
84
+
85
+ it("khoảng số vẫn là khoảng đóng — chỉ NGÀY mới được nới", () => {
86
+ expect(conditionForOperator("between", [1000, 5000])).toEqual({
87
+ gte: 1000,
88
+ lte: 5000,
89
+ });
90
+ });
91
+ });
92
+
93
+ describe("filter phẳng và filterTree dùng chung một bảng toán tử", () => {
94
+ const cases: [FilterOperator, unknown][] = [
95
+ ["contains", "an"],
96
+ ["in", [1, 2]],
97
+ ["notIn", [3]],
98
+ ["eq", 5],
99
+ ["ne", 5],
100
+ ["gt", 5],
101
+ ["gte", 5],
102
+ ["lt", 5],
103
+ ["lte", 5],
104
+ ["startsWith", "a"],
105
+ ["endsWith", "z"],
106
+ ["isNotNull", 1],
107
+ ["between", ["2026-08-01", "2026-08-31"]],
108
+ ];
109
+
110
+ it.each(cases)("%s cho cùng một điều kiện ở cả hai đường", (op, value) => {
111
+ const flat = buildListQuery({
112
+ entity: "orders",
113
+ config,
114
+ params: {
115
+ page: 1,
116
+ pageSize: 10,
117
+ filters: [{ name: "total", value, operator: op }],
118
+ },
119
+ }).where.total;
120
+
121
+ expect(flat).toEqual(conditionForOperator(op, value));
122
+ });
123
+ });
@@ -29,11 +29,46 @@ export type FilterTreeNode =
29
29
  const MAX_DEPTH = 4;
30
30
  const MAX_LEAVES = 30;
31
31
 
32
+ const DATE_KEY = /^\d{4}-\d{2}-\d{2}$/;
33
+ const DAY_MS = 86_400_000;
34
+
35
+ /**
36
+ * Cận TRÊN của `between` khi mốc gửi xuống không mang giờ.
37
+ *
38
+ * Bộ chọn ngày chỉ chở ngày trần ("2026-08-31", hoặc `Date` đã ép về nửa đêm
39
+ * UTC — xem `coerceFieldValue`). Dịch thẳng thành `lte` là chặn ở 00:00 của
40
+ * ngày cuối, tức NGÀY CUỐI BỊ CẮT gần hết: chọn 01/08–31/08 thì bản ghi tạo
41
+ * lúc 9h sáng 31/08 rơi ra ngoài. Người dùng không thấy lỗi, chỉ thấy thiếu
42
+ * dữ liệu — kiểu hỏng khó phát hiện hơn hẳn một cái exception.
43
+ *
44
+ * Nên khoảng dựng NỬA MỞ: `[từ, đến+1 ngày)`. Không cộng múi giờ ở đây —
45
+ * cận dưới cũng đang hiểu theo nửa đêm UTC, thêm offset vào một đầu thì hai
46
+ * đầu lệch nhau. App nào cần biên theo lịch địa phương thì tự quy đổi trước
47
+ * khi gửi (xem cách tanloc-spartronics dùng `vnDateKeyToDbDate`).
48
+ *
49
+ * Mốc CÓ giờ thật thì giữ nguyên `lte` — đó là ý người gọi, không phải ngày trần.
50
+ */
51
+ function upperDateBound(value: unknown): Record<string, unknown> {
52
+ if (typeof value === "string" && DATE_KEY.test(value)) {
53
+ return { lt: new Date(Date.parse(`${value}T00:00:00.000Z`) + DAY_MS) };
54
+ }
55
+ // Nửa đêm UTC chằn chặn = ngày trần đã qua `new Date("YYYY-MM-DD")`.
56
+ if (value instanceof Date && value.getTime() % DAY_MS === 0) {
57
+ return { lt: new Date(value.getTime() + DAY_MS) };
58
+ }
59
+ return { lte: value };
60
+ }
61
+
32
62
  /** Điều kiện Prisma cho một toán tử — dùng chung với filter phẳng. */
33
63
  export function conditionForOperator(op: string, value: unknown): unknown {
34
64
  switch (op) {
35
65
  case "contains":
36
66
  return { contains: value, mode: "insensitive" };
67
+ // Khoảng [từ, đến] của bộ chọn ngày. Trước 0.1.89 KHÔNG có nhánh này: cả
68
+ // mảng rơi xuống `default` và đi thẳng vào Prisma dạng array ⇒ nổ ở driver.
69
+ case "between":
70
+ if (!Array.isArray(value) || value.length !== 2) return value;
71
+ return { gte: value[0], ...upperDateBound(value[1]) };
37
72
  case "in":
38
73
  return { in: value };
39
74
  case "notIn":
@@ -1,4 +1,4 @@
1
- import { compileFilterTree } from "./filter-tree";
1
+ import { compileFilterTree, conditionForOperator } from "./filter-tree";
2
2
  import { CrudRequestError } from "./errors";
3
3
  import type { CrudQueryParams, EntityConfig } from "../../types";
4
4
 
@@ -58,25 +58,10 @@ export function buildListQuery({
58
58
  key = parts.pop()!;
59
59
  for (const p of parts) { if (!target[p]) target[p] = {}; target = target[p]; }
60
60
  }
61
- const op = operator as string;
62
- if (op === "contains") target[key] = { contains: value, mode: "insensitive" };
63
- // Khoảng [từ, đến] của bộ chọn ngày. Thiếu nhánh này thì cả mảng rơi
64
- // xuống `else` đi thẳng vào Prisma dưới dạng array ⇒ nổ ở tầng driver.
65
- else if (op === "between" && Array.isArray(value) && value.length === 2)
66
- target[key] = { gte: value[0], lte: value[1] };
67
- else if (op === "in") target[key] = { in: value };
68
- else if (op === "notIn") target[key] = { notIn: value };
69
- else if (op === "eq") target[key] = value;
70
- else if (op === "ne") target[key] = { not: value };
71
- else if (op === "gt") target[key] = { gt: value };
72
- else if (op === "gte") target[key] = { gte: value };
73
- else if (op === "lt") target[key] = { lt: value };
74
- else if (op === "lte") target[key] = { lte: value };
75
- else if (op === "startsWith") target[key] = { startsWith: value, mode: "insensitive" };
76
- else if (op === "endsWith") target[key] = { endsWith: value, mode: "insensitive" };
77
- else if (op === "isNull") target[key] = null;
78
- else if (op === "isNotNull") target[key] = { not: null };
79
- else target[key] = value;
61
+ // MỘT bảng toán tử cho cả hai đường (filter phẳng ở đây, `filterTree` ở
62
+ // dưới). Trước đây chỗ này chép lại chuỗi if riêng, bản chép thiếu
63
+ // `between` bộ lọc khoảng ngày của app khai đúng type vẫn nổ ở driver.
64
+ target[key] = conditionForOperator(operator as string, value);
80
65
  }
81
66
  }
82
67