# 02 — Domain: Kênh bán hàng & Cấu hình hệ thống

> Domain này là "xương sống" cấu hình của toàn hệ thống — mọi dữ liệu sản phẩm/đơn hàng/khách hàng đều thuộc về một `Channel`. Đọc file này trước khi đọc các domain nghiệp vụ khác (03, 04, 05).

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

```
Channel (kênh bán / cửa hàng / tenant)
 ├─ 1-n  ChannelLink              (link mạng xã hội hiển thị ở footer webshop)
 ├─ 1-n  ChannelEcomShippingType  (các phương thức vận chuyển ecom + phí)
 ├─ 1-n  ChannelPrepaidLog        (lịch sử số dư trả trước)
 ├─ 1-n  BannerSet                (banner trang chủ webshop)
 ├─ 1-n  WebshopMenuItem          (menu điều hướng webshop, dạng cây cha/con)
 ├─ n-n  WikiPage                 (trang nội dung/CMS được gán cho kênh)
 ├─ n-n  ProductCategory          (danh mục sản phẩm)
 ├─ n-n  Product                  (sản phẩm thuộc kênh)
 └─ 1-n  UserMultiChannel         (người dùng Channel/khách hàng B2B có quyền truy cập)

User (tài khoản đăng nhập)
 ├─ 1-n  UserMultiChannel   (danh sách kênh mà user được phép truy cập — chỉ áp dụng role CHANNEL)
 └─ 1-n  UserCustomLink     (link tắt cá nhân hoá trên giao diện)

StatusList — bảng "enum" dùng chung toàn hệ thống, được tham chiếu (qua ID) từ rất nhiều entity khác (Channel, Product, Order, Invoice, Credit...)

AutoCount — bộ đếm số thứ tự dùng chung (Channel, User, Customer, Product, Order, Invoice... đều lấy số ở đây)

ActivityLog — nhật ký thao tác polymorphic (không có FK cứng, tham chiếu bằng tên class + ID)
```

Toàn bộ liên kết `channelId`, `userId`... trong domain này là **cột số nguyên thường**, không phải quan hệ Doctrine thật (trừ các quan hệ liệt kê rõ M2M/O2M ở trên) — nghĩa là không có ràng buộc khoá ngoại ở tầng ORM, việc đồng bộ do tầng Service tự đảm nhiệm.

---

## 2. Channel — Entity trung tâm của cấu hình

**Bảng:** `channels` | **Mục đích:** đại diện một "tenant" — có thể là một cửa hàng ecom công khai, một khách hàng B2B nội bộ, hoặc một shop Shopify/WooCommerce đồng bộ về hệ thống. Loại kênh (`type`): `PDO`, `SPEC`, `ECOM`.

### Nhóm trường theo chức năng

| Nhóm | Trường tiêu biểu | Ghi chú |
|---|---|---|
| Định danh chung | `channelName`, `channelShortName` (unique), `slug` (unique, bắt buộc nếu `showOnEcom`), `type`, `color`, `isActive` | |
| Đơn hàng/giao hàng mặc định | `orderStatusId`, `defaultPaymentTypeId`, `defaultShippingFee`, `deliveryTypeId`, `codeMoms25/12/6` (mã thuế VAT), `defaultProductProcentagePod/Stock` | Giá trị mặc định khi tạo đơn mới cho kênh này |
| Hoá đơn & kickback | `invoiceOncePerMonth` (mặc định true), `invoiceIndividual`, `kickbackInvoiceActive`, `kickbackInvoicePeriodInterval` (tháng chốt kỳ), `kickbackInvoiceNrOfMonth`, `kickbackInvoiceSeparateByBrand` | Điều khiển cronjob `kickbackInvoice:create` (xem file 10) |
| Trả trước (prepaid) | `prepaidDate`, `prepaidSum`, `prepaidWatcherLevel` (ngưỡng cảnh báo), `prepaidCurrentSum` | Kết hợp `ChannelPrepaidLog` để lưu lịch sử |
| Shopify | `shopifyApiKey`, `shopifySecretKey`, `shopifyAccessToken`, `shopifyUrl`, `shopifySyncOrder`, `shopifySyncProduct` | Xem file 09 |
| Svea | `sveaAllowImport`, `sveaAccessToken`, `sveaMerchantId`, `sveaMerchantSecret` | **Lưu ý:** các field này hiện không được `SveaService` thực sự sử dụng — Svea dùng credential toàn cục qua biến môi trường (xem file 09 §Svea) |
| Đồng bộ tồn kho WooCommerce | `wooSyncStockActive`, `wooSyncStockInterval` (1/2/3 lần/ngày) | |
| Giao diện webshop (theming) | `ecomTemplate` (`standard`/`niclas`/`nakata`), `ecomFontFamily`, các `ecomColor*`, `ecomLogo`, `logo`, `banner`/`banner2`/`banner3`/`bannerMobile`, `ecomFavicon`, `ecomGTM`, `showOnEcom` | |
| Bật/tắt tính năng cho tài khoản Channel (guest) | `guestLoginAddOrder`, `guestLoginAddProduct`, `guestLoginProduction`, `guestLoginShowKickback`, `guestLoginShowPrepaid` | Quyết định 1 tài khoản `ROLE_CHANNEL` được làm gì trên cổng tự phục vụ |

### Quan hệ Doctrine thật

| Quan hệ | Loại | Chi tiết |
|---|---|---|
| `Channel` ↔ `WikiPage` | ManyToMany (Channel là chủ sở hữu) | bảng nối `channel_wiki_pages` |
| `Channel` ↔ `ProductCategory` | ManyToMany (mappedBy phía ProductCategory) | |
| `Channel` ↔ `Product` | ManyToMany (mappedBy phía Product) | |
| `Channel` → `ChannelLink` | OneToMany | |

### Logic nghiệp vụ đáng chú ý (`ChannelService`)

- `generateItem()` không chỉ trả về field của `Channel` mà **gộp thêm** dữ liệu từ nhiều bảng liên quan: danh sách phương thức vận chuyển ecom, wiki page, link mạng xã hội, banner set, **danh sách user thuộc kênh** (join `UserMultiChannel` với `User`, lọc `role = ROLE_CHANNEL`), lịch sử prepaid, danh mục sản phẩm — vì vậy 1 lần gọi `GET /channels/{id}` trả về rất nhiều dữ liệu lồng nhau.
- `updateEcomShippingType()`, `updateBannerSets()`, `updateLinks()`: đều theo pattern **"thay thế toàn bộ"** — xoá các bản ghi không còn trong payload, tạo/cập nhật các bản ghi còn lại (upsert + prune), không phải diff từng phần.
- Hàm `contrastColor()` tự tính màu chữ đen/trắng tương phản với màu nền theo công thức YIQ — phục vụ theming webshop tự động.

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

> **Tình huống 1 — Tạo một kênh bán hàng mới cho khách hàng "ACME Corp":**
> Admin gọi `POST /api/v1/channels/` với `channelName=ACME Corp`, `type=SPEC` (kênh nội bộ, không public ecom). Hệ thống sinh `countNr` tự động, mặc định `invoiceOncePerMonth=true`. Sau đó admin thêm tài khoản mua hàng cho ACME qua `UserMultiChannel` (xem mục 5) và thiết lập `defaultPaymentTypeId` = "30 ngày net" để mọi đơn hàng của ACME tự động có điều khoản thanh toán này.
>
> **Tình huống 2 — Bật webshop công khai:** Admin set `showOnEcom=true` cho kênh → hệ thống **bắt buộc** phải có `slug` hợp lệ và duy nhất (validator `Assert\Callback` trên entity) trước khi lưu được — nếu thiếu sẽ trả lỗi 400. Sau khi bật, khách vãng lai truy cập `/ecom/{slug}/` sẽ thấy giao diện theo `ecomTemplate` đã cấu hình.
>
> **Tình huống 3 — Cảnh báo số dư trả trước:** Số dư **không** tự trừ khi tạo/cancel đơn và không có cronjob — chỉ được tính lại khi FE gọi `POST /channels/{id}/get-prepaid-current-sum` (nút *Skicka* ở tab Prepaid): `OrderService::getChannelPrepaidCurrentSum()` tính `prepaidSum − SUM(totalSum + totalTax)` của các đơn không cancel / không soft delete, đặt **sau cuối ngày** `prepaidDate`. Nếu kết quả < `prepaidWatcherLevel` thì gọi `MailService::prepaidSumPassedWatcherLevel()` gửi cảnh báo nội bộ tới `email_from` (không phải `email_admin`; hiện cả 2 đều là `order@tshirt.se`), template `prepaid_sum_passed_watcher_level`. Mỗi lần gọi mà vẫn dưới ngưỡng sẽ gửi lại 1 email. Chi tiết + ví dụ: `docs/issues/TSHIRTORDER-1633-prepaid-transaction-log/`.

---

## 3. Các entity phụ trợ của Channel

### 3.1. ChannelLink

**Bảng:** `ChannelLink` | Widget link nhỏ (icon + tên + URL) hiển thị ở footer webshop, VD Facebook/Instagram. Quan hệ `ManyToOne → Channel`. Không có service riêng — quản lý hoàn toàn trong `ChannelService::updateLinks()`.

### 3.2. ChannelEcomShippingType

**Bảng:** `channels_ecom_shipping_type` | Các phương thức giao hàng khách được chọn khi checkout trên webshop (VD "Giao tận nhà", "Nhận tại điểm bưu cục"), mỗi loại có `shippingFeeIncTax` riêng và tham chiếu tới `StatusList` (`type = channel_ecom_shipping_type`) để chuẩn hoá tên loại.

### 3.3. ChannelPrepaidLog

**Bảng:** `channels_prepaid_logs` | Lịch sử snapshot số dư trả trước của kênh — mỗi lần gọi `POST /channels/{id}/get-prepaid-current-sum` sẽ ghi 1 dòng (kể cả khi số dư không đổi) (đọc lại qua `ChannelService::generateItem()` → mảng `prepaidLogs`).

### 3.4. BannerSet

**Bảng:** `banner_set` | Bộ banner khuyến mãi trang chủ webshop, hỗ trợ 2/3/4 khối ảnh (`type`: `TYPE_2_BLOCKS`/`TYPE_3_BLOCKS`/`TYPE_4_BLOCKS`), mỗi khối có ảnh + link riêng (`block1Image`/`block1Link` ... tới `block4`). `BannerSetService` **chỉ có 1 hàm đọc** (`getActiveList()`) — việc tạo/sửa/xoá nằm trong `ChannelService::updateBannerSets()`.

### 3.5. WebshopMenuItem

**Bảng:** `webshop_menu_item` | Menu điều hướng webshop, dạng cây cha/con (`parentId`), gắn với 1 trong 3 vị trí (`addToEcomMenuMain`, `addToEcomMenuRight`, `addToEcomMenuMobileLeft`). Loại mục: `link` (URL tự do) hoặc `category` (trỏ tới `ProductCategory`, tự động resolve `slug` khi build cây menu). Hàm quan trọng: `WebshopMenuService::getTreeMenu($channelId, $position)` — dựng cây menu đệ quy cho từng vị trí, được gọi ở **mọi trang webshop**.

### 3.6. WikiPage

**Bảng:** `wiki_page` | Trang nội dung tĩnh (CMS) dạng HTML, có thể gán cho nhiều `Channel` (n-n) và tuỳ chọn public trên webshop (`showOnEcom`, yêu cầu `slug` unique). Truy cập tại `/ecom/{channelEcomId}/sida-{slug}`.

### 3.7. EmailTemplate

**Bảng:** `email_template` | Nội dung email có thể chỉnh sửa qua admin, tra cứu bằng khoá ổn định `emailKey` (VD `invoice_mail`, `user_forgot_password`, `kickback_invoice_mail`...) thay vì ID cứng trong code. `content` được render như **Twig template động** ngay tại thời điểm gửi (xem `MailService` ở file 08). `delete()` ở đây là **xoá cứng**, khác với đa số entity khác trong hệ thống.

---

## 4. StatusList — bảng "enum" dùng chung

**Bảng:** `status_list` | Đây là cơ chế thay thế cho việc hard-code enum trong PHP: một bảng tra cứu tổng quát, phân loại bằng cột `type`, mỗi dòng có `name` (hiển thị), `uniqueKey` (khoá tra cứu ổn định trong code, VD `order_status_complete`, `default_currency`), `color`, `code`, `position`, `step` (thứ tự bước, dùng cho quy trình sản xuất), `isActive`.

### Các nhóm `type` đang được dùng trong hệ thống

`invoice_status`, `order_status`, `order_payment_type`, `order_payment_status`, `order_delivery_type`, `kickback_invoice_status`, `credit_status`, `return_order_status`, `currency`, `down_payment_row_type`, `production_type`, `order_production_type_{24,25,26,203}_status` (4 nhóm loại sản xuất song song), `product_size`, `product_type`, `coupon_campaign_type`, `customer_status`, `customer_type`, `supplier_number`, `supplier_model`, `channel_ecom_shipping_type`.

### Vì sao cần biết bảng này

Rất nhiều field "trạng thái/loại" trên các entity khác thực chất là **2 cột song song**: một cột lưu `xxxId` (khoá ngoại tới `StatusList.id`) và một cột `xxx` lưu sẵn `name` đã được copy (denormalized) tại thời điểm gán — để tránh phải join khi hiển thị danh sách. Khi sửa dữ liệu, luôn phải cập nhật cả 2 cột song song này (các Service đã tự làm điều này trong `generateUpdate()`).

> **Ví dụ tình huống:** Muốn thêm một trạng thái đơn hàng mới "Đang chờ vải" vào quy trình, chỉ cần thêm 1 dòng vào `status_list` với `type=order_status`, không cần sửa code/migration. Nhưng nếu quy trình cần code kiểm tra đặc biệt theo trạng thái này (VD khoá không cho sửa đơn), code phải tra theo `uniqueKey` — nên `uniqueKey` cần được tạo nhất quán, không đổi tuỳ tiện.

---

## 5. User & phân quyền truy cập nhiều kênh

### 5.1. User

**Bảng:** `users` | Tài khoản đăng nhập — cả nhân viên nội bộ lẫn khách hàng B2B (guest/channel user). 4 role: `ROLE_SUPER_ADMIN`, `ROLE_CHANNEL`, `ROLE_PRODUCTION`, `ROLE_INTEGRATION`. **Mỗi user chỉ có đúng 1 role** (không phải mảng roles). Chi tiết cơ chế đăng nhập/token xem file 12.

Trường đáng chú ý: `lockForOnlyIntegration` (tài khoản chỉ được dùng bởi hệ thống tích hợp bên ngoài, không login UI), `channelUserOtp`/`channelUserOtpExpiredDate` (đăng nhập bằng OTP dành cho `ROLE_CHANNEL`), `accessToken`/`dateAccessTokenExpired`.

### 5.2. UserMultiChannel

**Bảng:** `user_multi_channel` | Bảng nối User ↔ Channel, cho phép 1 user (thường là `ROLE_CHANNEL`) truy cập **nhiều kênh**. Thiết kế **phi chuẩn hoá hoàn toàn** — copy sẵn `username`, `email`, `role`, `brandId/Name/Logo`, `channelName` vào từng dòng thay vì join. Ràng buộc `UniqueEntity(['userId', 'channelId'])` — 1 user không thể gắn 2 lần vào cùng 1 kênh.

Đồng bộ hoàn toàn thủ công qua `UserService::__updateMultiChannels()` — mỗi khi cập nhật user với field `channelIds`, hệ thống xoá các dòng không còn trong danh sách và tạo dòng mới cho các kênh vừa thêm.

### 5.3. UserCustomLink

**Bảng:** `user_custom_link` | Link tắt cá nhân hoá (tên, màu nút, URL) hiển thị trên giao diện của riêng 1 user — không liên quan tới `ChannelLink`.

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

> Một nhà phân phối B2B "Sport AB" cần truy cập **2 kênh khác nhau** (kênh bán lẻ và kênh bán buôn) bằng cùng 1 tài khoản. Admin tạo `User` với `role=ROLE_CHANNEL`, sau đó gửi `channelIds: [12, 15]` khi tạo/cập nhật user — hệ thống tự tạo 2 dòng `UserMultiChannel` tương ứng. Khi "Sport AB" đăng nhập (`POST /users/channel/login/login-by-otp`), response trả về **danh sách cả 2 kênh** để họ chọn, và mỗi lần thao tác đơn hàng, hệ thống kiểm tra kênh đang thao tác có nằm trong danh sách `UserMultiChannel` của họ hay không (xem `ApiService::dashboard()`).

---

## 6. AutoCount — bộ đếm số thứ tự dùng chung

**Bảng:** `auto_counts` | Chỉ có 2 cột nghiệp vụ: `name` (khoá bộ đếm, VD `NAME_ORDER`, `NAME_INVOICE`, `NAME_KICKBACK_INVOICE`...) và `value` (giá trị hiện tại). Toàn bộ số chạy (`orderCountNr`, `invoiceCountNr`, `customerNr`, `channel.countNr`...) trong hệ thống đều lấy từ đây qua **một hàm atomic duy nhất**:

```php
AutoCountService::generateNewCount($name, $defaultValue = 0)
// Mở transaction → SELECT ... FOR UPDATE (khoá dòng) → tăng value → COMMIT
```

Cơ chế khoá dòng (`FOR UPDATE`) đảm bảo 2 request tạo đơn hàng đồng thời **không bao giờ** nhận trùng số thứ tự — đây là điểm quan trọng cần giữ nguyên nếu có sửa đổi liên quan tới việc sinh số chứng từ.

---

## 7. ActivityLog — nhật ký thao tác

**Bảng:** `activity_log` (**phân vùng theo tháng** trên PostgreSQL — `activity_log_y2026m07` v.v.) | Ghi lại hành động `create`/`update`/`delete`/`send_mail` trên các entity quan trọng (chủ yếu `Order`, `Invoice`, `BulkOrder`). Tham chiếu tới entity gốc bằng **cặp (`entity` = tên class, `entityId`)** — không phải khoá ngoại thật (polymorphic reference).

### Cơ chế phân vùng tự động

`ActivityLogService` tự tạo partition tháng hiện tại + 2 tháng tiếp theo (`createUpcomingPartitions()`), và có hàm dọn partition cũ hơn N tháng (`dropOldPartitions()`, mặc định giữ 12 tháng) — **cần đảm bảo có job định kỳ gọi các hàm này**, nếu không bảng sẽ thiếu partition cho tháng mới và insert sẽ lỗi.

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

> Khi nhân viên cập nhật trạng thái đơn hàng từ "Đang xử lý" sang "Hoàn thành", `OrderService::update()` tính `changeSet` (Doctrine UnitOfWork diff) rồi gọi `ActivityLogService::addLog($user, ActivityLog::ACTION_UPDATE, $order, $data, $changeSet)`. Log này ghi lại: ai đổi (`userEmail`), đổi gì (`data` serialize), có phải đổi trạng thái không (`changeStatus: true` được suy ra từ changeSet) — phục vụ tra soát sau này qua `GET /api/v1/activity-logs/?entityId=...`.

---

## 8. Các điểm cần lưu ý khi mở rộng domain này

- Thêm 1 setting mới cho `Channel` phải theo đúng quy trình trong `CLAUDE.md`: thêm cột vào entity → viết migration tay (`doctrine:migrations:generate`, **không dùng `migrations:diff`** — xem mục *Migrations* trong `CLAUDE.md`) → `migrate` → thêm vào `ChannelService::generateItem()` (đọc) và `generateUpdate()` (ghi).
- `ChannelEcomShippingType`, `ChannelPrepaidLog`, `BannerSet`, `WebshopMenuItem`, `UserMultiChannel` đều **không có quan hệ Doctrine thật** tới `Channel` — chỉ là cột `channelId` số nguyên. Nếu cần join hiệu quả trong báo cáo lớn, cân nhắc bổ sung index thủ công thay vì trông chờ ORM.
- `WebshopMenuItemRepository::query()` có 2 filter tham chiếu tới field không tồn tại trên entity (`path`, `brandKey`) — nhiều khả năng là code chết/copy nhầm từ repository khác, không nên dựa vào các filter này.
