# TSHIRTORDER-1628 — New Produkt type "Tryck" (print product) — nhóm sản phẩm in gắn kèm sản phẩm thường

## Yêu cầu gốc

> TSHIRTORDER-1628 New Produkt type - Print products - Tryck.
> Tryck producst is like a group producst, its connect to normal products and when add the normal products to order we add this tryck producst as well
>
> remove all fields as in image
> add new
> 1 fields for color select from list
> 2 fields for select size select from list
> 3 connect producst to this

Kèm 3 ảnh mockup (xem `files/tryck1.jpg`, `files/tryck2.jpg`, `files/tryck3.jpg`) — chụp trang **Redigera produkt** (`/app/products/{id}/detail`) hiện tại, đánh dấu tay các field cần xoá/giữ/thêm trên từng tab.

---

## Tổng quan

Thêm 1 loại sản phẩm mới **"Tryck"** (in ấn) trong catalogue — không phải sản phẩm bán độc lập như bình thường, mà là **sản phẩm nhóm/gắn kèm**: 1 Tryck product được **kết nối tới các sản phẩm thường** (normal product). Khi 1 sản phẩm thường có Tryck liên kết được thêm vào order → **backend tự động thêm luôn dòng Tryck product đó vào order** (không cần FE/người dùng chọn thủ công).

Phạm vi: **Backend** — entity `Product` (field mới + form rút gọn), `OrderService` (logic tự thêm dòng Tryck khi thêm sản phẩm thường vào order), migration schema + seed. Frontend (form Redigera produkt theo mockup, dropdown color/size, picker kết nối sản phẩm) là **repo khác**, doc này chỉ mô tả field/API để FE dùng.

> ⚠️ **Phát hiện quan trọng: hạ tầng liên quan đã có sẵn, tận dụng lại**
>
> | Phần | File | Trạng thái |
> |---|---|---|
> | Field `type` / `typeId` trên `Product`, dùng `StatusList` type `product_type` | `src/Entity/Product.php:194-198`, `StatusList::TYPE_PRODUCT_TYPE` | ✅ có sẵn — **nhưng KHÔNG dùng cho phân biệt normal/tryck** (xem mục Questions #1), list `product_type` hiện chỉ có 2 giá trị `POD` / `STOCK` (id 135, 136), là 1 khái niệm khác |
> | Field `removeFromGuestPortal` (boolean) | `Product.php:279`, getter/setter đã có | ✅ chỉ cần set default `true` khi tạo Tryck product, không cần field mới |
> | Field `color` (string free-text) | `Product.php:153` | ✅ field đã có, chỉ đổi UI (FE) sang dropdown chọn từ list — nếu list color là `StatusList` type mới thì BE cần validate/lookup như field `sizeId` bên dưới |
> | Cơ chế "sản phẩm nhóm/gắn kèm" gần giống add-on hiện có | `Product.isAddOnProduct`, `addOnProducts` (M2M tự tham chiếu, bảng `products_add_on`), xử lý trong `OrderService.php:912-939` | ⚠️ **gần giống nhưng khác chiều và khác cơ chế trigger** — add-on hiện tại do FE chủ động gửi `addOnParentProductId` trong payload dòng order, KHÔNG phải BE tự suy ra và tự thêm dòng. Yêu cầu #3 (BE tự thêm) cần logic mới trong `OrderService::add()`/`update()`, không tái dùng được thẳng flow add-on. |
> | Toàn bộ field tab "Produktioner" (`productionComment`≈Beskrivning tryck, `productionId`/Name/Sku/Price/TypeId, `productionPrintFileId`, `productionPlacements`) | `Product.php:71-97` | ✅ đã đủ, **giữ nguyên 100% cho Tryck**, không cần thêm/sửa |
> | Design biến thể kiểu Shopify (`variantType=dynamic`, `ProductAttribute`/`ProductVariant`) | `docs/issues/TSHIRTORDER-1594-dynamic-product-variants/` | ⚠️ **chưa implement**, chỉ là đề xuất — KHÔNG liên quan trực tiếp task này nhưng cùng đụng vào "size/color select from list", cần lưu ý tránh xung đột hướng thiết kế nếu 1594 sau này được làm |

---

## Chi tiết theo mockup

> ⚠️ **Cập nhật theo trả lời sếp 24/09/2026 (Questions #7)**: form Tryck chỉ còn **2 tabs** — "Information" (gộp toàn bộ field còn giữ, kể cả field vốn thuộc Produktioner/Inköp cũ) và "Connect products" (kết nối sản phẩm normal). Các mục 2/3/4 dưới đây giữ nguyên phần liệt kê field nào bị xoá/giữ theo mockup ảnh gốc (`files/tryck1.jpg`, `tryck2.jpg`, `tryck3.jpg`), chỉ khác ở chỗ **tất cả field "giữ" ở cả 3 nhóm giờ nằm chung trong 1 tab Information duy nhất**, không tách tab riêng như form normal.

### 1. Field phân biệt normal / tryck product — `productKind` (đã chốt với sếp)

Field **mới hoàn toàn**, tách biệt với `type`/`typeId` (POD/STOCK) hiện có — vì đây là 2 sản phẩm có **cấu trúc form khác hẳn nhau**, không phải phân loại mở rộng được.

- Tên: `productKind`
- Kiểu: string/text (không dùng `StatusList`, theo đúng convention field boolean/text đơn giản kiểu `isAddOnProduct` — vì chỉ có đúng 2 giá trị cố định)
- Giá trị: `normal` | `tryck`
- Default: `normal` (áp cho toàn bộ sản phẩm hiện có khi migrate — không cần backfill data vì cột mới sẽ default sẵn)

### 2. Tab "Information" (gộp — field gốc từ `files/tryck1.jpg`)

**Field bị xoá khỏi form khi `productKind = tryck`** (theo dấu X trong mockup):
- Varumärke (`brandId`)
- Vikt (`weight`)
- Sök modell i order (`modelNr`)
- Länk (`link`)
- Checkbox "Add on product" (`isAddOnProduct`)
- Miniatyrbild (`thumbnail` — ảnh đại diện riêng, khác `images` gallery bên "Bilder" vẫn giữ)
- Checkbox "Synka lager" (`wooSyncStock`)
- Toàn bộ bảng **Storlekar** (cơ chế sub-product size variant hiện tại: `productParent`/`productChildren` + bảng SKU/Lager/Slut i lager/Storlek/Sortera order) — Tryck **không dùng** cơ chế nhiều sub-product theo size

**Field giữ nguyên:** Namn (`name`), SKU (`sku`), Kategorier (`categories`), Kanaler (`channels`), Pris ÅF ex moms (`price`), Bilder (`images` gallery), Aktiv (`isActive`), Utskriftsstatus (`printStatus`).

**Field mới cần thêm:**

| # | Field | Mô tả | Field/entity liên quan |
|---|---|---|---|
| 1 | Färg (color) | **Free text + autocomplete** gợi ý từ 1 list (KHÔNG phải dropdown chọn cứng — theo trả lời sếp Questions #8) | List là **list mới, sếp chưa gửi data** — tạm giữ cột `color` (string) trên `Product` để lưu giá trị nhập; nguồn list gợi ý autocomplete (StatusList type mới hay bảng riêng) **chưa chốt, chờ data mẫu từ sếp** |
| 2 | Storlek (size) | **Free text + autocomplete** gợi ý từ 1 list, tương tự Färg (Questions #8) | List mới, sếp chưa gửi data. Vẫn có thể tái dùng cột đơn `sizeId`/`size` (`Product.php:120-123`) để lưu giá trị nhập, nhưng lookup theo `StatusList` type `product_size` có sẵn **không còn phù hợp** vì đây là autocomplete tự do chứ không phải chọn từ list cố định — cần xác nhận lại nguồn list khi có data |
| 3 | Kết nối sản phẩm (connect products) | Chọn 1 hoặc nhiều sản phẩm thường để gắn Tryck này vào — nằm ở **tab riêng "Connect products"** (xem mục 3 bên dưới), không chung tab Information | Quan hệ **N-N đã xác nhận** (Questions #5: 1 normal có thể thuộc nhiều tryck) — thiết kế bảng nối theo mục API contract |

**Mặc định khi tạo Tryck product:**
- "Ta bort från gästportalen" (`removeFromGuestPortal`) → **default `true`** (thay vì `false` như sản phẩm thường)

**Đã xác nhận: tồn kho không áp dụng cho Tryck** — `stock`/`manageStock`/`outStock` (cấp `Product`) không xuất hiện/không dùng trong form Tryck. Điều này vốn đã ngầm đúng theo mockup (không có field Lager nào ở Information tab ngoài trong bảng Storlekar đã bị xoá ở trên), giờ ghi rõ tường minh để tránh nhầm khi build form.

**Đã xác nhận: không loại trừ Tryck khỏi product picker khi thêm sản phẩm vào order** — Tryck hiện bình thường trong danh sách/tìm kiếm sản phẩm ở trang tạo/sửa order (giống add-on hiện tại, field `isAddOnProduct` cũng chỉ là filter tuỳ chọn qua query param ở `ProductRepository::query()`, không hard-code loại trừ). Không cần thêm logic loại trừ riêng cho `productKind = tryck` ở BE cho use-case này.

**Đã xác nhận: Tryck KHÔNG hiển thị trên ecom** (Questions #9 — `"no - we dont use this for ecom"`) — ngược lại với product picker order ở trên. Cần loại trừ tường minh `productKind = tryck` khỏi query listing sản phẩm phía webshop (`ProductRepository`, các hàm phục vụ `/ecom/{channelEcomId}/...` trong `ApplicationWebshopBundle`) — xem `docs/spec/` phần webshop bundle để xác định đúng method cần sửa.

**Field gộp thêm từ tab "Produktioner" gốc** (`files/tryck2.jpg`, giữ 100% không đổi field, chỉ đổi vị trí — nay nằm chung trong tab Information thay vì tab riêng): Beskrivning tryck (`productionComment`), Välj leverantörsmodell, Leverantörs kommentar, upload Printfil (`productionPrintFileId`), chọn Produktion (`productionId`/Name/Sku/Price/TypeId), Print file Id, Kommentar (`productionPlacements`).

**Field gộp thêm từ tab "Inköp" gốc** (`files/tryck3.jpg`) — chỉ giữ field không bị đánh dấu X, cũng nay nằm chung trong tab Information: Moms kod (`taxCode`), Sale account (`exportSaleAccount`), Export account (`exportAccount`).

Field bị xoá hoàn toàn khỏi tab Inköp gốc (không gộp vào Information, không hiển thị ở form Tryck):
- Välj leverantör (`supplierName`/liên kết supplier — cần xác nhận field FE đang bind, xem Pending #7)
- Inköpspris exkl. moms (`purchasePriceExcTax`)
- Leverantör SKU (`supplierSKU`)
- Leverantörspris (`supplierPrice`)
- Leverantörsmodell ID (`supplierModelId`)

### 3. Tab "Connect products" (mới — theo trả lời sếp Questions #7)

Tab riêng, chỉ chứa UI kết nối sản phẩm: picker chọn 1 hoặc nhiều sản phẩm **normal** để gắn vào Tryck đang sửa (quan hệ N-N, xem Questions #5 và mục API contract). Không có mockup ảnh cụ thể cho tab này (đây là yêu cầu mới, chưa có trong `files/tryck*.jpg` gốc) — FE tự thiết kế UI picker tương tự các picker chọn nhiều item khác đã có trong hệ thống.

---

## Luồng hoạt động — auto-add Tryck vào order (yêu cầu #3, đã chốt: BE tự thêm)

> Khi 1 `OrderProduct` với `productId` là sản phẩm **normal** có 1+ Tryck product liên kết được thêm vào order (qua `OrderService::add()` hoặc `update()`), backend **tự động tạo thêm dòng `OrderProduct` cho từng Tryck product liên kết**, không cần FE gửi thêm trong payload.

**Đã chốt (Questions #5, #6, #10):**
- 1 normal product có thể liên kết **nhiều** Tryck product (N-N) → khi thêm normal vào order, BE phải add **tất cả** Tryck đang liên kết, không chỉ 1 dòng (Liem: *"yes if have added"*).
- Dòng `OrderProduct` của Tryck **không tính kickback**, chỉ lấy **Price** của chính Tryck product (giá phải khác 0) — không áp công thức giá/kickback như dòng normal (Liem: *"tryck products dont have kickback etc, only price is not zero"*).
- Xoá dòng normal khỏi order → **cascade xoá** luôn (các) dòng Tryck liên kết đã tự thêm trước đó (Liem: *"yes correct"*).

**Đã chốt (Kevin quyết định 24/09/2026, không cần hỏi lại sếp):** Quantity dòng Tryck tự thêm **mặc định = 1**, là dòng độc lập, không tự động sync theo quantity dòng normal khi normal thay đổi quantity sau đó.

> ✅ **Đã code 25/09/2026** — xem `OrderService::addTryckOrderProducts()` (gọi từ trong `OrderService::addProducts()`, dùng chung cho cả tạo mới và sửa order).
>
> **Cột mới** `orders_products.tryck_parent_order_product_id` (int, nullable, migration `Version20260925130000.php`) — lưu **id dòng `OrderProduct` (normal)** đã sinh ra dòng Tryck này (không lưu `normalProductId` — lý do: 1 sản phẩm normal có thể có nhiều dòng trong cùng order, ví dụ khác size/`subProductId`, nên phải trỏ đúng về dòng cụ thể để xoá đúng dòng khi cascade).
>
> **Cơ chế cascade-delete tận dụng lại pattern có sẵn**: `addProducts()` vốn đã coi `data['items']` là toàn bộ danh sách dòng mong muốn của order — dòng nào không có mặt trong `$itemIds` sau khi xử lý hết payload thì bị xoá (đoạn "Remove old current items"). Vì vậy chỉ cần: dòng Tryck tự thêm chỉ được add lại vào `$itemIds` khi dòng normal cha của nó **được gửi lại** trong payload → nếu dòng normal bị xoá (không gửi lại), dòng Tryck con của nó tự động rơi vào diện bị dọn theo, không cần code cascade riêng.
>
> **Idempotent khi lưu lại order nhiều lần**: tra dòng Tryck đã auto-add trước đó qua `tryckParentOrderProductId`, nếu đã tồn tại đúng `productId` thì giữ nguyên (không tạo trùng); nếu quan hệ connect đã bị gỡ giữa 2 lần lưu → xoá dòng Tryck đó đi.
>
> **Bug đã vá khi code**: đoạn dọn dẹp cuối `addProducts()` gọi `updateStock()` cộng trả tồn kho cho mọi dòng bị xoá — phải loại trừ dòng Tryck (`tryckParentOrderProductId` khác null) khỏi lệnh gọi này, vì lúc tạo dòng Tryck không hề trừ tồn kho (đúng theo mục "tồn kho không áp dụng cho Tryck" ở trên) — nếu không loại trừ, field `stock` của Tryck product sẽ bị cộng khống dần mỗi lần order có dòng Tryck bị dọn.
>
> **Bug đã vá khi tự review lại 25/09/2026 (quan trọng)**: `updatePriceKickback($order, true)` — gọi khi **tạo mới order** — tính lại `priceKickback` cho **mọi** dòng dựa trên `procentage`/`fixSum` của chính Product, **ghi đè** luôn `priceKickback = 0` đã set cứng cho dòng Tryck. Trước khi vá, kết quả *vô tình* vẫn ra 0 vì `procentage`/`fixSum` mặc định = 0 và form Tryck không có field nào set 2 giá trị này — nhưng không được đảm bảo (ai đó set `procentage` cho Tryck qua `massedit`/API trực tiếp là kickback rò rỉ ngay). Đã sửa: thêm điều kiện `$product->getProductKind() === Product::PRODUCT_KIND_TRYCK` song song với `isNoKickback()` trong nhánh tính kickback — ép `priceKickback = 0` cho Tryck bất kể `procentage`/`fixSum` là gì. Path `update()` (sửa order) không bị bug này vì gọi `updatePriceKickback($order)` không truyền `isAddNew`, chỉ đọc lại giá trị đã lưu.
>
> **Gap đã vá (quyết định khi review — Kevin xác nhận cần)**: dòng Tryck tự thêm ban đầu **không** copy field Production (`productionId`, `productionPlacements`, `productionPrintFileId`, `productionComment`...) từ chính Tryck product, và không khởi tạo `productionStatusId` — nghĩa là dòng Tryck sẽ bị "vô hình" với các màn hình/cronjob theo dõi tiến độ sản xuất dù bản chất Tryck = sản phẩm in. Đã sửa `addTryckOrderProducts()`: copy toàn bộ field Production từ Tryck product sang dòng `OrderProduct`, và khởi tạo `productionStatusId`/`productionStatus`/`productionStatusColor` (bước đầu tiên) qua `getProductionNextStep()` — y hệt cách dòng normal làm với `item['productionId']` từ payload, chỉ khác nguồn là chính Tryck product thay vì payload FE gửi.
>
> **Hardening thêm khi review**: `ProductService::updateTryckConnections()` thêm `array_unique()` cho `connectedProductIds` (tránh tạo connection trùng nếu FE lỡ gửi id lặp); `addTryckOrderProducts()` thêm guard chống xử lý trùng `tryckProductId` trong cùng 1 lần chạy (phòng data connection cũ đã lỡ bị trùng từ trước khi có `array_unique()`) — cả 2 đều để tránh tạo 2 dòng Tryck trùng nhau cho cùng 1 lần in (tính tiền đôi).
>
> Đã re-test lại toàn bộ (tạo temp Command, rollback transaction, xoá sau khi test) sau các bản vá trên — tất cả pass, bao gồm 1 test riêng dựng kịch bản `procentage=0.15` trên Tryck để xác nhận bug kickback đã hết, và 1 test riêng verify field Production + `productionStatusId` được copy đúng.
>
> **Audit riêng 25/09/2026 — rà soát TẤT CẢ chỗ tạo order trong hệ thống** (UI/API, các nguồn import, ecom — theo yêu cầu review lại toàn diện): tìm thấy `OrderService.php` có **6 chỗ** tạo `new OrderProduct()`, không chỉ mỗi `addProducts()`. Kết quả rà từng chỗ:
> - `add()`/`update()` (UI/API) → qua `addProducts()` → ✅ có logic Tryck
> - `importCsvData()`, `importOrder1239BlavittPOD()`, `importOneOrder()` → cuối cùng đều gọi `$this->update($orderId, ...)` → ✅ có logic Tryck (gián tiếp)
> - `importFromWPData()` (WooCommerce), `importFromDecoNetwork()` (Deco Network) → tạo dòng **trực tiếp** (bypass `addProducts()`), nhưng cả 2 đều có pattern "tạo xong rồi gọi lại `$this->update($orderId, $this->generateItem($order))`" ở cuối hàm → **re-sync qua `addProducts()` lần 2** → ✅ có logic Tryck (đã verify bằng test thật, ban đầu tưởng bug nhưng do data test thiếu `channelId` khiến sản phẩm test bị match nhầm — sau khi sửa fixture thì pass đúng)
> - `__importDecoArtworkProduct()`/`__importDecoDecorationProduct()` (helper phụ của Deco, tạo dòng phí "artwork fee"/"decoration fee" bằng SKU cố định `start`/`DTG`) → **không cần** logic Tryck vì đây không phải sản phẩm catalog thật, không ai gắn kết nối Tryck vào các SKU giả này
> - **`addEcomOrder()` (đơn hàng thật từ ecom checkout) → ❌ KHÔNG có logic Tryck** — đây là gap thật, tạo dòng trực tiếp và **không** có bước resync như 2 hàm import kia. Đã vá: gọi thêm `addTryckOrderProducts()` ngay trong loop tạo dòng. Đã test bằng `EcomOrderTemp`/`EcomOrderTempItem` dựng tay (in-memory, không persist) — pass, dòng Tryck tự thêm đúng.
> - `copyOrder()` (nhân bản order) → clone nguyên xi từng dòng `OrderProduct`, kể cả `tryckParentOrderProductId` → bug nhỏ: giá trị này sau khi clone vẫn trỏ về id dòng của **order gốc**, không phải dòng vừa clone trong order mới → dòng Tryck trong bản copy bị "lạc cha" (tự phục hồi được ở lần sửa order copy tiếp theo, nhưng vẫn nên vá cho sạch). Đã vá: remap lại `tryckParentOrderProductId` sang id mới sau khi clone xong toàn bộ.

---

## Questions / Đã xác nhận

Đợt 1 (đã chốt trước khi hỏi sếp thêm):

| # | Câu hỏi | Trả lời | Ghi chú kỹ thuật |
|---|---------|---------|-------------------|
| 1 | Dùng list `product_type` (POD/STOCK) có sẵn hay field mới để phân biệt normal/tryck? | **Field mới `productKind`** (string, 2 giá trị `normal`/`tryck`), tách biệt hoàn toàn với `type`/`typeId` (POD/STOCK) | Không đụng `StatusList` type `product_type` hiện có |
| 2 | BE tự động thêm Tryck vào order hay chỉ trả data liên kết cho FE tự thêm? | **BE tự thêm** | Cần logic mới trong `OrderService`, không tái dùng flow add-on hiện tại (khác cơ chế trigger) |
| 3 | Tồn kho (`stock`/`manageStock`/`outStock`) có áp dụng cho Tryck không? | **Không áp dụng** | Khớp mockup — Storlekar (nơi duy nhất có field Lager) đã bị xoá hoàn toàn |
| 4 | Product picker chọn sản phẩm thêm vào order có cần loại trừ Tryck không? | **Không cần** — Tryck hiện bình thường như sản phẩm thường trong picker | Không thêm filter loại trừ riêng ở `ProductRepository::query()` cho use-case này |

Đợt 2 — sếp (Liem) trả lời **24/09/2026**, kèm follow-up làm rõ thêm với Kevin cùng ngày:

| # | Câu hỏi gửi sếp | Trả lời | Ghi chú kỹ thuật |
|---|---|---|---|
| 5 | 1 tryck product will have multi normal products. But 1 normal product can belong to ONE or MULTI tryck products? | **Multi** — 1 normal product có thể liên kết nhiều tryck product (quan hệ **N-N** xác nhận). Follow-up Kevin hỏi thêm: "khi add normal vào order, có add TẤT CẢ tryck liên kết của normal đó vào order không?" → Liem: **"yes if have added"** — đúng, add hết tất cả tryck đang liên kết | Bảng nối `products_tryck_connection` thiết kế N-N (không phải 1-N) là đúng hướng đã nháp ở mục API contract; auto-add order phải loop qua **toàn bộ** tryck liên kết, không chỉ 1 |
| 6 | Giá/kickback + quantity của dòng Tryck tự thêm vào order tính thế nào? | **"tryck products dont have kickback etc, only price is not zero"** — dòng Tryck **không tính kickback**, chỉ dùng **Price** của chính Tryck product (giá phải khác 0). Quantity: sếp chưa trả lời rõ phần này trong câu hỏi gộp — **Kevin quyết định luôn (24/09/2026): mặc định = 1**, không sync theo quantity normal | Dòng `OrderProduct` cho Tryck: lấy `price` từ chính Tryck product, **không** áp công thức kickback như dòng normal; `quantity` set cứng = 1 khi tạo dòng |
| 7 | When add/edit tryck product, we keep all tabs same as normal? | Ban đầu: **"no see image, if can move all to one tab information and one tab for connect products"**. Kevin hỏi lại làm rõ ý là **Tabs** (không phải field) vì ảnh mockup chỉ đánh dấu field. Liem xác nhận lại: **"yes tabs, if can only keep 2, one for all information we keep and one for connecting the products"** | **Gộp về chỉ còn 2 tabs**: 1 tab **"Information"** duy nhất chứa toàn bộ field còn giữ lại (kể cả field vốn thuộc tab Produktioner/Inköp cũ), và 1 tab **"Connect products"** riêng để chọn sản phẩm normal liên kết. Không còn tách tab Produktioner/Inköp riêng cho Tryck — xem mục "Chi tiết theo mockup" đã cập nhật lại theo cấu trúc 2 tab này |
| 8 | Color list and size list => Where we get these lists? | **"its new i will send it for you, use add free text and list for autokomplete"** | List Färg/Storlek là **list hoàn toàn mới**, sếp sẽ gửi data sau (chưa có). UI: **free text input + autocomplete gợi ý từ list** (không phải dropdown chọn cứng như nháp ban đầu). Cần API BE trả về list gợi ý cho autocomplete — chờ sếp gửi data trước khi thiết kế field/nguồn list (có thể là `StatusList` type mới, hoặc bảng riêng — chưa chốt, sẽ quyết khi có data mẫu) |
| 9 | On ecom page, tryck products are shown??? | **"no - we dont use this for ecom"** | Tryck **không** hiển thị/không dùng ở webshop `/ecom/...`. Cần loại trừ `productKind = tryck` khỏi listing ecom (khác với câu hỏi #4 ở trên — đó là product picker trong trang **order**, vẫn giữ nguyên không loại trừ; đây là trang **ecom** khách hàng xem, phải loại trừ) |
| 10 | In order, if we remove normal product out of order, do we also remove tryck product? | **"yes correct"** | Cascade xoá dòng `OrderProduct` Tryck liên kết khi xoá dòng normal tương ứng trong `OrderService` |

> ⚠️ **Chưa hỏi nhưng nên bổ sung khi có dịp** (không blocking, có thể để dev tự quyết mặc định hợp lý nếu sếp không đề cập):
> - Nhân viên tự tay xoá riêng dòng Tryck (giữ nguyên dòng normal) → lần save/update order tiếp theo có bị BE tự thêm lại không? (tránh loop khó chịu cho user)
> - **Credit/hoàn tiền**: phát hiện khi audit lại toàn bộ 25/09/2026 — `CreditService.php` thao tác credit theo từng dòng `OrderProduct` riêng lẻ. Khi credit dòng normal có Tryck liên kết, dòng Tryck **không tự động được credit theo** (2 dòng độc lập, credit flow không biết chúng liên quan). Chưa hỏi sếp, chưa code gì cho case này — nằm ngoài phạm vi 10 câu hỏi đã chốt (toàn bộ Q&A chỉ nói về lúc *thêm* vào order).

## Pending Clarification — kỹ thuật, không cần hỏi sếp (verify khi code)

| # | Vấn đề | Hướng xử lý |
|---|---|---|
| 7 | Field "Välj leverantör" ở tab Inköp bind vào field nào trên entity? (`supplierName` là string free-text, không thấy field ID liên kết supplier riêng trong `Product.php`) | Cần đọc code FE (hoặc hỏi FE dev) đang bind field/API nào trước khi xoá ở BE — không phải quyết định nghiệp vụ |
| 8 | WooCommerce (`importWpProducts()`) / Shopify sync cronjob (`ShopifyCloneProductStockCommand`) có cần loại trừ tường minh `productKind = tryck` không, hay chỉ cần default `wooSyncStock=false`/không set `idWp` là đủ an toàn? | Mặc định field đã đủ an toàn (không có `idWp` thì không match được để sync) — verify bằng test case chạy cronjob với data có Tryck, không cần sửa code trước |

---

## API contract / Thiết kế kỹ thuật

### Field mới trên `Product`

| Field | Type | Ý nghĩa |
|---|---|---|
| `productKind` | `string`, nullable, default `'normal'` | `normal` \| `tryck` |

Field `sizeId` (int, đã có sẵn `Product.php:122-123`, getter/setter `getSizeId()`/`setSizeId()` đã tồn tại) — **không còn dự kiến dùng cho dropdown chọn cứng** như nháp ban đầu, vì Questions #8 xác nhận Storlek/Färg dùng free text + autocomplete từ list mới (chưa có data). Có thể vẫn tái dùng cột này để lưu giá trị text đã nhập — verify khi sếp gửi data mẫu.

### Quan hệ "kết nối sản phẩm" *(N-N đã xác nhận — Questions #5)*

1 normal product có thể liên kết nhiều Tryck product, và ngược lại 1 Tryck cũng liên kết nhiều normal → thêm 1 bảng nối mới (không tái dùng `products_add_on` để tránh lẫn với cơ chế add-on hiện có).

> ⚠️ **Chốt lại 25/09/2026 (Kevin quyết định qua trao đổi)**: **KHÔNG** dùng ORM `#[ORM\ManyToMany]`/`JoinTable` kiểu `addOnProducts` (Product.php:49-53) như nháp ban đầu. Lý do: Kevin yêu cầu tường minh không dùng ORM relationship cho quan hệ này, chỉ lưu id thuần để tự tra cứu qua `findBy()`, tránh lazy-load Collection không cần thiết.
>
> Thay vào đó, tạo **entity riêng `ProductTryckConnection`** (bảng `products_tryck_connection`) theo đúng khuôn có sẵn `ProductProductions` (`src/Entity/ProductProductions.php` — entity độc lập, cột FK là `integer` thường, không khai báo ORM relation, query qua `$repo->findBy(['tryckProductId' => ...])`/`findBy(['normalProductId' => ...])`):
>
> | Cột | Kiểu | Ý nghĩa |
> |---|---|---|
> | `id` | PK, sequence `my_products_tryck_connection_id_seq` | |
> | `tryckProductId` | `integer`, nullable | id sản phẩm Tryck |
> | `normalProductId` | `integer`, nullable | id sản phẩm normal (đổi tên từ `connectedProductId` ban đầu theo yêu cầu Kevin) |
> | `dateCreated`/`dateUpdated` | `datetime`, nullable | lifecycle callback chuẩn |
>
> Có index riêng trên cả `tryck_product_id` và `normal_product_id` (2 chiều tra cứu đều cần nhanh: `OrderService::add()` tra theo `normalProductId` — đường nóng, chạy mỗi lần thêm sản phẩm vào order; tab "Connect products" khi sửa Tryck tra theo `tryckProductId` — đường ít chạy hơn).

### `ProductService::generateItem()` / `update()`

Theo convention `ChannelService` nêu ở `CLAUDE.md`: thêm `productKind` (và field liên kết) vào `generateItem()` (đọc) và vào nhánh xử lý payload trong `add()`/`update()` (ghi) — đối chiếu code hiện tại ở `ProductService.php:300-330` (khu vực xử lý `typeId`, `channelId`, `brandId`) làm mẫu.

#### ⚠️ Lưu ý bẫy quan trọng

- `productKind` **không filter được** qua field cũ `typeId`/`type` — nếu FE/BE nhầm lẫn 2 field này (do cùng nằm trên `Product`, cùng mang nghĩa "loại sản phẩm") dễ gây bug hiển thị sai. Cần đặt tên field/getter rõ ràng (`getProductKind()`/`setProductKind()`), tránh trùng với `getType()`/`setType()` hiện có.
- Xoá field ở Inköp tab (mục 4) chỉ là **ẩn ở FE form khi `productKind=tryck`** — theo pattern hệ thống, **KHÔNG xoá cột DB**, các cột (`supplierSKU`, `purchasePriceExcTax`...) vẫn tồn tại trên entity, chỉ không hiển thị/không cho nhập ở form Tryck. BE cần quyết định có validate chặn ghi giá trị vào các field này khi `productKind=tryck` hay không (chưa chốt).

---

## TODO List

> Đã đủ thông tin để bắt đầu code toàn bộ phần Backend. Color/size list (Questions #8) sếp sẽ gửi data sau — có thể code sẵn field free-text + autocomplete UI, chỉ chờ data để nối nguồn list.

### Backend — Entity & Migration
- [ ] `src/Entity/Product.php`: thêm field `productKind` (string, nullable, default `'normal'`) + getter/setter
- [ ] Entity mới `src/Entity/ProductTryckConnection.php` (bảng `products_tryck_connection`, plain columns `tryckProductId`/`normalProductId`, KHÔNG dùng ORM relationship — xem mục API contract) + `ProductTryckConnectionRepository`
- [ ] Color/size: tạm giữ cột `color`/`sizeId` hiện có trên `Product` để lưu giá trị free-text; nguồn list gợi ý autocomplete để sau — chưa cần migration riêng cho tới khi có data từ sếp
- [ ] Migration **viết tay** (không chạy `doctrine:migrations:diff` — xem `Version20260924090615.php` làm mẫu, tránh vụ diff kéo theo DROP TABLE partition `activity_log_yYYYYmMM`): `ALTER TABLE products ADD product_kind`, `CREATE TABLE products_tryck_connection` + 2 index + sequence

### Backend — Service
- [x] `ProductService::generateItem()`: thêm `productKind` vào response
- [x] `ProductService::add()`/`update()`: `productKind` tự ghi qua generic setter loop có sẵn; validate giá trị chỉ `normal`/`tryck` trong `validateData()`
- [x] `ProductService::updateTryckConnections()`: CRUD quan hệ N-N "kết nối sản phẩm" qua payload `connectedProductIds` (mảng id normal product) — chỉ chạy khi `productKind = tryck`; `generateItemDetail()` hydrate ngược `connectedProductIds`/`connectedProducts` để FE hiển thị
- [x] `OrderService::addTryckOrderProducts()`: tự thêm `OrderProduct` cho **toàn bộ** Tryck liên kết khi thêm/giữ sản phẩm normal trong order; giá lấy từ `price` của Tryck product (bỏ qua nếu giá = 0), **không** tính kickback; `quantity = 1` cố định; idempotent qua nhiều lần lưu
- [x] `OrderService`: cascade xoá dòng `OrderProduct` Tryck liên kết khi xoá dòng normal tương ứng — tận dụng cơ chế "Remove old current items" có sẵn trong `addProducts()`, không cần code riêng
- [x] Loại trừ `productKind = tryck` khỏi query listing sản phẩm phía ecom — `ProductRepository::findChannelEcom()` (Questions #9). Đây là **điểm chốt chặn duy nhất** dùng chung cho toàn bộ trang ecom (listing, detail, related, new, popular đều gọi qua hàm này — xem `AppController.php`), nên chỉ cần sửa 1 chỗ. `CartService.php` (giỏ hàng) không cần sửa vì khách không thể lấy được `productId` của Tryck để bỏ vào giỏ ngay từ đầu (không hiện ở listing/search) — giống cách `isAddOnProduct` đang được loại trừ sẵn trong cùng hàm mà không cần chặn thêm ở CartService.

### Backend — Controller & Routing
- [ ] Kiểm tra route `product.yaml` hiện có đã đủ generic (payload tự do qua `generateUpdate()`-style hay field cụ thể) hay cần thêm field khai báo tường minh

### Frontend — React *(repo khác)*
- [ ] Form Redigera produkt: thêm select `productKind` (normal/tryck) — khi chọn `tryck` thì chuyển sang layout **2 tabs** ("Information" gộp + "Connect products"), ẩn/hiện field theo mục "Chi tiết theo mockup"
- [ ] Field Färg (color): free text input + autocomplete gợi ý từ list (list chờ data từ sếp)
- [ ] Field Storlek (size): free text input + autocomplete gợi ý từ list (list chờ data từ sếp)
- [ ] Tab "Connect products" — picker chọn nhiều sản phẩm normal để gắn Tryck
- [ ] Default checkbox "Ta bort från gästportalen" = checked khi tạo mới Tryck product

### Test / kiểm tra
> ✅ **Đã test tay 25/09/2026** — viết 1 Symfony Command tạm (`debug:test-tryck-1628`), gọi thẳng `ProductService`/`OrderService` thật (không mock), bọc trong 1 DB transaction rồi `rollBack()` ở cuối nên **không để lại data test nào trong DB** (đã verify `SELECT count(*) FROM products WHERE sku LIKE 'TEST-1628%'` = 0 sau khi chạy). File command đã xoá sau khi test xong, không nằm lại trong codebase. Kết quả: **17/17 checks pass**.

- [x] `php -l` các file sửa; `php bin/console cache:clear`
- [x] Tạo Tryck product qua API (`ProductService::add()`), verify quan hệ `connectedProductIds` lưu đúng
- [x] Thêm sản phẩm normal có **nhiều** Tryck liên kết vào order → verify BE tự thêm **đủ hết** các dòng Tryck còn giá ≠ 0 (dòng Tryck giá = 0 bị bỏ qua đúng chốt), giá lấy đúng từ `price` của từng Tryck, `priceKickback = 0`, `quantity = 1`
- [x] Lưu lại order y nguyên nhiều lần → không tạo trùng dòng Tryck (idempotent, giữ nguyên `orderProductId`)
- [x] Xoá dòng normal có Tryck liên kết khỏi order → verify (các) dòng Tryck bị xoá cascade theo, dòng normal khác trong order không bị đụng
- [x] Sản phẩm normal **không** có Tryck liên kết → order không bị thêm dòng thừa (regression)
- [ ] Sản phẩm/Order hiện có (không liên quan Tryck) → không bị ảnh hưởng (`productKind` default `normal`) — chưa test riêng, nhưng logic default + `isset()` guard ở mọi chỗ đã đảm bảo về mặt code
- [x] Trang ecom `/ecom/{channelEcomId}/...` → verify Tryck product không xuất hiện trong listing kể cả khi đã active + showOnEcom=true + đúng channel

---

## Các file/files liên quan

| File | Mục đích |
|------|----------|
| `src/Entity/Product.php` | Thêm `productKind` |
| `src/Entity/ProductTryckConnection.php` (mới) | Entity riêng lưu quan hệ N-N tryck ↔ normal, plain columns không ORM relationship (xem mục API contract) |
| `src/Entity/StatusList.php` | Có thể cần thêm type mới cho list gợi ý autocomplete Färg/Storlek — chưa chốt, chờ sếp gửi data (Questions #8) |
| `src/Service/ProductService.php` | `generateItem()`, `add()`/`update()` — đọc/ghi field mới |
| `src/Service/OrderService.php` | Logic tự thêm `OrderProduct` cho Tryck liên kết (khu vực `:870-940`, cạnh xử lý add-on hiện tại) |
| `src/Repository/ProductRepository.php` | Filter theo `productKind` cho danh sách chọn "kết nối sản phẩm" ở FE, và loại trừ Tryck khỏi listing ecom |
| `src/Application/WebshopBundle/...` (xem `docs/spec/` phần webshop bundle) | Loại trừ `productKind = tryck` khỏi trang `/ecom/{channelEcomId}/...` (Questions #9) |
| `src/Repository/OrderProductRepository.php` | `getByOrderId()` — thêm `tryckParentOrderProductId`/`productKind` vào response order item để FE phân biệt dòng Tryck |
| `docs/issues/TSHIRTORDER-1628-tryck-print-product/frontend-api-notes.md` (mới) | Ghi chú API bằng tiếng Anh cho FE (repo khác) — field mới, payload contract, ví dụ JSON |
| `docs/issues/TSHIRTORDER-1594-dynamic-product-variants/` | Design liên quan (chưa implement) — tham khảo để tránh xung đột hướng thiết kế size/color sau này |

---
