# CRM Quản trị Bán hàng — hướng dẫn cho phiên làm việc AI

Dự án SaaS quản trị bán hàng cho doanh nghiệp phân phối qua kênh BV/PK, TMV/Spa.
Phạm vi bám sát tài liệu "Các yêu cầu Bộ phận Kinh doanh triển khai CRM" của khách hàng,
chia thành 8 phân hệ M0–M6.

## Nguyên tắc sản phẩm — đọc trước khi thêm bất cứ thứ gì

Khách hàng bỏ Getfly CRM vì **"giao diện rườm rà, khó sử dụng"**. Đó là lý do tồn tại
của dự án này, nên:

1. **Không thêm module ngoài phạm vi.** Menu cố định 8 mục. Muốn thêm mục thứ 9 phải
   có yêu cầu rõ ràng từ khách hàng, không thêm vì "CRM nào cũng có".
2. **NVKD thao tác dưới 1 phút.** Màn hình `/cong-viec` và lên đơn tối ưu cho điện thoại.
3. **Số liệu tự tính, không bắt người dùng nhập.** Phân hạng đại lý, KPI, cảnh báo,
   dự báo đều do máy tính ra từ đơn hàng. Nếu thấy mình sắp thêm ô nhập cho một con số
   có thể suy ra được, hãy dừng lại.

## Kiến trúc

| Thành phần | Lựa chọn | Lý do |
|---|---|---|
| Framework | Next.js 15 App Router + React 19 | Một codebase cho web máy tính và PWA điện thoại |
| Dữ liệu | Drizzle ORM + PostgreSQL | SQL tường minh, dễ đọc khi làm việc cùng AI |
| DB máy dev | PGlite (Postgres nhúng, thư mục `.pgdata`) | Chạy demo không cần cài PostgreSQL hay Docker |
| DB production | PostgreSQL thật | Đổi `DB_DRIVER=pg` + `DATABASE_URL`, không sửa code |
| Xác thực | JWT trong cookie httpOnly (`jose`) + scrypt | Không cần bảng session, scale ngang được |
| Giao diện | Tailwind CSS v4 | |
| Biểu đồ | Recharts | |

### Ràng buộc quan trọng của PGlite

**PGlite chỉ cho một tiến trình mở thư mục dữ liệu.** Phải **tắt dev server** trước khi
chạy `db:seed`, `db:push` hay `jobs:nightly`, nếu không script sẽ lỗi hoặc ghi vào bản
sao mà web không nhìn thấy.

**Tắt dev server bằng Ctrl+C, đừng `kill -9`.** PGlite không đóng file sạch khi bị giết
đột ngột; đã nhiều lần phải `rm -rf .pgdata && npm run db:reset` vì lý do này. Kết nối
tới cơ sở dữ liệu được tạo ở lần truy vấn đầu tiên chứ không phải lúc import module
(xem `src/db/index.ts`) — nếu không, `next build` nạp module trong nhiều tiến trình
worker và làm hỏng thư mục dữ liệu.

Cả hai ràng buộc trên biến mất khi dùng Postgres thật. **Khi cho khách dùng thử, hãy
chuyển sang Postgres thật** (Neon/Supabase bản miễn phí là đủ); PGlite chỉ nên dùng cho
máy dev của một người.

## Lệnh thường dùng

```bash
npm run dev            # chạy web ở http://localhost:3000
npm run db:generate    # sinh SQL migration sau khi sửa src/db/schema.ts
npm run db:push        # áp dụng migration
npm run db:seed        # tạo lại dữ liệu mẫu (12 tháng đơn hàng)
npm run db:reset       # xoá sạch + push + seed
npm run jobs:nightly   # chạy tay job hằng đêm
npm run typecheck      # bắt buộc chạy trước khi coi là xong
npm run lint            # bắt buộc chạy trước khi coi là xong
npm run test            # bắt buộc chạy trước khi coi là xong (xem mục Kiểm thử tự động)
npm run test:e2e        # chậm hơn (10–20s) — khoá quyền qua server action thật, không bắt buộc mỗi lần sửa
```

Đừng dùng `next lint` — lệnh đó ngừng phát triển từ Next 15.5, gỡ ở Next 16.
Không chạy `npm run build` khi `npm run dev` đang chạy: hai tiến trình tranh nhau
thư mục `.next`, kết quả là dev server trả 500 và bản build gãy với lỗi khó hiểu
`<Html> should not be imported outside of pages/_document`. Dừng dev, `rm -rf .next`,
rồi mới build.

Tài khoản mẫu: `giamdoc@minhanh.vn` (BGĐ) · `gdkv@minhanh.vn` (Giám đốc Khu vực) ·
`quanly.bac@minhanh.vn` (quản lý vùng) · `nv.hanoi@minhanh.vn` (NVKD) — mật khẩu chung
`matkhau123`.

## Kiểm thử tự động

**Đợt 1 (27/09/2026)** — trước đó dự án không có test tự động, mọi kiểm tra làm tay (xem lịch sử làm việc bên dưới). `npm test` (Vitest) chạy `tests/lib/*.test.ts` (hàm thuần: `period.ts`, `alerts.ts`
`findDrops`/`monthsSinceLast`, `forecast.ts seasonality`, `classification.ts revenueTier`/`skuTier`/`countMonthsInactive`/`quarterMonthsOf` — ba hàm sau vừa được **export** riêng để test được, trước đó là
hàm nội bộ) và `tests/scope-leak.test.ts` (tích hợp thật trên PGlite — lớp lỗi hay gặp nhất của dự án: rò rỉ phạm vi dữ liệu giữa vai trò/công ty).

**Cơ chế `tests/scope-leak.test.ts`:** `vitest.config.mts` đặt `DB_DRIVER=pglite`, `PGLITE_DIR=memory://` — PGlite chạy HOÀN TOÀN TRONG BỘ NHỚ (không phải thư mục `.pgdata`), nên chạy song song với
`npm run dev` mà không tranh khoá tệp, và mỗi test file có cơ sở dữ liệu riêng, tự huỷ khi xong. `tests/helpers/db.ts` (`migrateTestDb`, `seedMinimalFixture`) áp toàn bộ `drizzle/*.sql` qua CHÍNH kết nối
`db` (không mở client riêng như `push.ts`, vì `memory://` không phải thư mục dùng chung — hai kết nối riêng sẽ là hai cơ sở dữ liệu trống khác nhau), rồi rắc một fixture tối thiểu (2 tenant, vài người
dùng/khách/đơn) — không dùng bộ 40 đại lý của `db:seed` vì chậm và không cần cho việc kiểm phạm vi. Gói `"server-only"` (Next.js) luôn `throw` ngoài trình đóng gói RSC của Next — `vitest.config.mts` alias
nó sang `tests/helpers/server-only-stub.ts` (module rỗng) để import được các hàm ở `src/lib/*.ts` mà không cần dựng cả runtime Next.

**Thêm test khi thêm mã có phạm vi mới:** mọi hàm dùng `scopeFilter`/`visibleUserIds`/`visibleTerritoryIds`/`visibleDepartmentIds` xứng đáng có một test kiểu `scope-leak.test.ts` (dựng session hai vai
trò khác nhau trên cùng fixture, xác nhận vai trò hẹp hơn không thấy dữ liệu của nhánh/tenant khác) — đây là lớp lỗi thật đã lặp lại nhiều lần nhất trong lịch sử dự án (KPI lộ cả công ty, ô lọc lộ tên
ngoài nhánh, trang sự kiện lộ phạm vi…), không phải suy đoán.

**Đợt 2 (27/09/2026) — `npm run test:e2e`, `tests/e2e/`.** Test server action (mutation + khoá quyền) qua HTTP thật — `requireSession()` đọc `cookies()` từ `next/headers`, chỉ chạy trong ngữ cảnh request
thật nên không gọi thẳng được trong Vitest như các hàm ở Đợt 1. Phạm vi: "thông tin cần duyệt không được sửa/xoá, ngoại trừ Admin" — đơn hàng và phiếu đề xuất đã duyệt/đã có kết quả (22 test, 2 file:
`tests/e2e/helpers/server.ts` dựng máy chủ + `tests/e2e/action-locks.test.ts` kịch bản).

**Cơ chế `tests/e2e/helpers/server.ts`:** khởi động `next dev` (KHÔNG PHẢI `next build && next start` — bản build thật chỉ ghi id chunk webpack vào `server-reference-manifest.json`, mất hẳn chuỗi
`actions=[[...]]` kèm tên hàm; định dạng đọc được chỉ có ở bản dev) trên một cổng riêng, trỏ `PGLITE_DIR` vào một **thư mục tạm thật** (không phải `memory://` của Đợt 1 — tiến trình `next dev` và tiến
trình test là hai tiến trình khác nhau, không chia sẻ bộ nhớ được). PGlite chỉ cho một tiến trình mở một thư mục cùng lúc nên thứ tự bắt buộc: mở kết nối riêng → seed → **đóng hẳn** → mới khởi động
`next dev`. Sau khi máy chủ sẵn sàng (`/api/suc-khoe`), ghé qua từng trang trong `WARM_UP_PATHS` (`/don-hang/moi`, `/cong-viec/de-xuat`) một lần — `next dev` biên dịch route theo yêu cầu, và mọi export
`"use server"` trong CÙNG MỘT FILE actions.ts được đăng ký vào manifest CÙNG LÚC khi bất kỳ trang nào import file đó được ghé, nên chỉ cần ghé một trang là đủ cho toàn bộ action của cả file (không cần ghé
đủ mọi route dùng riêng lẻ từng action, kể cả những route cần id chưa tồn tại lúc khởi động như `/don-hang/[id]`). Gọi action qua `callAction()`: multipart FormData các trường `_1_<tên>` RỒI MỚI đến phần
gốc `0` = `["$K1"]` (`[null,"$K1"]` khi action dùng `useActionState`) — thứ tự bắt buộc vì máy chủ giải mã theo luồng; đọc dòng `"1:"` của phản hồi Flight làm giá trị trả về. `E2E_DEBUG=1 npm run test:e2e`
in nguyên văn phản hồi Flight khi cần dò một action mới.

**Bẫy đã gặp khi dựng fixture — nhắc để không lặp lại:** GĐKV trong fixture ban đầu không gán `userTerritories`, nên dù đúng vai trò vẫn không duyệt được đơn (`approveOrderAction` báo "Không tìm thấy đơn
hàng"). Nguyên nhân: GĐKV chưa gán phòng ban thì `visibleUserIds` trả `null` (coi là "không giới hạn qua phòng ban"), đẩy `orderViewChecker` xuống nhánh lùi về khu vực (`visibleTerritoryIds`) — nhưng
GĐKV cũng chưa có dòng `user_territories` nào nên khu vực nhìn thấy là mảng RỖNG (không phải "không giới hạn"), khác hẳn ý nghĩa của `null`. Bài học: GĐKV/MANAGER trong fixture test PHẢI có ít nhất một
trong hai — `departmentId` hoặc một dòng `user_territories` — nếu không mọi test tưởng "đúng vai trò nhưng vẫn bị chặn" thật ra là lỗi ở fixture, không phải lỗi khoá quyền đang kiểm.

**Đã ưu tiên đúng như dự tính:** khoá đơn hàng và phiếu đề xuất đã duyệt (mục "Yêu cầu thêm" của khách). Mở rộng thêm sau (chưa làm): khu vực/phòng ban/chỉ tiêu/KPI tự tạo — cùng kỹ thuật, chỉ cần thêm
`describe` mới trong `action-locks.test.ts` hoặc file `tests/e2e/*.test.ts` khác (mỗi file tự có `beforeAll`/`afterAll` riêng, `fileParallelism: false` trong `vitest.e2e.config.mts` đã đảm bảo không có
hai `next dev` tranh cổng/PGlite cùng lúc).

**`tests/lib/import.test.ts` (27/09/2026) — Nhập Excel với file .xlsx THẬT (ExcelJS sinh buffer thật, không mock `parseSheet`).** Đóng test case "Chưa test lại" của tính năng nhập hàng loạt (Mục 7.6
`DAC-TA-KY-THUAT.md`). Phát hiện qua test, không phải suy đoán: `importProducts` KHÔNG có quy tắc "để trống thì giữ nguyên" như `importCustomers` — với sản phẩm, cột có mặt trong file nhưng ô để trống sẽ
**xoá** giá trị cũ (ghi `null`), trong khi khách hàng đã có sẵn thì ô để trống **giữ nguyên** dữ liệu cũ. Hai hàm cố ý viết khác nhau, không phải lỗi, nhưng dễ hiểu lầm nếu chỉ đọc tên tính năng — đã ghi
rõ sự khác biệt này vào Mục 7.6.

**Bẫy khi thêm dữ liệu riêng cho một file test: đừng sửa `seedMinimalFixture()` dùng chung.** Lần đầu viết test này, mình thêm thẳng 1 sản phẩm + 1 khách hàng vào `tests/helpers/db.ts` cho tiện — làm vỡ
`scope-leak.test.ts` (test đó giả định CHÍNH XÁC 2 khách có sẵn trong tenant A, so khớp danh sách id tuyệt đối). Sửa lại: `import.test.ts` tự `db.insert()` thêm dữ liệu riêng trong `beforeAll` của chính
nó, dùng lại id có sẵn từ `Fixture` (`fx.tenantA`, `fx.hanoi`, `fx.channel`, `fx.salesBac`), không đụng file fixture dùng chung. Bài học: fixture dùng chung giữa nhiều test file là hợp đồng ngầm — thêm
bản ghi vào đó có thể âm thầm phá test khác đang đếm số lượng chính xác; test cần dữ liệu riêng thì tự rắc trong `beforeAll` của mình.

CI (`.github/workflows/ci.yml`) có hai job chạy song song trên mỗi push/PR vào `main`: `kiem-tra` (`lint → typecheck → test → build`, không cần cấu hình cơ sở dữ liệu — test dùng PGlite trong bộ nhớ, build
không cần biến môi trường nào vì mọi trang có số liệu điều hành đều `force-dynamic`) và `kiem-khoa-quyen` (`npm run test:e2e`, tự dựng máy chủ + cơ sở dữ liệu riêng, không phụ thuộc job kia).

**Bắt buộc sau khi push nếu commit có đụng `package.json`/`package-lock.json`
(thêm gói, nâng cấp, sửa tay lock file):** kiểm tra CI thật trên GitHub
(`gh run list --branch main --limit 1`, hoặc `gh run watch <id> --exit-status`
để chờ tới khi xong) trước khi báo đã xong việc. Lý do: `npm install`/`npm run
dev` chạy ổn ở máy Mac **không chứng minh** `npm ci` sẽ chạy được trên máy
Linux sạch của CI — xem bài học "CI fail âm thầm vì package-lock.json lệch,
27/09/2026" bên dưới. Nếu chỉ sửa code/docs không đụng tới hai file trên thì
không bắt buộc, nhưng vẫn nên liếc `gh run list` khi nghi ngờ.

## Bố cục mã nguồn

```
src/
├── db/
│   ├── schema.ts        Toàn bộ mô hình dữ liệu 8 phân hệ, chú thích theo mục yêu cầu
│   ├── index.ts         Kết nối, chọn PGlite hay Postgres theo DB_DRIVER
│   ├── push.ts          Áp dụng migration cho cả hai chế độ
│   └── seed.ts          Dữ liệu mẫu, dùng seed ngẫu nhiên cố định để tái lập được
├── lib/
│   ├── auth.ts          Phiên đăng nhập; getSession() đối chiếu lại DB mỗi request
│   ├── password.ts      Băm scrypt — tách riêng để script ngoài Next.js dùng được
│   ├── rbac.ts          scopeFilter() — MỌI truy vấn nghiệp vụ phải đi qua đây
│   ├── period.ts        Kỳ báo cáo dạng chuỗi: '2026-08' | '2026-Q3' | '2026'
│   ├── reports.ts       Truy vấn dùng chung cho báo cáo ngày/tuần/tháng và phân tích
│   ├── classification.ts Phân hạng đại lý A–E — ngưỡng cấu hình ở đầu file
│   ├── kpi.ts           KPI tháng từng NVKD
│   ├── audit.ts         writeAudit + diffFields (ghi nhật ký); audit-view.ts đọc/trình bày nhật ký cho Ban giám đốc
│   ├── alerts.ts        7 loại cảnh báo tự động (đại lý, sản phẩm, thị trường, nhân sự) + vòng đời cảnh báo
│   └── forecast.ts      Dự báo dự trù 3 tháng tới: bình quân trượt 3 tháng đóng sổ × hệ số mùa vụ
├── jobs/nightly.ts      Chạy phân hạng → KPI → cảnh báo → dự báo, theo đúng thứ tự
├── app/(app)/           Các màn hình, đường dẫn tiếng Việt
└── components/          ui.tsx (thành phần dùng lại), shell.tsx (khung), charts.tsx
```

## Quy tắc bắt buộc khi viết mã

**Phân quyền dữ liệu.** Mọi truy vấn bảng nghiệp vụ bắt đầu bằng `scopeFilter(session, …)`
hoặc kèm điều kiện `eq(bang.tenantId, session.tenantId)`. Không bao giờ truy vấn không
lọc tenant — dữ liệu nhiều khách hàng nằm chung một database.

**Tiền tệ.** Lưu `bigint`, đơn vị VNĐ, không thập phân. Hiển thị bằng `formatVnd` /
`formatVndShort`, không tự viết định dạng mới.

**Kỳ báo cáo.** Luôn dùng helper trong `period.ts`. Không so sánh ngày bằng đối tượng
`Date` trong truy vấn — dùng chuỗi `YYYY-MM-DD` để tránh lệch múi giờ máy chủ.

**Trang có số liệu điều hành** phải khai báo `export const dynamic = "force-dynamic"`,
nếu không Next.js phục vụ bản cache và người dùng thấy số liệu cũ.

**Sắp xếp giảm dần trên cột có NULL** phải thêm `nulls last`, nếu không bản ghi rỗng
chiếm hết đầu bảng.

**Không dùng truy vấn con tương quan trong `sql` template.** Đã gặp lỗi thật:

```ts
// SAI — trả về 0 một cách âm thầm
lineCount: sql<number>`(select count(*)::int from order_lines ol where ol.order_id = ${orders.id})`
```

Khi câu lệnh ngoài không có JOIN, Drizzle sinh ra `"id"` không kèm tên bảng. Postgres
hiểu đó là `order_lines.id` của chính bảng con, nên điều kiện không bao giờ đúng và kết
quả luôn bằng 0 — không báo lỗi, chỉ ra số sai. Câu lệnh có JOIN lại chạy đúng vì lúc đó
Drizzle mới ghi đủ `"orders"."id"`, khiến lỗi càng khó phát hiện.

Cách làm đúng: truy vấn gộp riêng rồi ghép bằng `Map` trong JS — xem
`src/app/(app)/don-hang/page.tsx`.

**Cảnh báo và mọi so sánh kỳ này với kỳ trước** phải chạy trên tháng đã đóng sổ
(`lastCompletedMonth()`). Đem 10 ngày đầu tháng so với trọn tháng trước sẽ kết luận sai
rằng mọi thứ đều sụt giảm.

**Nhật ký chỉnh sửa (`writeAudit`, `src/lib/audit.ts`).** Mọi thao tác TẠO / SỬA / XOÁ dữ liệu nghiệp vụ hoặc cấu hình phải ghi một dòng `audit_logs` — khách yêu cầu "thông tin chỉnh sửa được lưu lại,
Admin xem được lịch sử". Sửa: ghi `changes: diffFields(trước, sau)` (khoá trường → `{from, to}`); xoá: ghi TRƯỚC khi xoá, kèm đủ giá trị (sau đó không còn gì để đối chiếu). Không ghi mật khẩu/mã đặt lại
mật khẩu. `entityId` = mã bản ghi chính (để `loadEntityHistory` gom được lịch sử của nó; bản ghi con như liên hệ/ghi chú khách ghi mã khách). Cố ý KHÔNG ghi thao tác cá nhân không đổi dữ liệu đã có
(check-in, tick việc lộ trình, đánh dấu đã đọc thông báo). Thêm loại đối tượng mới → thêm nhãn vào `ENTITY_LABEL`/`FIELD_LABEL` ở `src/lib/audit-view.ts` (không thêm thì hiện tên kỹ thuật).

**Không đưa mảng JS vào `sql` template** (`sql\`${cot} = any(${mang})\``): Drizzle bung mảng thành danh sách tham số `($1,$2)` nên Postgres báo `malformed array literal` khi mảng ≥ 2 phần tử (còn 1
phần tử thì chạy đúng — lỗi chỉ lộ ở dữ liệu thật). Dùng `inArray(cot, mang)`. Đã gặp ở nút "Ngừng phòng ban" (phòng có phòng con).

## Việc còn lại

Phạm vi tài liệu yêu cầu gốc **và** phiếu yêu cầu tùy biến của Mediworld đều đã hoàn thành.
Toàn bộ câu hỏi mở đã có trả lời tính tới 19/09/2026 — không còn mục nào cần hỏi thêm khách.
Nhật ký đầy đủ câu hỏi/trả lời/quyết định: `trao-doi-khach/cau-hoi-bo-sung-2026-09-16.md`.

Các quyết định đáng chú ý gần nhất (19/09/2026):

- **Ngân sách khuyến mãi**: khách xác nhận **không có khái niệm ngân sách** — khuyến mãi
  là chính sách chiết khấu theo bậc giá trị đơn, luôn quy ra hàng chứ không hoàn tiền, khớp
  đúng cơ chế `qtyPromo`/`promoValue` đã có sẵn. M5 Phân tích (`p3-1`) coi như đã xong đầy đủ.
- **Chỉ tiêu mời khách theo sự kiện**: chỉ áp dụng cho loại sự kiện hiếm (~1 tháng/lần toàn
  công ty) có giao chỉ tiêu thật — lớp học và sự kiện khu vực thường không cần chỉ tiêu. Đã
  xây bảng `inviteEvents`/`inviteEventTerritoryQuotas`/`inviteEventStaffQuotas`/
  `inviteEventGuests` và trang `/danh-muc/su-kien-moi-khach`: TGĐ giao chỉ tiêu theo khu
  vực, GĐKV giao lại cho từng NVKD, mỗi khách mời có cờ CME/chứng nhận tham dự riêng để tra
  nhanh — đúng yêu cầu chính khách đưa ra (Excel tra chậm mỗi đợt cấp chứng nhận).
- **Phân quyền chi tiết theo từng chức năng** (đã từng cân nhắc làm ma trận
  Truy cập/Thêm/Sửa/Xoá/Duyệt như Getfly) — khách xác nhận chức vụ (Trợ lý GĐ, HCNS, Kế
  toán trưởng...) chỉ là tên gọi tự đặt, không quyết định quyền hạn. Quyền hạn vẫn đi theo
  4 vai trò cố định (`role`); `users.jobTitle` chỉ là trường hiển thị tự do. Quyền đặc biệt
  cho một chức vụ cụ thể thì thêm cờ riêng như `canUploadDocuments`, không xây ma trận phân
  quyền động.
- **Nhắn tin nội bộ** — khách xác nhận **bỏ hẳn**, không build. Không có bảng dữ liệu, route,
  hay UI nào cho tính năng này trong mã nguồn — đúng như quyết định, không phải thiếu sót.

**Bổ sung 25/09/2026 — đối chiếu lại từng dòng file "Tính năng CRM.xlsx" sau khi khách xem demo và báo thiếu.**
Kết quả đối chiếu (từng dòng, kèm trạng thái) nằm ở sheet "Đối chiếu Tính năng CRM" trong
`trao-doi-khach/bo-test-case-2026-09-19.xlsx`. Các bổ sung và quyết định đáng nhớ:

- **Sửa hồ sơ khách hàng** trước đó chưa có (chỉ tạo mới) — đã thêm `/khach-hang/[id]/sua`, mọi thay đổi ghi vào nhật ký
  dạng `trường: cũ → mới`. Mở rộng hồ sơ: ngày sinh/chức vụ người liên hệ, phường/xã, người nhận hàng, nhu cầu/nguồn/trạng
  thái chuyển đổi khách tiềm năng (`NOT_CONTACTED|CONTACTED|CANCELLED`; "Đã chốt" = chuyển sang Đang/Đại lý).
- **Đơn đã duyệt bị khoá** (yêu cầu bổ sung của khách: "thông tin cần duyệt không được sửa/xoá, ngoại trừ Admin"): chỉ
  DIRECTOR sửa được đơn `CONFIRMED`, chỉ DIRECTOR huỷ đơn đã duyệt và gỡ tệp đính kèm của đơn đã duyệt. Trạng thái
  "Điều chỉnh" = đơn `DRAFT` có cờ `needsRevision` (người duyệt trả về), **không** thêm giá trị enum.
- **Không thêm giá trị vào enum Postgres** — `ALTER TYPE ... ADD VALUE` không chạy được trong giao dịch trên PostgreSQL < 12
  (HawkHost đang là 10). Trạng thái mới dùng cột `text` kiểm ở tầng ứng dụng (xem `tasks.status`, `src/lib/tasks.ts`).
- **Danh sách công việc** (`tasks`, `/cong-viec/danh-sach`) tách khỏi `planItems` (lộ trình tuyến theo tuần): 6 trạng thái, cấp
  trên giao việc cho cấp dưới trong phạm vi `visibleUserIds`. KPI tự tạo có ghi nhận hằng ngày (`custom_kpi_logs`), mỗi lần
  ghi **cộng dồn vào** `custom_kpi_entries.actual_value` để mọi báo cáo đang đọc số đó vẫn khớp.
- **Thông báo** có ghim/hết hạn/ngày sự kiện/đính kèm; quyền đăng theo cấp (`posterScopes`): Ban giám đốc mọi phạm vi,
  `canUploadDocuments` đăng toàn công ty, GĐKV/Quản lý đăng cho nhánh mình. **Chuông** (`src/lib/notifications.ts`) suy ra từ
  dữ liệu sẵn có, chỉ lưu `users.notificationsSeenAt`.
- **Cờ quyền riêng mới `canEditStaffInfo`** (HCNS sửa thông tin cá nhân nhân viên) — cùng kiểu với `canUploadDocuments`;
  vẫn **không** xây ma trận phân quyền động (khách đã xác nhận). Người có cờ này xem được hồ sơ nhưng không xem báo cáo
  kinh doanh của người ngoài phạm vi.
- **Khác biệt còn lại có chủ đích, cần nói rõ với khách nếu họ hỏi:** ma trận tick quyền kiểu Getfly và việc cấp trên tự cấp quyền
  cho cấp dưới (không làm — quyền theo 4 vai trò + cờ ngoại lệ do Ban giám đốc cấp); luồng duyệt đơn 4 cấp (chỉ GĐKV duyệt, TGĐ/KTT
  khi xin thêm KM/CK — khách xác nhận 19/09); nhắn tin nội bộ (khách xác nhận bỏ, nhưng file yêu cầu gốc vẫn còn dòng này).
- **Rà soát lần 2 (25/09):** báo cáo có bộ chọn phạm vi (`pv=terr|dept|user:<uuid>`, `src/lib/focus.ts`) — chỉ THU HẸP,
  điều kiện được AND vào phạm vi quyền gốc trong `orderScope`/`customerScope` (`reports.ts`). Mọi số liệu tính trên khách
  hàng phải dùng `customerScope` (không tự viết `role === "SALES" ? ... : undefined` — cách đó để quản lý thấy khách toàn
  công ty). Cảnh báo lọc theo `alertScope` (`src/lib/alerts-scope.ts`), chỉ cảnh báo sản phẩm là cấp công ty.
- **Phân quyền cha–con và quản lý tài khoản (rà soát mục 1–2):** logic nằm ở `src/lib/accounts.ts` (`accountScope`,
  `canManageAccount`, `validateAssignment`). Ban giám đốc quản lý mọi tài khoản; GĐKV/Quản lý chỉ quản lý tài khoản
  **cùng nhánh phòng ban VÀ vai trò thấp hơn**, chỉ gán vai trò thấp hơn mình, chỉ đặt vào phòng ban/khu vực trong
  nhánh, chỉ cấp lại cờ quyền mà chính mình đang có. Người chưa gán phòng ban không quản lý được ai (khác phạm vi xem dữ
  liệu — ở đây KHÔNG lùi về "không giới hạn"). Mọi thao tác ghi tài khoản phải gọi `loadManagedTarget`. Giao chỉ tiêu
  dùng `canAssignTargetTo`; nhập Excel, cây khu vực chỉ Ban giám đốc. Danh sách đơn/xuất Excel/trang chủ dùng
  `scopeFilter` (phòng ban), không tự lọc theo khu vực.
- **Phiên đăng nhập:** `users.password_changed_at` (migration 0019) — token cấp trước mốc này bị từ chối trong `getSession`;
  người tự đổi mật khẩu được cấp lại phiên ngay. Email đăng nhập phải duy nhất **toàn hệ thống** (màn đăng nhập không hỏi công ty).
  Quên mật khẩu: chỉ đường dẫn mới nhất còn hiệu lực, tối đa 5 yêu cầu/giờ/tài khoản.
- **Thông báo trang chủ (mục 3):** quyền đăng — Ban giám đốc mọi phạm vi; `canUploadDocuments` đăng toàn công ty; GĐKV/Quản lý
  đăng cho nhánh mình; `canPostAnnouncements` (migration 0020) cho người ủy quyền (Trợ lý...) đăng trong phòng/khu vực của
  chính họ. Cha thấy tin của con (khu vực và phòng ban); tệp đính kèm chỉ mở được khi người xem thấy được thông báo
  (`/api/anh` kiểm bằng `visibleAnnouncements`). Chuông chia 5 nhóm (Team = phòng ban từ cấp 3 của cây). Tin lịch sự kiện không đặt
  hạn thì tự hết hạn ở ngày diễn ra. `/api/anh` gửi `Vary: Cookie` để trình duyệt dùng chung máy không hiện lại tệp của người trước.
- **KPI (mục 4):** sự kiện mời khách có chuỗi giao chỉ tiêu Ban giám đốc (khu vực) → GĐ khu vực (phòng ban/team, bảng
  `invite_event_dept_quotas`, migration 0021) → Quản lý (nhân viên). Cha chỉ giao xuống phòng nằm DƯỚI phòng mình và nhân viên
  trong nhánh; giao vượt cấp trên chỉ cảnh báo, không chặn. **Con không thấy anh em:** NVKD chỉ thấy chỉ tiêu và khách mời của
  chính mình (`inviteScopeUserIds` — Ban giám đốc: tất cả; quản lý: cả nhánh). `invite_events.invite_deadline` là hạn chót
  cộng dồn (bỏ trống = ngày sự kiện). Ghi nhanh theo số lượng (`addGuestBatchAction`) tạo N dòng khách "chưa ghi tên" cùng loại.
  **Ngày theo giờ Việt Nam:** không dùng `new Date().toISOString().slice(0, 10)` (ra ngày UTC, lệch 1 ngày từ 0h–7h sáng) —
  dùng `today()` / `dayInTz()` trong `period.ts`; ở client dùng ngày của thiết bị.
- **Danh sách công việc và Lịch (mục 4):** `tasks.status` là chuỗi (không enum Postgres); Dời/Huỷ/Chưa hoàn thành bắt buộc có lý do, Dời
  có thể kèm ngày bắt đầu/hạn mới. `tasks.updated_at` (migration 0022) để chuông báo cấp trên khi người nhận đổi trạng thái. Giao việc:
  `canAssignTo` trong `cong-viec/danh-sach/actions.ts` — cha giao cho con (vai trò thấp hơn và trong `visibleUserIds`), Ban giám đốc giao mọi
  người, NVKD chỉ tự ghi cho mình. Chỉ người tạo sửa nội dung/xoá; người thực hiện hoặc người giao đổi được trạng thái. Lịch sử từng việc
  đọc từ `audit_logs` (entityType `task`). Lịch (`cong-viec/lich`) chỉ gom mục của CHÍNH người xem (`src/lib/agenda.ts`).
  **Ngày nhận từ form/URL phải qua `isRealDate` / `isRealMonth` (`period.ts`)** — regex `\d{4}-\d{2}-\d{2}` một mình cho qua 2026-02-31 và
  làm lộ lỗi cơ sở dữ liệu / trang 500.
- **Danh sách khách hàng (mục 5):** toàn bộ lọc/xếp hạng/chỉ số nằm ở `src/lib/customer-list.ts` (`parseCustomerListQuery`,
  `loadCustomerList`), dùng chung cho trang `/khach-hang` và file Excel `/api/xuat-excel?loai=khach-hang` (xuất đúng bộ lọc + thứ tự
  đang xem). Phạm vi luôn qua `scopeFilter` (Ban giám đốc tất cả; GĐKV/Quản lý khách của NV trong nhánh; NVKD khách của mình) —
  bộ lọc `nv`/`kv` chỉ thu hẹp thêm. Số "bình quân tháng" chia cho số tháng ĐÃ BẮT ĐẦU trong kỳ; công nợ bình quân tháng = trung bình
  công nợ cuối mỗi tháng (tháng đang chạy: tới hôm nay), công nợ chỉ tính đơn ĐÃ DUYỆT và tiền thu của chính các đơn đó.
  "Trên N SP" là LỚN HƠN N (cùng mốc hạng SKU), không phải ≥. Cảnh báo trên dòng khách: nợ quá hạn (tính trực tiếp theo hạn thanh toán
  từng đơn), lâu chưa lấy hàng (`monthsInactive` của kỳ hiện tại), giảm doanh thu (bảng `alerts` của tháng đã đóng sổ gần nhất, chỉ
  lấy cảnh báo đang mở của kỳ đó). Xếp hạng chạy trong JS trên tối đa 5000 khách (`MAX_MATCHED`).
  **Gán khách:** kênh/khu vực/người phụ trách gửi từ form phải qua `checkCustomerReferences` (cùng công ty, còn hoạt động, trong
  nhánh người thao tác; cấp dưới không được để trống người phụ trách vì khách chưa phân công chỉ Ban giám đốc thấy). Form sửa phải
  nạp sẵn giá trị đang lưu vào danh sách chọn (`loadCustomerFormOptions(session, current)`), nếu không bấm Lưu sẽ xoá mất giá trị đó.
  `formatDate(Date)` đổi sang ngày giờ Việt Nam (không dùng `toISOString()`).
- **Hồ sơ khách hàng (mục 5):** trường bắt buộc theo yêu cầu (dấu *): loại (kênh), tên cơ sở, người liên hệ, điện thoại, tỉnh; từ trạng thái
  "Đang" trở đi thêm phường + số nhà/đường (`missingRequired` trong `khach-hang/actions.ts`; chuyển trạng thái cũng kiểm lại). **MỌI thao tác trên
  hồ sơ con của khách** (người liên quan, ghi chú, tệp, khoá học, chuyển trạng thái, Key) phải đi qua `scopedCustomer` — kiểm tenant thôi thì
  quản lý/NVKD nhánh khác vẫn sửa được khách không thuộc mình bằng cách đổi mã trong request; xoá chỉ quản lý; các action trả `{ok|error}` (ném lỗi
  bị bản production giấu thành câu chung chung). Tệp `/api/anh/khach-hang|checkins|don-hang` cũng kiểm phạm vi (khách / nhánh người chụp / `orderViewChecker`).
  Form khách dùng `useActionState` + trả lại `values` khi lỗi và `key` theo values (React 19 xoá trắng form sau mỗi lần gửi; `defaultValue` của ô chọn
  không tự cập nhật). Nhập Excel khách: khách MỚI phải đủ trường bắt buộc, tên kênh/khu vực/NVKD sai báo lỗi đúng dòng, dòng cập nhật khách cũ để
  trống ô = giữ nguyên (trước đây xoá mất chủ sở hữu/trạng thái), ngày không có thật (31/02) báo lỗi. Trang chi tiết: "đang ứng dụng" = có mua trong
  cửa sổ SKU 3 tháng (khớp hạng SKU); công nợ theo kỳ có 2 số — nợ phát sinh của kỳ và tổng công nợ cuối kỳ (`averageMonthlyDebt` với một mốc ngày).
- **Khách tiềm năng (mục 5):** = khách giai đoạn "Sẽ" (LEAD) HOẶC đã chốt từ tiềm năng (`customers.lead_status = 'CONVERTED'`, chuỗi — không enum);
  trạng thái chuyển đổi NOT_CONTACTED → CONTACTED / CANCELLED (bắt buộc báo cáo, `updateLeadStatusAction`) → CONVERTED (`convertLeadAction`: bắt buộc báo cáo +
  hồ sơ đủ trường bắt buộc + tên cơ sở thật). Báo cáo lưu ở `customer_notes` với nhãn đầu dòng `[Đã liên hệ]`... (`src/lib/lead-status.ts`). LEAD → khác chỉ đi qua
  `convertLeadAction` (không qua `changeStageAction`); đưa về "Sẽ" thì reset trạng thái. Danh sách `/khach-hang/tiem-nang` (`src/lib/lead-list.ts`, cùng
  `scopeFilter`; phân trang trong SQL; xuất Excel `loai=khach-tiem-nang` theo đúng bộ lọc). Khách chưa phân công chỉ Ban giám đốc thấy → `assignLeadsAction` giao
  hàng loạt (chỉ trong nhánh người giao). Tên cơ sở KHÔNG bắt buộc với LEAD (tạm dùng tên người liên hệ, `resolveOrgName`). Chuông báo "N khách tiềm năng chưa liên hệ".
  Nhập Excel có cột "Ngày thêm mới" (ngày Marketing thu được khách). Không thêm mục menu mới — chỉ tab trong mục Khách hàng.
- **Đơn hàng (mục Bán hàng):** danh sách/lọc/phân trang/xuất Excel ở `src/lib/order-list.ts` (dùng chung màn hình và file; phạm vi `scopeFilter` theo NVKD của đơn).
  **"Phải thu" của đơn = tiền hàng + phụ thu phí** ở MỌI nơi tính công nợ (chi tiết đơn, danh sách đơn, `customerMetrics`, `averageMonthlyDebt`/`overdueDebt`,
  `debtSummary`, xếp hạng NVKD, hồ sơ khách, hồ sơ nhân viên); doanh thu vẫn chỉ tiền hàng. **Không dùng truy vấn con tương quan `${orders.id}` trong câu lệnh một bảng**
  (Drizzle ghi `"id"` trần → trỏ nhầm cột bảng con): `debtSummary.overdue` từng bỏ qua toàn bộ tiền đã thu (báo 55,7 tỷ quá hạn trong khi thật là 2,85 tỷ) — dùng JOIN + GROUP BY.
  Lên/sửa đơn: khách phải trong phạm vi người lập (`loadOrderCustomer`; trước chỉ NVKD bị kiểm), ngày đặt không ở tương lai, có trần số lượng/đơn giá, sửa đơn KHÔNG tính lại
  NVKD/phòng ban/khu vực của đơn trừ khi đổi khách (số liệu lịch sử), không giảm đơn xuống dưới số đã thu. Thu tiền / xoá phiếu thu / duyệt / huỷ / xoá đơn qua `scopedOrder`
  (`orderViewChecker`), trả `{ok|error}`; chỉ đơn ĐÃ DUYỆT mới ghi nhận thu tiền; duyệt dùng cập nhật có điều kiện `status = DRAFT` (2 người bấm cùng lúc). Trang chi tiết có quy trình
  duyệt + lịch sử đọc từ `audit_logs` (entityType order/payment/order_attachment), banner VAT nổi bật (kèm cảnh báo thiếu MST/địa chỉ) và cờ "đơn giá khác bảng giá" cho người duyệt.
  Duyệt 1 cấp (GĐKV/Ban giám đốc) là quyết định của khách — KHÔNG làm 4 cấp (đề xuất → Trưởng bộ phận → TBP liên quan → TGĐ).
- **Phiếu đề xuất (mục Đề xuất):** 4 loại cố định (`src/lib/proposal-types.ts`: mở lớp chuyển giao / khuyến mãi / quà tặng / khác), mỗi loại có vài trường riêng lưu ở
  `proposals.details` (jsonb, migration 0023). **Duyệt LẦN LƯỢT** theo bước (`proposal_approvers.sort_order` = bước, `step_label`): Trưởng bộ phận (tự tìm từ
  `departments.head_user_id` của phòng người đề xuất, đi lên phòng cha nếu chính họ là trưởng) → Trưởng bộ phận liên quan (chọn bộ phận, cùng bước = song song) → người duyệt
  thêm (tuỳ chọn) → TGĐ (vai trò DIRECTOR, bắt buộc). Chuỗi dựng và kiểm ở máy chủ (`resolveChain`: người duyệt phải là nhân viên đang hoạt động cùng công ty). Chỉ đến lượt khi mọi
  bước trước đã duyệt (`decideProposalAction`; chuông chỉ báo đúng người đến lượt); từ chối phải có lý do; quyết định không đổi được; người đề xuất thu hồi khi CHƯA ai quyết định.
  Xem phiếu: người đề xuất, người trong chuỗi duyệt, Ban giám đốc (tất cả). Đây là quyết định riêng của phiếu đề xuất, KHÁC với đơn hàng (đơn chỉ 1 cấp GĐKV — khách xác nhận).
- **Nhân viên (mục Nhân viên):** danh sách `danh-muc/nhan-vien` lọc/đếm/phân trang hoàn toàn bằng SQL (tab Kinh doanh/Chuyên môn dùng `coalesce` — `not (null or false)` là null nên
  nhân viên chưa có chức vụ từng biến mất khỏi tab Kinh doanh); các ô lọc Khu vực/Phòng ban CHỈ liệt kê cơ cấu của những nhân viên trong phạm vi người xem (không lộ phòng ban/khu
  vực ngoài nhánh). **Cảnh báo nhân viên chỉ tính kỳ đã đóng sổ gần nhất** (`currentStaffAlertCondition`; bảng `alerts` cũng tự đóng cảnh báo kỳ cũ khi quét kỳ mới — xem mục Cảnh báo & dự báo). Hồ sơ nhân viên: dữ liệu ở `src/lib/staff-report.ts`
  (`canViewStaffData` kiểm phạm vi trước khi truy vấn; quản lý/GĐKV có thêm bảng nhân sự trực thuộc). **Mã trong đường dẫn `[id]` phải qua `idOr404` (`src/lib/route-params.ts`)** — đưa
  chuỗi không phải uuid thẳng vào truy vấn làm Postgres báo lỗi cú pháp → trang 500; ngày trên URL phải qua `isRealDate`.
- **Nhân viên chuyên môn (phụ trách đào tạo):** định nghĩa DUY NHẤT ở `src/lib/specialist.ts` — chức vụ chứa "chuyên môn" HOẶC thuộc phòng ban có tên chứa "chuyên môn" (kể cả team con).
  **Phụ trách vài lớp/chuyên đề KHÔNG làm nhân viên kinh doanh thành chuyên môn** (trước đây tab Chuyên môn tính nhầm cả NVKD có lớp). Quyền hạn vẫn như NVKD (role SALES), quản lý
  chuyên môn khu vực là MANAGER trong phòng ban chuyên môn — phạm vi xem theo cây phòng ban như mọi vai trò ("cha thấy con"); GĐKV kinh doanh KHÔNG tự thấy nhánh chuyên môn (nhánh riêng cạnh
  Phòng kinh doanh) trừ khi khách yêu cầu khác. Hồ sơ chuyên môn xoay quanh lớp đã dạy (ẩn khối doanh số/khách/công nợ toàn số 0), bảng nhân sự trực thuộc của quản lý chuyên môn hiện số lớp/lượt dự
  thay vì doanh thu. **Nhân viên chuyên môn bị loại khỏi `recomputeKpi`** (không có chỉ tiêu doanh thu) và `scanStaff` chỉ xét dòng KPI có `revenueTarget > 0` — trước đây ai chưa được giao chỉ
  tiêu (chuyên môn, trưởng phòng, người mới) đều bị cảnh báo "không đạt chỉ tiêu 2 tháng liên tiếp" (0% < 70%).
- **Văn bản công ty (`/van-ban`):** toàn công ty xem/tải, chỉ Ban giám đốc hoặc người có cờ `canUploadDocuments` tải lên/sửa/xoá. Có phân loại (`company_documents.category`: Văn bản / Thông báo / Quy định –
  Quy chế / Tài liệu / Khác — khoá chuỗi ở `src/lib/document-categories.ts`, thêm loại không cần migration) + trích yếu, tìm kiếm, phân trang. **Action trả `{ error }`, không `throw`** (production
  giấu nội dung lỗi ném ra). Tệp: PDF/PNG/JPEG/docx/xlsx/pptx ≤ 8MB; **`saveImage` kiểm chữ ký nội dung tệp** (PDF `%PDF-`, PNG, JPEG, ZIP cho Office) chứ không tin loại do trình duyệt khai báo;
  máy Windows không có Office gửi loại rỗng nên `resolveDocumentMime` lấy theo đuôi. Xoá văn bản xoá cả tệp trên đĩa (`deleteStored`). `/api/anh/...` kèm tên tệp gốc (Content-Disposition), `?tai=1` ép tải
  về, đường dẫn có ký tự lạ (byte NUL…) → 404 (trước đây 500). Seed sinh 6 PDF mẫu tên cố định (`storeFixedFile`) nên chạy lại seed không để tệp mồ côi. "Thông báo" trang chủ (`danh-muc/thong-bao`) là
  chức năng riêng có đính kèm; loại "Thông báo" ở đây để lưu thông báo lâu dài.
- **Báo cáo bán hàng / Phân tích (`/phan-tich`, `/bao-cao`):** dữ liệu ở `src/lib/sales-analysis.ts` (kế hoạch–thực hiện, tỷ trọng, sản phẩm, khuyến mãi, định biên, xu hướng tháng), vẽ ở
  `phan-tich/views.tsx`, trang chia 6 mục bằng `?muc=` (+ `?dv=` chiều xem, `?pv=` bộ chọn phạm vi). Quy tắc bắt buộc: **mọi truy vấn đơn đi qua `orderScope(session, "confirmed"|"active")`**
  (doanh thu = đơn đã duyệt; doanh số = đơn lập chưa huỷ) và **mọi bảng theo NHÂN VIÊN đi qua `scopedUserIds(session)`** — trước đây `kpiForPeriod`, đại lý Key, chỉ tiêu theo khách và sheet KPI
  của Excel chỉ lọc theo tenant nên mọi vai trò (kể cả NVKD) thấy KPI cả công ty. Trang Phân tích chặn `SALES` ở phía máy chủ (menu ẩn không đủ), xuất Excel `phan-tich` trả 403.
  Bảng dạng cây (khu vực miền→tỉnh, phòng ban phòng→team) đi qua `visibleTreeNodes`: nút tổ tiên ngoài phạm vi bị ẩn (nếu không, "Ban giám đốc" hiện số của riêng vùng người xem như số công ty).
  Kế hoạch: cha = kế hoạch giao riêng, chưa có thì cộng con; **dòng Tổng chỉ so các đối tượng đã có kế hoạch** (`plannedActual`) — cộng cả phần chưa có kế hoạch làm % bị thổi phồng; thu hẹp khác loại
  chiều xem (chọn nhân viên rồi xem theo khu vực) → `comparable=false`, chỉ hiện thực hiện. "Định biên" = số NVKD (SALES không phải chuyên môn) trong phạm vi, KHÔNG phải số người có dòng KPI.
  "Ngân sách khuyến mãi đã dùng" = giá trị hàng KM (SL × đơn giá) vì khách xác nhận không có ngân sách tiền. Seed: chỉ tiêu công ty/khu vực/phòng ban = tổng chỉ tiêu NVKD (cộng khớp mọi tầng).
  Đã kiểm chứng bằng script SQL độc lập (695 phép so ở 8 phạm vi × tháng/quý/năm) — sửa các hàm này thì chạy lại kiểu kiểm chứng đó.
- **Xếp hạng nhân viên (KPI nhân viên, `phan-tich?muc=xep-hang`):** `salesLeaderboard` (`src/lib/reports.ts`) — mọi cột (doanh thu, doanh số, công nợ, khách mở mới) đi qua ĐÚNG phạm vi của các số tổng
  (`orderScope` / `customerScope`, gồm bộ chọn thu hẹp) nên cộng cột khớp thẻ tổng; bảng có mọi NVKD trong phạm vi kể cả người 0 đơn; **đồng hạng** (1, 1, 3…), bằng điểm thì doanh thu cao hơn trước rồi theo tên.
  Khách mở mới chưa có người phụ trách (từ MKT) không thuộc ai nên cộng cột ≤ thẻ tổng — trang ghi chú số này. Tên mỗi người bấm vào hồ sơ `danh-muc/nguoi-dung/[id]` đúng kỳ đang xem
  (tháng/quý/năm; kỳ đang chạy lấy hôm nay, kỳ đã qua lấy ngày cuối kỳ). Dưới 1024px dùng thẻ thay bảng. Excel `phan-tich` nhận `xh=doanhthu|doanhso|no|moi` và xếp theo đó.
- **Báo cáo khách hàng (`phan-tich?muc=khach-hang`):** dữ liệu ở `src/lib/customer-report.ts` (xếp hạng khách theo doanh thu / doanh số / công nợ của kỳ, số lượng và tỷ lệ khách mở mới / tổng, danh sách khách mới,
  chia theo khu vực / phòng ban / nhân viên); bộ lọc loại khách `?lk=` (Đại lý / KH "Đang" / KH "Sẽ" — nhãn của `STAGE_LABEL`), mục con `?kh=moi`, xếp hạng khách `?xk=`, phân trang `?trang=`. Khách đi qua
  `customerScope` (vai trò + bộ chọn + **chỉ khách còn hoạt động**), đơn qua `orderScope`. **Ngày của cột thời điểm (`created_at`, `checked_at`) phải qua `vnDate()` (`src/lib/sql-dates.ts`)** — `cột::date` cắt
  theo múi giờ cơ sở dữ liệu (UTC) nên khách tạo sau 0 giờ sáng và check-in 6 giờ sáng bị tính vào hôm trước/tháng trước. Danh sách Khách hàng có thêm lọc Phòng ban / Team (`?pb=`, gồm team con).
- **Báo cáo chuyển giao (`/bao-cao/chuyen-giao`) và lớp / chuyên đề (`events`):** dữ liệu ở `src/lib/transfer-report.ts`, phạm vi ở `src/lib/events-scope.ts`. `eventScope(session, "view")` (báo cáo) = lớp do nhân viên trong
  nhánh phụ trách HOẶC lớp tổ chức tại khu vực mình phụ trách (GĐKV thấy mọi lớp trong khu vực, kể cả do chuyên môn nhánh khác dạy); `"edit"` (ghi/sửa/xoá) = chỉ lớp của nhân viên trong nhánh (+ lớp chưa gán ở khu vực mình) —
  xem được không có nghĩa là sửa được; trước đây trang Danh mục → Chuyên đề & lớp học chỉ lọc tenant nên quản lý xem/sửa/xoá lớp của mọi vùng. Nhân viên chuyên môn (`canManageEvents`) tự ghi lớp của mình, `ownerId` bị ép
  thành chính họ ở máy chủ. Loại lớp là chuỗi tự do (`events.kind`); danh sách gợi ý `EVENT_KINDS` ở `src/lib/event-kinds.ts` (form luôn kèm loại hiện tại của lớp đang sửa, nếu không lưu sẽ âm thầm đổi loại). Biểu đồ cột chồng theo
  loại: ngày → 7 ngày tới ngày xem, tuần → từng ngày, tháng → từng tuần, quý/năm → từng tháng (`buildBuckets`). Màu theo loại ở `src/lib/chart-colors.ts` (không nằm trong file biểu đồ `"use client"` vì máy chủ cũng gọi).
- **Cảnh báo & dự báo (`/canh-bao`, `src/lib/alerts.ts`, `forecast.ts`, `alerts-scope.ts`):** 7 loại (`DEALER_INACTIVE/REVENUE_DROP`, `PRODUCT_NO_REVENUE/VOLUME_DROP`, `TERRITORY_NO_REVENUE/REVENUE_DROP`,
  `STAFF_UNDERPERFORM`), quét trong job hằng đêm trên THÁNG ĐÃ ĐÓNG SỔ (`lastCompletedMonth()`). **Mọi so sánh giảm sút tính TRỰC TIẾP từ đơn hàng theo tháng**, không dùng ảnh chụp phân hạng (bình quân
  12 tháng nên đại lý sụt 100% trong tháng chỉ làm bình quân giảm ~8% — bản cũ vì thế KHÔNG BAO GIỜ sinh cảnh báo "giảm doanh thu"; nay chạy được ngay từ ngày đầu, không cần chờ ảnh chụp tích luỹ).
  Ba mốc `findDrops`: tháng (tháng quét vs tháng liền trước), quý (3 tháng vs 3 tháng liền trước), năm (12 tháng vs 12 tháng liền trước); kỳ trước bằng 0 thì bỏ qua; mỗi đối tượng CHỈ MỘT cảnh báo giảm
  trong kỳ (mốc giảm mạnh nhất làm tiêu đề, các mốc còn lại ghi trong nội dung). Ngưỡng % ở `tenants.alertRevenueDropPercent` (30) và `alertStaffMinAchievementPercent` (70). **Chú ý chiều của `shiftMonth`:
  số DƯƠNG = lùi về quá khứ** (`shiftMonth('2026-08', 1)` = '2026-07'; tháng tới là `-1`). Đại lý ≥ 2 tháng không lấy hàng (2 = lưu ý, 3 = cảnh báo, 6 = nghiêm trọng): số tháng đếm từ ngày bắt đầu làm đại lý
  (`dealerSince`, hoặc ngày tạo) chứ không đếm mặc định 24 — đại lý mới tạo chưa có đơn không bị báo "24 tháng"; đã ngừng lấy hàng thì KHÔNG sinh thêm cảnh báo "giảm 100%" (trùng). Sản phẩm/thị trường
  "không có doanh thu" chỉ báo khi ĐÃ TỪNG có doanh thu trong 12 tháng gần nhất (chưa từng bán không phải vấn đề mới) và lặp lại mỗi tháng còn không bán; thị trường cộng dồn tỉnh → miền qua chuỗi cha,
  chỉ xét cấp miền/tỉnh. **Vòng đời:** ghi bằng upsert theo (tenant, loại, đối tượng, kỳ) nên quét lại không trùng; quét lại cập nhật nội dung cảnh báo còn mở; OPEN của kỳ này mà điều kiện hết → RESOLVED;
  OPEN của kỳ cũ → RESOLVED (kỳ mới đã đánh giá lại); RESOLVED mà điều kiện đúng lại → OPEN; ACKNOWLEDGED (người dùng bấm "đã xử lý", `acknowledgeAlertAction`) luôn giữ nguyên làm lịch sử.
  Phạm vi `alertScope`: Ban giám đốc thấy hết; cấp còn lại chỉ thấy cảnh báo đại lý/nhân viên/khu vực trong phạm vi, cảnh báo sản phẩm là cấp công ty. Chuyên môn cấp thấp (SALES) không vào được trang.
  **Dự báo dự trù:** `recomputeForecast(tenant, kỳ đích, basedOn = lastCompletedMonth())` — bình quân 3 tháng ĐÃ ĐÓNG SỔ × hệ số mùa vụ (cùng tháng năm ngoái / bình quân tháng năm ngoái tính trên số tháng còn bán, kẹp
  0,5–2,0, chỉ áp khi năm ngoái ≥ 6 tháng dữ liệu); job hằng đêm tính 3 tháng tới (`FORECAST_HORIZON_MONTHS`). **Chạy lại KHÔNG xoá số chỉnh tay** `forecasts.adjustedQty` (bản cũ xoá hết rồi ghi lại nên mọi lần
  chỉnh tay mất sạch mỗi đêm; và dùng tháng đang chạy dở làm dữ liệu nên dự báo thấp giả). Số chỉnh tay giới hạn 0–10.000.000 (trần cột int), bỏ trống = quay lại số máy tính; bảng xếp theo số máy tính để dòng không
  nhảy vị trí sau khi lưu. Dự báo là cấp công ty, không lọc theo phạm vi người xem. Mục Sản phẩm của Phân tích không còn phần dự báo (chuyển hẳn sang `/canh-bao?muc=du-bao`). Xuất Excel `loai=canh-bao`/`du-bao`
  (403 nếu không phải quản lý trở lên). **Chưa làm / hỏi khách:** "70% doanh số" hiện đo trên doanh thu KPI (đơn đã duyệt); sản phẩm/thị trường chưa từng bán không cảnh báo; phương pháp dự báo là bình quân trượt × mùa vụ.
- **Lịch sử chỉnh sửa & khoá thông tin đã duyệt (mục "Yêu cầu thêm" của khách):** đọc/trình bày nhật ký ở `src/lib/audit-view.ts` — đổi mã uuid trong nội dung thành TÊN thật (người, khu vực, kênh, phòng ban, khách,
  sản phẩm; mã đã xoá hiện "(đã xoá)"), enum → tiếng Việt, ngày ISO → dd/mm/yyyy; `loadAuditPage` (lọc người/hành động/đối tượng/ngày theo giờ VN/tìm chữ, phân trang 50, ký tự `%` `_` trong ô tìm được thoát),
  `loadEntityHistory` / `loadHistoryByEntity` (lịch sử một/nhiều bản ghi, dùng chỉ mục `audit_entity_idx` — migration 0025). Chỉ Ban giám đốc (`canManageSettings`): trang `danh-muc/nhat-ky`, xuất Excel `loai=nhat-ky`,
  khung `EntityHistory` ở đơn hàng / hồ sơ khách / hồ sơ nhân viên, và từng phiếu đề xuất. **Sửa đơn ghi cả dòng hàng** (`changes.lines` = "SP: n bán + m KM × giá" trước → sau; trước đây chỉ có tổng tiền nên Admin sửa
  số lượng của đơn đã duyệt không ai biết đã đổi gì). **Khoá thông tin đã duyệt:** đơn CONFIRMED — không sửa/huỷ/xoá/gỡ tệp trừ `DIRECTOR` (`updateOrderAction`, `cancelOrderAction`, `deleteOrderAction`,
  `deleteOrderAttachmentAction`); phiếu đề xuất — người lập chỉ thu hồi được khi chưa ai quyết định, không ai sửa/xoá trừ `DIRECTOR` (`updateProposalAction`, `deleteProposalAction`: sửa tiêu đề/nội dung/trường riêng,
  KHÔNG đụng chuỗi duyệt và kết quả duyệt; xoá ghi nguyên nội dung + kết quả duyệt vào nhật ký TRƯỚC khi xoá; quyết định cũ "phiếu không bao giờ bị xoá" đã được thay bằng ngoại lệ Admin này). Trường chọn khoá/khách
  của phiếu cũ có thể chỉ lưu TÊN, không lưu mã → form sửa cho chọn "Giữ nguyên" (`KEEP`) thay vì tự đổi sang khoá khác. Chưa khoá: xoá phiếu thu ghi nhầm (quản lý làm được, có nhật ký) và **thêm** tệp vào đơn đã
  duyệt (ai xem được đơn cũng thêm được, không gỡ được) — hai điểm hỏi khách. **Kiểm thử quyền không cần bấm giao diện:** gọi thẳng server action như trình duyệt — `POST` tới đường dẫn trang, header `Next-Action: <id>`
  (id lấy từ `.next/server/server-reference-manifest.json`, cần mở trang chứa action trước), cookie `crm_session` ký bằng `SESSION_SECRET`, body multipart: các trường tên `_1_<tên>` RỒI mới đến phần gốc `0` =
  `["$K1"]` (action `useActionState` dùng `[null,"$K1"]`); thứ tự này quan trọng vì máy chủ giải mã theo luồng. Kết quả nằm ở dòng `1:` của phản hồi. Đã kiểm 34 phép cho đơn hàng + phiếu đề xuất và 32 phép cho các thao tác còn lại theo từng vai trò (0 sai).
- Sắp xếp danh sách khách theo doanh số/doanh thu/số đơn/số SP dùng **whitelist khoá sắp xếp và sắp trong JS** — không đưa
  chuỗi người dùng vào `orderBy`/`sql.identifier` (xem cảnh báo drizzle-orm ở phần rà soát lỗ hổng bên dưới).

Đã hoãn có chủ đích, làm **sau khi chốt hợp đồng** (quyết định ngày 22/08/2026):

- [ ] Nâng `drizzle-orm` 0.38 → 0.45.2 và `next` 15 → 16. Cả hai là nâng major nên phải làm
      trong nhánh riêng, có thời gian chạy lại toàn bộ. Giữ nguyên phiên bản cho tới sau
      demo vì rà soát đã xác nhận không lỗ hổng nào chạm tới hệ thống — xem mục rà soát
      22/08/2026 ở phần ghi chú bên dưới. Nếu trong lúc chờ mà thêm sắp xếp theo cột do
      người dùng chọn, hoặc bắt đầu dùng `next/image`, thì phải nâng cấp ngay.

Mở rộng ngoài phạm vi, chỉ làm khi khách nêu:

- [ ] VAT và chiết khấu trên dòng hàng (Getfly có; yêu cầu của khách chưa nhắc tới)
- [ ] Kỳ thanh toán nhiều đợt có lịch hẹn (hiện ghi nhận từng lần thu, không lập lịch trước)
- [ ] Sinh PDF phía máy chủ; người dùng tự đổi mật khẩu; kết nối MISA / Zalo OA; app cài đặt

## Ghi chú về những quyết định đã chốt

**Nhập Excel** (`src/lib/import.ts`) theo nguyên tắc **không ghi một phần**: file còn lỗi
thì không dòng nào được ghi. Nhập nửa vời để lại dữ liệu rác mà người dùng không lần ra
được, tệ hơn là bắt sửa file. Đơn hàng có mã trùng sẽ bị bỏ qua để nhập lại cùng file
không nhân đôi doanh thu.

**Check-in GPS** tính khoảng cách ở phía máy chủ, không nhận từ trình duyệt — nếu tin số
trình duyệt gửi lên thì bằng chứng đi thị trường mất giá trị. Check-in ngoài bán kính vẫn
được lưu nhưng gắn cờ, không chặn: GPS trong nhà lệch được hàng trăm mét. Ảnh kèm theo là
tuỳ chọn, vì sóng yếu ngoài thị trường làm hỏng việc tải ảnh.

**Ảnh check-in** lưu ngoài `public/` và phục vụ qua `/api/anh/...` có kiểm tra đăng nhập.
Đây là dữ liệu kinh doanh của khách hàng, để trong `public/` nghĩa là ai đoán đúng đường
dẫn cũng xem được. Đổi `src/lib/storage.ts` sang S3/R2 khi chạy nhiều máy chủ.

**Nén ảnh trước khi tải lên** (`src/lib/image-compress.ts`) là bắt buộc về mặt chi phí, không
phải tối ưu cho vui: 131.000 ảnh/năm ở kích thước gốc là 262–524 GB, nén còn 20–33 GB. Đo
thật trên luồng check-in: 5,0 MB → 135 KB. Phải truyền `imageOrientation: "from-image"` cho
`createImageBitmap`, nếu không mọi ảnh chụp dọc sẽ nằm ngang — canvas đọc pixel thô và bỏ
qua thẻ EXIF. Mọi lỗi nén đều rơi về ảnh gốc; đừng bao giờ để lỗi nén chặn việc check-in.

**Rà soát lỗ hổng thư viện, 22/08/2026.** `npm audit` báo 3 gói ở mức Cao. Đã rà từng cái,
kết luận: **không cái nào chạm tới hệ thống này**, nên chưa nâng cấp (cả hai bản vá đều đổi
major, rủi ro hơn lợi ích ngay trước lúc demo).
- `drizzle-orm` <0.45.2 (CVE-2026-39356) — chèn SQL qua **tên định danh**, chỉ dính khi đưa
  dữ liệu người dùng vào `sql.identifier()` hoặc `.as()`. Toàn bộ mã không dùng
  `sql.identifier`/`sql.raw`; mọi `.as()` là chuỗi cố định; mọi `orderBy` tham chiếu đối
  tượng cột; 6 chỗ nội suy giá trị đều thành tham số `$1`. Đã thêm luật ESLint chặn hai hàm
  này để không lọt vào sau này.
- `sharp` <0.35.0 (lỗ hổng libvips) — chỉ chạy qua bộ tối ưu ảnh của Next. Không dùng
  `next/image`, `next/og`, không import ảnh tĩnh. Thử thật: `/_next/image` trỏ vào
  `/api/anh/...` trả 400 vì bộ tối ưu gọi nội bộ **không mang cookie phiên**, nên bị route
  chặn; URL ngoài bị chặn sẵn do không khai `images.remotePatterns`.
- `postcss` — chỉ chạy lúc build trên CSS của chính dự án, không xử lý CSS người dùng gửi.
- `exceljs` → `uuid` 8.3.2 — CVE cần gọi v3/v5/v6 kèm `buf`; exceljs chỉ gọi `v4()`.
  (`npm audit fix` gợi ý exceljs 3.4.0 là **hạ cấp**, đừng làm theo.)

Rà lại khi: thêm sắp xếp/lọc theo cột do người dùng chọn, hoặc bắt đầu dùng `next/image`.

**Bỏ remote GitLab, 27/09/2026.** Repo từng có 2 remote: `github`
(`github.com/nghixn/crm-saas`, dùng thật) và `origin`
(`gitlab.com/nghinx/crm-saas`, không còn dùng, máy làm việc cũng chưa từng
đăng nhập được nên `git push` vào đó lỗi `could not read Username`). Theo
quyết định của người dùng, đã `git remote remove origin` — repo giờ chỉ còn
một remote duy nhất là `github`. Luôn push/pull qua remote đó; không tạo lại
`origin` trỏ GitLab trừ khi có yêu cầu rõ ràng.

**CI fail âm thầm vì `package-lock.json` lệch, 27/09/2026.** Từ commit thêm
Vitest (`a91e70a`) trở đi, mọi lần push đều fail ngay ở bước `npm ci
--include=dev` trên GitHub Actions (job dừng sau ~10s, trước cả lint) — nhưng
không ai để ý vì chạy `npm install`/`npm run dev` trên máy Mac vẫn bình
thường. Lỗi thật: `npm ci` (Ubuntu, node_modules sạch) báo thiếu bản resolve
cho `@emnapi/core`/`@emnapi/runtime` mà lock file đang có sẵn không hoàn
chỉnh — do lần trước lock được cập nhật bằng `npm install` **khi vẫn còn
`node_modules` cũ**, nên không tính lại đủ nhánh phụ thuộc tùy chọn đa nền
tảng (nhánh này tới từ `@img/sharp-wasm32`, phụ thuộc tùy chọn bắc cầu của
`next`). Cách sửa đúng: `rm -rf node_modules package-lock.json && npm
install`, rồi xác nhận bằng `npm ci` trên **một thư mục hoàn toàn mới** (mô
phỏng CI thật) trước khi tin lock file đã đúng — chỉ xoá lock rồi
`npm install` (không xoá `node_modules`) từng không đủ, vẫn lệch trên máy
Linux sạch dù máy Mac báo ổn.

**Bài học chung:** `npm run dev`/`npm install` chạy được trên máy local
**không chứng minh** `package-lock.json` đúng cho CI. Sau bất kỳ lần đổi
dependency nào (thêm gói, nâng cấp, hoặc lock bị sửa tay), nên kiểm tra CI
trên GitHub thật (`gh run list`/`gh run watch`) ít nhất một lần, đừng chỉ
tin kết quả chạy tay tại máy.

**Cập nhật 27/09/2026.** `npm audit` có thêm cảnh báo mức nghiêm trọng cho `next` (RCE trên máy chủ Windows và qua bộ tối ưu ảnh AVIF — không áp dụng: HawkHost là Linux và hệ thống không dùng
`next/image`), `nodemailer`, `sharp`. Đã chạy `npm audit fix` (KHÔNG `--force`): `next` 15.5.23 → 15.5.26, `nodemailer` 9.0.5 → 9.1.1, `sharp` → 0.35.4 — chỉ đổi `package-lock.json`, trên máy chủ chạy lại
`npm install --include=dev`. Còn lại: `drizzle-orm` (nâng 0.45 là bản chính), `uuid` qua `exceljs`, `postcss` lồng trong `next` — lý do bỏ qua như trên. Header bảo mật (chống nhúng khung, nosniff, HSTS…) khai ở
`next.config.ts`; `X-Frame-Options: DENY` chặn cả khung cùng nguồn nên KHÔNG kiểm tra bố cục điện thoại bằng `<iframe>` — dùng `resize_window mobile` của trình duyệt trong app.

**% thực hiện trong báo cáo ngày** tính từ lộ trình đã đăng ký, không cho nhập tay.

**Chương trình bán hàng** được gắn tự động vào dòng hàng theo bảng `programProducts`, không
để người lên đơn chọn tay — chọn tay là chắc chắn sót và tiến độ chương trình sẽ sai.

**Sửa đơn hàng** xoá toàn bộ dòng cũ rồi ghi lại, thay vì so khớp từng dòng. Đơn chỉ có vài
dòng nên chi phí không đáng kể, đổi lại không thể sót dòng người dùng đã xoá trên giao diện.

**Xoá hẳn đơn hàng** chỉ Ban giám đốc làm được và ghi mã đơn, ngày, giá trị vào nhật ký
trước khi xoá. Đơn có thật nhưng khách trả lại thì dùng "Huỷ đơn" để giữ dấu vết.

**Khoá tài khoản** có hiệu lực tức thì vì `getSession()` đối chiếu lại với cơ sở dữ liệu ở
mỗi request. Hệ thống chặn khoá tài khoản Ban giám đốc đang hoạt động cuối cùng.

**Gửi cảnh báo** (`src/lib/notify.ts`) theo mô hình nhiều kênh; kênh thiếu cấu hình thì tự
bỏ qua chứ không làm dừng job hằng đêm. Email và Zalo cần thông tin tài khoản của khách
hàng — xem `.env.example`.

**Chỉ tiêu chỉ giao theo tháng.** Kỳ quý và năm cộng dồn chỉ tiêu các tháng bên trong
(`targetFor` + `monthsInPeriod`), không có bảng chỉ tiêu riêng cho quý. Giao ở hai nơi thì
sớm muộn cũng lệch nhau.

**KPI theo quý/năm phải cộng dồn rồi mới chia**, không được lấy trung bình cộng của các
tỷ lệ tháng — các tháng có chỉ tiêu khác nhau nên trung bình đó sai. Xem `kpiForPeriod`.

**"Đã chốt tuần" căn cứ vào việc có nhận định tổng kết hay chưa**, không căn cứ cột
`weeklyPlans.submittedAt` — cột đó được ghi ngay từ lúc nộp kế hoạch tuần, dùng nó sẽ báo
"đã chốt" cho cả những tuần chưa ai tổng kết.

**Lý do bỏ điểm được hỏi ngay lúc bấm "Không đi được"**, không để tới cuối tuần. Tới lúc
chốt tuần thì NVKD đã quên vì sao, và bản tổng kết chỉ còn những dòng "bận việc khác".

**Cấp khu vực suy ra từ khu vực cha, không cho chọn tay.** Chọn tay thì sớm muộn sẽ có
phường nằm thẳng dưới miền, và mọi phép gộp dữ liệu theo cấp sau đó đều sai. Khu vực đã
có đơn hàng thì đánh dấu ngừng dùng chứ không xoá — xoá đi thì báo cáo lịch sử mất tên.

**Lý do đại lý không lấy hàng lưu theo từng kỳ**, không đặt vào hồ sơ khách hàng. Lý do
tháng này là "còn tồn kho", tháng sau có thể là "đang so sánh giá bên khác" — ghi đè lên
nhau thì mất hẳn thông tin điều hành cần nhất: lý do có đổi hay vẫn thế.

**File `"use server"` chỉ được export hàm async.** Export thêm một hằng số từ file action
làm cả trang lỗi 500 lúc tải, và thông báo lỗi không chỉ ra dòng nào — đã mất thời gian
truy vì chuyện này. Hằng số dùng chung thì để ở `src/lib/`.

**Phòng ban là chiều phân quyền, khu vực là chiều địa lý.** Hai thứ này trông giống nhau khi
công ty chia team thuần theo miền, nhưng tách ra ngay khi có team theo dòng sản phẩm — khách
hàng thật có phòng ban tên "Sale Solar Team – Hồ Chí Minh". Quản lý thấy dữ liệu của cả nhánh
phòng ban mình phụ trách (`visibleUserIds`); khu vực chỉ dùng để phân tích thị trường. Người
dùng chưa gán phòng ban thì lùi về phạm vi khu vực cũ để dữ liệu đang chạy không đứt.

**Công nợ = tổng tiền đơn − tổng đã thu.** Cố ý không dựng hệ thống định khoản kế toán: khách
chỉ cần biết đơn nào còn nợ và nợ bao lâu. Tuổi nợ tính từ `orders.dueDate`, không phải từ ngày
đặt hàng — đơn còn trong hạn thì chưa phải nợ quá hạn dù đã đặt từ lâu.

**Không đặt `step` trên ô nhập tiền.** HTML tính bậc thang của `step` **từ `min`, không phải từ
0**: `min={1} step={1000}` chỉ chấp nhận 1, 1001, 2001… nên số tiền còn phải thu 50.975.000 bị
trình duyệt chặn và nút Ghi nhận không làm gì cả, không báo lỗi gì rõ ràng. Ô nhập tiền để mặc
định (`step` = 1, chấp nhận mọi số nguyên).

**Cửa sổ doanh thu bình quân là 12 tháng, cửa sổ đếm SKU là 3 tháng.** Khách nói rõ "doanh số
trung bình tháng trong 1 năm"; còn SKU thì hỏi đại lý *đang* dùng bao nhiêu loại sản phẩm, lấy
12 tháng sẽ đếm cả thứ họ đã bỏ từ lâu.

**CSS cho bản in phải nằm ngoài mọi `@layer`.** Tailwind v4 loại bỏ các at-rule thô như
`@media print` và `@page` khi chúng đặt trong `@layer components`, chỉ giữ lại biến thể
`print:` của chính nó — và không báo lỗi nào. Đặt nhầm thì thanh điều hướng vẫn in ra giấy.
