# 03 — Domain: Sản phẩm & Sản xuất

> Domain catalogue sản phẩm và các lựa chọn in ấn/gia công (production) gắn kèm sản phẩm.

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

```
Brand ─────────────┐
                    │ (chỉ liên kết bằng brandId, KHÔNG có quan hệ Doctrine)
ProductModel        │
 └─ 1-n ProductModelVariant
                    │ (liên kết bằng productModelId/productModelVariantId, KHÔNG có quan hệ Doctrine)
Product (bảng trung tâm catalogue)
 ├─ n-n  ProductCategory        (products_with_categories)
 ├─ n-n  StatusList             (products_with_size — size cho model "classic")
 ├─ n-n  Product (addOnProducts) (products_add_on — sản phẩm add-on: in tên, phụ kiện...)
 ├─ n-n  Channel                (products_channels)
 ├─ 1-n  ProductImages          (ảnh gallery)
 ├─ tự tham chiếu productParent/productChildren  (sản phẩm cha ↔ biến thể size/màu "sub-product")
 └─ (liên kết ngoài ORM) ProductProductions → Production  (các lựa chọn in/gia công gắn với sản phẩm)

Order/OrderProduct
 └─ (liên kết ngoài ORM) OrderProductProduction  (snapshot lựa chọn in ấn ĐÃ CHỌN trên 1 dòng đơn hàng)
 └─ (liên kết ngoài ORM) OrderProductionTypeStatus  (trạng thái hoàn thành theo từng loại sản xuất trên 1 đơn)
```

**Lưu ý quan trọng:** phần lớn quan hệ trong domain này **không phải** quan hệ Doctrine (ManyToOne/OneToMany) mà chỉ là cột số nguyên (`productId`, `brandId`, `productionId`...) kèm theo **các cột copy sẵn tên/giá/ảnh** (denormalized). Chỉ 5 quan hệ liệt kê ở trên (category, size, add-on, channel, images, parent/children) là quan hệ ORM thật.

---

## 2. Product — Entity trung tâm catalogue

**Bảng:** `products` | Đại diện cả sản phẩm "cha" (sellable chính) lẫn biến thể "con" theo size/màu (sub-product), qua cơ chế tự tham chiếu `productParent`/`productChildren`. Một sản phẩm có thể bán trên nhiều kênh (`channels`), đồng bộ được với WooCommerce (`idWp`) và Shopify.

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

| Nhóm | Trường | Ghi chú |
|---|---|---|
| Định danh | `sku`, `slug`, `name`, `idWp` | `sku` **không unique ở tầng DB**, mà được ràng buộc unique **theo từng kênh** qua validator `UniqueProductSku` (xem mục 3) |
| Tồn kho | `stock`, `manageStock`, `outStock`, `wooSyncStock` | Nếu `manageStock=true`, `outStock` được **tự động** tính lại mỗi khi `stock` thay đổi (`stock <= 0` → hết hàng) |
| Giá | `price`, `purchasePriceExcTax`, `priceEcomIncTax`, `priceEcomOriginal` | Giá nội bộ B2B tách biệt với giá hiển thị trên ecom |
| Kickback (hoa hồng) | `procentage`, `fixSum`, `noKickback` | Công thức: `(giá_ecom_trước_thuế × SL × procentage) + (fixSum × SL)` — xem `Product::getPriceKickback()` |
| Add-on | `isAddOnProduct`, `addOnProductType` (`option`/`input`), `addOnInputAllLetterInputCapital`, `addOnInputMaxLength`, `addOnDefaultSupplierId` | Sản phẩm phụ trợ gắn kèm sản phẩm chính, VD "In tên trên áo" (loại `input` cho khách tự gõ chữ) |
| Ecom | `showOnEcom`, `popularProduct`, `newProduct`, `ecomFavorite`, `ecomSortOrder` (mặc định `999999` nếu trống — đẩy xuống cuối) | |
| Denormalized copy | `brandId/Name/Logo`, `channelId/Name/ShortName` (kênh "chính", tách biệt với M2M `channels`), `productModelId/Name/...`, `productModelVariantId/Name/...`, `productionId/Name/Sku/Price/...` (1 lựa chọn in "chính" gắn trực tiếp) | |

### Quan hệ Doctrine

| Quan hệ | Loại | Bảng nối / cột |
|---|---|---|
| `categories` | ManyToMany | `products_with_categories` |
| `sizes` | ManyToMany → `StatusList` | `products_with_size` |
| `addOnProducts` | ManyToMany (tự tham chiếu) | `products_add_on` |
| `channels` | ManyToMany | `products_channels` |
| `images` | OneToMany → `ProductImages` | mappedBy `product` |
| `productParent` / `productChildren` | ManyToOne/OneToMany (tự tham chiếu) | cột `parent_product_id` |

### Ràng buộc SKU/Slug — `UniqueProductSku` (class-level validator)

Đây là quy tắc nghiệp vụ **quan trọng nhất** của catalogue, áp lên toàn bộ entity `Product`:

- **SKU** phải duy nhất **theo từng kênh** mà sản phẩm thuộc về: nếu sản phẩm không gắn kênh nào, kiểm tra unique toàn cục; nếu gắn ≥1 kênh, kiểm tra unique **riêng trong từng kênh** đó (một SKU có thể trùng giữa 2 kênh khác nhau, nhưng không được trùng trong cùng 1 kênh).
- **Slug** chỉ bắt buộc và phải unique toàn cục **khi** `showOnEcom = true`.

> **Ví dụ tình huống:** Kênh "Shop A" và "Shop B" đều có thể có sản phẩm SKU `TSHIRT-001` mà không xung đột (vì unique theo kênh). Nhưng nếu import 2 sản phẩm cùng SKU `TSHIRT-001` vào **cùng** Shop A, request thứ 2 sẽ bị từ chối với lỗi `SKU "TSHIRT-001" already exists for this channel.`

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

- **Đồng bộ 2 chiều dữ liệu phi chuẩn hoá**: khi payload gửi `categories`, `channelId(s)`, `typeId`, `brandId`, `productModelVariantId`, `productionId`..., Service tự tra bảng nguồn và copy tên/ảnh/giá vào các cột denormalized tương ứng trên `Product` — đây là cơ chế giữ các cột "cache" luôn khớp dữ liệu gốc.
- **Quản lý biến thể size ("classic" sub-product)** — `updateChildrenProducts()`: full upsert + prune theo `sizeId` — sản phẩm con không còn trong payload sẽ bị **xoá mềm và đổi SKU** (thêm hậu tố `-null`) để giải phóng SKU cho lần tạo mới sau này.
- **Nhập hàng từ WooCommerce** (`importWpProducts()`): khớp sản phẩm theo `sku` (+ hậu tố `_​{sizeId}` nếu có size) và `channelId`; tự tạo `Brand`/`ProductCategory` nếu chưa có (khớp theo slug hoá tên); tự tải ảnh đại diện về lưu local ở lần import đầu.
- **Nhân bản sản phẩm** (`copyToNew()`): clone toàn bộ ảnh, lựa chọn in ấn, biến thể, danh mục, kênh, add-on — SKU/slug mới được gắn hậu tố `-copy-{timestamp}` để đảm bảo unique.

### ⚠️ Tính năng đang trong giai đoạn thiết kế (chưa triển khai)

Ticket **TSHIRTORDER-1594** (`docs/issues/TSHIRTORDER-1594/dynamic-product-variants.md`) đề xuất một mô hình biến thể mới kiểu Shopify (`variantType = dynamic`, với `ProductAttribute`/`ProductAttributeValue`/`ProductVariant`...) để thay thế/mở rộng mô hình "classic" hiện tại. **Tính đến thời điểm viết tài liệu này, chưa có entity/code nào của thiết kế trên được implement** — chỉ mới có validator `UniqueProductSku` được merge. Khi làm việc với ticket này, cần đọc kỹ tài liệu thiết kế gốc trước khi code.

---

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

### 3.1. ProductImages

**Bảng:** `product_images` | Ảnh gallery bổ sung (ngoài `thumbnail` chính trên `Product`). Quan hệ `ManyToOne → Product`. Quản lý qua `ProductService::updateImages()` (xoá theo `deletedImages`, upload mới qua `FileService::uploadImage()`).

### 3.2. ProductProductions

**Bảng:** `product_productions` | Bảng nối Product ↔ Production (n-n **không** dùng ORM, chỉ cột `productId`/`productionId`) — cho phép 1 sản phẩm gắn **nhiều** lựa chọn in ấn/gia công (khác với field `productionId` đơn lẻ "chính" trên `Product`). Mỗi dòng có `comment`, `placements` (vị trí in), `printFileId` riêng. Quản lý theo kiểu **thay thế toàn bộ** mỗi lần cập nhật sản phẩm (`ProductService::addProductions()` — xoá hết, tạo lại từ payload).

### 3.3. Production

**Bảng:** `productions` | Danh mục **loại hình sản xuất/in ấn** dùng chung (VD: In lụa, DTG, Thêu, Ép chuyển nhiệt), mỗi loại có `sku` **unique thật ở tầng DB** (khác `Product.sku`), `price`, và `typeId` trỏ tới `StatusList` (nhóm loại sản xuất). Đây là bảng "master data" — không gắn trực tiếp với 1 sản phẩm cụ thể.

### 3.4. Brand

**Bảng:** `brands` | Thương hiệu/nhãn bán lại (white-label) — mỗi brand có thể có 1 khách hàng mặc định (`orderDefaultCustomerId`) dùng khi tự động tạo đơn. `path` (slug) tự sinh từ `name` nếu để trống, dùng để khớp brand khi import từ WooCommerce. Không có quan hệ ORM tới `Product` (chỉ `Product.brandId`).

### 3.5. ProductCategory

**Bảng:** `product_categories` | Danh mục sản phẩm, hỗ trợ hiển thị ecom (`showOnEcom`, yêu cầu `slug`), phân biệt `mainCategory` (danh mục gốc) và danh mục con (lưu qua `childCategoryIds` — chuỗi serialize, **không phải** quan hệ cây thật). Quan hệ `ManyToMany` thật với `Channel` và `Product`.

### 3.6. ProductModel / ProductModelVariant

**Bảng:** `product_models` / `product_model_variants` | Dữ liệu "mẫu" (model) áo/kiểu dáng gốc, độc lập với `Product` theo từng kênh — dùng làm **thư viện tham chiếu** khi tạo `Product` mới (copy `model`, `modelNr`, `thumbnail`... sang). `ProductModelVariant` là biến thể màu/kiểu của 1 model. Cả 2 hỗ trợ **nhập hàng loạt qua Excel** (`public/model_data/model.xlsx`, `variant.xlsx`) — khớp bằng `modelNr`.

---

## 4. Theo dõi sản xuất trên đơn hàng

### 4.1. OrderProductProduction

**Bảng:** `order_product_productions` | **Snapshot lịch sử** — ghi lại lựa chọn in ấn nào đã áp dụng cho **một dòng sản phẩm cụ thể trong một đơn hàng cụ thể**, tại đúng thời điểm đặt hàng (copy `productionName`, `productionSku`, `productionType`...) — độc lập với việc sau này master data `Production` có bị sửa hay không. Đây chính là nguồn dữ liệu để `PdfService::orderPrintProduktionLista()` in phiếu sản xuất.

### 4.2. OrderProductionTypeStatus

**Bảng:** `orders_production_type_status` | Theo dõi **mức đơn hàng** (không phải mức dòng sản phẩm): với các đơn có nhiều loại sản xuất song song (VD vừa in lụa vừa thêu), cho phép đánh dấu **từng loại** đã hoàn tất (`isDone`) độc lập với nhau — phục vụ workflow xưởng sản xuất theo dõi tiến độ.

### Ví dụ tình huống — quy trình sản xuất 1 đơn hàng có 2 loại in

> Đơn hàng #1024 có 2 dòng sản phẩm: áo in lụa (Screen) và áo thêu logo (Special). Khi tạo đơn, mỗi dòng `OrderProduct` được gắn 1 `OrderProductProduction` snapshot loại in tương ứng. Đơn hàng có 2 cột trạng thái song song `productionType24ScreenStatus` (in lụa) và `productionType203SpecialStatus` (thêu) — xưởng in lụa cập nhật xong trước, xưởng thêu vẫn đang chạy. Chỉ khi **cả 2** `OrderProductionTypeStatus` đều `isDone=true`, đơn mới đủ điều kiện in phiếu giao hàng đầy đủ. `OrderService::changeOrderProductionTypeStatus()` là hàm xử lý việc chuyển bước cho từng nhóm loại.

---

## 5. Ví dụ tình huống tổng hợp — vòng đời một sản phẩm mới

> 1. Admin catalogue tạo `ProductModel` "Áo phông cổ tròn 180gsm" từ file Excel import, kèm 3 `ProductModelVariant` (Trắng/Đen/Xanh).
> 2. Tạo `Product` mới cho kênh "ACME Shop", chọn `productModelVariantId` = biến thể "Trắng" → hệ thống tự copy tên/ảnh model vào các cột denormalized của `Product`.
> 3. Gắn 2 lựa chọn in ấn qua `ProductProductions`: "In lụa 1 màu" và "In chuyển nhiệt DTF" (cả 2 tham chiếu `Production` master data).
> 4. Cấu hình size: thêm 3 size S/M/L vào `sizes` (M2M `StatusList`) → gọi API cập nhật sản phẩm với `subProducts` → hệ thống tự sinh 3 `Product` con (sub-product) với SKU `{sku_cha}_{sizeId}`.
> 5. Bật `showOnEcom=true`, gán `slug=ao-phong-co-tron-trang` → sản phẩm xuất hiện trên webshop `/ecom/acme-shop/ao-phong-co-tron-trang`.
> 6. Khách đặt hàng size M, chọn add-on "In tên trên áo" (loại `input`, tối đa 10 ký tự, viết hoa) → dòng `OrderProduct` lưu `subProductId` trỏ tới sub-product size M, kèm `addOnText` là tên khách nhập.
> 7. Xưởng sản xuất nhận đơn, snapshot lựa chọn in ấn được ghi vào `OrderProductProduction`, xưởng cập nhật `isDone=true` khi hoàn tất → `OrderService` tự tính lại `productionTypeCount` trên đơn.

---

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

- Vì phần lớn liên kết là cột số nguyên thường (không FK thật), **xoá một `Production`/`Brand`/`ProductModel` sẽ không tự động dọn dữ liệu tham chiếu** ở `Product`/`OrderProduct` — cần xử lý thủ công ở tầng Service nếu bổ sung tính năng xoá.
- `Brand.id` dùng chung sequence Postgres với `EmailTemplate` (`my_email_template_id_seq`) — có vẻ là lỗi copy-paste khi tạo entity, không ảnh hưởng chức năng nhưng cần biết khi đọc migration.
- Khi thêm field enum mới cho sản phẩm (loại sản phẩm, size...), ưu tiên thêm vào `StatusList` (loại `product_type`/`product_size`) thay vì hard-code, theo đúng pattern toàn hệ thống (xem file 02 mục 4).
