# TSHIRTORDER-1635 — Kickback email: gửi copy + hiển thị có gửi copy hay không

## Yêu cầu gốc

> TSHIRTORDER-1635 Kickback email show if send copy to
> customer say on live we send kickback , we send copy and need to see if one copy is send or not
>
> i think to invoice service we always send copy

Kèm ảnh chụp màn hình popup gửi mail của kickback invoice (xem `files/kickback-send-mail-popup.png`).

---

## Tổng quan

Khách báo: trên live, khi gửi kickback invoice có điền ô **"Kopiera till adress"** thì **người nhận copy không nhận được mail**. Hiện tại cũng không có chỗ nào để kiểm tra copy có được gửi hay không, vì activity log không lưu địa chỉ copy và trang chi tiết chỉ có `dateSend`.

Ticket này làm 3 việc:
1. **Đảm bảo bản copy thực sự được gửi** (đúng 1 lần) và biết được Mandrill có chấp nhận nó hay không.
2. **Activity log** ghi địa chỉ copy + **trạng thái Mandrill của từng người nhận** (to + copy). Áp dụng cho **kickback invoice, invoice và invoice reminder**, vì 3 loại dùng chung code gửi.
3. **Trang chi tiết kickback invoice** hiển thị lần gửi gần nhất: gửi tới ai, copy tới ai, thành công hay không.

Phạm vi: Backend (entity + migration + service) và Frontend React (repo `frontend-tshirt-order`).

---

## Phân tích nguyên nhân copy không tới

### Code hiện tại gửi copy ở đâu

| Layer | File / dòng | Hiện trạng |
|---|---|---|
| FE | `frontend-tshirt-order/src/pages/order/components/SendEmail.js:167-173` | Popup dùng chung cho order/invoice/reminder/kickback. POST `email_copy` đúng key cho cả 4 loại ✅ |
| BE | `src/Application/ApiBundle/Controller/KickbackInvoiceController.php:94-95` | Truyền nguyên request data sang service ✅ |
| BE | `src/Service/KickbackInvoiceService.php:208-219, 240-241` | Validate `email_copy`, truyền vào `MailService::kickbackInvoice()` ✅ |
| BE | `src/Service/MailService.php:383-387, 722-729` | Thêm copy vào `message.to[]` với `type = bcc` ✅ |
| BE | `src/Service/MailService.php:389-402` | Log **không** lưu copy ❌ |
| BE | `src/Service/MailService.php:733-738` | Trả `200` khi Mandrill trả về mảng, **bỏ qua `status` từng người nhận** ❌ |

Kickback, invoice và reminder đi qua **cùng một đường code**: cùng component FE, cùng cách gửi BCC, cùng hàm `sendMail()`. Nhánh `main` đã cũ (2024), còn `beta`/`develop` đều có commit `a5f6cb2` (TSHIRTORDER-1500, thêm copy cho kickback).

→ **Chỉ đọc code thì không thấy lỗi nào làm mất copy.** Vì hệ thống không lưu gì về copy nên cũng không có dữ liệu để kiểm tra lại. Các nguyên nhân có thể:

| # | Giả thuyết | Ghi chú |
|---|---|---|
| 1 | Mandrill từ chối người nhận BCC (reject list, bounce trước đó, …) | Code bỏ qua `status` từng người, nên API vẫn trả 200 và FE báo "Email sent successfully" |
| 2 | Bản BCC bị bộ lọc spam bên nhận chặn / chuyển vào spam | BCC hay bị lọc hơn vì địa chỉ người nhận không có trong header `To` |
| 3 | Kickback gửi 1 lần cho **mỗi** người nhận "to", lần nào cũng kèm BCC copy (`KickbackInvoiceService.php:240`) | Copy nhận N email giống nhau, có thể bị gộp hoặc coi là spam |

> 💡 Có thể kiểm tra giả thuyết 1 ngay bằng API `messages/search` của Mandrill (tìm theo email copy, khoảng ngày khách báo). Chưa làm vì API này đọc dữ liệu mail thật trên live, cần bạn đồng ý trước.

### Hướng sửa

- **Gửi copy thành một message riêng** (copy nằm ở `to` của message đó) thay vì BCC:
  - Mandrill trả về **kết quả riêng cho copy**, chắc chắn có `status` để log.
  - Tránh bộ lọc spam với BCC (giả thuyết 2).
  - Với kickback nhiều người nhận: copy chỉ gửi **1 lần** (giả thuyết 3).
- **Parse `status` từng người nhận** từ response Mandrill. `rejected`/`invalid` phải tính là **lỗi** và báo cho FE (giả thuyết 1), không được báo "sent successfully" như bây giờ.
- **Log + lưu trạng thái** để lần sau biết ngay copy có được gửi hay không.

---

## Luồng hoạt động

### Hiện tại

```
FE popup "Skicka e-post"
  email       = "a@x.com,b@x.com,c@x.com"
  email_copy  = "copy@x.com"
→ POST /api/v1/kickback-invoices/{id}/send-mail
→ KickbackInvoiceService::sendMail()
    foreach (to in [a, b, c]):
        MailService::kickbackInvoice(to, toCopy)     ← mỗi vòng đều kèm BCC copy
            → Mandrill messages->send (to + bcc copy)
            → activityLog { to, status_code }        ← không có copy, không có status từng người
        if 200 → status = "Sent", dateSend = now
    return kết quả của vòng lặp CUỐI
```

### Sau khi sửa

```
→ KickbackInvoiceService::sendMail()
    foreach (to in [a, b, c]) with index i:
        MailService::kickbackInvoice(to, i == 0 ? copy : null)
            → sendMailWithCopy(): 1 message cho "to" + 1 message RIÊNG cho copy (không còn BCC)
            → parse response: {email, is_copy, status, reject_reason, _id}
            → activityLog { to, email_copy, recipients: [...], status_code }
    → lưu lastSendMailEmail / lastSendMailCopyEmail / lastSendMailCopyStatus / lastSendMailResult
    → status "Sent" + dateSend nếu có ít nhất 1 "to" gửi OK
    → response gộp kết quả tất cả người nhận
```

Invoice và invoice reminder cũng sửa theo cách này: 1 message cho `to`, 1 message riêng cho copy, log cả hai.

### Trạng thái Mandrill cho từng người nhận

Mandrill `messages/send` trả về một mảng, mỗi phần tử là kết quả của một người nhận:

```json
[{"email": "copy@x.com", "status": "rejected", "reject_reason": "hard-bounce", "_id": "def456"}]
```

| `status` | Ý nghĩa | Coi là |
|---|---|---|
| `sent` | Server bên nhận đã chấp nhận | ✅ Thành công |
| `queued` / `scheduled` | Mandrill đang xếp hàng gửi | ✅ Thành công (đang gửi) |
| `rejected` | Bị từ chối, xem `reject_reason` | ❌ Thất bại |
| `invalid` | Địa chỉ không hợp lệ | ❌ Thất bại |

`sent` chỉ có nghĩa server bên nhận đã **chấp nhận**, không đảm bảo mail vào inbox (vẫn có thể bounce muộn hoặc vào spam). **Không** kiểm tra lại sau khi gửi (xem câu C).

---

## Questions / Đã xác nhận

| # | Câu hỏi | Trả lời | Ghi chú kỹ thuật |
|---|---------|---------|-------------------|
| 1 | Hiển thị thông tin copy ở đâu? | **Cả activity log và trang chi tiết** kickback invoice | Thêm cột vào `kickback_invoice` + mở rộng data của activity log |
| 2 | Log có cho biết gửi thành công không? | Hiện tại **không**, chỉ có `status_code` của API call | Parse `status` từng người nhận từ response Mandrill |
| A | "Invoice luôn gửi copy" nghĩa là gì? | Vấn đề thật là **copy của kickback không tới được người nhận**. Phải **gửi được copy** và **có log** | Gửi copy thành message riêng + log trạng thái (xem phần Phân tích nguyên nhân) |
| B | Có làm log copy cho invoice và invoice reminder không? | **Có**, làm luôn | Sửa `MailService::invoice()`, `invoiceReminder()` + `InvoiceService`, `InvoiceReminderService` |
| C | Có kiểm tra bounce muộn (`messages/info`/webhook) không? | **Không** | Chỉ dựa vào response lúc gửi |

---

## Thiết kế kỹ thuật

### Entity `KickbackInvoice`: field mới

| Field (PHP) | Column | Type | Ý nghĩa |
|---|---|---|---|
| `lastSendMailDate` | `last_send_mail_date` | `datetime` nullable | Thời điểm lần gửi gần nhất, **kể cả khi thất bại** (`dateSend` = lần gửi thành công gần nhất) |
| `lastSendMailEmail` | `last_send_mail_email` | `text` nullable | Các email "to" của lần gửi gần nhất (phân cách bởi dấu phẩy) |
| `lastSendMailCopyEmail` | `last_send_mail_copy_email` | `varchar(255)` nullable | Email copy của lần gửi gần nhất (`null` = không gửi copy) |
| `lastSendMailCopyStatus` | `last_send_mail_copy_status` | `varchar(50)` nullable | Trạng thái Mandrill của copy: `sent`/`queued`/`rejected`/`invalid`/`error` |
| `lastSendMailResult` | `last_send_mail_result` | `json` nullable | Kết quả từng người nhận: `[{email, is_copy, status, reject_reason, _id}]` |

Bảng: `kickback_invoices`. `dateSend` đã có sẵn, dùng làm thời điểm gửi gần nhất. Invoice/reminder **không** thêm cột: chỉ log (`Invoice` đã có sẵn `lastSendMailEmail`/`lastSendMailDate`).

Migration: `Version20260925080612`, viết tay (không dùng `migrations:diff`), đã chạy ở local.

### MailService

- `sendMail()`: giữ nguyên chữ ký, `status_code` và `data` (dùng chung cho mọi loại email), chỉ **thêm** key top-level `recipients = [{email, status, reject_reason, _id}]`, build từ response Mandrill.
- `sendMailWithCopy()` (private): gửi `$to`, rồi gửi `$toCopy` thành **message riêng**; trả `recipients` gộp, có `is_copy`. Nếu API call lỗi thì trả một dòng `status = error`, `reject_reason` = message lỗi.
- Helper public: `isRecipientOk()`, `isMainRecipientSent()`, `getCopyRecipient()`, `getRecipientErrors()` (ví dụ `"Copy a@b.se: rejected (hard-bounce)"`).
- `kickbackInvoice()`, `invoice()`, `invoiceReminder()`: giữ chữ ký, thay BCC bằng `sendMailWithCopy()`, log thêm `email_copy` + `recipients`.

### API

**Không đổi route và request.** Vẫn `POST .../send-mail` với `email`, `email_copy`, `email_content`, `user_login_email` (+ `type` cho invoice/reminder).

**Response `send-mail`**: thêm `errors` (kickback) và `recipients`:

```json
{
  "recipients": [
    {"email": "a@x.com",    "is_copy": false, "status": "sent",     "reject_reason": null},
    {"email": "copy@x.com", "is_copy": true,  "status": "rejected", "reject_reason": "hard-bounce"}
  ]
}
```

- Kickback: trả **400** (`message`, `errors`, `recipients`) nếu **mọi** "to" đều thất bại. Còn lại trả **200** với `errors[]` (ví dụ copy bị reject) + `recipients`.
- Invoice/reminder: giữ `errors[]`, mỗi người nhận lỗi là 1 dòng `"Invoice: <email>: <status> (<reason>)"` (copy có tiền tố `Copy`). `lastSendMailDate/Email` chỉ cập nhật khi "to" được Mandrill chấp nhận.

**Response detail kickback** (`GET /api/v1/kickback-invoices/{id}`): `generateItem()` dùng reflection nên **tự động** trả thêm 4 field mới.

**Activity log** (action `send_mail`): mỗi lần gọi hàm mail là 1 dòng log, chứa cả "to" và copy:

```json
{
  "template": "kickback_invoice_mail",
  "from": "...",
  "to": "a@x.com",
  "email_copy": "copy@x.com",
  "recipients": [
    {"email": "a@x.com",    "is_copy": false, "status": "sent", "reject_reason": null, "_id": "..."},
    {"email": "copy@x.com", "is_copy": true,  "status": "sent", "reject_reason": null, "_id": "..."}
  ],
  "status_code": 200,
  "email_body": "...",
  "email_subject": "..."
}
```

Log cũ không có `email_copy`/`recipients`, FE phải xử lý trường hợp thiếu key.

#### ⚠️ Lưu ý bẫy quan trọng

- **`__generateUpdate()` ghi đè field theo key FE gửi lên** (`KickbackInvoiceService.php:367-400`: gọi `set{Key}` cho mọi key có setter). Phải thêm 4 field mới vào `$ignoreFields`, nếu không FE gửi lại object detail khi update sẽ ghi đè thông tin gửi mail.
- **`sendMail()` của kickback hiện trả kết quả của vòng lặp cuối** (`KickbackInvoiceService.php:251`), nên phải gộp kết quả.
- **`MailService::sendMail()` dùng chung cho mọi email** (order, payment link, …). Chỉ thêm key vào `data`, không đổi `status_code`, không đổi chữ ký hàm.
- **Invoice:** `setLastSendMailEmail($email)` chỉ ghi email "to". Giữ nguyên, **không** ghi đè bằng email copy.

---

## Sửa thêm: popup Activity Logs luôn trống

Phát hiện khi kiểm tra cách xem log kickback. `GET /api/v1/activity-logs/?entity=...&entityId=...`: popup log trên FE (`ActivityLogModal`) gửi tên class đầy đủ `entity=App\Entity\KickbackInvoice`. Nhưng `ActivityLogRepository::query()` ghép chuỗi này thẳng vào `LIKE '%\app\entity\kickbackinvoice'`. Trong PostgreSQL, `\` là ký tự escape của `LIKE`, nên pattern thành `appentitykickbackinvoice` và **không khớp dòng nào**. Lỗi này ảnh hưởng mọi loại đối tượng (Order, Invoice, …), không chỉ kickback.

Kết quả trên DB local:

| `entity` | Trước khi sửa | Sau khi sửa | SQL thực tế |
|---|---|---|---|
| `App\Entity\KickbackInvoice` | 0 | 85 | 85 |
| `KickbackInvoice` | 85 | 85 | 85 |
| `App\Entity\Order` | 0 | 38957 | 38957 |
| `Order` (không được khớp `BulkOrder`) | | 38957 | 38957 |
| `App\Entity\Invoice` (không được khớp `KickbackInvoice`) | 0 | 3906 | 3906 |

Cách sửa: so sánh bằng (`=`) với tên đầy đủ, **hoặc** `LIKE` hậu tố `\<tên ngắn>` sau khi đã escape `\ % _`, và dùng parameter thay vì `literal()`. FE không cần sửa.

---

## TODO List

### Backend — Entity & Migration
- [x] `src/Entity/KickbackInvoice.php`: thêm `lastSendMailDate`, `lastSendMailEmail`, `lastSendMailCopyEmail`, `lastSendMailCopyStatus`, `lastSendMailResult` (json) + getter/setter
- [x] `migrations/Version20260925080612.php`: viết tay `ALTER TABLE kickback_invoices ADD ...` / `DROP`
- [x] `doctrine:migrations:migrate` (local)

### Backend — MailService
- [x] `sendMail()`: thêm `recipients` build từ response Mandrill; giữ `status_code`/`data`
- [x] `sendMailWithCopy()` + helper `isRecipientOk()`, `isMainRecipientSent()`, `getCopyRecipient()`, `getRecipientErrors()`, `getSendMailErrors()` (xử lý cả trường hợp return sớm không có `recipients`, ví dụ thiếu email template)
- [x] `kickbackInvoice()`, `invoice()`, `invoiceReminder()`: bỏ BCC, log thêm `email_copy`, `recipients`

### Backend — KickbackInvoiceService
- [x] `sendMail()`: bỏ địa chỉ rỗng/trùng trong danh sách "to" (trước đây `",a@x.se"` làm mất copy vì copy đi kèm phần tử rỗng đầu tiên); chỉ gửi copy cùng người nhận đầu tiên, gộp `recipients`
- [x] Dừng và trả lỗi nếu tạo PDF thất bại (trước đây truy cập `file_path` không tồn tại)
- [x] Lưu `lastSendMailEmail`, `lastSendMailCopyEmail`, `lastSendMailCopyStatus`, `lastSendMailResult`
- [x] Chỉ set status "Sent" + `dateSend` khi có ít nhất 1 "to" OK
- [x] Response gộp (400 nếu mọi "to" lỗi; 200 + `errors` + `recipients` nếu không)
- [x] Thêm 4 field mới vào `$ignoreFields` của `__generateUpdate()`

### Backend — InvoiceService & InvoiceReminderService
- [x] `InvoiceService::sendMail()`: lỗi từng người nhận (kể cả copy) vào `errors[]`, trả thêm `recipients`; PDF lỗi cũng đưa vào `errors[]` (trước đây trả 200 không báo gì)
- [x] `InvoiceReminderService::sendMail()`: tương tự

### Frontend — React (`frontend-tshirt-order`)
- [x] `src/pages/order/components/SendEmail.js`: invoice/reminder/kickback có `errors` thì hiện toast cảnh báo "E-post skickad med fel: ..." (không tự đóng); lỗi 4xx/5xx hiện message từ BE
- [x] `src/pages/kickback-invoice/detail.js`: block "Senast skickad" / "Mottagare" / "Kopia till" (✓/✗ + status/reason, "Ingen kopia"); `onSuccess`/`onError={refetch}` cho popup gửi mail; ngày hiển thị = `lastSendMailDate` (fallback `dateSend`)
- [x] `src/utils/audit-logs/auditLogUtils.js` (case `send_mail`): thêm dòng Mottagare/Kopia till + trạng thái từ `recipients`; log cũ chỉ có `email_copy` thì hiện địa chỉ copy; log cũ không có gì thì giữ nguyên
- [x] `src/locales/se.json`: `Copy to`, `Recipient`, `Last sent`, `No copy`, `Email sent with errors`

### Test / kiểm tra
- [x] Filter activity log: kiểm tra `ActivityLogRepository::query()` trên DB local với tên đầy đủ, tên ngắn, `Order` vs `BulkOrder`, `Invoice` vs `KickbackInvoice`, ký tự `%`/`_` → đúng số lượng như SQL
- [x] Unit test `tests/Service/MailServiceSendCopyTest.php` (7 test): copy là message riêng không BCC, không copy → 1 message, copy bị reject, API lỗi, activity log có `email_copy`/`recipients`, status OK, lỗi khi return sớm
- [x] Unit test `tests/Service/KickbackInvoiceServiceSendMailTest.php` (10 test): copy gửi 1 lần, lưu kết quả, không copy, địa chỉ rỗng/trùng, copy reject → 200 + errors, mọi "to" reject → 400 + giữ status, 1 "to" OK là đủ, PDF lỗi, email không hợp lệ, update từ FE không ghi đè
- Chạy: `php vendor/bin/phpunit tests/Service`
- [x] Script test với Mandrill giả (không gửi mail thật) + DB local trong transaction đã rollback: 3 "to" + copy → copy chỉ gửi **1** lần, lưu `copyStatus = sent`, 4 kết quả
- [x] Copy bị reject → response 200 + `errors: ["Copy copy@x.se: rejected (hard-bounce)"]`, lưu `copyStatus = rejected`
- [x] Mọi "to" bị reject → response 400
- [ ] Test thật trên beta với Mandrill: gửi kickback + copy, kiểm tra copy nhận được mail, log và trang chi tiết hiển thị đúng
- [ ] Test thật invoice + copy và reminder + copy
- [ ] Update kickback invoice từ FE (Spara) → 4 field gửi mail **không** bị ghi đè
- [ ] Các email khác dùng `MailService::sendMail()` (order, payment link, …) vẫn chạy bình thường

---

## Các file liên quan

| File | Mục đích |
|------|----------|
| `src/Entity/KickbackInvoice.php` | Thêm 4 field lưu thông tin lần gửi gần nhất |
| `migrations/Version20260925080612.php` | Migration viết tay thêm cột |
| `src/Service/MailService.php` | `sendMail()` (parse `recipients`), `sendMailWithCopy()` + helper, `kickbackInvoice()`, `invoice()`, `invoiceReminder()` (bỏ BCC, log copy) |
| `src/Service/KickbackInvoiceService.php` | `sendMail()` (gửi copy riêng 1 lần, lưu kết quả), `__generateUpdate()` (ignoreFields) |
| `src/Service/InvoiceService.php` | `sendMail()`: gửi copy riêng, báo lỗi copy |
| `src/Repository/ActivityLogRepository.php` | Sửa filter `entity` (popup log luôn trống) |
| `src/Service/InvoiceReminderService.php` | `sendMail()`: gửi copy riêng, báo lỗi copy |
| `frontend-tshirt-order/src/pages/order/components/SendEmail.js` | Popup gửi mail dùng chung, hiện cảnh báo copy lỗi |
| `frontend-tshirt-order/src/pages/kickback-invoice/detail.js` | Block "Senast skickad" |
| `frontend-tshirt-order/src/utils/audit-logs/auditLogUtils.js` | Hiển thị copy + trạng thái trong activity log |
| `frontend-tshirt-order/src/locales/se.json` | Bản dịch mới |
