# 04 — Domain: Khách hàng & Đơn hàng

> Đây là domain trung tâm và phức tạp nhất hệ thống. `Order` là entity lớn nhất (~150 cột) và `OrderService` (~5.170 dòng) là service lớn nhất codebase — mọi nghiệp vụ khác (hoá đơn, kickback, tích hợp Shopify/WooCommerce...) đều xoay quanh nó.

## 1. Sơ đồ quan hệ tổng quan

```
Customer (khách hàng)
 ├─ 1-n  CustomerContact           (người liên hệ đặt hàng)
 └─ (ngoài ORM) CustomerDeliveryAddress   (địa chỉ giao hàng thay thế)

Order (đơn hàng — entity trung tâm)
 ├─ 1-n  OrderProduct     (dòng sản phẩm)
 ├─ 1-n  OrderFiles       (file đính kèm)
 ├─ 1-n  OrderKorrectur   (file duyệt mẫu/proof)
 ├─ 1-n  OrderPdf         (PDF đã sinh: xác nhận đơn, phiếu giao, nhãn, báo giá...)
 ├─ (ngoài ORM) → BulkOrder    (gộp nhiều đơn để giao/xuất hoá đơn chung)
 ├─ (ngoài ORM) → Invoice      (đơn đã được xuất hoá đơn)
 ├─ (ngoài ORM) → ReturnOrder  (đơn có yêu cầu trả hàng)
 └─ (ngoài ORM) OrderResyncWp  (hàng đợi retry khi đồng bộ ngược ra ngoài thất bại)

BulkOrder (gộp đơn)
 └─ 1-n BulkOrderPdf (phiếu giao gộp)
 (liên kết tới Order chỉ qua Order.bulkOrderId + BulkOrder.orderIds serialize — KHÔNG có quan hệ Doctrine 2 chiều)

ReturnOrder (đơn trả hàng)
 └─ (ngoài ORM) ReturnOrderItem  (dòng sản phẩm trả)

EcomOrderTemp (đơn TẠM trên webshop, chờ thanh toán)
 └─ 1-n EcomOrderTempItem
 (khi thanh toán xong → OrderService::addEcomOrder() chuyển thành Order thật)
```

---

## 2. Customer & thông tin liên quan

### 2.1. Customer

**Bảng:** `customers` | Đại diện một khách hàng (công ty hoặc cá nhân), luôn gắn với 1 `Channel`. 3 loại: `ECOM`, `B2B`, `B2C`. `customerNr` **unique theo từng channel** (`UniqueEntity(['customerNr', 'channelId'])`).

Trường quan trọng: `defaultPaymentTypeId` (điều khoản thanh toán mặc định khi tạo đơn cho khách này — override giá trị mặc định của `Channel`), `invoiceEmail`/`kickbackInvoiceEmail` (email nhận hoá đơn/hoa hồng riêng biệt với `email` liên hệ).

Quan hệ Doctrine thật: `OneToMany → CustomerContact` (mappedBy `customer`).

Logic đáng chú ý (`CustomerService`):
- `find()` tính kèm **tổng hoá đơn năm nay, năm ngoái, và công nợ chưa thu** — trực tiếp query `InvoiceRepository` mỗi lần xem chi tiết khách hàng (không cache).
- `getDefaultCustomerByUserForGuestOrder()`: khi 1 tài khoản `ROLE_CHANNEL` tạo đơn (guest order), hệ thống tự chọn khách hàng mặc định theo thứ tự ưu tiên: `Channel.bulkOrderDefaultCustomerId` → `Brand.orderDefaultCustomerId` của user đó.

### 2.2. CustomerContact

**Bảng:** `customers_contact` | Người liên hệ đặt hàng thuộc 1 khách hàng (VD nhân viên mua hàng). Khi tạo đơn chọn `customerContactId`, email người này được gán vào `Order.erReference` (mã tham chiếu đơn hàng phía khách).

### 2.3. CustomerDeliveryAddress

**Bảng:** `customers_delivery_address` | Nhiều địa chỉ giao hàng thay thế cho 1 khách hàng — **không phải** quan hệ ORM, chỉ liên kết bằng `customerId`. Khi tạo đơn, nếu `customerDeliveryAddressId = -1`, hệ thống copy trực tiếp địa chỉ chính của `Customer` làm địa chỉ giao thay vì dùng địa chỉ đã lưu.

Cả `CustomerContact` và `CustomerDeliveryAddress` đều được đồng bộ theo pattern **upsert + prune** trong `CustomerService` (khớp theo `id`/`email`, xoá các dòng không còn trong payload).

### Ví dụ tình huống

> Khách hàng "ACME Corp" có 1 địa chỉ hoá đơn (trụ sở chính) nhưng 3 kho hàng khác nhau cần giao riêng. Nhân viên bán hàng lưu trước 3 `CustomerDeliveryAddress` cho ACME. Khi tạo đơn hàng, chỉ cần chọn `customerDeliveryAddressId = 2` (kho miền Nam) — hệ thống tự copy toàn bộ thông tin địa chỉ giao vào đơn (không cần gõ lại), độc lập với địa chỉ hoá đơn.

---

## 3. Order — Entity trung tâm hệ thống

**Bảng:** `orders` | Đại diện một đơn hàng, gộp thông tin khách hàng, giao hàng, thanh toán, sản xuất, tích hợp bên ngoài, và các cờ trạng thái tài liệu (đã in PDF nào chưa) — tất cả trong **một entity duy nhất ~150 cột**.

### Nhóm trường chính

| Nhóm | Trường tiêu biểu | Ghi chú |
|---|---|---|
| Định danh & nguồn gốc | `orderCountNr`, `orderNr`, `channelId/Name`, `createdByUserId`, `createdFrom` (`ecom_page`/`shopify`/`deco`/`wordpress`...) | |
| Tích hợp ngoài | `shopifyOrderId`, `shopifyShop`, `sveaOrderId`, `sveaOrderDeliveryId`, `resyncUrlIntegration` (webhook lưu sẵn để báo hoàn tất), `decoNeedResync` | |
| Khách hàng (snapshot) | `customerId`, `customerName`, `customerEmail`, `customerAddress`... | Copy tại thời điểm tạo đơn, **không** join sống tới `Customer` |
| Giao hàng | `deliveryType/Id`, `deliveryName/Street/Company/...`, `customerDeliveryAddressId`, `alwaysUseInvoiceAsDeliveryAddress` (mặc định `true`) | |
| Trạng thái | `orderStatus/Id` + `orderStatusPrevious/Id` (lưu cả trạng thái trước đó), `cancel`, `editable` (khoá sửa sau khi hoàn tất/in báo giá), `invoiced` | |
| Sản xuất (4 nhóm song song) | `productionType{24,25,26,203}Count/StatusId/Status/StatusColor` | 24=Screen(in lụa), 25=DTG, 26=Transfer(ép nhiệt), 203=Special(đặc biệt/thêu) |
| Tài chính | `subTotal`, `shippingFee`, `discount`, `totalTax`, `totalSum`, `totalSumInclTax` (getter tính = `totalSum+totalTax`), `totalSumInclTaxPaid/NotPaid`, `totalKickback`, `orderStats*` (snapshot chi tiết breakdown) | |
| Coupon | `couponCode`, `couponCampaignId`, `couponCampaignCanUseMultiTimes`, `couponCampaignRemoveKickBackOnUsedOrder` | |
| Thanh toán | `paymentType/Id`, `paymentStatus/Id`, `swishPhoneNr`, `swishPaymentId` | |
| Vận chuyển PostNord | `postNordShipmentId`, `postNordPrintId`, `postNordBookingId`, `postNordTrackingUrl`, `postNordLatestData` | |
| Cờ đã in tài liệu | `printPackingSlip`, `printOffert`, `printOrder`, `printDeliveryLabel` | |
| Liên kết chứng từ khác | `bulkOrderId/Nr`, `invoiceId/Nr`, `copiedFromOrderId` (đơn được nhân bản từ đơn nào), `creditCount`, `returnOrderCount`, `refundReturnOrderId` | |

### Quan hệ Doctrine

| Quan hệ | Loại |
|---|---|
| `items` (`OrderProduct`) | OneToMany, sắp xếp theo `sortOrder ASC` |
| `files` (`OrderFiles`) | OneToMany |
| `korrektur` (`OrderKorrectur`) | OneToMany |
| `pdfs` (`OrderPdf`) | OneToMany, sắp xếp `id DESC` |

### Ví dụ tình huống — đọc hiểu trạng thái đơn hàng

> Đơn #2088 có `orderStatus = "Đang sản xuất"`, `productionType24ScreenStatus = "SCREEN"` (đang in lụa), `productionType26TransferStatus = "KLAR!"` (ép chuyển nhiệt đã xong). Khi nhân viên xưởng in lụa cập nhật dòng sản phẩm cuối cùng sang bước cuối, `OrderService::changeOrderProductionTypeStatus()` tự nâng `productionType24ScreenStatus` lên bước tiếp theo. Khi **toàn bộ 4 nhóm loại sản xuất áp dụng cho đơn này** đều đạt bước cuối, đơn đủ điều kiện tạo nhãn PostNord và tự động chuyển `orderStatus = "complete"`.

---

## 4. Các entity phụ trợ của Order

### 4.1. OrderProduct — dòng sản phẩm trong đơn

**Bảng:** `orders_products` | Một dòng sản phẩm (có thể là biến thể size/màu qua `subProductId`), lưu snapshot giá/thuế/kickback tại thời điểm đặt, và trạng thái sản xuất **riêng của dòng này** (`productionStatusId/Status/StatusColor`). Hỗ trợ sản phẩm add-on (`isAddOnProduct`, `addOnText` — VD tên khách muốn in).

Hàm quan trọng: `OrderProductRepository::getByOrderId($orderId)` — query chuẩn dùng để load toàn bộ dòng sản phẩm của 1 đơn kèm dữ liệu master `Product` (ảnh, model, brand...) cho màn hình chi tiết đơn hàng.

### 4.2. OrderFiles / OrderKorrectur / OrderPdf

| Entity | Bảng | Mục đích |
|---|---|---|
| `OrderFiles` | `order_files` | File đính kèm chung (VD file thiết kế khách gửi) |
| `OrderKorrectur` | `order_korrectur` | File "korrektur" — bản duyệt mẫu/proof gửi khách xác nhận trước khi sản xuất, gửi qua `MailService::orderKorrektur()` |
| `OrderPdf` | `order_pdfs` | PDF đã sinh cho đơn (xác nhận đơn, phiếu sản xuất, phiếu giao, nhãn vận chuyển, báo giá) — do `PdfService` tạo ra |

Cả 3 đều quản lý qua các hàm `updateFiles()`/`updateKorrectur()`/`removeFile()` trong `OrderService`, và được **copy vật lý trên đĩa** khi nhân bản đơn hàng (`copyOrder()`).

### 4.3. OrderResyncWp — hàng đợi retry đồng bộ ngược

**Bảng:** `orders_resync_wp` | Ghi log lỗi khi đồng bộ trạng thái "hoàn tất" ngược ra hệ thống nguồn (WordPress/Shopify/Deco) thất bại — tích luỹ mảng lỗi (`message`, JSON) cho tới khi thành công thì **xoá hẳn dòng**. Cronjob `resync-wp-completed-order` (file 10) định kỳ quét bảng này để thử lại.

> **Ví dụ tình huống:** Đơn hàng nhập từ Shopify hoàn tất sản xuất, hệ thống gọi API Shopify báo fulfillment nhưng Shopify tạm thời lỗi 500. `OrderService::resyncToWpAddLog()` ghi 1 dòng vào `OrderResyncWp` với lỗi cụ thể. 30 phút sau, cronjob chạy lại, gọi lại `postOrderUpdateComplete()` cho đơn này — nếu thành công lần này, dòng log được xoá (`resyncToWpRemoveLog()`).

---

## 5. BulkOrder — Gộp nhiều đơn hàng

**Bảng:** `bulk_orders` | Gộp nhiều `Order` riêng lẻ của cùng 1 khách hàng thành **một đơn vị giao hàng và/hoặc hoá đơn chung** (VD nhà phân phối lớn có nhiều đơn con cần giao 1 lần). Danh sách đơn con lưu ở `orderIds` (chuỗi serialize).

### Cơ chế đồng bộ thành viên (`BulkOrderService::postUpdate()`)

Khi cập nhật danh sách `orderIds`:
1. Gỡ liên kết khỏi các đơn **không còn** trong danh sách mới (xoá `bulkOrderId` trên `Order`).
2. Chỉ **nhận thêm** các đơn **chưa thuộc bulk order nào khác** (`not_added_bulk_order`) — tránh 1 đơn bị gộp vào 2 bulk order cùng lúc.
3. Ép `deliveryType` của các đơn con về loại "bulk" dùng chung.

Khi đổi `statusId` của `BulkOrder`, trạng thái mới được **cascade xuống toàn bộ đơn con** (`OrderService::update()` gọi cho từng `orderId`).

### Ví dụ tình huống

> Nhà phân phối "Sport AB" đặt 5 đơn hàng riêng lẻ trong tuần cho 5 cửa hàng nhượng quyền khác nhau, nhưng muốn **giao 1 lần bằng 1 xe tải và nhận 1 hoá đơn duy nhất**. Nhân viên tạo `BulkOrder`, chọn 5 `orderId` → hệ thống tự gỡ các đơn này khỏi trạng thái đơn lẻ, gắn `bulkOrderId` chung. Khi in phiếu giao (`printFoljesedel`), PDF gộp cả 5 đơn con vào 1 file. Khi tạo hoá đơn (`createInvoice`), `InvoiceService::addFromMultiOrders()` tạo 1 `Invoice` duy nhất tổng hợp cả 5 đơn, `BulkOrder.invoiced=true`.

---

## 6. ReturnOrder — Trả hàng / hoàn tiền

**Bảng:** `return_orders` (+ dòng chi tiết `return_order_items`) | Yêu cầu trả hàng gắn với 1 `Order` (hoặc 1 `Invoice` khi tạo từ hoá đơn gộp nhiều đơn). ⚠️ **Đây là entity duy nhất trong domain này bị xoá cứng** (`ReturnOrderService::delete()` gọi `$em->remove()` thay vì set `dateDeleted`), dù entity có sẵn cột `dateDeleted` — không nhất quán với phần còn lại của hệ thống, cần lưu ý khi thao tác xoá.

### Luồng xử lý (`ReturnOrderService`)

- `createFromOrder()`: tạo return order snapshot từ 1 đơn, số lượng trả **bị giới hạn** tối đa bằng số lượng gốc đã mua trên `OrderProduct` tương ứng, kickback được **chia tỷ lệ** theo số lượng trả.
- `refund()`: nếu đơn gốc **không** đến từ WordPress, chỉ cập nhật trạng thái + `refundDate` cục bộ. Nếu đơn gốc đến từ WordPress (`createdFrom === 'wordpress'`), hệ thống **gọi webhook** lưu sẵn trên đơn (`resyncUrlIntegration`) với `resync_type: 'refund_order'` để báo ngược cho storefront — chỉ đánh dấu hoàn tiền thành công khi webhook trả về HTTP 200.

### Ví dụ tình huống

> Khách mua 10 áo qua webshop WooCommerce, sau đó phát hiện 2 áo lỗi in. Nhân viên CSKH tạo `ReturnOrder` từ đơn gốc, chọn dòng sản phẩm và số lượng trả = 2 (không thể nhập quá 10 — hệ thống tự chặn). Khi xác nhận hoàn tiền, vì đơn này `createdFrom = wordpress`, hệ thống gọi webhook về WooCommerce để đồng bộ trạng thái hoàn tiền trên storefront gốc, đồng thời tạo `Credit` tương ứng để hạch toán (xem file 05).

---

## 7. EcomOrderTemp — Đơn tạm trên webshop (giỏ hàng đang chờ thanh toán)

**Bảng:** `ecom_orders_temp` (+ dòng chi tiết `ecom_orders_temp_items`) | Đây **không phải** `Order` thật — là bản ghi tạm được tạo ngay khi khách bấm "Đặt hàng" trên webshop, **trước khi** thanh toán được xác nhận. Trạng thái: `pending` → `paid`/`cancel`.

### Vòng đời

1. Khách checkout trên `/ecom/{channelEcomId}/kassa` → `CartService::addCartTemp()` tạo `EcomOrderTemp` + các `EcomOrderTempItem` từ giỏ hàng session (trong 1 transaction DB, sinh `orderCountNr` ngay tại bước này).
2. Nếu thanh toán qua Svea/Swish: khách được chuyển tới cổng thanh toán; khi có callback/webhook xác nhận thanh toán thành công, `OrderService::addEcomOrder()` được gọi để **chuyển đổi** `EcomOrderTemp` thành `Order` thật (tạo `Customer` nếu chưa có, tạo các `OrderProduct` từ `EcomOrderTempItem`, tính lại giá từ giá ecom bao gồm thuế), đổi `EcomOrderTemp.status = paid`.
3. Nếu là coupon loại "invoice" (mua trước trả sau) hoặc tổng tiền = 0, đơn được chốt **ngay lập tức** không cần qua bước thanh toán.

Chi tiết luồng checkout đầy đủ (giỏ hàng, coupon, thanh toán) xem file `07-webshop-bundle.md`.

### Ví dụ tình huống

> Khách thêm 3 sản phẩm vào giỏ trên webshop, áp mã giảm giá `SUMMER10` (giảm 10%), chọn thanh toán Svea. Bấm "Đặt hàng" → hệ thống tạo `EcomOrderTemp` trạng thái `pending`, chuyển hướng sang trang thanh toán Svea. Nếu khách **đóng tab giữa chừng không thanh toán**, `EcomOrderTemp` này **vẫn tồn tại ở trạng thái `pending` mãi mãi** — không có cơ chế tự động dọn dẹp được ghi nhận trong code hiện tại, cần lưu ý nếu cần báo cáo "đơn bỏ giỏ hàng" hoặc dọn dữ liệu định kỳ.

---

## 8. OrderService — Service trung tâm (đọc sâu)

`src/Service/OrderService.php` là service lớn nhất hệ thống, đảm nhiệm gần như mọi nghiệp vụ liên quan tới `Order`. Các nhóm chức năng chính:

| Nhóm | Hàm tiêu biểu | Mô tả |
|---|---|---|
| CRUD & vòng đời | `add`, `update`, `delete`, `copyOrder` | Xem chi tiết luồng bên dưới |
| Dòng sản phẩm | `addProducts`, `updateStock` | Tạo/sửa/xoá `OrderProduct`, **tự động điều chỉnh tồn kho** `Product.stock` tương ứng |
| Tính toán tài chính | `updateSum`, `updatePriceKickback` | Tính lại toàn bộ tổng tiền/thuế và hoa hồng kickback |
| Theo dõi sản xuất | `getProductionNextStep`, `changeOrderProductTypeStatus`, `changeOrderProductionTypeStatus` | Chuyển bước quy trình sản xuất theo `StatusList.step` |
| Sinh & gửi tài liệu | `printProduktionLista/Order/DeliveryLabel/Offert/Foljesedel`, `sendMail`, `massedit` (in/xuất hàng loạt) | |
| Đồng bộ ngược ra ngoài | `resyncOrderToShopify`, `resyncOrderToDeco`, `postOrderUpdateComplete` | Kích hoạt khi đơn chuyển "complete" — xem file 09 |
| Nhập đơn từ nguồn ngoài | `importCsvData`, `importFromDecoNetwork`, `importFromWPData`, `importOneOrder`, `importOrder1239BlavittPOD`, `addEcomOrder` | Mỗi hàm ứng với 1 nguồn tích hợp (xem file 09/10) |
| Báo cáo | `reportOrder`, `reportOrderProduct` (gộp cả return order dưới dạng số âm), `getChannelPrepaidCurrentSum` | |

### 8.1. Luồng tạo đơn (`add`)

1. Sinh `orderCountNr` (trừ khi được truyền sẵn từ luồng import).
2. Nếu người tạo là `ROLE_CHANNEL`, kiểm tra `Channel.guestLoginAddOrder` — không được phép thì trả 403.
3. Lấy điều khoản thanh toán mặc định: `Customer.defaultPaymentTypeId` override `Channel.defaultPaymentTypeId`.
4. Gọi `generateUpdate()` (setter chung), validate bằng Symfony Validator.
5. Đơn tạo bởi tài khoản guest/channel hoặc đơn "báo giá" (`create_a_quote`) được gán trạng thái khởi tạo đặc biệt.
6. Lưu, ghi `ActivityLog::ACTION_CREATE`, sau đó gọi tuần tự `updateFiles → updateKorrectur → addProducts → updateProductionTypeCount → updateSum → updatePriceKickback(isAddNew=true)`.

### 8.2. Luồng cập nhật đơn (`update`)

1. **Từ chối sửa đơn đã huỷ** (400).
2. Áp dụng thay đổi, validate, ghi nhận `changeSet` (Doctrine UnitOfWork) để ghi log.
3. Nếu payload set `invoiced=true`, **tự động ép** trạng thái đơn sang "complete" và stamp `dateComplete`.
4. Khi trạng thái **lần đầu** chuyển sang "complete" → gọi `postOrderUpdateComplete()` (kích hoạt đồng bộ ra ngoài).
5. Đơn báo giá đã in mà vừa hoàn tất → khoá sửa (`editable = false`).
6. Ghi `ActivityLog::ACTION_UPDATE` với toàn bộ changeSet.

### 8.3. Tính kickback (`updatePriceKickback`)

Với **đơn mới tạo**, mỗi dòng sản phẩm được tính `(giá × SL × procentage) + (fixSum × SL)`, **trừ khi**:
- Đơn được tạo bởi tài khoản `ROLE_CHANNEL`/guest (kickback bị tắt cho luồng tự phục vụ), **hoặc**
- Coupon áp dụng có cờ `removeKickBackOnUsedOrder`, **hoặc**
- Sản phẩm được đánh dấu `noKickback`.

Với đơn đã tồn tại, giá trị kickback **giữ nguyên** như đã lưu trước đó (không tính lại), chỉ cộng tổng lại vào `Order.totalKickback`.

> ⚠️ Đây là quy tắc nghiệp vụ tinh tế — nếu cần sửa logic tính kickback, phải phân biệt rõ "đơn mới" và "đơn cập nhật", nếu không sẽ vô tình tính lại kickback cho các đơn cũ đã chốt số liệu.

---

## 9. Lưu ý khi mở rộng domain này

- `Order` không có quan hệ Doctrine tới `BulkOrder`, `Invoice`, `ReturnOrder`, `Credit` — mọi liên kết là số nguyên + đồng bộ 2 chiều thủ công. Khi thêm tính năng mới liên quan tới các liên kết này, **phải** cập nhật cả 2 phía (VD: xoá `BulkOrder` phải tự tay gỡ `bulkOrderId` khỏi các đơn con).
- Việc trừ/hoàn tồn kho (`updateStock`) chạy đồng bộ ngay trong request tạo/sửa đơn — cần cẩn trọng nếu tương lai có yêu cầu xử lý concurrent nhiều đơn cùng 1 sản phẩm số lượng ít (hiện chưa thấy cơ chế khoá dòng `Product` khi trừ tồn kho, khác với cơ chế `FOR UPDATE` đã dùng cho `AutoCount`).
- `EcomOrderTemp` ở trạng thái `pending` quá lâu không có cơ chế dọn dẹp tự động — cân nhắc bổ sung cronjob nếu cần.
