# TSHIRTORDER-1576 — Cancellation Function (Self-Service)

## Yêu cầu gốc

> **Step 1: Add new Search Page — not logged in**
> Customers enter their Order number and Email address to find and validate their order.
>
> **Step 2: Status Check**
> - Allowed (New, Web, Plockad): Displays order and lets the customer choose products to return.
> - Disallowed (Any other status): Blocks the process and shows a message directing the customer to customer service.
>
> **Step 3: Select Products**
> An interface with checkboxes or quantity selectors allowing the customer to pick specific items and quantities to cancel.
>
> **Step 4: Create Credit Order & Logic**
> Generates a linked Credit_Order with negative amounts and optionally restores inventory stock if the original status was only New or Web.
>
> **Email Templates**
> - To Customer: Confirms receipt of the cancellation, lists the credited products, and states that the refund will process via the original payment method within 3–5 business days.
> - To Admin: Alerts team of the cancellation with key details (Original Order #, Customer Info, Order Status) and a list of products to stop/credit, prompting manual payment gateway management if necessary.

---

## Tổng quan

Đây là một **self-service cancellation flow** cho khách hàng ecom (webshop), cho phép họ tự hủy đơn hàng mà không cần liên hệ customer service — nhưng chỉ khi đơn hàng chưa vào production.

Flow nằm hoàn toàn trong **`ApplicationWebshopBundle`** (server-side Twig), không yêu cầu login.

---

## Flow chi tiết

### Step 1 — Trang tìm đơn (public, không cần login)

> *"Add new Search Page — not logged in. Customers enter their Order number and Email address to find and validate their order."*

- Route mới trong WebshopBundle: `/ecom/{channelEcomId}/cancel`
- Form nhập `orderNr` + `customerEmail`
- Validate: tìm order theo orderNr + khớp customerEmail → nếu không tìm thấy thì báo lỗi

#### Điều kiện order hợp lệ (theo thứ tự kiểm tra trong code)

| # | Điều kiện | Field / Query | Lỗi hiển thị khi không hợp lệ |
|---|-----------|---------------|-------------------------------|
| 1 | Order tồn tại theo `orderNr` + `customerEmail` (case-insensitive) | `findOrderForCancel()` — query DQL với `LOWER(customerEmail)` | "No order found matching that order number and email address." |
| 2 | Order chưa bị xóa | `Order::$dateDeleted IS NULL` — lọc trong query | (order không được tìm thấy) |
| 3 | Order chưa bị cancel | `Order::$cancel = false` → `isCancel() === false` | "Order #XXX has already been cancelled." |
| 4 | Status hợp lệ để cancel online | `Order::$orderStatusId` thuộc nhóm được phép (xem bảng dưới) | "Order #XXX (status: YYY) can no longer be cancelled online." |

#### Status được phép cancel

Status ID được lookup từ bảng `status_list` theo `uniqueKey` (không hardcode string):

| `uniqueKey` trong `status_list` | Ý nghĩa | Restore stock? |
|---------------------------------|---------|----------------|
| `order_status_new` | New | ✅ |
| `order_status_web_id` | Web | ✅ |
| `order_status_plockad` | Plockad | ❌ |

- `CancellationService::isStatusAllowed()` → lookup 3 uniqueKeys, compare `Order::getOrderStatusId()`
- `CancellationService::restoreStock()` → chỉ lookup 2 uniqueKeys (`new` + `web`), Plockad không restore

### Step 2 — Kiểm tra status

> *"Allowed (New, Web, Plockad): Displays order and lets the customer choose products to return. Disallowed (Any other status): Blocks the process and shows a message directing the customer to customer service."*

| Status | Kết quả |
|--------|---------|
| `New`, `Web`, `Plockad` | Cho phép tiếp tục |
| Bất kỳ status khác | Chặn, hiển thị thông báo "vui lòng liên hệ customer service" |

Field liên quan: `Order::$orderStatusId` (int) — so sánh qua `status_list.uniqueKey`, không dùng string `Order::$orderStatus`.

### Step 3 — Chọn sản phẩm muốn hủy

> *"An interface with checkboxes or quantity selectors allowing the customer to pick specific items and quantities to cancel."*

- Hiển thị danh sách `OrderProduct` (items) của đơn hàng
- Partial cancellation: khách có thể chọn từng item và số lượng cụ thể — yêu cầu nói "specific items **and quantities**"
- Không bắt buộc phải hủy toàn bộ đơn

### Step 4 — Tạo Credit Order

> *"Generates a linked Credit_Order with negative amounts and optionally restores inventory stock if the original status was only New or Web."*

- Tạo một `Credit` entity (bảng `credits`) linked về original order qua `orderId`
- Items lưu vào `CreditItem` (bảng `credits_items`)
- `creditCount` trên original order tăng 1, `refundDate` được set
- **Restore stock** chỉ khi status gốc là **New** hoặc **Web** — tức **Plockad không restore stock**

---

## Email

### Email đến khách hàng

> *"To Customer: Confirms receipt of the cancellation, lists the credited products, and states that the refund will process via the original payment method within 3–5 business days."*

- Xác nhận đã nhận yêu cầu hủy
- Liệt kê sản phẩm bị hủy (tên, số lượng, giá)
- Thông báo hoàn tiền trong vòng **3–5 ngày làm việc** qua phương thức thanh toán gốc (`paymentMethod` / `paymentType`)

### Email đến Admin

> *"To Admin: Alerts team of the cancellation with key details (Original Order #, Customer Info, Order Status) and a list of products to stop/credit, prompting manual payment gateway management if necessary."*

- Alert nội bộ
- Thông tin bắt buộc theo yêu cầu: Original Order #, Customer Info, Order Status tại thời điểm hủy
- Danh sách sản phẩm cần stop/credit
- Lưu ý xử lý thủ công trên payment gateway (Svea / Swish) — yêu cầu nói *"if necessary"*, tức chỉ khi payment gateway không tự động

---

## Các files liên quan

| File | Liên quan |
|------|-----------|
| `src/Entity/Credit.php` | Entity chính cần tạo (`credits` table) |
| `src/Entity/CreditItem.php` | Line items của credit (`credits_items` table) |
| `src/Entity/Order.php` | Original order — đọc status, tăng `creditCount`, set `refundDate` |
| `src/Entity/OrderProduct.php` | Nguồn dữ liệu để tạo `CreditItem` |
| `src/Service/CreditService.php` | Gọi `add()` để tạo Credit — pattern tham khảo chính |
| `src/Service/CancellationService.php` | File mới — chứa toàn bộ logic cancel |
| `src/Service/MailService.php` | Gửi email theo pattern `EmailTemplate` + `emailKey` |
| `src/Application/WebshopBundle/Controller/AppController.php` | Thêm 5 action mới |
| `src/Application/WebshopBundle/Resources/config/routing.yaml` | Thêm cancel routes (trước `ecom_product`) |
| `config/services.yaml` | Thêm `email_admin` parameter |
| `templates/webshop/cancel/` | Twig templates cho cancel pages |
| `templates/webshop/__email_cancel_items.html.twig` | Partial HTML danh sách items cho email |

---

## Đã xác nhận

| # | Câu hỏi | Câu trả lời |
|---|---------|-------------|
| 1 | Plockad có restore stock không? | **Không** restore stock |
| 2 | `createdByType` của Credit Order? | **`ecom`** |
| 3 | Cần Channel flag bật/tắt? | **Không cần** |
| 4 | Partial cancel — Credit Order chỉ chứa items đã chọn? | **Đúng** |

---

## TODO List

### 1. Routes

- [x] Thêm vào `src/Application/WebshopBundle/Resources/config/routing.yaml` — **không** tạo file mới
- [x] **Chú ý**: phải thêm các routes này **trước** `ecom_product` (route `/{productSlug}` ở cuối file) — nếu không path `/cancel` sẽ bị bắt nhầm bởi catch-all đó
- [x] Các routes cần thêm:
  - `GET  /cancel` → `cancelAction` — trang nhập orderNr + email
  - `POST /cancel/search` → `cancelSearchAction` — xử lý tìm đơn, lưu session, redirect sang select
  - `GET  /cancel/select` → `cancelSelectAction` — hiển thị order + chọn sản phẩm (đọc từ session)
  - `POST /cancel/submit` → `cancelSubmitAction` — xử lý hủy, tạo Credit, gửi email, redirect success
  - `GET  /cancel/success` → `cancelSuccessAction` — trang xác nhận thành công
- [x] Prefix `/ecom/{channelEcomId}` được apply tự động từ `config/routes.yaml`

### 2. Controller

- [x] Thêm 5 action mới vào `src/Application/WebshopBundle/Controller/AppController.php`:
  - `cancelAction` — hiển thị form tìm đơn
  - `cancelSearchAction` — validate orderNr + email, kiểm tra status; nếu ok lưu `orderId` vào session, redirect sang select; nếu lỗi addFlash + redirect về search
  - `cancelSelectAction` — guard: nếu không có `orderId` trong session → redirect về search; load order + items, render select page
  - `cancelSubmitAction` — guard session; nhận `[orderProductId => quantity]`; gọi `CancellationService::createCredit()`; gọi `restoreStock()`; gửi 2 emails; lưu `creditNr` vào session; xoá `orderId` session; redirect success
  - `cancelSuccessAction` — đọc `creditNr` từ session, xoá session, render success page
- [x] Tất cả actions đều validate channel tồn tại (theo pattern `index()` hiện có)
- [x] **Double-cancel protection**: trong `cancelSearchAction`, nếu `$order->getCreditCount() > 0` → addFlash error "Order already has a cancellation request" → redirect về search
- [x] CSRF token ẩn trong form submit của `select.html.twig`, validate trong `cancelSubmitAction`

### 3. Service — CancellationService (file mới)

Tạo `src/Service/CancellationService.php`

> **Lưu ý quan trọng**: "Credit Order" trong requirement là tạo record trong bảng `credits` (entity `Credit`) + `CreditItem` — **không phải** tạo `Order` mới. Pattern tham khảo: `CreditService::createFromOrder()` và `CreditService::add()`.

#### 3.1 `findOrderForCancel(string $orderNr, string $email): ?Order`
- [x] Tìm order theo `orderNr` + `customerEmail` (trim, case-insensitive)
- [x] Phải kiểm tra `dateDeleted = null`
- [x] Trả về `null` nếu không tìm thấy

#### 3.2 `isStatusAllowed(Order $order): bool`
- [x] Cho phép: status string là `New`, `Web`, `Plockad` (so sánh `$order->getOrderStatus()`)
- [x] Từ chối: mọi status khác

#### 3.3 `createCredit(Order $originalOrder, array $selectedItems): Credit`

> `$selectedItems` = `[orderProductId => cancelQuantity, ...]`
>
> **Pattern tham khảo chính**: `CreditService::createFromOrder()` (line 567) + `CreditService::add()` (line 465).
> Lý do không gọi thẳng `createFromOrder()`: method đó chỉ tạo `CreditItem` khi `openCredit = true`,
> còn ta cần `openCredit = false` (credit cố định) và tạo items từ `OrderProduct` thay vì từ Product entity.

**Bước 1 — Gọi `CreditService::add()` để tạo Credit** (không truyền items)

Build `$creditData` giống hệt `createFromOrder()` line 593–616:
- [x] `'orderId'` → `$originalOrder->getId()`
- [x] `'customerId'` → `$originalOrder->getCustomerId()` — `add()` sẽ lookup Customer và fill đủ customer fields
- [x] `'orderCountNr'` → `$originalOrder->getOrderCountNr()`
- [x] `'orderNr'` → `$originalOrder->getOrderNr()`
- [x] `'dateOrder'` → `$originalOrder->getDateOrder()`
- [x] `'dateCompleteOrder'` → `$originalOrder->getDateComplete()`
- [x] `'channelName'` → `$originalOrder->getChannelName()`
- [x] `'channelId'` → `$originalOrder->getChannelId()`
- [x] `'channelColor'` → `$originalOrder->getChannelColor()`
- [x] `'shippingFee'` → `0` (không credit shipping)
- [x] `'openCredit'` → `false`
- [x] `'createdFrom'` → `Credit::CREATED_FROM_ORDER`
- [x] `'statusId'` → lookup `StatusList` với `uniqueKey = 'credit_status_id_when_create_from_order'`, `type = TYPE_CREDIT_STATUS`
- [x] **Không truyền `'items'`** — sẽ tạo thủ công ở bước sau

`add()` sẽ tự động:
- Sinh `creditCountNr` từ `AutoCount::NAME_CREDIT`
- Set `creditNr` theo format `K01-{orderNr}`
- Tăng `$originalOrder->creditCount + 1`
- `flush()`

- [x] Kiểm tra result `status_code == 200`, lấy `creditId` từ `$result['data']['id']`
- [x] Load lại Credit entity: `$credit = $em->find(Credit::class, $creditId)`

**Bước 2 — Tạo CreditItem từ OrderProduct**
- [x] Với mỗi `[orderProductId => cancelQuantity]` trong `$selectedItems`:
  - Load `OrderProduct` gốc, kiểm tra `order = $originalOrder` và `dateDeleted = null`
  - Cap: nếu `cancelQuantity > $originalItem->getQuantity()` thì dùng `$originalItem->getQuantity()`
  - `$creditItem = new CreditItem()`
  - `setCreditId($credit->getId())`
  - `setOrderItemId($originalItem->getId())` — giữ traceability về OrderProduct gốc
  - `setOrderId($originalOrder->getId())` — CreditItem có field `orderId` riêng
  - `setOrderCountNr($originalOrder->getOrderCountNr())` — CreditItem có field `orderCountNr`
  - `setOrderNr($originalOrder->getOrderNr())` — CreditItem có field `orderNr`
  - `setProductId($originalItem->getProductId())`
  - `setProductName($originalItem->getProductName())`
  - `setProductSku($originalItem->getProductSku())`
  - `setSubProductId($originalItem->getSubProductId())`
  - `setSubProductSku($originalItem->getSubProductSku())`
  - `setProductSizeId($originalItem->getProductSizeId())`
  - `setProductSizeText($originalItem->getProductSizeText())`
  - `setProductPrice($originalItem->getPrice())` — giá gốc tại thời điểm mua, không lấy từ Product entity hiện tại
  - `setProductTaxCode($originalItem->getProductTaxCode())`
  - `setProductProcentage($originalItem->getProductProcentage())`
  - `setProductFixSum($originalItem->getProductFixSum())`
  - `setQuantity($cancelQuantity)`
  - `persist($creditItem)` + `flush()`

**Bước 3 — Tính lại totals trên Credit** (inline theo `CreditService::__postUpdate()` line 236)
- [x] Load tất cả `CreditItem` của credit (`dateDeleted = null`)
- [x] `totalSum += price * quantity`; `totalTax += price * (taxCode / 100) * quantity`; `totalQuantity += quantity`
- [x] `$credit->setTotalSum($totalSum)->setTotalTax($totalTax)->setTotalQuantity($totalQuantity)`
- [x] `flush()`

**Bước 4 — Đánh dấu refund trên original order**
- [x] `$originalOrder->setRefundDate(new DateTime())`
- [x] `flush()`

#### 3.4 `restoreStock(Order $originalOrder, array $selectedItems): void`
- [x] Chỉ chạy nếu `in_array($originalOrder->getOrderStatus(), ['New', 'Web'])`
- [x] Với mỗi `[orderProductId => cancelQuantity]`:
  - Load `OrderProduct` gốc, lấy `productId`
  - Load `Product` entity, tăng `stock += cancelQuantity`
  - `flush()`

### 4. Templates (Twig)

- [x] `templates/webshop/cancel/search.html.twig` — form nhập Order Number + Email
  - Extend layout webshop hiện tại của channel
  - Input: `orderNr`, `email`
  - Hiển thị flash message lỗi nếu có (order not found / status blocked)

- [x] `templates/webshop/cancel/select.html.twig` — hiển thị order + chọn sản phẩm
  - Hiển thị thông tin đơn hàng (orderNr, ngày đặt, customerName, tổng tiền)
  - Danh sách items: thumbnail, tên sản phẩm, size, màu, quantity gốc
  - Mỗi item có: checkbox bật/tắt + input số lượng (min=1, max=quantity gốc)
  - CSRF token ẩn
  - Nút "Confirm Cancellation"

- [x] `templates/webshop/cancel/success.html.twig` — trang xác nhận thành công
  - Hiển thị Credit Order number
  - Nhắc khách kiểm tra email

### 5. Email Templates

> **Pattern của MailService**: không dùng standalone Twig file. Thay vào đó dùng `EmailTemplate` entity trong DB (lookup bằng `emailKey`), render body bằng `renderMailBody()`. Xem pattern `createNewEcomOrder()` line 630.

- [x] Tạo `EmailTemplate` record trong DB với `emailKey = 'cancel_confirmation_customer'`
  - Tự tạo trong DB lần đầu gọi (pattern `sendChannelUserLoginOtp`)
  - Subject: `"Cancellation confirmed — Order #{{ orderNr }}"`
  - Content: tên khách, order nr, danh sách items (inject qua `itemHtml`), tổng tiền hoàn, thông báo 3–5 ngày làm việc

- [x] Tạo `EmailTemplate` record trong DB với `emailKey = 'cancel_alert_admin'`
  - Tự tạo trong DB lần đầu gọi (pattern `sendChannelUserLoginOtp`)
  - Subject: `"CANCEL REQUEST — Order #{{ orderNr }} | {{ customerName }}"`
  - Content: Original Order #, Customer Name + Email, Order Status, danh sách items (inject qua `itemHtml`), Credit Nr, lưu ý payment gateway

- [x] Tạo Twig partial `templates/webshop/__email_cancel_items.html.twig` — render danh sách `CreditItem` thành HTML để inject vào email (theo pattern `webshop/__email_created_order_item_list.html.twig`)

- [x] Thêm `email_admin: order@tshirt.se` vào `config/services.yaml` parameters — dùng cho `$to` của admin email

### 6. MailService

- [x] Thêm method `sendCancelConfirmationToCustomer(Credit $credit, Order $originalOrder): void` vào `src/Service/MailService.php`
  - `emailKey = 'cancel_confirmation_customer'`
  - `$from` = `$this->parameterBag->get('email_from')`
  - `$to` = `$originalOrder->getCustomerEmail()`
  - Render `itemHtml` từ Twig partial `__email_cancel_items.html.twig` với danh sách `CreditItem`
  - Gọi `$this->sendMail($subject, $from, $to, $body)`

- [x] Thêm method `sendCancelAlertToAdmin(Credit $credit, Order $originalOrder): void`
  - `emailKey = 'cancel_alert_admin'`
  - `$from` = `$this->parameterBag->get('email_from')`
  - `$to` = `$this->parameterBag->get('email_admin')`
  - Render `itemHtml` từ cùng Twig partial
  - Gọi `$this->sendMail($subject, $from, $to, $body)`

### 7. Config

- [x] Thêm vào `config/services.yaml`:
  ```yaml
  email_admin: order@tshirt.se   # địa chỉ nhận alert cancel
  ```

### 8. Kiểm tra / Test

- [ ] Search: nhập sai email hoặc orderNr không tồn tại → flash error, ở lại search page
- [ ] Search: status không cho phép (vd "Produktion") → flash error "contact customer service"
- [ ] Search: order đã có credit trước đó (`creditCount > 0`) → flash error "already cancelled"
- [ ] Direct GET `/cancel/select` không qua search → redirect về search (session guard)
- [ ] Select page: quantity input HTML max = quantity gốc của từng item
- [ ] Submit: partial cancel (1 trong 3 items) → `CreditItem` chỉ có 1 record
- [ ] Submit: full cancel → `CreditItem` đủ tất cả items
- [ ] `Credit` tạo đúng trong bảng `credits`, `orderId` khớp
- [ ] `creditNr` đúng format `K01-{orderNr}`
- [ ] `CreditItem`: `orderId`, `orderNr`, `orderCountNr`, `orderItemId` được set đầy đủ
- [ ] `CreditItem.productPrice` = giá từ `OrderProduct` (không phải giá Product hiện tại)
- [ ] `Credit.totalSum` = tổng (productPrice × quantity) của tất cả CreditItems
- [ ] `Order.creditCount` tăng 1 sau submit
- [ ] `Order.refundDate` được set sau submit
- [ ] Restore stock: status `New` → `Product.stock` tăng đúng số lượng cancel
- [ ] Restore stock: status `Web` → tương tự
- [ ] Restore stock: status `Plockad` → `Product.stock` không đổi
- [ ] Email khách: gửi đến `customerEmail`, subject đúng, có danh sách items
- [ ] Email admin: gửi đến `email_admin` từ config, có orderStatus + creditNr
- [ ] CSRF: POST submit không có token → 403
- [ ] Success page: hiển thị `creditNr` đúng; reload success page sau đó → redirect về search (session đã xoá)