# TSHIRTORDER-1627 — Tích hợp nhập đơn từ PINshirt (CSV qua SFTP)

## Yêu cầu gốc

> TSHIRTORDER-1627 New folder integration "Pinshirt " mapp fields
> create new integration on DEV for pinshirt i addded test files etc
>
> they is going to upload files that we need to map and add to files in order Korrekture
>
> create new PINshirt channel
>
> need folders new , prints and archived
>
> all text field add in description internal

Bổ sung từ trao đổi: cũng đọc CSV qua SFTP, xử lý xong thì chuyển file vào thư mục archive. File CSV mẫu: xem `files/pinshirt_order_10005.csv`.

---

## Tổng quan

Thêm một nguồn nhập đơn mới — đối tác **PINshirt** — theo cùng mô hình các đối tác Boltic/Nakata/Blavitt: một Console Command đọc file CSV từ SFTP, tạo `Order` + `OrderProduct` trong channel **PINshirt**, đính kèm file in (PNG) vào phần **Korrektur** của order, rồi chuyển CSV đã xử lý vào thư mục archive. Phạm vi: **chỉ backend** (Cronjob + Service), không có thay đổi API/frontend. Ban đầu làm trên DEV.

**Khác biệt so với 3 đối tác cũ:**
- CSV phân cách bằng `;` (Boltic/Nakata dùng `,`) và có header đặt tên → đọc theo **tên cột**, không theo chữ cột A, B, AH...
- **Một file CSV chứa nhiều order** (đã xác nhận). Mỗi dòng = 1 item của 1 order (`line_no`); các dòng cùng `order_id` thuộc cùng 1 order. File mẫu chỉ có 1 order (3 dòng) nên không thể hiện điều này.
- CSV **không có giá và không có SKU**: SKU của product = `print_id`, giá lấy theo product (product mới tạo = 0).
- Có kèm file PNG cần đính kèm vào order.

### ⚠️ Phát hiện quan trọng: nhiều phần đã có sẵn từ trước

| Hạng mục | File | Trạng thái |
|---|---|---|
| Tạo channel qua `ChannelService::add()` (mẫu tự tạo channel theo `synId`) | `src/Service/OrderService.php:1815-1838` (Nakata), `2040-2055` (Blavitt) | ✅ Có mẫu; channel PINshirt ❌ chưa có |
| Entity `OrderKorrectur` + `OrderService::updateKorrectur()` | `src/Entity/OrderKorrectur.php`, `OrderService.php:1607` | ✅ Đã có |
| `add()` và `update()` đều gọi `updateKorrectur($order, $data)` với `$data['newKorrectur']` | `OrderService.php:1150, 1238` | ✅ Đã có — có thể truyền file thẳng qua `newKorrectur` |
| `OrderProduct.comment` — `addProducts()` đọc `$item['comment']` | `OrderService.php:818, 865` | ✅ Đã có |
| `OrderProduct.productColor` (fallback `Product.color`) | `OrderService.php:822, 960` | ✅ Đã có |
| `Order.internComments` (ghi qua dynamic setter trong `generateUpdate`) | `src/Entity/Order.php:371, 1277` | ✅ Đã có |
| Logic map size → `StatusList::TYPE_PRODUCT_SIZE` + tạo sub-product theo size | `OrderService.php:2215-2255` (`importOrder1239BlavittPOD`) | ✅ Có mẫu để tái sử dụng |
| Thư viện SFTP `phpseclib3\Net\SFTP` | dùng trong `FileService` | ✅ Đã có |
| Command nhập đơn PINshirt, hàm đọc file trong `FileService`, hàm import trong `OrderService` | — | ❌ Chưa có, cần làm |
| Param SFTP cho PINshirt trong `config/services.yaml` | — | ❌ Chưa có, cần làm |

Không cần entity mới, không cần migration.

---

## Luồng hoạt động

### Cấu trúc thư mục SFTP (đã xác nhận `prints` nằm trong `new`)

```
{root}/
├── new/                 ← PINshirt upload CSV vào đây
│   ├── pinshirt_order_10005.csv
│   └── prints/          ← file PNG in, CSV tham chiếu dạng `prints/xxx.png`
│       └── pinshirt_order_10005_item_20008_print_111880.png
└── archived/            ← CSV đã xử lý xong được chuyển vào đây
    └── prints/          ← PNG đã dùng của các CSV đã archive, cùng tên (đã chốt #6)
```

### Các bước của command

1. Kết nối SFTP, liệt kê `.csv` trong `new/` (bỏ `.`, `..`, `prints`, và file không phải `.csv`).
2. Với mỗi file: tải về local, đọc CSV (delimiter `;`, dòng đầu là header, bỏ dòng trống cuối file).
3. **Gom dòng theo `order_id`** (gom theo key, không giả định các dòng cùng order nằm liền nhau; giữ thứ tự xuất hiện đầu tiên). Một file → N order. Gom trước rồi mới xử lý — **không** dùng kiểu từng dòng của `importCsvData` (dòng 2 sẽ thấy order do dòng 1 vừa tạo, rồi theo quy tắc "order đã tồn tại → thay item" sẽ xoá item của dòng 1, cuối cùng order chỉ còn item của dòng cuối).
4. **Với mỗi order (xử lý độc lập, mỗi order 1 lần gọi `importOrderPinshirt()`):**
   1. **Order đã tồn tại** (cùng `orderNr` + `channelId`) → **thay item giống Blavitt**: xoá hết item cũ, insert item mới từ file; header không sửa; tính là "updated" (đã xác nhận). **Trừ khi order đang ở status Complete** (Pending #15) → **không sửa gì**, tính là "skipped" + log warning. Xem mục "Order đã tồn tại".
   2. **Order chưa có:** tạo `Customer` (tìm theo email + channel), tạo `Order` + `OrderProduct` cho từng dòng của order đó; tính là "created".
   3. Với mỗi dòng có `print_file`: tải PNG từ `new/prints/`, đính kèm vào **order đó** qua `newKorrectur`. **Nếu PNG không tồn tại → bỏ qua file đó** (ghi log), các phần khác vẫn import.
   4. Order lỗi (product/size không resolve được, `add()` trả khác 200...) → rollback riêng order đó, ghi `logger->critical` kèm `orderNr` và lý do, **tiếp tục** order kế tiếp — 1 order hỏng không được làm hỏng cả file.
5. Sau khi xử lý hết các order trong file, tổng hợp kết quả (created / updated / skipped / failed) và ghi 1 dòng log tổng kết.
6. **Xử lý xong toàn bộ order trong file thì chuyển CSV sang `archived/`** (tên gắn timestamp `YmdHis_` như các job cũ) — **bất kể có order failed hay không** (đã xác nhận). Order failed chỉ được log, không giữ file lại, **không dùng `dd()`**.

> ⚠️ **Hệ quả cần biết:** vì file đã archive, order failed **sẽ không tự được thử lại** ở lần cron sau. Muốn nhập lại phải để PINshirt gửi lại (hoặc người vận hành đưa file từ `archived/` về `new/`; order đã thành công sẽ **không bị tạo trùng** nhưng sẽ bị **thay item lần nữa** vì quy tắc "order đã tồn tại → xoá item cũ, insert item mới" (đã đồng ý không chống chạy lại; order đã Complete thì bị chặn, Pending #15)). Vì log là kênh duy nhất báo lỗi nên phải ghi đủ `orderNr`, tên file và lý do lỗi.
>
> **Ngoại lệ cấp file** (đã xác nhận): nếu **không xử lý được file** — không tải được, không parse được CSV, thiếu header bắt buộc — thì file chưa được "xử lý" nên giữ nguyên trong `new/` và log critical, không archive.

### Order đã tồn tại (cùng `order_id` xuất hiện ở file khác)

Ví dụ: file 1 có order X với 3 item, file 2 (gửi sau) cũng có order X với 2 item. Các import cũ xử lý 3 kiểu khác nhau (đã đọc code):

| Import | Tìm order theo | Khi order đã có | Với ví dụ trên |
|---|---|---|---|
| `importCsvData` (Boltic, Nakata, Olamerlin) | `orderNr` (**không** lọc channel) | Nạp lại các item hiện có (`generateItem`, kèm `orderProductId`) rồi **nối thêm** item của dòng mới, gọi `update()`. Header không sửa | **5 item** (3 + 2). Gửi lại đúng 2 item cũ vẫn bị thêm → **trùng**, không có check ở mức item; chỉ tránh được nhờ file đã bị archive |
| `importOrder1239BlavittPOD` | `orderNr` + `channelId` | Không sửa header; `items = []` rồi chỉ nạp item của file mới (không có `orderProductId`) | **2 item**: `addProducts()` **xoá mọi item cũ không có trong payload** (`OrderService.php:999-1010`) và tạo lại item mới với id mới. 3 item cũ bị xoá (mất trạng thái sản xuất/incomplete gắn với item cũ), tồn kho được hoàn lại rồi trừ lại |
| `importFromWPData` (Shopify, WooCommerce) | `orderNr` + `channelId` | **Upsert theo `item_id`** (`OrderProduct.itemId`): trùng `item_id` → cập nhật tại chỗ; `item_id` mới → thêm; item cũ không còn trong payload → xoá | Nếu 2 item của file 2 trùng `item_id` với 2 trong 3 item cũ → còn **2 item**, item thứ 3 bị xoá; 2 item được giữ nguyên id/dữ liệu sản xuất |

Áp dụng chung khi gọi `update()`: order đã **cancel** thì trả 400 `Order is cancelled. Cannot edit it`; **không** thấy chặn order đã complete/invoiced (đoạn kiểm tra đó đang bị comment, `OrderService.php:1196-1203`). Nghĩa là import cũ có thể sửa item của order đang sản xuất hoặc đã xuất hoá đơn.

**Đã chốt: PINshirt làm giống Blavitt** — khi order đã có (bước 4.1), **xoá hết item cũ và insert item mới** từ file. Với ví dụ trên, order X còn đúng 2 item của file 2 (3 item cũ bị xoá).

**Cách làm** (theo `importOrder1239BlavittPOD`): gọi `update($orderId, ['items' => [...item mới...]])`. Vì item mới không có `orderProductId` nên `addProducts()` tạo mới, rồi xoá mọi item cũ không có trong payload (`OrderService.php:999-1010`), hoàn lại tồn kho của item cũ, trừ tồn kho của item mới, và `updateSum()` tính lại tổng. Khác Blavitt một chút: Blavitt lấy `generateItem($order)` rồi ghi đè `items`, còn PINshirt chỉ truyền `items` (+ `newKorrectur`) để không ghi lại toàn bộ field header từ dữ liệu cũ — cần kiểm tra khi implement rằng `update()` chạy đúng với payload chỉ có `items`.

**Hệ quả đã biết:**
- Item cũ bị **xoá và tạo lại với id mới** → mất dữ liệu gắn với item cũ (trạng thái sản xuất/incomplete...), giống Blavitt.
- **Header không được cập nhật** (địa chỉ giao, sđt, email... của file mới bị bỏ qua), giống Blavitt — đã đồng ý (#14a).
- **Có chặn thay item khi order đang ở status Complete** (đã đồng ý, #14d, chi tiết Pending #15): code cũ chỉ có `update()` từ chối order đã **cancel** (400 `Order is cancelled. Cannot edit it`), còn đoạn kiểm tra complete/invoiced đang bị comment (`OrderService.php:1196-1203`). PINshirt tự kiểm tra `order_status_id == order_status_complete_id` trước khi gọi `update()`; bị chặn → "skipped" + log warning, không sửa gì. Order đang ở các status khác (kể cả đang xử lý/sản xuất) vẫn bị thay item.
- **Chạy lại cùng file** (vd đưa từ `archived/` về `new/`) cũng xoá và tạo lại item dù dữ liệu không đổi (đã đồng ý **không** làm cơ chế chống, #14c). Order đã Complete thì bị chặn như trên.
- **Korrektur:** PNG của file 2 được thêm vào order; PNG cũ không bị xoá; PNG trùng tên đã có thì bỏ qua (đã đồng ý, #14b).

Không dùng kiểu `importCsvData` (nối thêm) vì gửi lại cùng dữ liệu sẽ nhân đôi item, và không dùng kiểu upsert theo `itemId` của `importFromWPData` vì CSV PINshirt không có cột id item ổn định.

### Mapping field: array (output của `importOrderPinshirtReadFileContent`) → bảng/cột DB

Đầu vào là 1 phần tử của `orders[]`, gồm `order_id`, `header` (cột cấp order, lấy từ dòng đầu) và `items[]` (mỗi dòng 1 item). Tên cột DB theo quy ước snake_case của Doctrine (tên property PHP ghi trong ngoặc). Các dòng đánh dấu *(chờ ...)* là chưa chốt, xem Pending Clarification.

#### 1. `channels` — tìm hoặc tạo 1 lần

| Nguồn | Cột | Ghi chú |
|---|---|---|
| hằng số `'pinshirt'` | `channels.syn_id` | Tìm theo `syn_id` + `date_deleted IS NULL` |
| hằng số `'PINshirt'` | `channels.name`, `channel_name`, `channel_short_name` | Chỉ khi tạo mới |
| hằng số `true` | `channels.active` | Chỉ khi tạo mới |

#### 2. `customers` — tìm theo email + channel, chưa có thì tạo (đã chốt: người đặt = `ordered_by`)

| Nguồn | Cột | Ghi chú |
|---|---|---|
| `header.ordered_by_email` | `customers.email` | `strtolower`, dùng làm khoá tìm cùng `channel_id` |
| `header.ordered_by` | `customers.name` | |
| channel ở bước 1 | `customers.channel_id` | |
| `status_list` (`default_customer_type`) | `customers.type_id` | Như `importCsvData` |
| `status_list` (name `Active`) | `customers.status_id` | Như `importCsvData` |
| — | `customers.active` = `true` | |

Không có dữ liệu billing trong CSV nên `address`, `post_code`, `city`, `country`, `mobile` của customer để trống. Các cột `ship_*` đi vào `orders`, không đi vào `customers`.

#### 3. `orders` — 1 dòng cho mỗi order

| Nguồn | Cột (`Order::$…`) | Ghi chú |
|---|---|---|
| `order_id` | `orders.order_nr` (`orderNr`) | Check trùng cùng `channel_id` + `date_deleted IS NULL`; kiểu string |
| channel ở bước 1 | `orders.channel_id` | |
| customer ở bước 2 | `orders.customer_id` | |
| `header.order_date` | `orders.date_order` (`dateOrder`) | Truyền qua `dateOrderString` dạng `Y-m-d H:i:s`; múi giờ *(chờ #8)* |
| `header.ship_first_name` + `header.ship_last_name` | `orders.name` | `trim("{first} {last}")` |
| `header.ship_street` | `orders.delivery_address` | |
| `header.ship_postalcode` | `orders.delivery_post_nr` | Giữ nguyên `234 31` |
| `header.ship_city` | `orders.delivery_city` | |
| `header.ship_country` | `orders.delivery_country` | `SE` → `Sverige` như `importCsvData` |
| `header.ship_phone` | `orders.mobile_nr` | Giữ số 0 đầu |
| `header.ship_email` | `orders.contact_email` | |
| hằng số `'import_pinshirt'` | `orders.created_from` | Phân biệt nguồn, như Blavitt dùng `'import_blavitt_POD'` |
| hằng số `false` | `orders.always_use_invoice_as_delivery_address` | Vì có địa chỉ giao riêng |
| `status_list` (`order_status_web_id`) | `orders.order_status_id` | Như Blavitt; cần xác nhận đây là status đúng cho đơn PINshirt |
| `header.payment_method` | `orders.payment_method` (text thô, vd `Swish`) + `orders.payment_type_id` / `orders.payment_type` (id + tên từ `status_list`, type `order_payment_type`) | Truyền `paymentMethod` (text) và `paymentTypeId` (id) cho `add()`; `generateUpdate` tự điền `payment_type` = tên của status_list (`OrderService.php:515-525`). Chọn dòng status_list nào cho `Swish` — xem "Payment type" bên dưới và Pending #12. Nếu `payment_method` rỗng thì không truyền `paymentTypeId` (`add()` lấy mặc định của channel/customer) |

Các cột do `add()` tự sinh: `order_count_nr`, `currency_id`, `payment_type_id` (mặc định), `date_created`, và các tổng tiền qua `updateSum()`.

#### 4. `orders_products` — 1 dòng cho mỗi phần tử `items[]`

| Nguồn (`items[].…`) | Cột (`OrderProduct::$…`) | Ghi chú |
|---|---|---|
| `product` (tên loại sản phẩm) + `print_id` (SKU) | `orders_products.product_id`, `product_name`, `product_sku` | **SKU của product = `print_id`** (đã chốt). **Tìm product trong DB theo SKU trước, chưa có thì tạo mới** (đã xác nhận), xem "Luồng tìm hoặc tạo product". `product_name` của item = `product` trong CSV (importer truyền `$item['productName']`, `OrderService.php:816`), nên item vẫn thể hiện đúng loại sản phẩm dù product chung; `product_sku` do `addProducts()` snapshot từ `products.sku` |

**Luồng tìm hoặc tạo product** cho mỗi item (theo mẫu `importCsvData` / `importOrder1239BlavittPOD`):

1. **SKU = `items[].print_id`** (đã chốt, Pending #1). `print_id` rỗng thì order failed (`Item line N has empty print_id`).
2. Tìm trong `products`: `Product::query(['sku' => $sku, 'channelId' => $channelId, 'show_product_children' => 'yes', 'limit' => 1, 'offset' => 0])` — **giống hệt** `importCsvData`, `importOrder1239BlavittPOD`, `importFromWPData`. **Chỉ dùng SKU để tìm** (đã xác nhận), không dùng tên/màu. Hành vi của truy vấn này (`ProductRepository.php:206-253`):
   - SKU so khớp **không phân biệt hoa thường, khớp toàn bộ** (`LOWER(p.sku) LIKE '{sku}'`, không có `%`). Lưu ý: dùng `LIKE` nên ký tự `_` và `%` trong SKU là ký tự đại diện (SKU dạng `ABC_12` có thể khớp nhầm `ABCx12`) — rủi ro thấp, ghi nhận.
   - Chỉ tìm thấy product **đã gắn với channel PINshirt** (`channel.id = channelId`). SKU trùng nhưng đang ở channel khác → coi là chưa có → tạo product mới trong channel PINshirt (2 product cùng SKU ở 2 channel).
   - `'limit' => 1` không có `ORDER BY`: nếu channel có nhiều product cùng SKU thì lấy 1 cái tuỳ ý.
3. **Có** → dùng product đó (`product_id`). **Chưa có** → `ProductService::add()` tạo mới với `channelId`, `sku`, `name` (= `items[].product`), `slug` (`slugify("{name}-{channelId}-{sku}")`), `price = 0`, `taxCode` (= `tax_default * 100`), `active = true`; lấy `id` của product vừa tạo để làm tiếp.
4. Dùng `product_id` đó cùng `size` (tìm/tạo sub-product) để dựng `items[]` cho `add()` của order.

**Màu:** cùng `T-shirt` khác màu mà dùng chung SKU thì chấp nhận (đã xác nhận). Màu của từng dòng lưu ở `orders_products.product_color` (như `importFromWPData`), không dựa vào `products.color`.

**Hệ quả của SKU = `print_id`:** 1 product ứng với 1 mẫu in. Cùng `print_id` ở nhiều dòng hoặc nhiều order thì dùng chung 1 product (đã test: T-shirt M và Hoodie L cùng print 555 → 1 product `555` + 2 sub-product `555_31`, `555_32`; order khác dùng lại print 555 vẫn 1 product). Trường hợp ngược lại — cùng loại sản phẩm nhưng khác mẫu in — sinh ra nhiều product cùng tên. `products.name` của product tạo mới lấy theo `product` của **dòng đầu tiên** dùng print đó nên có thể không khớp loại sản phẩm của các dòng sau; tên loại đúng nằm ở `orders_products.product_name`. Số product sẽ tăng theo số mẫu in khác nhau. Sub-product theo size cũng dùng chung giữa các loại sản phẩm cùng print (T-shirt M và Hoodie M cùng print dùng chung sub-product `{print_id}_{sizeId}` nên chung tồn kho).


**Size** (làm giống `importOrder1239BlavittPOD` / `importOneOrder` / `importFromWPData`; `importCsvData` của Boltic/Nakata bỏ qua size hoàn toàn):
1. Tìm `status_list` (type `product_size`, đang active) theo **tên**, so khớp không phân biệt hoa thường: `M` → id 31, `L` → 32, `One size` → 814 (`ONE SIZE`, không phải id 38 `ONESIZE`). Chưa có thì tạo mới bằng `StatusListService::add()`.
2. Tìm product con (sub-product) của product cha có `size_id` đó (`productParent` + `sizeId`). Chưa có thì tạo: gắn channel, `setProductParent`, `setSize`, `setSizeId`, **SKU = `{sku cha}_{sizeId}`** (dùng id của `status_list`, không dùng tên size — quy ước của `ProductService::importWpProducts`, vd `TEST-PIN-TSHIRT_31`), tên = tên size.
3. Truyền `subProductId` cho item; `addProducts()` điền `product_size_id`, `product_size_text`, `sub_product_id`, `sub_product_sku` cho `orders_products`. Giá vẫn lấy của product cha.
4. Size trống thì không tạo sub-product (Blavitt gọi `$subProduct->getId()` khi `$subProduct = null` nên sẽ lỗi ở trường hợp này; PINshirt đã xử lý).
5. Lưu ý: `updateStock()` luôn trừ tồn kho của sub-product/product theo `quantity` (và có thể đặt `outStock` nếu product bật `manageStock`); import cũ cũng vậy. Product/sub-product mới tạo bắt đầu từ tồn kho 0 nên sẽ âm.
6. Item có `quantity < 1` bị bỏ qua (log warning); nếu mọi item đều bị bỏ qua thì order failed (`Order has no valid item`).


**Khác với các import cũ (đề xuất, xem Pending #13):**
- Product **đã có sẵn** thì **không sửa gì** trên product đó. Các import cũ gọi `setTaxCode(...)` + `flush()` lên product tìm thấy (`importFromWPData`, `importOrder1239BlavittPOD`), nghĩa là mỗi lần import ghi đè thuế của product có sẵn.
- Product **tạo mới** thì không set `color` (các import cũ set `color` của dòng đầu tiên; với SKU dùng chung nhiều màu thì giá trị này sẽ gây hiểu nhầm).
- `ProductService::add()` thất bại thì **order failed** (các import cũ `continue`, bỏ item im lặng).

Product (và sub-product size) tạo mới nên nằm **trong cùng transaction với order**, để order rollback thì không để lại product mồ côi. Cần kiểm tra `ProductService::add()` có tự `flush`/commit độc lập không khi implement.
| `size` | `orders_products.sub_product_id`, `product_size_id`, `product_size_text` | Tra `status_list` (type `product_size`), rồi tìm/tạo sub-product trong `products` (`product_parent_id`, `size_id`) theo logic Blavitt |
| `color` | `orders_products.product_color` (`productColor`) | *(chờ client, #1)* — hoặc chỉ ghi vào comment |
| `quantity` | `orders_products.quantity` | Đã là số nguyên |
| — (CSV không có giá) | `orders_products.price` | **Giá của product nếu product đã có sẵn; product mới tạo thì `0`** (đã chốt). Luôn truyền key `price` cho mỗi item vì `addProducts()` bỏ qua item không có key này (`isset`, `OrderService.php:792`); `isset(0)` vẫn `true` nên `0` hợp lệ. Tổng tiền order = tổng giá product x quantity |
| `line_no` | `orders_products.item_id` (`itemId`, string) | **Đề xuất**: lưu `line_no` để sau này có thể upsert theo item như `importFromWPData` |
| `print_side`, `print_id`, `print_width_cm`, `print_height_cm`, `print_placement`, `other` | **`orders_products.comment`** (`comment`) | Gộp text, xem định dạng bên dưới. Cột đổi từ `varchar(255)` sang **`text`** (migration `Version20260921075000`) |
| `print_file` | *không* vào `orders_products` | Đi vào `order_korrectur` (bảng ngay dưới) |

#### 5. `order_korrectur` — file in đính kèm order (không gắn theo item)

| Nguồn | Cột | Ghi chú |
|---|---|---|
| `items[].print_file` (PNG tải từ `new/prints/`) | `order_korrectur.file_path` | Lưu file vào `public/uploads/orders/{orders.id}/{tên file}`, `file_path` = `uploads/orders/{orders.id}/{tên file}` |
| order vừa tạo | `order_korrectur.order_id` | Mỗi PNG 1 dòng; PNG thiếu thì bỏ qua (đã chốt) |

Ghi qua `$orderData['newKorrectur']` khi gọi `add()` — `add()` tự gọi `updateKorrectur()`.

#### Payment type (`header.payment_method`)

`Order` có 3 field liên quan phương thức thanh toán:

| Cột | Kiểu | Ý nghĩa |
|---|---|---|
| `orders.payment_method` | text | Chuỗi thô do nguồn gửi (webshop ghi `swish`/`svea`; PINshirt sẽ ghi `Swish`) |
| `orders.payment_type` + `orders.payment_type_id` | text + int | Tên + id của dòng `status_list` (type `order_payment_type`). Truyền `paymentTypeId`, `generateUpdate` tự điền `payment_type` = `status_list.name` (`OrderService.php:515-525`) |
| `orders.payment_status` + `payment_status_id` | text + int | Trạng thái thanh toán (`status_list` type `order_payment_status`); **không** set được qua `generateUpdate` (nằm trong `notUpdatedFields`); CSV không có |

Cách các import cũ tìm/tạo dòng `status_list`:
- `importFromWPData`: tìm theo **tên** (`query(['name' => ..., 'type' => 'order_payment_type'])`), chưa có thì `status_list.add()` tạo mới rồi lấy id.
- `addEcomOrder` (webshop): tìm theo **`uniqueKey = '{method}_payment'`**, chưa có thì tạo mới; truyền cả `paymentTypeId` lẫn `paymentMethod`.

⚠️ Trên DB đang cấu hình hiện có **2 dòng Swish** (id `61` tên `Swish`, không có unique_key; id `723` tên `swish`, unique_key `swish_payment`). Tìm theo tên khớp cả hai (`StatusListRepository`: so khớp `LOWER(name)` toàn bộ). **Đã chốt: tìm theo tên, lấy phần tử đầu tiên** như `importFromWPData`. `StatusListRepository::query` mặc định `ORDER BY p.id DESC` (dòng 125) nên phần tử đầu là id lớn nhất, tức **id `723` (`swish`)**, không phải id `61`. Hệ quả: `orders.payment_type` của đơn PINshirt sẽ là `swish` (chữ thường), giống đơn từ webshop. Kết quả phụ thuộc dữ liệu `status_list` của từng môi trường (id trên DEV và production có thể khác).

#### Nội dung `orders_products.comment` cho mỗi item

```
Side: front | Print ID: 111880 | Size: 32 x 43.8 cm | Placement: Centrerat, överkant 10 cm under kragsömmen
```

Thêm `| Other: {other}` nếu `other` không rỗng.

> **Đã đổi `orders_products.comment` từ `varchar(255)` sang `text`** (đã xác nhận), nên không cần cắt chuỗi. Lý do: `OrderProduct` không có ô ghi chú nào kiểu `text` sẵn — `comment`, `productionComment`, `addOnText` đều `varchar(255)`, `OrderProductProduction.comment` cũng 255; cột `text` duy nhất là `texts` nhưng nó **không phải ô ghi chú** (lưu JSON tối đa 3 chuỗi, `addProducts()` chỉ nhận 3 phần tử đầu, `OrderService.php:968-984`; được decode để đưa vào PDF và dùng khi tìm kiếm order) nên không dùng cho print info.
>
> Thay đổi gồm 2 phần:
> - `src/Entity/OrderProduct.php`: `#[ORM\Column(type: 'text', nullable: true)]` cho `$comment`.
> - `migrations/Version20260921075000.php`: `ALTER TABLE orders_products ALTER comment TYPE TEXT`, **viết tay** (đã xác nhận). Không dùng `doctrine:migrations:diff` cho ticket này vì diff hiện đang kéo theo `DROP TABLE` toàn bộ partition `activity_log_yYYYYmMM` (bảng partition do DB quản lý, Doctrine không hiểu) và tạo lại `activity_log` thành bảng thường — chạy vào sẽ mất dữ liệu activity log. `down()` sẽ lỗi `value too long for type character varying(255)` nếu đã có comment > 255 ký tự.
>
> Cột `comment` dùng chung cho mọi order (có `LOWER(p.comment) LIKE` trong `OrderRepository`/`BulkOrderRepository`, vẫn chạy được trên `text`). Cần chạy migration trên từng môi trường (DEV trước) trước khi deploy code import.

**`print_id` là khoá khớp item ↔ file in** (Liem xác nhận): toàn bộ thông tin print đi vào `OrderProduct.comment`, còn `print_id` dùng để đối chiếu dòng item với file PNG trong Korrektur. Tên PNG có dạng `..._print_{print_id}.png` nên người xem order đọc `Print ID` trong comment là tìm được đúng file. Khi tìm PNG để tải, dùng đường dẫn đầy đủ ở cột `print_file` làm chính (tên file có cả order và item nên không nhầm giữa các order); `print_id` dùng để kiểm tra tên file có khớp và làm phương án dự phòng.

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

- **`addProducts()` im lặng bỏ qua item lỗi.** Nó `continue` khi thiếu `productId`/`quantity`/`price` hoặc không tìm thấy product (`OrderService.php:792-794, 813-815`) mà không báo lỗi, và `add()` vẫn trả 200. Order sẽ được tạo **thiếu item mà không có cảnh báo nào**. Vì `price` bắt buộc phải có key (kể cả `0`) nên item không truyền `price` cũng bị bỏ qua. Sau khi `add()` phải so số `OrderProduct` của order với số `items[]` đầu vào; lệch thì coi là failed và rollback.
- **CSV mẫu có UTF-8 BOM (`EF BB BF`) ở đầu file và xuống dòng CRLF** (kiểm tra bằng `xxd` trên bộ test `temp_files/pinshirt-test-import`). Nếu tra cột theo tên header mà không bỏ BOM thì cột đầu thành `"﻿order_id"` và `$row['order_id']` không tìm thấy; `trim()` của PHP **không** bỏ BOM. Cần strip BOM tường minh (và `trim` cả key lẫn value, kể cả ký tự `\r`) trước khi map header. Boltic/Nakata không gặp vấn đề này vì đọc theo chữ cột (A, B, C...), không theo tên.
- `OrderKorrectur` gắn theo **order**, không gắn theo item → nếu order có nhiều dòng, các PNG đều nằm chung trong Korrektur của order. Việc biết PNG nào thuộc item nào chỉ dựa vào tên file (chứa `item_{id}`/`print_{id}`) và `Print ID` trong comment của từng dòng.
- `FileService::uploadFile()` nhận `Symfony\...\UploadedFile` (dùng `getClientOriginalName()`, `move()`), **không nhận đường dẫn file thường**. File PNG tải từ SFTP về local phải bọc thành `UploadedFile` ở chế độ test (`new UploadedFile($path, $name, null, null, true)`) hoặc viết hàm copy riêng. `move()` sẽ xoá file local sau khi chuyển.
- `OrderService::addProducts()` đọc `$item['comment']` với mặc định `''` nếu thiếu key (dòng 818) → nếu sau này có luồng update order từ import mà không gửi lại `comment` thì comment cũ sẽ bị ghi đè rỗng. Với luồng hiện tại (bỏ qua order đã tồn tại) thì không ảnh hưởng.
- Job Boltic/Nakata hiện **archive file ngay cả khi lỗi** và có `dd()` trong khối catch — **không copy pattern này**. Job PINshirt chỉ archive **sau khi đã xử lý xong** file (order failed được log, không giữ file lại), và file không xử lý được ở cấp file thì không archive.
- Credential SFTP của các đối tác cũ đang **hard-code** trong `FileService.php`. Job mới đặt credential vào `config/services.yaml`/biến môi trường và inject qua `ParameterBagInterface`, đúng quy ước `woo_ftp_*` trong `CLAUDE.md`.

---

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

| # | Câu hỏi | Trả lời | Ghi chú kỹ thuật |
|---|---------|---------|-------------------|
| 1 | Cấu trúc thư mục SFTP? | `prints` nằm trong `new` | CSV ghi `prints/xxx.png` → tính từ thư mục `new/` |
| 2 | "Description internal" là field nào? | `OrderProduct.comment` | Ghi theo từng dòng item, không phải `Order.internComments` |
| 3 | Product mapping (tên → SKU), color lưu ở đâu? | **Đang hỏi client** | Xem Pending |
| 4 | Giá và `payment_method`? | Giá: `price = 0` (#12). `payment_method`: ghi vào field payment của order (#14) | Còn chọn dòng Swish: Pending #12 |
| 5 | Chỉ archive khi thành công, check trùng `orderNr`+channel, không `dd()`? | Chuẩn (check trùng + không `dd()`); phần archive được **điều chỉnh ở #7** | |
| 6 | PNG bị thiếu? | Bỏ qua file đó | Ghi log warning, vẫn import order |
| 7 | 1 file có nhiều order, order failed thì sao? | Xử lý xong hết thì archive; order nào fail thì chỉ log | File luôn được archive dù có order failed → order failed không tự retry, xem cảnh báo ở bước 6 |
| 8 | File không xử lý được ở cấp file (không tải được, không parse được CSV, thiếu header bắt buộc)? | Giữ nguyên trong `new/`, không archive | Log critical; khác với #7 vì lúc này chưa có order nào được xử lý |
| 9 | Các cột `print_*` xử lý thế nào? (Liem trả lời trên ticket, 21/09/2026) | Toàn bộ print info ghi vào description internal (`OrderProduct.comment`); `print_id` dùng để khớp với file in | Khớp với câu trả lời #2. Xem ghi chú "`print_id` là khoá khớp" ở phần mapping |
| 10 | `orders_products.comment` chỉ 255 ký tự, đổi sang `text` hay cắt chuỗi? | Đổi sang `text`; migration viết tay | Entity + `Version20260921075000`, chưa chạy. Không dùng `migrations:diff` vì diff kéo theo `DROP TABLE activity_log_*` |
| 11 | Product chưa có trong DB thì sao? | Tìm theo SKU, chưa có thì tạo mới rồi lấy id làm tiếp; SKU = `print_id` (Liem chốt, xem #22) | Xem "Luồng tìm hoặc tạo product" |
| 12 | CSV không có giá, xử lý thế nào? | `price = 0` | Áp dụng cho `orders_products.price` và `products.price` của product tạo mới. Hệ quả: tổng tiền order = 0 (`updateSum`); ảnh hưởng tới hoá đơn/kickback chưa kiểm tra. Trường hợp product đã có sẵn với giá > 0: xem Pending #11 |
| 13 | Cùng `T-shirt` nhưng khác màu mà dùng chung SKU thì sao? | Kệ, chấp nhận; SKU chỉ dùng để tìm product | Logic tìm product giống các import cũ: `Product::query(['sku', 'channelId', 'show_product_children' => 'yes', 'limit' => 1])`. Màu lưu ở `orders_products.product_color` |
| 14 | `payment_method` map vào đâu? | Field payment của order: text + id (id lấy từ `status_list`) — bạn xác nhận | Đúng: `orders.payment_method` (text), `orders.payment_type_id` + `payment_type` (id + tên, `status_list` type `order_payment_type`). Chi tiết lựa chọn dòng Swish: Pending #12 |
| 15 | Order đã tồn tại (file thứ 2 cùng `order_id`, vd file 1 có 3 item, file 2 có 2 item) thì sao? | Làm giống Blavitt: xoá hết item cũ, insert item mới | Order còn đúng item của file mới; item cũ bị xoá và tạo lại với id mới; header không sửa. Header không sửa (a, đồng ý); Korrektur cũ giữ, PNG trùng tên bỏ qua (b, đồng ý); chạy lại file cũ xử lý như file khác, không chống (c, đồng ý); có chặn thay item khi order đang ở status Complete (d, đồng ý; chi tiết: Pending #15) |
| 16 | Log của PINshirt ghi ở đâu? | File log riêng cho PINshirt, không dùng chung log mặc định | Trên PROD log dưới mức `critical` không được lưu ở logger mặc định (`fingers_crossed`), nên nếu dùng chung thì `warning`/`info` bị mất. Cơ chế đã chốt: Monolog channel `pinshirt` + `rotating_file` → `var/log/pinshirt-{Y-m-d}.log`, giữ 30 ngày, level `info` (Pending #16) |
| 17 | Customer của order là ai? (#4, #5) | `ordered_by` / `ordered_by_email` là thông tin customer; tìm theo email + channel như các import khác | Ghi vào `customers`, không vào `orders.intern_comments`. Email được so khớp bằng `=` (không phân biệt hoa thường) sau khi repository trả kết quả, vì query của repository dùng `LIKE` nên `_` trong email là ký tự đại diện |
| 18 | Giá khi product đã có sẵn? (#11) | Dùng giá của product; không có thì `0` | Item `price` = `products.price` của product cha |
| 19 | Payment method chưa có trong status_list, payment status? (#12) | Tạo mới như `importFromWPData`; payment status để mặc định | Chính xác theo đề xuất |
| 20 | Tax code khi product có sẵn? (#13) | `orders_products` có sẵn field tax code; không sửa product | `product_tax_code` của item được `addProducts()` lấy từ product |
| 21 | Size xử lý thế nào? | Làm giống các import cũ (đã đọc lại code) | Tìm/tạo `status_list` `product_size` theo tên rồi tìm/tạo sub-product `{sku cha}_{sizeId}`; xem mục "Size" |
| 22 | Product SKU và giá lấy ở đâu? (Kevin hỏi Liem, 21/09/2026: "We dont have sku, price in csv"; Liem: "Bo price, sku is print id. No price") | **SKU = `print_id`; không có giá** | Bỏ bảng map tên → SKU. Giá = giá product (product mới tạo = 0), khớp #11. Xem "Hệ quả của SKU = print_id" |
| 23 | Size chưa có trong `status_list` (vd `Onesize`, `One-size`) thì sao? | **A: giữ nguyên như các import cũ** — tự tạo dòng size mới | Chấp nhận việc mỗi biến thể chính tả tạo thêm 1 dòng `product_size`; không chặn order. Phương án B (fail order để thêm size thủ công) không chọn |
| 24 | Customer đã tồn tại (cùng email + channel) có cập nhật theo file mới không? | **Không cập nhật**, giữ nguyên như các import cũ | Chỉ tạo customer mới khi chưa có; tên/thông tin của customer có sẵn không đổi. Customer chỉ được tìm/tạo khi tạo order mới, order đã có (thay item) không đụng tới customer |
| 25 | PINshirt SFTP giống ai? (Kevin, 21/09/2026: "pinshirt sẽ giống nakata") | Giống Nakata: cùng server `161.35.83.188`, cổng 22, tài khoản `integration`, thư mục `/home/integration/Pinshirt` | Không phải kiểu Blavitt (FTP thường cổng 21, tài khoản riêng `blavitt_integration`). Đã đặt mặc định trong `services.yaml`; mật khẩu qua `INTEGRATION_FTP_PASSWORD`. Cấu trúc thư mục con (`new`, `prints`, `archived`) theo ticket, chưa xác nhận trên server: Pending #6, #7 |
| 26 | Lưu thông tin SFTP của Nakata vào .env và cho các import dùng chung tài khoản đọc từ đó? (Kevin, 21/09/2026) | **Làm**: host/cổng/tài khoản `INTEGRATION_FTP_HOST/PORT/USERNAME` trong `.env`; mật khẩu `INTEGRATION_FTP_PASSWORD` trong `.env.local` (không đặt trong `.env` vì file này được commit). Boltic, Nakata, Olamerlin, 377 Sport (`FileService`) và PINshirt đều đọc từ đó (`FileService::getIntegrationFtpCredentials()`, param `integration_ftp_*`) | Blavitt (FTP, tài khoản `blavitt_integration`) và `DownPaymentFileService` (tài khoản `sveasftp`) dùng tài khoản khác nên giữ nguyên. **Mỗi môi trường phải đặt `INTEGRATION_FTP_PASSWORD` (và có 3 dòng `INTEGRATION_FTP_*` trong `.env`) trước khi deploy, nếu không 4 import trên sẽ báo lỗi cấu hình và không chạy** |

---

## Pending Clarification

Chưa có câu trả lời — **không tự quyết định** khi implement, đợi xác nhận:

1. ~~**Quy tắc SKU cho product**~~ — **đã chốt (Liem, 21/09/2026): SKU của product = `print_id`, không có giá** ("Bỏ price, sku is print id. No price"). Product tìm theo SKU = `print_id` trong channel PINshirt, chưa có thì tạo mới (price 0); tên loại sản phẩm trong CSV (`T-shirt`, `Hoodie`, `Tygkasse`) không dùng để tìm product mà ghi vào `orders_products.product_name` của từng dòng. Không còn cần bảng cấu hình tên → SKU.
2. ~~**Giá**~~ — **đã chốt: `price = 0`** (cho cả item trong order lẫn product tạo mới), xem Questions #12.
3. ~~**`payment_method` (Swish)**~~ — **đã chốt hướng**: ghi vào field payment của order (text + id từ `status_list`), xem "Payment type" ở mục mapping. Các điểm còn lại tách thành #12.
4. ~~**Text cấp order không có chỗ riêng**~~ — **đã chốt: `ordered_by` / `ordered_by_email` là thông tin customer**, ghi vào `customers.name` / `customers.email` (bảng `customers`), **không** ghi vào `orders.intern_comments`.
5. ~~**Ai là Customer của order?**~~ — **đã chốt: `ordered_by` / `ordered_by_email`**. Các import cũ (`importCsvData`, `importFromWPData`) tìm customer **theo email + channel**, chưa có thì tạo; PINshirt làm giống vậy. Chi tiết: mục "Customer" trong mapping.
6. ~~**Vị trí thư mục `archived`, và khi archive CSV có chuyển luôn các PNG liên quan không?**~~ — **đã chốt (Kevin, 22/09/2026): có, chuyển.** Vị trí `archived` ngang hàng `new` (theo cấu trúc thư mục đã xác nhận ở đầu tài liệu). Khi archive CSV, các PNG đã dùng (đã tải thành công, `print_local_path !== null`) được chuyển từ `new/prints/{tên file}` sang `archived/prints/{tên file}` (giữ nguyên tên, không thêm prefix vì tên PNG đã gắn order/item/print id). Chuyển PNG là **best-effort**: 1 PNG chuyển lỗi chỉ ghi `warning` và ở lại `new/prints/`, không ảnh hưởng tới kết quả của file/order (CSV vẫn đã archive). Đã implement: `PinshirtStorageInterface::archivePrint()` (2 cài đặt `SftpPinshirtStorage`/`LocalPinshirtStorage`), gọi từ `FileService::importOrderPinshirtProcessFile()` sau khi archive CSV thành công.
7. **Thông tin SFTP trên DEV** — **đã chốt một phần (Kevin, 21/09/2026: "pinshirt sẽ giống nakata")**: cùng server `161.35.83.188`, cổng 22, tài khoản dùng chung `integration`, thư mục `/home/integration/Pinshirt` (như `/home/integration/Nakata`). Đã đặt làm giá trị mặc định trong `config/services.yaml`; **mật khẩu** đặt bằng `INTEGRATION_FTP_PASSWORD` trong `.env.local` (đã gitignore, không commit) — thiếu mật khẩu thì command từ chối chạy SFTP. Còn cần xác nhận: (a) thư mục `/home/integration/Pinshirt` trên server đã có `new/`, `new/prints/`, `archived/` chưa; (b) lưu ý Nakata để CSV **trực tiếp** trong thư mục đối tác và archive vào `archive/` (tên khác `archived`), còn PINshirt theo ticket dùng `new/`, `new/prints/`, `archived/` — "giống Nakata" được hiểu là cùng server/tài khoản/kiểu đường dẫn, không phải cấu trúc thư mục con; cần xác nhận (liên quan #6). **Đã thử kết nối thật (21/09/2026, chỉ đọc):** `INTEGRATION_FTP_PASSWORD` đã đặt trong `.env.local` (mật khẩu tài khoản `integration`, lấy từ cấu hình Nakata); đăng nhập SFTP **thành công**, nhưng `/home/integration/Pinshirt/new` **không tồn tại**. Liệt kê tên trong `/home/integration` chỉ thấy `377_sport/`, `Boltic/`, `Nakata/`, `Olamerlin/`, `blavitt_pod/` — **không có thư mục PINshirt nào** (tên thư mục các đối tác không thống nhất chữ hoa/thường, nên tên đúng của PINshirt cũng chưa biết). Ticket nói Liem đã đưa file test lên **DEV**, nên có thể DEV dùng một server SFTP khác `161.35.83.188`. Cần biết: server/host SFTP DEV của PINshirt là gì, hay sẽ tạo `/home/integration/Pinshirt/{new,new/prints,archived}` trên server này (cần người có quyền, và tài khoản đối tác PINshirt dùng để upload).
8. **Múi giờ `order_date`**: CSV có offset `+02:00`. Khi convert sang `Y-m-d H:i:s` lưu theo giờ Thuỵ Điển (giữ nguyên) hay theo timezone của server?
9. **Tần suất chạy cronjob**: cần khai báo trong crontab (nằm ngoài repo) — bao lâu chạy 1 lần?
10. **Dung lượng/số lượng PNG khi 1 file có nhiều order**: `new/prints/` sẽ chứa PNG của nhiều order (liên quan #6). Cần biết quy mô điển hình (bao nhiêu order/file, bao nhiêu PNG) để quyết định tải từng PNG khi xử lý order hay tải hàng loạt trước.
11. ~~**Giá khi product đã có sẵn trong DB với giá > 0**~~ — **đã chốt: product có sẵn giá thì dùng giá của product, không thì `0`** (product tạo mới có giá `0`).
12. ~~**Payment type cho `payment_method`**~~ — **đã chốt**: tìm dòng `status_list` theo tên, lấy dòng đầu tiên (Swish ra id `723`); `payment_method` chưa có trong `status_list` thì tự tạo mới như `importFromWPData`; `payment_status` để mặc định.
14. ~~**Order đã tồn tại: file thứ 2 có cùng `order_id`**~~ — **đã chốt: giống Blavitt, xoá hết item cũ và insert item mới** (mục "Order đã tồn tại"). Các điểm phụ: (a) ✅ **Header** không cập nhật khi file mới khác (như Blavitt) — đã đồng ý. (b) ✅ **Korrektur**: không xoá file Korrektur cũ (nhân viên có thể đã tự đính kèm bản proof); chỉ thêm PNG mới và **bỏ qua PNG trùng tên** đã có trong Korrektur của order (vì `updateKorrectur()` luôn tạo thêm 1 dòng `order_korrectur` cùng `file_path` mỗi lần thêm) — đã đồng ý. (c) ❌ **Chạy lại cùng file**: **không** làm cơ chế chống, xử lý như file khác — item bị xoá và tạo lại (id mới) mỗi lần. (d) ✅ **Có chặn** thay item — điều kiện: order đang ở status Complete (Pending #15).
15. ~~**Điều kiện chặn thay item**~~ — **đã chốt: chặn khi order đang ở status Complete** (`status_list` type `order_status`, `unique_key = order_status_complete`, tên `Komplett`, id `22` trên DB hiện tại). Không chặn theo các status khác. Chi tiết:
    - So sánh `orders.order_status_id` với param **`order_status_complete_id`** trong `config/services.yaml` (hiện `22`), đúng param mà `update()` (`OrderService.php:1222`) và `postOrderUpdateComplete` đang dùng; không hard-code id vì id khác nhau giữa các môi trường. Đối chiếu: id `22` có `unique_key = order_status_complete`.
    - Order đã **invoiced** cũng được coi là đã chặn: `update()` tự chuyển order đã invoiced sang status Complete (`OrderService.php:1220-1235`).
    - Bị chặn → **không thay item**, tính là "skipped", `logger->warning` kèm `orderNr`, tên file, và số item của file không được áp dụng. File vẫn được archive, nên người vận hành phải xử lý tay theo log.
    - Order **cancel** không nằm trong điều kiện chặn: `update()` tự từ chối (400 `Order is cancelled. Cannot edit it`) → tính là failed (rollback + log critical).
    - Hệ quả cần biết: order ở các status "đang xử lý" khác (`Skickat korrektur`, `Ny`, `Pågående`, `Plockad`, `Utskriven`, `Skickas`, `Klar att faktureras`...) **vẫn bị thay item** khi PINshirt gửi file mới.
16. ~~**Cơ chế file log riêng cho PINshirt**~~ — **đã chốt: (A) Monolog channel `pinshirt` + handler `rotating_file`, tên `pinshirt`.** Ghi `var/log/pinshirt-{Y-m-d}.log`, giữ 30 ngày, level `info`, JSON mỗi dòng. **Đã cấu hình và kiểm tra** (dev và prod) trong phần chung của `config/packages/monolog.yaml`:

    ```yaml
    monolog:
        channels:
            - deprecation
            - pinshirt          # thêm
        handlers:
            pinshirt:
                type: rotating_file
                path: "%kernel.logs_dir%/pinshirt.log"   # Monolog tự thêm ngày: pinshirt-2026-09-21.log
                level: info
                max_files: 30                            # tự xoá file cũ hơn 30 ngày
                channels: ["pinshirt"]                   # chỉ nhận bản ghi của channel này
                formatter: monolog.formatter.json
    ```

    - `FileService` và `OrderService` cần thêm argument `LoggerInterface $pinshirtLogger` (autowire theo tên channel) để ghi vào channel này — hoặc tách logic PINshirt sang service riêng. Cách đưa logger vào chưa chốt, chọn khi implement.
    - Bản ghi `critical` của channel này vẫn đồng thời đi vào `prod_critical.log`/`dev_critical.log` (handler `app_critical`/`dev_critical` không lọc channel); ở DEV handler `main` cũng ghi thêm vào `dev.log`.
    - **Kết quả kiểm tra** (chạy `order:import_pinshirt` với file mẫu, file nhiều order có cảnh báo, file lỗi cấp file): (1) DEV — `var/log/pinshirt-2026-09-21.log` được tạo, mỗi dòng 1 JSON (`message`, `context`, `level_name`, `channel: pinshirt`, `datetime`); (2) `APP_ENV=prod` — bản ghi `INFO`/`WARNING` **vẫn nằm trong file log riêng** (không bị `fingers_crossed` loại bỏ); (3) bản ghi `CRITICAL` đồng thời có trong `dev_critical.log` / `prod_critical.log`.
    - Channel chỉ tồn tại khi có service dùng nó (Symfony bỏ service không dùng), nên phải có nơi inject `LoggerInterface $pinshirtLogger`. Hiện `ImportOrderPinshirtCommand` đã dùng (ghi tổng kết file và các cảnh báo parse); `FileService`/`OrderService` sẽ inject khi làm phần import chính.
13. ~~**Khác biệt so với các import cũ**~~ — **đã làm**: (a) product có sẵn thì không sửa `taxCode` — order item tự lấy tax code từ product vào `orders_products.product_tax_code` (đã kiểm tra: product tax 12 → item tax 12), nên không cần ghi đè; (b) product tạo mới không set `color`; (c) tạo product lỗi thì fail cả order.

---

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

- **Command:** `src/Command/Cronjob/ImportOrderPinshirtCommand.php`, `#[AsCommand(name: 'order:import_pinshirt')]` — đặt tên theo `order:import_boltic`/`order:import_nakata`. Chỉ gọi `FileService`, không chứa logic.
- **Kho file `src/Service/Pinshirt/`** (đã làm): `PinshirtStorageInterface` (`listCsvFiles()`, `fetch()`, `archive()`) với 2 cài đặt: `SftpPinshirtStorage` (phpseclib, kết nối lười) và `LocalPinshirtStorage` (thư mục local có `new/` + `archived/`, để test không cần server). `AbstractPinshirtStorage` là **nơi duy nhất kiểm tra đường dẫn**: tên file trong CSV do đối tác gửi nên không được tin, chỉ chấp nhận `tên_file` hoặc `prints/tên_file`; từ chối `..`, đường dẫn tuyệt đối, `\`, thư mục lồng nhau (path traversal).
- **`FileService`** (đã làm, phần tải/parse/archive): `importOrderPinshirtProcess($storage, ?callable $orderHandler, bool $archive)` — liệt kê CSV trong `new/`, với mỗi file: tải về thư mục tạm (mỗi file 1 thư mục, xoá ngay khi xong), `importOrderPinshirtReadFileContent()` parse và **gom dòng theo `order_id`**, tải PNG của từng order, gọi `$orderHandler($order)` (mỗi item có thêm `print_local_path`), rồi archive CSV nếu `$archive`. Lỗi cấp file (không tải/parse được) → log critical, giữ file trong `new/`. Bỏ qua: file không phải `.csv` (kể cả `.CSV` viết hoa thì vẫn nhận), thư mục tên `xxx.csv`, thư mục `prints/`. `$orderHandler` sẽ là `OrderService::importOrderPinshirt()` (chưa làm); handler ném exception thì order tính là failed.
- **Cách chạy command:** `php bin/console order:import_pinshirt [--local-dir=DIR] [--dry-run | --parse-only] [--no-archive] [--dump]`. Không có `--local-dir` thì dùng SFTP theo `PINSHIRT_FTP_*` (host rỗng → từ chối chạy). Thư mục local phải có `new/` (CSV) và `new/prints/` (PNG); `archived/` tự tạo. **Mặc định (import thật)**: tạo/cập nhật order và **archive CSV đã xử lý** (mặc định archive để cron không nhập lại cùng file — order đã có sẽ bị thay item mỗi lần); `--no-archive` để giữ file. `--dry-run`: chạy toàn bộ import trong 1 transaction bao ngoài rồi **rollback**, không upload PNG, không archive (thử an toàn, ví dụ `php bin/console order:import_pinshirt --local-dir=temp_files/pinshirt-local-sftp --dry-run`). `--parse-only`: chỉ tải + parse, không đụng DB, không archive. `--dump` in dữ liệu order đã gom. `--dry-run` và `--parse-only` không dùng chung.
- **Đã test (bằng thư mục local):** sample thật (1 order, 3 item, 3 PNG đều tìm thấy); archive đổi tên có tiền tố timestamp, PNG ở lại `new/prints/`, chạy lại không còn file; file nhiều order; PNG thiếu (chỉ warning); `print_file` độc hại (`prints/../../secret.txt`, `/etc/passwd`, `prints\evil.png`) bị từ chối, file `secret.txt` ngoài `new/` không bị đụng tới; `print_file` rỗng; file lỗi cấp file ở lại `new/` với exit code 1; `.txt` và thư mục `dir.csv` bị bỏ qua; không sót thư mục tạm; SFTP không kết nối được / chưa cấu hình / `--local-dir` sai đều ra lỗi rõ và exit code 1. **Chưa test `SftpPinshirtStorage` với SFTP thật** (chưa có server, Pending #7), mới test đường lỗi kết nối.
- **Đã test importer (harness tạm ngoài repo, 62 kiểm tra đạt, chạy trong transaction rồi rollback trên DB local `127.0.0.1`; DB giữ nguyên số dòng sau khi chạy):** order mới đủ trường (name, delivery, mobile giữ số 0 đầu, ngày giữ giờ trong file, status `WEB`, payment method + payment type, customer, channel); 3 item (product_name, SKU, size id/text, sub-product SKU, màu, comment đủ thông tin print, `item_id` = `line_no`, tax code); 3 dòng `order_korrectur` + file tồn tại trên đĩa; file thứ 2 cùng order → thay item, header không đổi, Korrektur cũ giữ và không nhân đôi PNG trùng tên; order Complete → `skipped`, item giữ nguyên; order cancel → `failed`, item giữ nguyên; `print_id` rỗng → `failed`, không để lại order lẫn product; cùng `print_id` ở nhiều item/order dùng chung 1 product (SKU = `print_id`, sub-product `{print_id}_{sizeId}`); payment method lạ → tạo `status_list` mới; comment 600 ký tự lưu đủ (cột `text`); product có sẵn giá 99.5 → item dùng giá đó, product không bị sửa, tổng tiền > 0; size trống; email có `_` không khớp nhầm khách khác; `quantity 0` bị bỏ qua, mọi item `quantity 0` → `failed`. Chạy end-to-end bằng command với `--dry-run` cho file mẫu thật: 1 order, 3 item, DB không đổi.
- **`PinshirtOrderImporter`** (`src/Service/Pinshirt/`, đã làm) thay cho `OrderService::importOrderPinshirt()` trong kế hoạch ban đầu: tách ra service riêng để không phình thêm `OrderService` (hơn 5000 dòng) và nhận trực tiếp `$pinshirtLogger`; vẫn gọi `OrderService::add()` / `update()` nên dùng lại toàn bộ logic cũ. `import(array $order, bool $dryRun): array` nhận **1 order** đã gom dòng, trả `[status: created|updated|skipped|failed, message?, order_id?, items?]`, chạy trong **1 transaction riêng** (lỗi bất kỳ → rollback riêng order đó, log critical, trả `failed`; sau mỗi order gọi `EntityManager::clear()`). Thứ tự: tìm/tạo channel `pinshirt` → tìm order theo `orderNr` + channel → nếu đã có và đang Complete thì `skipped` → dựng items (tìm/tạo product, size, sub-product) → dựng PNG upload (bỏ PNG trùng tên) → `add()` (order mới: kèm customer, payment type, status `order_status_web_id`) hoặc `update($id, ['items' => ..., 'newKorrectur' => ...])` (order đã có: thay item, header không sửa) → **đối chiếu số item đã lưu với số item đã gửi** (vì `addProducts()` im lặng bỏ qua item lỗi) → log.
- **Transaction:** `add()` `persist`+`flush` order trước khi `addProducts()` và **nuốt exception** (trả 500), nên importer bọc mỗi order trong `beginTransaction()/commit()`, kiểm tra `status_code` rồi `rollBack()`. Đã kiểm tra: order failed không để lại order/item nào. Lưu ý: bộ đếm `AutoCount` nằm trong transaction nên cũng rollback, nhưng **sequence của PostgreSQL thì không** (id order/product nhảy cóc), và file PNG đã `move()` vào `public/uploads/orders/{id}/` không rollback được (thư mục theo id nên không đụng order khác). Nếu Doctrine đóng `EntityManager` sau lỗi flush thì các order còn lại của lần chạy đó cũng failed (`EntityManager is closed`).
- **Channel:** `synId = 'pinshirt'`, `channelName`/`channelShortName`/`name` = `PINshirt`, `active = true`. Tạo tự động nếu chưa có theo mẫu hiện tại; hoặc tạo tay trên DEV rồi job chỉ tra cứu — cần chốt khi làm.
- **`createdFrom`:** đặt `'import_pinshirt'` cho order (Blavitt dùng `'import_blavitt_POD'`) để phân biệt nguồn.
- **Config:** `config/services.yaml` thêm `integration_ftp_host/username/password/port` (+ `.env` nếu dùng biến môi trường). Riêng `remote_path` (`/home/integration/Pinshirt`) không phải secret và cố định theo PINshirt nên **hard-code trực tiếp** ở chỗ khởi tạo `SftpPinshirtStorage` (`ImportOrderPinshirtCommand::REMOTE_PATH`), không đưa vào `services.yaml`.
- **Log:** **file log riêng cho PINshirt** (đã đồng ý), không dùng chung log mặc định như các job SFTP cũ. Mọi chỗ "ghi log" trong doc này là ghi vào log riêng đó. Cơ chế đã chốt: **Monolog channel `pinshirt` + `rotating_file`** → `var/log/pinshirt-{Y-m-d}.log` (Pending #16).

  **Hiện trạng (đã đọc code/config):**
  - Các job SFTP cũ (Boltic, Nakata, Blavitt, Olamerlin, 377 Sport) ghi bằng `LoggerInterface->critical(...)` cho **mọi thứ**, kể cả thông báo "DONE". Nơi ghi: DEV → `var/log/dev.log` (mọi level) và `var/log/dev_critical.log`; PROD → chỉ `var/log/prod_critical.log` (handler `app_critical`, level critical trở lên), xem `config/packages/monolog.yaml`.
  - **Trên PROD, log level thấp hơn `critical` không được lưu ở đâu cả.** Handler `main` là `fingers_crossed` (`action_level: error`): chỉ khi có bản ghi mức error trở lên thì mới xả buffer (tối đa 50 bản ghi) ra `php://stderr`, còn lại bị bỏ. Vì vậy `warning`/`info` như "order bị skip vì đã Complete", "PNG bị thiếu", "order đã cập nhật" sẽ **mất** nếu chỉ dùng logger mặc định. Đây là lý do các job cũ dùng `critical` cho tất cả.
  - Các cronjob khác (Shopify, Woo, down payment) ghi file phẳng JSON-mỗi-dòng theo ngày: `shopify_logs/clone_order_{Y-m-d}.txt`, `woo_stock_logs/export_{Y-m-d}.txt`, `app_logs/down_payment_import/{Y-m-d}.txt`.
  - Mức log dự kiến cho PINshirt: `info` = created / updated / tổng kết file; `warning` = order skipped (đã Complete), PNG thiếu, header lệch giữa các dòng; `error`/`critical` = order failed, lỗi cấp file (không tải/parse được, thiếu header). Bản ghi `critical` vẫn đồng thời đi vào `prod_critical.log`/`dev_critical.log` (handler đó không lọc theo channel).

---

## TODO List

```
### Backend — Config
- [x] config/services.yaml (đã làm; giá trị đọc từ biến môi trường INTEGRATION_FTP_*, mặc định rỗng): thêm param integration_ftp_host / username / password / port (đọc qua ParameterBagInterface). `remote_path` không đưa vào services.yaml — hard-code thẳng trong `ImportOrderPinshirtCommand::REMOTE_PATH` (không phải secret, cố định theo PINshirt, đổi thì sửa code)
- [ ] Trên từng môi trường (.env.local hoặc biến môi trường server): đặt `INTEGRATION_FTP_PASSWORD` (mật khẩu tài khoản `integration`, cùng tài khoản với Nakata). Host `161.35.83.188`, cổng 22, tài khoản `integration`, thư mục `/home/integration/Pinshirt` đã có mặc định trong services.yaml. KHÔNG commit mật khẩu
- [x] Log riêng: config/packages/monolog.yaml thêm channel `pinshirt` + handler rotating_file (path %kernel.logs_dir%/pinshirt.log, level info, max_files 30, channels ["pinshirt"], formatter json) ở phần chung — đã làm và kiểm tra trên dev + prod (xem Pending #16)
- [x] ImportOrderPinshirtCommand nhận LoggerInterface $pinshirtLogger, ghi tổng kết file (info), cảnh báo parse (warning), lỗi cấp file (critical)
- [x] FileService nhận LoggerInterface $pinshirtLogger (đã làm cùng phần tải/parse)
- [x] PinshirtOrderImporter nhận LoggerInterface $pinshirtLogger trực tiếp (không cần thêm vào OrderService)
- [ ] Test SftpPinshirtStorage với SFTP thật (mới test đường lỗi kết nối; chờ credential — Pending #7) — cách thử an toàn khi đã đặt `INTEGRATION_FTP_PASSWORD`: `php bin/console order:import_pinshirt --parse-only --dump` (chỉ đọc + tải về thư mục tạm; không ghi DB, không archive, không sửa gì trên server)
- [x] Thay chặn `--archive` bằng: import thật mặc định archive (`--no-archive` để tắt), `--dry-run` / `--parse-only` không archive
- [x] Chuyển PNG đã dùng sang archived/prints/ khi archive CSV xong (Pending #6, đã chốt: có chuyển) — PinshirtStorageInterface::archivePrint(), gọi sau khi archive CSV thành công, best-effort (lỗi chỉ warning, không đổi kết quả file)
- [x] Thay mọi logger->critical('...') kiểu "DONE"/thông báo của các job cũ bằng đúng level (info/warning/error/critical) ghi vào log PINshirt, mỗi bản ghi kèm orderNr + tên file + lý do — code PINshirt mới đã dùng đúng level; không sửa các job cũ

### Backend — Service
- [x] FileService::importOrderPinshirtProcess() + PinshirtStorageInterface (SftpPinshirtStorage / LocalPinshirtStorage): kết nối SFTP, liệt kê *.csv trong new/ (bỏ ., .., prints, file không phải .csv)
- [x] FileService: tải CSV + các PNG tham chiếu từ new/prints/ về thư mục local tạm; bỏ qua (log warning) khi PNG thiếu
- [x] FileService::importOrderPinshirtReadFileContent(): parse CSV delimiter ';', đọc theo tên header, bỏ dòng trống cuối file, trim giá trị
- [x] FileService: gom dòng theo order_id (theo key, không giả định dòng liền nhau) → mảng order, mỗi order có items[]; lấy header order từ dòng đầu, log warning nếu các dòng cùng order lệch nhau ở field cấp order
- [x] FileService: lặp từng order, gọi callable $orderHandler (sẽ là OrderService::importOrderPinshirt(), chưa làm), gom kết quả created/updated/skipped/failed; 1 order failed không dừng các order còn lại; ghi 1 dòng log tổng kết mỗi file
- [x] FileService: rename CSV sang archived/{YmdHis}_{tên file} sau khi xử lý xong toàn bộ order (kể cả khi có order failed — chỉ logger->critical kèm orderNr + tên file + lý do); không dd()
- [x] FileService: file không tải/parse được hoặc thiếu header bắt buộc → giữ nguyên trong new/ + logger->critical, không archive (đã xác nhận)
- [x] PinshirtOrderImporter($order): nhận 1 order đã gom dòng; tìm/tạo Channel synId 'pinshirt'
- [x] PinshirtOrderImporter: bọc mỗi order trong beginTransaction/commit; add() trả khác 200 → rollback (add() nuốt exception nên phải tự kiểm tra status_code)
- [x] PinshirtOrderImporter: sau add() so số OrderProduct của order với số items[] đầu vào; lệch → rollback + failed (vì addProducts() im lặng bỏ qua item thiếu productId/quantity/price)
- [x] PinshirtOrderImporter: tìm/tạo Customer (email + channel) theo mẫu importCsvData
- [x] PinshirtOrderImporter: tìm Order cùng orderNr + channelId (dateDeleted null); chưa có → tạo mới, đã có → thay item (xem TODO bên dưới)
- [x] PinshirtOrderImporter: map field Order theo bảng mapping (orderNr, dateOrderString, name, delivery*, mobileNr, contactEmail, createdFrom='import_pinshirt')
- [x] PinshirtOrderImporter: tạo OrderProduct từng dòng (product, size, color, quantity, price = 0) — CHỜ sếp chốt SKU
- [x] PinshirtOrderImporter: gộp print_side/print_id/kích thước/placement/other vào item['comment'] (không kèm tên file; không cần cắt vì cột đã là text)
- [x] Quy tắc SKU: product tìm/tạo theo SKU = print_id (Liem chốt); bỏ bảng cấu hình `pinshirt_product_sku_map` không còn cần
- [x] PinshirtOrderImporter: payment — tìm/tạo dòng status_list (type order_payment_type) cho header.payment_method, truyền paymentTypeId + paymentMethod cho add(); rỗng thì không truyền paymentTypeId. Tìm theo TÊN, lấy phần tử đầu tiên (query mặc định ORDER BY id DESC → hiện ra id 723 'swish'); chưa có thì tạo mới như importFromWPData (chờ xác nhận — Pending #12b)
- [x] PinshirtOrderImporter: order đã tồn tại → KIỂM TRA CHẶN trước (Pending #15): order_status_id == param order_status_complete_id (không hard-code id); bị chặn → không sửa gì, trả 'skipped' + logger->warning (orderNr, tên file, số item không áp dụng). Qua được → thay item giống Blavitt: gọi update($orderId, ['items' => [...]]) (+ newKorrectur); addProducts() tự xoá item cũ không có trong payload và tạo item mới; trả 'updated'; header không sửa; update() trả lỗi → failed + rollback; logger->info ghi số item cũ bị thay / số item mới. Kiểm tra update() chạy đúng khi payload chỉ có items. Không có cơ chế chống chạy lại (đã xác nhận)
- [x] PinshirtOrderImporter: Korrektur khi order đã có — không xoá Korrektur cũ; chỉ thêm PNG mới, bỏ qua PNG có tên file trùng với file_path đã có trong Korrektur của order (đã xác nhận)
- [x] PinshirtOrderImporter: đính kèm PNG vào Korrektur qua $orderData['newKorrectur'] (bọc UploadedFile chế độ test, hoặc viết hàm copy riêng — vì FileService::uploadFile chỉ nhận UploadedFile)

### Backend — Command
- [x] src/Command/Cronjob/ImportOrderPinshirtCommand.php: #[AsCommand(name: 'order:import_pinshirt')], gọi FileService + PinshirtOrderImporter, trả Command::SUCCESS/FAILURE (không dd()); có --local-dir/--dry-run/--parse-only/--no-archive/--dump

### Cấu hình môi trường
- [ ] Xác nhận thư mục `/home/integration/Pinshirt` trên server đã có `new/`, `new/prints/`, `archived/` (tạo nếu chưa có; ticket nói file test đã được đưa lên DEV)
- [ ] Khai báo crontab cho order:import_pinshirt (ngoài repo) sau khi chốt tần suất

### Test / kiểm tra
- [x] Chạy với files/pinshirt_order_10005.csv: tạo 1 order (orderNr 10005), 3 OrderProduct đúng size/color/quantity
- [x] OrderProduct.comment mỗi dòng chứa đủ print_side, print_id, kích thước, placement (và other nếu có)
- [x] print_placement/other rất dài (> 255 ký tự) → comment được lưu đủ, không bị cắt, order vẫn được tạo (cần đã chạy migration Version20260921075000)
- [x] Product chưa có trong DB → được tạo mới đúng channel/SKU, order dùng đúng id đó; chạy lại thì không tạo product trùng
- [x] Order bị rollback → không để lại product/sub-product mới tạo — đã kiểm tra (harness S5: item 2 lỗi thì product của item 1 cũng rollback)
- [x] Import file 1 (order X, 3 item) rồi file 2 (order X, 2 item): order X còn đúng 2 item của file 2 (3 item cũ bị xoá), tổng tiền và tồn kho tính lại đúng, header không đổi, file 2 được archive, có log nêu số item cũ bị thay
- [x] Order X đang ở status Complete (kể cả order đã invoiced) nhận file 2 → không bị sửa (item giữ nguyên), ghi nhận skipped + log warning, các order khác trong file vẫn xử lý bình thường
- [x] Order X đã cancel nhận file 2 → `update()` từ chối, ghi nhận failed (log critical), không đổi item, các order khác vẫn xử lý bình thường
- [x] Order X ở status khác Complete (vd WEB, Ny, Pågående) nhận file 2 → item được thay; Korrektur cũ giữ nguyên; PNG trùng tên không tạo thêm dòng order_korrectur
- [x] Order có payment_method `Swish` → `orders.payment_method` = `Swish`, `payment_type_id`/`payment_type` đúng dòng status_list đã chốt; payment_method lạ → theo quyết định Pending #12; payment_method rỗng → payment type mặc định của channel
- [x] 3 file PNG xuất hiện trong Korrektur của order (public/uploads/orders/{id}/)
- [x] Parse đúng header của CSV có BOM + CRLF (file mẫu): `order_id` phải tra được, không bị dính `﻿` hay `\r` — đã kiểm tra (file mẫu thật và file test có BOM + CRLF)
- [ ] Tạo CSV test có nhiều order (file mẫu chỉ có 1 order): gồm cả trường hợp các dòng của cùng 1 order nằm không liền nhau
- [ ] File nhiều order → mỗi order tạo đúng 1 Order với đúng số OrderProduct; PNG của order nào gắn Korrektur của order đó — MỚI KIỂM TRA MỘT PHẦN: gom dòng và điều phối file nhiều order đã test (--parse-only), importer đã test từng order trong harness; CHƯA chạy 1 file nhiều order xuyên suốt qua command vào DB
- [ ] 1 order trong file hỏng (vd product không resolve được) → các order còn lại vẫn được tạo, order hỏng không để lại header thiếu items (đã rollback), có log critical kèm orderNr + lý do, file vẫn được archive — MỚI KIỂM TRA MỘT PHẦN: rollback riêng order hỏng đã test trong harness; CHƯA chạy file nhiều order có order hỏng xen kẽ qua command
- [ ] Đưa file từ archived/ về new/ chạy lại → không có order trùng; order từng hỏng được tạo; order đã có và còn nguyên trạng bị thay item lần nữa (đã xác nhận không chống chạy lại); order đã đi tiếp thì bị chặn (skipped) — CHƯA chạy (cần import thật, chưa có lần chạy commit nào)
- [x] File không parse được / thiếu header → giữ nguyên trong new/, có log critical, không archive — đã kiểm tra bằng thư mục local (bad_cols.csv ở lại new/, log critical, exit 1)
- [ ] CSV được chuyển sang archived/ sau khi xử lý xong; chạy lại không tạo order trùng — MỚI KIỂM TRA MỘT PHẦN: archive đã test khi handler chưa tạo order; CHƯA chạy kết hợp import thật + archive
- [ ] CSV tham chiếu PNG không tồn tại → vẫn import order, log warning, bỏ qua file đó — MỚI KIỂM TRA MỘT PHẦN: tải PNG thiếu chỉ warning đã test; CHƯA import vào DB file có PNG thiếu
- [x] CSV lỗi định dạng → file giữ nguyên trong new/, có log critical, command không dừng bằng dd() — đã kiểm tra bằng thư mục local
- [x] Thư mục new/ trống → command thoát sạch, không lỗi — đã kiểm tra bằng thư mục local ("Files: 0")
- [ ] Sau 1 lần chạy có đủ 4 kết quả (created / updated / skipped / failed): file log PINshirt có đúng bản ghi cho từng order (level đúng, có orderNr + tên file + lý do) và 1 dòng tổng kết mỗi file — MỚI KIỂM TRA MỘT PHẦN: từng kết quả đã test trong harness (log bằng handler test); CHƯA xem file log thật sau 1 lần chạy có đủ 4 kết quả
- [x] Môi trường prod (hoặc APP_ENV=prod): bản ghi warning/info của PINshirt vẫn nằm trong file log riêng (không bị mất do fingers_crossed); bản ghi critical cũng xuất hiện trong prod_critical.log — đã kiểm tra với APP_ENV=prod (info/warning nằm trong file riêng, critical vào prod_critical.log)
```

---

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

| File | Mục đích |
|------|----------|
| `docs/issues/TSHIRTORDER-1627-pinshirt-import/files/pinshirt_order_10005.csv` | CSV mẫu (3 dòng: T-shirt, Hoodie, Tygkasse) |
| `src/Service/FileService.php` | Thêm hàm tải SFTP + đọc CSV PINshirt; `uploadFile()` (dòng 77) chỉ nhận `UploadedFile` |
| `src/Service/OrderService.php` | Thêm `importOrderPinshirt()`; tham khảo `importCsvData()` (1789), `importOrder1239BlavittPOD()` (2037), `updateKorrectur()` (1607), `addProducts()` (783) |
| `src/Entity/OrderKorrectur.php` | Entity file Korrektur gắn với Order |
| `src/Entity/OrderProduct.php` | Field `comment`, `productColor`, `productSizeId` |
| `src/Command/Cronjob/ImportOrderBolticCommand.php` | Mẫu command để copy cấu trúc (nhưng bỏ `dd()`) |
| `config/services.yaml` | Dùng chung param `integration_ftp_*` với Nakata; remote path hard-code trong command, không đưa vào đây |
| `docs/spec/10-cronjob-dinh-ky.md`, `docs/spec/09-tich-hop-ben-thu-ba.md` | Cần cập nhật sau khi làm xong (thêm PINshirt) |
