# TSHIRTORDER-1598 — New list: products in orders (chưa complete + chưa cancel)

## Yêu cầu gốc

> New list - producst in order
> we need a new list to list all producst in all orders that dont have status complete + cancel
> show all producst from order in one list

Kèm 1 ảnh mockup (xem `files/mockup-order-product-table.png`).

---

## Tổng quan

Cần một **API list mới** trả về **tất cả dòng sản phẩm (`OrderProduct`)** thuộc **tất cả order**, gộp chung thành **1 danh sách phẳng** (không group theo order, không group theo SKU — mỗi dòng OrderProduct là 1 row riêng, kể cả khi cùng SKU nhưng khác size/khác order).

Chỉ lấy sản phẩm thuộc order **không** ở status **complete** và **không** bị **cancel**.

Đây là list **chỉ để xem** (read-only report), không phải bảng edit trong order detail.

### Mockup

Ảnh đính kèm thực chất là chụp lại **bảng chỉnh sửa sản phẩm trong 1 order** (order edit table), được gạch chéo (X) để đánh dấu phần **bỏ đi** khi làm list mới:

- Bỏ: link **Add / Remove** ở đầu bảng (thao tác thêm/xoá dòng — không cần cho list read-only)
- Bỏ: cột **PRODUKTIONER** (icon quản lý sản xuất)
- Bỏ: cột **SORT** (mũi tên sắp xếp thứ tự dòng trong order)
- Bỏ: icon xoá (trash) trong cột **MER INFO** — nhưng **giữ lại** nút "Ej klar" (trạng thái sản xuất chưa xong)

→ Cột **giữ lại**: `Order nr`, `SKU`, `Namn` (tên sp), `Antal` (qty), `Kickback`, `Storlekar` (size), `Pris ex.moms` (giá chưa VAT), `Lev.nr` (mã supplier), `Kommentar`, `Totalpris`, `Typ`, `Miniatyrbild` (thumbnail), trạng thái "Ej klar".

Vì repo này chỉ là **backend** (React frontend nằm ở repo khác), scope của task này chỉ là làm **API endpoint** trả đủ data cho bảng trên — phần UI/table do frontend team làm riêng.

---

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

### Cách xác định status "complete"

**Quyết định**: lấy id bằng **lookup runtime** qua bảng `status_list`, **không** đọc từ `services.yaml` (param `order_status_complete_id` — dù pattern này tồn tại ở `OrderService::listWithProductionType()`, không dùng cho endpoint mới vì param tĩnh dễ lệch với DB thực tế):

```php
$completeStatus = $this->em->getRepository(StatusList::class)->findOneBy([
    'type' => StatusList::TYPE_ORDER_STATUS,
    'uniqueKey' => 'order_status_complete',
    'dateDeleted' => null,
]);
```
Lấy `$completeStatus->getId()` để đưa vào `exclusiveStatusId`. Nếu không tìm thấy record → trả lỗi (pattern giống `ApiService.php:171-174`), không nên âm thầm bỏ qua filter.

Ghi chú: `OrderService::listWithProductionType()` (dòng 634) dùng cách đọc param tĩnh và đang có **bug** — set `$criteria['exclusive_status_id']` (snake_case) trong khi `OrderRepository::query()` chỉ đọc `exclusiveStatusId` (camelCase) → filter đó hiện **không có tác dụng gì** (silent no-op). Không copy pattern này.

### Cách xác định "cancel"

`Order::$cancel` (bool, nullable) — pattern chuẩn đã có sẵn trong `OrderProductRepository::query()` (nhánh `notCancel` / `report_order_product`):
```php
orX(isNull('o.cancel'), eq('o.cancel', false))
```

### Filter `exclusiveStatusId` — chưa tồn tại trong `OrderProductRepository`

`OrderRepository::query()` (dòng 244-260) đã có sẵn:
```php
if (!empty($criteria['exclusiveStatusId'])) {
    $qb->andWhere($qb->expr()->neq('p.orderStatusId', $criteria['exclusiveStatusId']));
}
if (!empty($criteria['exclusiveStatusIds'])) {
    $qb->andWhere($qb->expr()->notIn('p.orderStatusId', $criteria['exclusiveStatusIds']));
}
```
`OrderProductRepository::query()` **chưa có** filter này — cần thêm mới, alias order trong file này là `o` (không phải `p`):
```php
if (!empty($criteria['exclusiveStatusId'])) {
    $qb->andWhere($qb->expr()->neq('o.orderStatusId', $criteria['exclusiveStatusId']));
}
if (!empty($criteria['exclusiveStatusIds'])) {
    $qb->andWhere($qb->expr()->notIn('o.orderStatusId', $criteria['exclusiveStatusIds']));
}
```

### Convention cần theo (đã confirm qua code hiện có)

- Chỉ dùng `limit`/`offset` (không có `page`) — mặc định `limit=100`, `offset=0` như `OrderService::list()` / `listWithProductionType()`
- Response là **mảng phẳng** trong `data` (không có `total`/`items` wrapper) — giống `ProductService::list()` và `OrderService::reportOrderProduct()`
- Có filter theo `channelId` (đã có sẵn pattern trong nhánh `report_order_product`)

---

## Các files liên quan

| File | Thay đổi |
|------|----------|
| `src/Repository/OrderProductRepository.php` | Thêm filter `exclusiveStatusId`/`exclusiveStatusIds`; bổ sung field vào `select()` của nhánh `report_order_product` (thumbnail, production status) |
| `src/Service/OrderService.php` | Thêm method mới `listIncompleteOrderProduct($criteria)` |
| `src/Application/ApiBundle/Controller/OrderController.php` | Thêm controller method mới |
| `src/Application/ApiBundle/Resources/config/route/order.yaml` | Thêm route mới |
| `src/Entity/OrderProduct.php` | Tham khảo field có sẵn — không cần sửa entity |
| `src/Entity/StatusList.php` | Tham khảo — không cần sửa |

### Field có sẵn trên `OrderProduct` map với cột mockup

| Cột mockup | Field | Ghi chú |
|---|---|---|
| SKU | `p.productSku` | |
| Namn | `p.productName` | |
| Antal | `p.quantity` | |
| Kickback | `p.priceKickback` | |
| Storlekar | `p.productSizeText` | |
| Pris ex.moms | `p.price` | |
| Lev.nr | `p.supplierNr` / `p.supplierNrId` | |
| Kommentar | `p.comment` | |
| Totalpris | *(không trả field này)* | Đối chiếu `OrderProductRepository::getByOrderId()` (dùng cho order detail) — nơi này cũng **không** select `totalPrice`, chỉ trả `price` + `quantity` riêng. Endpoint mới theo đúng convention: không tính `price * quantity` ở backend, để frontend tự tính như đang làm với order detail |
| Typ | `p.productType` / `p.productTypeId` | |
| Miniatyrbild | `p.productThumbnail` | field riêng của `OrderProduct` (snapshot), khác `Product::$thumbnail` — nhánh `report_order_product` hiện **chưa select** field này |
| "Ej klar" (production status) | `p.productionStatus` / `p.productionStatusId` / `p.productionStatusColor` | nhánh `report_order_product` hiện **chưa select** field này |

---

## Đã xác nhận

| # | Câu hỏi | Câu trả lời |
|---|---------|-------------|
| 1 | Nguồn status complete? | Lookup runtime bảng `status_list`: `type = order_status`, `unique_key = order_status_complete`, `dateDeleted = null` — **không** đọc từ `services.yaml` |
| 2 | Nguồn cancel? | Field `cancel` trên `Order` |
| 3 | Tái sử dụng `listProductHistory`/`reportOrderProduct` hay tạo route mới? | **Tạo endpoint mới** |
| 4 | Filter theo channel? | **Có**, filter theo `channelId` |
| 5 | Phân trang? | **Có**, theo convention `limit`/`offset` như các list khác |
| 6 | Tên route/path? | `GET /api/v1/orders/incomplete-product-list` |
| 7 | Cột Totalpris tính ở backend? | **Không** — đối chiếu order detail (`getByOrderId`) không trả `totalPrice`, chỉ trả `price`+`quantity`; giữ đúng convention, để frontend tự tính |
| 8 | Thêm `productThumbnail` + `productionStatus`/`productionStatusId`/`productionStatusColor` vào response? | **Có** |

---

## TODO List

### 1. Repository — `OrderProductRepository::query()`

- [x] Thêm filter `exclusiveStatusId` (neq trên `o.orderStatusId`)
- [x] Thêm filter `exclusiveStatusIds` (notIn trên `o.orderStatusId`)
- [x] Đặt filter này ở scope chung (không lồng trong `if (isset($criteria['report_order_product']))`) vì alias `o` luôn được join sẵn ở đầu method
- [x] Bổ sung `p.productThumbnail`, `p.productionStatus`, `p.productionStatusId`, `p.productionStatusColor` vào mảng `select()` của nhánh `report_order_product` (dòng ~216-257)
- [x] Không thêm field `totalPrice` tính sẵn — theo đúng convention của `getByOrderId()` (chỉ trả `price` + `quantity` riêng)

### 2. Service — `OrderService`

- [x] Thêm method `listIncompleteOrderProduct($criteria)`:
  - `$criteria['limit'] = $criteria['limit'] ?? 100;`
  - `$criteria['offset'] = $criteria['offset'] ?? 0;`
  - `$criteria['report_order_product'] = 'yes';` (tái dùng nhánh select có sẵn trong repo)
  - Lookup `StatusList` (`type = TYPE_ORDER_STATUS`, `uniqueKey = 'order_status_complete'`, `dateDeleted = null`) → lấy id, set `$criteria['exclusiveStatusId'] = $completeStatus->getId();` (đúng camelCase, tránh lặp bug ở `listWithProductionType`); nếu không tìm thấy record → trả lỗi thay vì bỏ qua filter
  - Gọi `$this->em->getRepository(OrderProduct::class)->query($criteria)->execute()`
  - Trả `['status_code' => 200, 'data' => $entities]` — mảng phẳng, **không** merge return-order items (khác `reportOrderProduct`)

### 3. Controller — `OrderController`

- [x] Thêm method mới `listIncompleteProduct`: check token qua `ApiService::getToken()`, lấy `$data = $apiService->getRequestData($request)`, gọi `$orderService->listIncompleteOrderProduct($data)`, trả `$this->json($result['data'], $result['status_code'])`

### 4. Route — `order.yaml`

- [x] Thêm route mới:
  ```yaml
  api_order_incomplete_product_list:
      path: /incomplete-product-list
      controller: App\Application\ApiBundle\Controller\OrderController::listIncompleteProduct
      methods: ['GET']
  ```
  Full path: `GET /api/v1/orders/incomplete-product-list?channelId=&limit=&offset=`
  Đặt **trước** `api_order_detail` (`/{id}`) trong file — verify qua `php bin/console debug:router`: route match đúng thứ tự, không bị catch-all `/{id}` nuốt mất.

### 5. Đã verify

- [x] `php -l` cho cả 3 file PHP đã sửa — không lỗi cú pháp
- [x] `php bin/console cache:clear` — container build thành công (DI wiring service/controller ok)
- [x] `php bin/console debug:router` — route `api_order_incomplete_product_list` → `GET /api/v1/orders/incomplete-product-list`, nằm đúng trước `/{id}`
- [x] `php bin/console doctrine:mapping:info` — mapping `OrderProduct` hợp lệ
- [x] Query DB xác nhận `status_list` có row `unique_key = order_status_complete`, `id = 22`, khớp với param tĩnh cũ (chỉ dùng để đối chiếu, code không hardcode số này)
- [x] **Code review phát hiện + fix bug**: filter `exclusiveStatusId`/`exclusiveStatusIds` ban đầu dùng `neq`/`notIn` trực tiếp trên `o.orderStatusId` (nullable) — SQL `column != value` trả `NULL` (không phải `true`) khi `column IS NULL`, nên order chưa có `orderStatusId` sẽ **bị loại nhầm** khỏi list "incomplete". Đã sửa thành `orX(isNull('o.orderStatusId'), neq/notIn(...))` để order NULL vẫn được coi là "chưa complete" và xuất hiện đúng trong list.

### 6. Kiểm tra / Test — chưa làm được (cần token thật / môi trường có data, sandbox không cho phép đọc DB business data)

- [ ] Gọi thử `GET /incomplete-product-list` với token thật, xác nhận response 200 + đúng field
- [ ] Order status = complete → không xuất hiện trong list
- [ ] Order `cancel = true` → không xuất hiện trong list
- [ ] Order status khác (New, Web, Plockad, Produktion, ...) và `cancel = false/null` → xuất hiện đủ
- [ ] Filter `channelId` → chỉ trả sản phẩm thuộc đúng channel
- [ ] `limit`/`offset` hoạt động đúng, mặc định trả tối đa 100 dòng nếu không truyền
- [ ] Mỗi dòng OrderProduct trả riêng biệt (không gộp theo SKU khi khác size/khác order)
- [ ] Response field khớp với cột trong mockup (SKU, Namn, Antal, Kickback, Storlekar, Pris ex.moms, Lev.nr, Kommentar, Totalpris, Typ, Miniatyrbild)

---

## Bug phát hiện sau khi release: `Miniatyrbild` (thumbnail) luôn null

**Triệu chứng**: Field `productThumbnail` trong response của list mới luôn trả `null`/rỗng, trong khi field `thumbnail` ở order detail (`items[]`) luôn có giá trị.

**Nguyên nhân**: Hai nơi lấy thumbnail từ hai nguồn khác nhau:

- **Order detail** — `OrderProductRepository::getByOrderId()` (dòng 297-365) join sống sang `Product` (`leftJoin(Product::class, 'p', ..., 'p.id = op.productId')`) và select `p.thumbnail AS thumbnail` → luôn có giá trị miễn sản phẩm gốc còn thumbnail, không phụ thuộc lịch sử tạo order.
- **List mới (bug)** — nhánh `report_order_product` trong `OrderProductRepository::query()` đang select `p.productThumbnail` (`p` alias = `OrderProduct` trong file này) — đây là field **snapshot lưu trên chính `OrderProduct`**, chỉ được set tại thời điểm tạo dòng order-item, qua `OrderProduct::setProductThumbnail()`.

  Field snapshot này chỉ được ghi ở **2/6 nơi** tạo `OrderProduct` trong `OrderService.php`:
  - dòng 939 — flow admin thêm sản phẩm thủ công (`addProducts()`)
  - dòng 5002 — flow convert giỏ hàng ecom → order (từ `EcomOrderTempItem`)

  Các nơi còn lại **không set** field này, nên `productThumbnail` là `NULL` vĩnh viễn trong DB với order tạo từ:
  - WooCommerce import (`importFromWPData`, quanh dòng 2808)
  - Artwork-fee / decoration-fee auto-add item (dòng 2303, 2384)
  - Flow khác ở dòng 3238

  Vì phần lớn order thực tế đến từ WooCommerce import → hầu hết row có `productThumbnail = NULL`.

**Fix**: Đổi select trong `OrderProductRepository::query()` (nhánh `report_order_product`) từ `p.productThumbnail` (snapshot) sang `product.thumbnail as productThumbnail` (đọc sống từ `Product`, dùng alias `product` đã join sẵn ở đầu method — dòng 77: `leftJoin(Product::class, 'product', Join::WITH, 'product.id = p.productId')`) — cùng cơ chế với `getByOrderId()`.

- [x] Sửa `src/Repository/OrderProductRepository.php` dòng ~273: `'p.productThumbnail'` → `'product.thumbnail as productThumbnail'`
- [x] `php -l` — không lỗi cú pháp
- [ ] Gọi lại API, xác nhận `productThumbnail` có giá trị với order import từ WooCommerce (trước đây null)

---

## Bổ sung: thêm field `productColor`

Response endpoint chưa có field màu sản phẩm — bổ sung `p.productColor` (field có sẵn trên entity `OrderProduct`, `src/Entity/OrderProduct.php:79`) vào select của nhánh `report_order_product`.

- [x] Sửa `src/Repository/OrderProductRepository.php` — thêm `'p.productColor'` vào mảng `select()` (nhánh `report_order_product`)
- [x] `php -l` — không lỗi cú pháp
- [ ] Gọi lại API, xác nhận response có field `productColor`
