# Phương án: màn hình quản lý ZNS theo đơn vị

Ngày: 18/09/2026. Đối chiếu với code thật trên nhánh `develop`.
Đọc kèm [tich-hop-zalo-oa-ban-tinh-gon.md](tich-hop-zalo-oa-ban-tinh-gon.md) và
[gui-tin-hang-loat.md](gui-tin-hang-loat.md).

## 0. Kết luận ngắn

Mục tiêu: **một nơi nhìn thấy toàn bộ tình hình ZNS của các OA trong đơn vị** — template đang
có, lịch sử gửi, chi phí, và số liệu tổng quan theo thời gian.

Hiện trạng: **ZNS chưa có một dòng code nào** trong dự án (grep `zns` trên `backend/app`,
`worker/src`, `config`, `.env.example` đều trắng; ZNS mới chỉ được nhắc tới như một lối thoát
trong [gui-tin-hang-loat.md](gui-tin-hang-loat.md) khi hội thoại rơi ra ngoài cửa sổ 7 ngày
của OA). Vậy nên đây là **thêm một kênh, kèm màn hình**, không phải thêm một màn hình.

Ba thứ thuận lợi, khiến khối lượng nhỏ hơn vẻ ngoài:

1. **Ranh giới đơn vị không phải viết mới** — `ZaloAccount::scopeInUnit` đã định nghĩa sẵn
   "OA này thuộc đơn vị nào" (mục 2).
2. **Vòng đời token không phải viết mới** — ZNS dùng chung access token của OA, và
   `ZaloOaClient::withFreshToken()` đã xử lý phần khó nhất (mục 3).
3. **Khuôn màn hình + khuôn báo cáo theo thời gian đã có** — bám theo trang Báo cáo và trang
   Cài đặt đang chạy (mục 6, 7).

Khối lượng ước tính: **2 migration, 2 service mới, 1 controller, 1 view + 1 tab, 1 lệnh
artisan**. Không đụng tới worker, không đụng tới luồng chat của nick cá nhân.

Một đính chính so với trao đổi trước: Zalo **có** API tạo template ZNS
([tài liệu](https://developers.zalo.me/docs/zalo-notification-service/quan-ly-tai-san/tao-template)),
không phải chỉ tạo được trên portal. Nhưng phạm vi lần này vẫn **chỉ đọc** — xem mục 10.

---

## 1. Phạm vi

**Có trong phạm vi**

| Khu vực | Nội dung |
|---|---|
| Tổng quan | Tổng số OA, tổng số template, số tin đã gửi / thành công / thất bại theo khoảng ngày |
| Theo OA | Từng OA: trạng thái kết nối, hạn mức ZNS còn lại, số dư ví |
| Template | Danh sách template của từng OA: mã, tên, trạng thái duyệt, loại, giá, **danh sách tham số** |
| Lịch sử | Từng tin đã gửi: OA nào, template nào, số nhận, trạng thái giao, thời điểm, chi phí |
| Chi phí | Tổng chi theo khoảng ngày, bổ theo OA và theo template |

**Không có trong phạm vi** (xem mục 10): tạo/sửa template, gửi ZNS hàng loạt, nối ZNS vào
luồng CRM.

---

## 2. Ranh giới đơn vị: dùng lại, không định nghĩa lại

`zalo_accounts` **không có cột `unit_id`**. Đơn vị của một tài khoản được suy ra từ người
dùng gắn với nó — chủ sở hữu hoặc nhân viên được phân quyền:

- [ZaloAccount.php:178](../backend/app/Models/ZaloAccount.php#L178) — `scopeInUnit()`
- [ZaloAccount.php:153](../backend/app/Models/ZaloAccount.php#L153) — `scopeVisibleTo()`

Nên truy vấn gốc của toàn bộ màn hình chỉ là:

```php
ZaloAccount::query()->where('channel', 'oa')->visibleTo($viewer)
```

Admin đơn vị tự động chỉ thấy OA của đơn vị mình; super admin thấy tất cả và lọc thêm bằng
`unit_id` theo đúng khuôn [ReportService::resolveUnitFilter()](../backend/app/Services/Reports/ReportService.php#L41).

> **Không** viết điều kiện đơn vị bằng PHP ở controller. Lý do đã ghi ngay trong docblock của
> `scopeInUnit`: hai bản luật song song là sớm muộn lệch nhau, và chỗ lệch ở đây có nghĩa là
> đơn vị A nhìn thấy chi phí ZNS của đơn vị B.

---

## 3. Token: tuyệt đối không viết vòng refresh mới

ZNS dùng **chung access token của OA** mà hệ thống đang giữ trên `zalo_accounts`
(`oa_access_token`, `oa_refresh_token`, `oa_token_expires_at` — xem
[migration OA](../backend/database/migrations/2026_08_11_000001_add_oa_columns_to_zalo_accounts_table.php)).

Mọi lời gọi ZNS phải đi qua [ZaloOaClient::withFreshToken()](../backend/app/Services/Zalo/Oa/ZaloOaClient.php#L343),
hoặc một hàm dùng lại đúng cơ chế đó. Đây không phải chuyện gọn code mà là chuyện hỏng hệ thống:

> Refresh token của Zalo **chỉ dùng được một lần**. Hai request cùng thấy token hết hạn mà
> cùng gọi refresh thì cái chạy sau nhận lỗi và cặp token mới của cái chạy trước cũng bị vứt —
> OA coi như mất kết nối, phải đi cấp quyền lại từ đầu.

`refreshUnderLock()` đã giải quyết bằng khoá + kiểm tra lại sau khi giành được khoá. Một
client ZNS tự chế vòng refresh riêng sẽ **phá luôn kênh OA đang chạy**, chứ không chỉ hỏng ZNS.

**Quyết định**: tạo `ZaloZnsClient` như một class riêng (vì khác base URL và khác luật lỗi),
nhưng phần token thì tách `withFreshToken()` ra trait/service dùng chung với `ZaloOaClient` —
không sao chép.

---

## 4. API Zalo cần dùng

ZNS nằm ở **base URL khác** với OA OpenAPI đang cấu hình:

| | Base URL |
|---|---|
| OA OpenAPI (đang có) | `https://openapi.zalo.me` — [config/zalo.php](../backend/config/zalo.php) |
| **ZNS (phải thêm)** | `https://business.openapi.zalo.me` |

→ Thêm key `zalo.oa.zns_base_url` vào [config/zalo.php](../backend/config/zalo.php), cùng chỗ
với `api_base_url`.

| Việc | Endpoint | Trạng thái |
|---|---|---|
| Gửi ZNS theo template | `POST /message/template` | ✅ Đã xác minh |
| Danh sách template của OA | `/template/all` (cần xác nhận path) | ⚠️ Cần đối chiếu |
| Chi tiết template + tham số | [tài liệu](https://developers.zalo.me/docs/api/zalo-notification-service-api/truy-xuat-thong-tin/lay-thong-tin-chi-tiet-template-post-5222) | ⚠️ Cần đối chiếu path |
| Hạn mức ZNS | [tài liệu](https://developers.zalo.me/docs/zalo-notification-service/truy-xuat-thong-tin/lay-thong-tin-quota-zns) | ⚠️ Cần đối chiếu path |
| Tra trạng thái tin đã gửi | `/message/status` (cần xác nhận) | ⚠️ Cần đối chiếu |

> **Bắt buộc**: mở [tài liệu ZNS](https://developers.zalo.me/docs/zalo-notification-service/bat-dau/gioi-thieu-zalo-notification-service-api)
> đối chiếu từng path + tên tham số **trước khi code**. Trang tài liệu của Zalo là SPA nên
> không tra tự động được, phải mở bằng trình duyệt. Những dòng ⚠️ ở trên là tên endpoint suy
> từ nguồn thứ cấp, **chưa phải trích dẫn tài liệu gốc**.

Điều kiện vận hành, không phải chuyện code, nhưng quyết định màn hình có số liệu hay không:
OA phải **đã xác thực** và **có tiền trong ví ZNS**. Chưa đủ hai thứ đó thì mọi API trả lỗi
hạn mức và màn hình sẽ trắng.

---

## 5. Mô hình dữ liệu

Hai bảng mới. **Không** nhét ZNS vào `zalo_messages`.

Lý do: ZNS không phải hội thoại — không có `thread_id`, không hiển thị ở inbox, không có
chiều "khách trả lời". Nhét chung sẽ làm hỏng sidebar (`last_message_preview`,
`last_message_at`) và bộ đếm chưa đọc của màn chat, đổi lấy đúng một lần tiết kiệm migration.

### `zns_templates` — bản cache danh sách template

```php
$table->id();
$table->foreignId('zalo_account_id');       // OA sở hữu template
$table->string('template_id', 64);
$table->string('name');
$table->string('status', 32)->nullable();    // trạng thái duyệt bên Zalo
$table->string('template_type', 64)->nullable();
$table->unsignedInteger('price')->nullable();      // đồng/tin, tại lần đồng bộ gần nhất
$table->json('params')->nullable();          // danh sách tham số + kiểu
$table->timestamp('synced_at')->nullable();
$table->timestamps();

$table->unique(['zalo_account_id', 'template_id']);
```

Đây là **cache**, không phải nguồn sự thật — nguồn sự thật là Zalo. `synced_at` cho phép làm
mới định kỳ mà không cần cột trạng thái riêng, cùng lối với
[zalo_contacts](../backend/database/migrations/2026_08_01_000005_create_zalo_contacts_table.php).

### `zns_messages` — nhật ký gửi

```php
$table->id();
$table->foreignId('zalo_account_id');
$table->string('template_id', 64);
$table->string('phone_number', 20);
$table->string('zalo_message_id', 64)->nullable();  // msg_id Zalo trả về
$table->string('send_status', 32);                  // kết quả lúc gọi API
$table->string('delivery_status', 32)->nullable();  // trạng thái giao, tra lại sau
$table->unsignedInteger('charged_price')->nullable();
$table->text('error_message')->nullable();
$table->timestamp('sent_at')->nullable();
$table->timestamps();

$table->index(['zalo_account_id', 'sent_at']);
$table->index(['template_id', 'sent_at']);
```

**Hai quyết định đáng chú ý:**

1. **`charged_price` chốt tại thời điểm gửi, không join sang `zns_templates`.** Giá template
   đổi được. Nếu báo cáo chi phí join sang bảng template để lấy giá hiện tại thì mọi con số
   của tháng trước sẽ **tự đổi theo** mỗi lần Zalo chỉnh giá — báo cáo tài chính không được
   phép hành xử như vậy.

2. **`send_status` và `delivery_status` là hai cột khác nhau.** ZNS trả kết quả ngay lúc gọi
   API (đã nhận đơn hay không), nhưng trạng thái giao cuối cùng phải tra lại sau. Gộp một cột
   thì không phân biệt được "Zalo từ chối nhận" với "đã nhận nhưng không giao được".

---

## 6. Luồng đồng bộ

```
Đồng bộ template   →  lệnh artisan `zns:sync-templates`, scheduler gọi mỗi giờ
                      (lặp qua OA có channel=oa và còn token) → ghi đè zns_templates

Hạn mức + ví       →  gọi lúc mở màn hình, cache ngắn (5-10 phút)

Trạng thái giao    →  lệnh `zns:sync-status`, quét zns_messages có delivery_status null
                      trong N ngày gần đây → tra lại → cập nhật
```

Hạn mức **không** cache vào DB: nó đổi liên tục và chỉ có ý nghĩa tại thời điểm nhìn. Nhưng
cũng **không** gọi thẳng mỗi lần mở màn hình — mỗi OA là một lời gọi HTTP, đơn vị có 5 OA và
3 người cùng mở tab là 15 lời gọi cho một lần xem. Cache 5–10 phút là đủ.

---

## 7. Màn hình

Route mới, theo đúng khuôn các trang cài đặt đang có
([web.php:68-71](../backend/routes/web.php#L68-L71)):

```php
// Quản lý ZNS của các OA trong đơn vị - chỉ admin.
Route::get('/settings/zns', fn () => view('zns.index'))
    ->middleware('admin')
    ->name('zns.page');
```

Thêm tab vào nhánh `isAdmin()` của
[nav-tabs.blade.php](../backend/resources/views/settings/partials/nav-tabs.blade.php).

Bố cục 4 khu, từ trên xuống:

```
┌─ Bộ lọc: [khoảng ngày] [OA] ─────────── (super admin có thêm [đơn vị]) ─┐

┌─ Tổng quan ────────────────────────────────────────────────────────────┐
│  Tổng OA   Tổng template   Đã gửi   Thành công   Thất bại   Chi phí     │
│  Biểu đồ số tin theo ngày (thành công / thất bại)                       │
└────────────────────────────────────────────────────────────────────────┘

┌─ Theo OA ──────────────────────────────────────────────────────────────┐
│  Tên OA | Trạng thái token | Hạn mức còn | Số dư ví | Tin đã gửi | Chi  │
└────────────────────────────────────────────────────────────────────────┘

┌─ Template ─────────────────────────────────────────────────────────────┐
│  OA | Mã | Tên | Trạng thái duyệt | Loại | Giá | Tham số | Đã dùng      │
└────────────────────────────────────────────────────────────────────────┘

┌─ Lịch sử gửi (phân trang) ─────────────────────────────────────────────┐
│  Thời điểm | OA | Template | Số nhận | Trạng thái giao | Chi phí        │
└────────────────────────────────────────────────────────────────────────┘
```

**Cột "Tham số" của template là phần dễ bị xem nhẹ nhất nhưng có giá trị vận hành cao nhất**:
đây chính là thứ đội CRM cần để biết phải bắn sang những field nào. Nên hiển thị đầy đủ tên
tham số + kiểu, không rút gọn.

**Khi OA chưa xác thực / chưa có ví ZNS**: hiện thông báo nói rõ lý do và việc cần làm, không
để bảng trắng. Đây sẽ là trạng thái thường gặp nhất ở lần mở màn hình đầu tiên.

---

## 8. API nội bộ

Đặt dưới `Route::middleware('admin')` trong [api.php](../backend/routes/api.php), cạnh nhóm
`spam-campaigns`:

```
GET  /api/zns/overview      ?from&to&unit_id   → số liệu tổng quan + bổ theo OA
GET  /api/zns/templates     ?zalo_account_id   → danh sách template (đọc từ cache)
POST /api/zns/templates/sync                   → bấm đồng bộ lại ngay
GET  /api/zns/messages      ?from&to&...       → lịch sử gửi, phân trang
```

Chuẩn hoá khoảng ngày **dùng lại** khuôn [ReportController::period()](../backend/app/Http/Controllers/Reports/ReportController.php#L39):
mặc định 30 ngày, tự đảo nếu `from > to`, trần 180 ngày. Không tự chế lại — trần 180 ngày tồn
tại để một lần nhập nhầm không kéo sập DB.

---

## 9. Điểm va chạm và rủi ro

| Rủi ro | Xử lý |
|---|---|
| Client ZNS tự chế vòng refresh token → **đốt refresh token, mất kênh OA đang chạy** | Bắt buộc đi qua `withFreshToken()`, xem mục 3 |
| Số liệu của hệ thống lệch với số liệu Zalo (tin gửi lúc hệ thống chết, gửi từ portal Zalo) | Nêu rõ trên màn hình: đây là nhật ký **của hệ thống này**, không phải sao kê của Zalo. Cần đối soát định kỳ thì làm ở phase sau |
| Giá template đổi làm sai lệch báo cáo cũ | `charged_price` chốt tại thời điểm gửi, mục 5 |
| Gọi quota mỗi lần mở tab → chậm + dính rate limit | Cache 5–10 phút, mục 6 |
| Endpoint ⚠️ ở mục 4 sai tên | Đối chiếu tài liệu gốc trước khi code, không code theo bảng đó |
| Đơn vị A thấy chi phí của đơn vị B | Mọi truy vấn xuất phát từ `visibleTo($viewer)`, mục 2 |

---

## 10. Việc KHÔNG làm trong phạm vi này

- **Tạo/sửa template từ hệ thống.** API có tồn tại, nhưng template là tài sản phải qua duyệt
  của Zalo; làm form tạo template ở đây là mở một luồng nghiệp vụ mới (soạn, gửi duyệt, theo
  dõi phản hồi, sửa lại) chứ không phải thêm một nút. Để phase riêng.
- **Gửi ZNS hàng loạt.** `SpamCampaign` hiện đánh dấu "bỏ qua" cho người ngoài cửa sổ 7 ngày
  của OA ([SendSpamMessageJob.php:174](../backend/app/Jobs/SendSpamMessageJob.php#L174)). Nối
  ZNS vào đó là thay đổi lớn về chi phí — mỗi tin bỏ qua trước đây thành một tin mất tiền.
  Phải có màn hình này chạy trước để nhìn được số liệu thật rồi mới bàn.
- **Nối ZNS vào luồng CRM.** Phụ thuộc phương án gắn mã khách hàng lên hội thoại, là việc khác.

Riêng **nút gửi thử một tin** thì nên có ngay trong phạm vi này: không có nó thì không có cách
nào kiểm chứng template hoạt động đúng trước khi giao cho đội CRM, và cũng không có dòng dữ
liệu nào để màn hình lịch sử hiển thị.

---

## 11. Thứ tự triển khai

1. `config/zalo.php` — thêm `zns_base_url`
2. Đối chiếu tài liệu Zalo, chốt danh sách endpoint (mục 4) ← **làm trước khi viết client**
3. `ZaloZnsClient` — tách `withFreshToken()` dùng chung với `ZaloOaClient`
4. 2 migration + 2 model
5. `ZnsSyncService` + lệnh `zns:sync-templates`, gắn scheduler
6. `ZnsController` + 4 route + `ZnsReportService` (số liệu tổng quan)
7. View + tab + biểu đồ
8. Nút gửi thử + `zns:sync-status`

Bước 1–5 là phần nặng. Bước 7 nhẹ nhất, nhưng là thứ duy nhất người dùng nhìn thấy — nếu cần
xem bố cục sớm thì dựng bước 7 với dữ liệu giả trước, rồi nối vào sau.
