# TSHIRTORDER-1644 — Tích hợp NWG (Merch): nhập đơn từ FTP + gửi trạng thái qua API

## Yêu cầu gốc

> TSHIRTORDER-1644 NEW integration - NWG we setup FTP already here is the mapping for the fields
> original description in SV in PDF
>
> 1. Product, supplier SKU, and name in the file
> This has been arranged. Each order will now come with an order file, Order-{ordernumber}.csv, located in the order's folder on the FTP alongside the work order and print files. It contains which garment you should use (Supplier SKU), the product name, and our SKU.
>
> 2. Order number
> Yes, SP0590213353056447 is our order number. It is the name of the folder on the FTP, it appears on the work order, and it is listed on every row in the order file. This is the number you map against.
>
> 3. Order format
> The order file contains one row per product, and the order number is repeated on each row: Order number | Line | SKU | Supplier SKU | Product | Size | Quantity | Design version
> SKU is our item code, i.e., what the customer ordered.
> Supplier SKU is the garment you take from your inventory.
> The file is semicolon-separated and encoded in UTF-8. Let me know if you prefer a different format or additional columns.
>
> 4. Statuses
> You send status updates to our API per order using our order number:
> POST /printer/orders/{ordernumber}/status
> We will provide you with a key that you must include in every request header under x-functions-key. I will send the address and the key separately.
> Statuses: InProduction: The order is in production. Shipped: The order, or part of it, has been shipped. Cancelled: The order cannot be fulfilled.
> Examples:
> `{ "status": "InProduction" }`
> `{ "status": "Shipped", "shipment": { "carrier": "PostNord", "trackingNumber": "00370712345678901234", "trackingUrl": "https://..." } }`
> `{ "status": "Cancelled", "message": "Reason" }`
> Entire order in one package: Send Shipped with the tracking number, without specifying lines. Multiple packages: Send one Shipped call per package and specify what the package contains, including SKU (ours or supplier's) and quantity:
> `"lines": [ { "sku": "3410010-100-5", "quantity": 2 } ]`
> The order will be considered partially shipped until everything is delivered. Duplicate calls: Sending the same call multiple times won't cause any issues, so you can retry if you are unsure whether it was received. Non-cancellable: An order that has started shipping cannot be cancelled. Let me know if any of this does not suit your system.
>
> 5. Recurring SKUs
> Every row in the order file includes a Designversion, a code generated based on the print details: print file, technology, placement, size, and font/color for names and numbers. As long as the print details remain identical, the code stays the same across orders and sizes. If anything in the print changes, the line receives a new code, indicating that the product needs to be updated on your end.

Bản gốc tiếng Thụy Điển: email của Robin Persson (NWG) gửi Liem ngày 05/10/2026, xem `files/nwg-email-2026-10-05.pdf`. Ảnh chụp các ví dụ JSON: `files/status-examples-screenshot.png`.

Bộ file mẫu của đơn `SPO590213353056447` (lấy từ `temp_files/Merch-exempelorder-SPO590213353056447`), đã copy vào `files/`:

| File | Nội dung |
|---|---|
| `Arbetsorder-SPO590213353056447.pdf` | Phiếu sản xuất: ngày đặt, mã tham chiếu của khách, **địa chỉ giao hàng**, từng dòng kèm thông tin in (kỹ thuật, mặt in, file, kích thước, vị trí, font/màu), danh sách tên/số, ảnh preview |
| `Namnlista.csv` | Tên/số in theo từng cái áo: `Rad;Plagg;Storlek;Nr;name;number` |
| `404f97ec-test.png` | File in (DTF) mặt trước |
| `3410010-100-front-0-preview.png`, `3410010-100-back-0-preview.png` | Ảnh preview mặt trước/sau |

⚠️ Bộ mẫu cũ này **không còn dùng làm chuẩn**: nó chưa có `Order-….csv`, và NWG đã đổi format (xem bên dưới).

### Cập nhật 08/10/2026: NWG gửi bộ file test cuối cùng (format mới)

NWG nói: *"here is the final files for test, see and be back if have questions"*. Bộ file: `files/test-order-SPO59021261007093015654321/` (đơn `SPO59021261007093015654321`, 2 dòng, 3 áo). Mô tả format từ NWG (bản gốc tiếng Anh, dịch sang tiếng Việt):

Tất cả file phân cách bằng `;`, UTF-8, có dòng header, nối với nhau qua **mã đơn + dòng (`Rad`)**. **Đã xác nhận có BOM** (`EF BB BF`) nên bắt buộc strip.

| File | Nội dung |
|---|---|
| `Order-{nr}.csv` | File import. 1 dòng/sản phẩm, mã đơn lặp lại ở mỗi dòng. 8 cột đầu như cũ: `Ordernummer;Rad;SKU;Leverantör SKU;Produkt;Storlek;Antal;Designversion`. Vì **PDF không còn dùng**, NWG thêm thông tin đơn vào **cuối** mỗi dòng (lặp lại ở mọi dòng): `Orderdatum;Kundens ordernummer;Kundreferens;Kommentar;Leveransnamn;Adress;Adress 2;Postnummer;Ort;Land;E-post;Telefon` |
| `Tryckspec-{nr}.csv` | Thông số in theo từng dòng. 1 dòng cho mỗi phần tử in hoặc text. Cột: `Ordernummer;Rad;Designversion;Typ;Fil eller fält;Beskrivning;Teknik;Sida;Bredd cm;Höjd cm;Placering x;Placering y;Placering bredd;Placering höjd;Rotation;Typsnitt;Färg;Hex`. `Typ` = `Tryck` (file in) hoặc `Text` (tên/số). `Fil eller fält` = tên file in hoặc nguồn của text (`name`, `number`). `Placering x/y/bredd/höjd` = tâm và kích thước của hình in theo tỉ lệ ảnh sản phẩm của mặt đó (0–1). **Nếu mình đã đăng ký `Designversion` này rồi thì có thể bỏ qua các dòng này.** Số thập phân dùng dấu chấm (`6.2`) |
| `Namnlista.csv` | Danh sách tên/số theo từng áo: `Rad;Plagg;Storlek;Nr;name;number`. Chỉ được tạo khi đơn có tên/số |
| File in | Tên file khớp chính xác với `Fil eller fält` trong Tryckspec. Trong đơn test là file placeholder (`aec8004b-testklubben-logo.png`) |

Nội dung đơn test:

| Rad | SKU | Leverantör SKU | Produkt | Storlek | Antal | Designversion |
|---|---|---|---|---|---|---|
| 1 | `3410010001-100-5` | `3410010-100-5` | UM T-Shirt (OCS) Regular Fit Unisex | M | 2 | `b7cadba6b5bb` |
| 2 | `3410010001-100-6` | `3410010-100-6` | UM T-Shirt (OCS) Regular Fit Unisex | L | 1 | `b7cadba6b5bb` |

Thông tin đơn (giống nhau ở cả 2 dòng): ngày `2026-09-28`, mã đơn khách `utm-0928-1`, tham chiếu `Johan Svensson`, ghi chú `Leverans före fredag`, giao tới `AIK Fotboll, Solnavägen 1, 171 41 Solna, SE, kansli@aik.se, 08-123 45`.

Tryckspec (lặp lại y hệt cho Rad 1 và 2): logo `aec8004b-testklubben-logo.png` mặt `front` (DTF, 6.2×5.8 cm); text `name` mặt `back` (25×5 cm, font Standard, Opaque White `#FFFFFF`); text `number` mặt `back` (20×16 cm, cùng font/màu).

Namnlista: Rad 1 có 2 áo (ANDERSSON 10, BERG 7), Rad 2 có 1 áo (CEDERLUND 23). Số dòng Namnlista khớp `Antal`.

**Những điều bộ file mới đã giải quyết** (chi tiết ở Pending bên dưới): #1 (header), #2 (chữ `O`), #3 (địa chỉ), #5 (thông tin in), #12 (cách đọc Namnlista).

---

## Tổng quan

Tích hợp với đối tác mới **NWG** (shop merch, người liên hệ: Robin Persson). NWG là khách hàng, bên mình là xưởng in (POD). Tích hợp có 2 chiều:

1. **Chiều vào (Cronjob):** NWG tạo **một folder cho mỗi đơn** trên FTP, tên folder là mã đơn. Mình đọc `Order-{ordernumber}.csv` cùng các file đi kèm (phiếu sản xuất PDF, Namnlista, file in, preview), rồi tạo `Order` + `OrderProduct` trong channel NWG. Cách làm giống import PINshirt (TSHIRTORDER-1627).
2. **Chiều ra (gọi HTTP sang NWG):** khi đơn đổi trạng thái thì gửi `POST /printer/orders/{ordernumber}/status` sang API của NWG (`InProduction` / `Shipped` + tracking / `Cancelled`). Phần này **chưa có tiền lệ** trong codebase với đúng kiểu này. Gần nhất là các luồng resync khi đơn Complete (Shopify fulfillment, Deco, WP) trong `OrderService::postOrderUpdateComplete()`.

Phạm vi: chủ yếu backend (Cronjob + Service + entity/migration cho trạng thái đồng bộ). Frontend có thể cần nút gửi lại hoặc hiển thị trạng thái đồng bộ (repo React, chờ chốt).

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

| Hạng mục | File | Trạng thái |
|---|---|---|
| Khung import qua SFTP: storage interface (SFTP + local để test), option `--local-dir/--dry-run/--parse-only/--no-archive/--dump`, mỗi order chạy trong transaction riêng, kiểm tra số item đã lưu | `src/Command/Cronjob/ImportOrderPinshirtCommand.php`, `src/Service/Pinshirt/*` | ✅ Có mẫu để làm theo. Khác ở chỗ PINshirt là **1 file CSV chứa nhiều order**, còn NWG là **1 folder cho mỗi order** |
| Tìm/tạo channel theo `synId`, tìm/tạo customer, product theo SKU, sub-product theo size, đính file vào Korrektur | `PinshirtOrderImporter::findOrCreateChannel/Customer/Product/SubProduct`, `buildUploads` | ✅ Có mẫu |
| Credential SFTP dùng chung `INTEGRATION_FTP_*` (param `integration_ftp_*`) | `config/services.yaml:23-32`, `.env:55-58` | ✅ Có. Dùng được nếu NWG upload lên cùng server `integration` (chờ xác nhận) |
| Field theo từng dòng `OrderProduct.modelSku` / `modelName` (mẫu áo phôi, TSHIRTORDER-1642) và `supplierSku` / `supplierColor` (TSHIRTORDER-1616, nhập tay) | `src/Entity/OrderProduct.php` | ✅ Có. Chưa chốt cột `Leverantör SKU` vào field nào |
| Lưu tracking PostNord khi tạo label: `postNordShipmentId`, `postNordTrackingUrl` (chỉ của kolli đầu tiên); tất cả kolli lưu serialize trong `postNordLatestData` | `src/Service/PostNordService.php:517-672` | ✅ Có |
| Tạo label PostNord → order chuyển sang **Complete** → gọi `postOrderUpdateComplete()` (rẽ nhánh theo `createdFrom`: Svea, `shopify`, `deco`, còn lại là WP resync) | `PostNordService.php:595-620`, `OrderService.php:4770` | ✅ Có. Đây là chỗ móc lệnh gửi `Shipped` |
| Mẫu "đánh dấu cần resync + ngày resync" để cron gửi lại khi lỗi | `Order::$decoNeedResync`, `$decoResyncDate` | ✅ Có mẫu |
| Cronjob gửi lại đơn đã Complete | `src/Command/Cronjob/ResyncWpCompletedOrderCommand.php` | ✅ Có mẫu |
| Chức năng huỷ đơn (TSHIRTORDER-1576) | `docs/issues/TSHIRTORDER-1576-cancellation-function.md` | ⚠️ **Không dùng được cho NWG**: đây là luồng khách tự huỷ/credit trên webshop (tạo `Credit`, chỉ status New/Web/Plockad). Huỷ cả đơn của staff là cờ `Order::$cancel` (set qua `update()`), hiện **chưa có hook** nào. Xem mục "Đối chiếu với code hiện tại" |
| Command import NWG, client gọi API trạng thái NWG, field lưu trạng thái đã gửi | — | ❌ Chưa có, cần làm |

---

## Đối chiếu với code hiện tại (08/10/2026)

Kết quả đọc `PinshirtOrderImporter`, `FileService::importOrderPinshirtProcess`, `ImportOrderPinshirtCommand`, `OrderService` (`add`, `update`, `addProducts`, `postOrderUpdateComplete`), `PostNordService::orderPostnordCreateLabel`.

### Khớp (dùng lại được)

- `OrderService::add()` gán field theo setter động (`'set' . ucfirst($key)`), nên field mới thêm vào `Order` có setter là nhận được ngay qua `add()`/`update()`, không cần sửa service.
- `addProducts()` đã nhận `modelSku`, `modelName`, `supplierSku`, `supplierColor`, `itemId`, `comment`, `texts`, `productionPlacements`, `productionPrintFileId`.
- Mẫu "transaction riêng cho mỗi order", kiểm tra số item đã lưu, `firstExact()` (repository dùng `LIKE`), tìm/tạo channel theo `synId`, tìm/tạo sub-product theo size.
- PostNord tạo label xong set Complete rồi gọi `postOrderUpdateComplete()`: có điểm để móc `Shipped`.

### Bất cập / chỗ lệch cần xử lý

1. **Khác cấu trúc nguồn.** PINshirt: 1 CSV chứa nhiều order trong `new/`, archive là chuyển file CSV. NWG: 1 folder cho mỗi order, nhiều file. Nên `PinshirtStorageInterface` (`listCsvFiles`/`fetch`) và `FileService::importOrderPinshirtProcess*` không dùng lại trực tiếp được: cần interface mới liệt kê folder, và 2 hàm này gắn cứng logger/tên tạm của PINshirt.
2. **Hằng số path nằm trong command.** `REMOTE_PATH = '/home/integration/Pinshirt'` đặt cứng trong command (còn `pinshirt_ftp_remote_path` trong `services.yaml` thì command không dùng). NWG chưa biết path: nên làm param/env ngay từ đầu.
3. **Mapping giao hàng trong bản doc trước bị sai** (đã sửa ở bảng mapping): nhãn PostNord dùng `name`, `mobileNr`, `contactEmail`, `contactName`, `deliveryAddressOther` (c/o), không dùng `deliveryName`/`deliveryPhone`. Nếu map nhầm thì label thiếu tên và sđt người nhận.
4. **SKU và size trùng lặp.** SKU của NWG đã kèm mã size (`3410010001-100-5` cho M, `-6` cho L). Importer PINshirt tạo product cha theo SKU rồi tạo sub-product theo size, nếu lấy SKU NWG làm SKU product thì mỗi size thành 1 product cha riêng và còn sub-product bên trong. PINshirt tránh điều này vì lấy `print_id` (theo thiết kế) làm SKU product. Phương án phù hợp nhất là dùng **`Designversion`** làm SKU product (đúng ý NWG: đổi thiết kế → mã mới → product cần cập nhật), còn `SKU`/`Leverantör SKU` lưu ở field của dòng. **Cần sếp chốt (#4, #11)**, không tự quyết.
5. **Không có "InProduction" tương ứng.** Status đơn là dữ liệu trong `status_list` (có `order_status_new/web/plockad/utskriven/complete`...), không có hook nào khi đổi status ngoài nhánh Complete. Gửi `InProduction` phải chọn status nào (vd Plockad/Utskriven) và thêm hook vào `update()` (so `$oldStatusId`/`$newStatusId`, như nhánh Complete ở `OrderService.php:1516`). Chưa chốt (#9).
6. **`postOrderUpdateComplete()` chạy cả khi user đổi status Complete bằng tay** (không có label PostNord): lúc đó `postNordShipmentId` rỗng. Nhánh `import_nwg` phải xử lý trường hợp không có tracking (không gửi `Shipped` rỗng).
7. **Hàm `postOrderUpdateComplete()` bắt đầu bằng nhánh Svea** (`getSveaOrderId()`), rồi shopify, deco, cuối cùng WP. Order NWG không có Svea nên không vướng, nhưng nhánh `import_nwg` phải thêm **trước** nhánh WP, nếu không sẽ ra lỗi "Resync url is empty".
8. **Huỷ đơn chưa có hook.** `update()` chặn sửa khi `isCancel()`, và cờ `cancel` được set qua setter động, không có chỗ nào để gửi `Cancelled`. Phải thêm hook khi `cancel` chuyển `false → true`, đồng thời chặn huỷ khi đã gửi `Shipped`. Doc 1576 không liên quan.
9. **Trả về từ `postOrderUpdateComplete()` bị bỏ qua** ở cả `PostNordService` lẫn `update()` (không kiểm tra kết quả). Nên việc "đánh dấu cần gửi lại + cron retry" là bắt buộc, vì lỗi sẽ không ai thấy.
10. **Tên/số theo áo không vừa chỗ.** `addProducts()` cắt `texts` còn tối đa 3 chuỗi mỗi dòng, mà dòng NWG có `Antal` áo, mỗi áo 2 giá trị (name, number). Không lưu được dạng có cấu trúc, nên cần chốt #12 (gộp vào `comment`/field mới, hoặc đính `Namnlista.csv` vào Korrektur).
11. **Giá = 0 và stock.** Product mới tạo giá 0 (như PINshirt), và `addProducts()` vẫn trừ stock theo `quantity` của product/sub-product (`updateStock`). Với sub-product mới thì stock bắt đầu 0 nên sẽ âm. PINshirt có cùng hành vi, nhưng cần xác nhận chấp nhận được với NWG (hàng lấy từ kho riêng của mình).
12. **Cron import ghi đè.** Đơn đã tồn tại và chưa Complete thì PINshirt xoá hết item cũ và ghi lại; ở NWG nếu không archive folder thì mỗi lần chạy lại ghi đè (kể cả sửa tay của xưởng). Cần chốt #6 và #10 trước khi chạy cron thật.
13. **Chưa có cấu hình môi trường.** `.env` chưa có `NWG_*`; `.env` đang có thay đổi cục bộ chưa commit (thông tin Svea/DB local), **không commit file này** khi làm task NWG.

---

## Luồng hoạt động

### 1. Cấu trúc FTP (theo email; chưa thấy trên server)

```
{root NWG}/
├── SPO590213353056447/                 ← 1 folder = 1 đơn, tên = mã đơn
│   ├── Order-SPO590213353056447.csv    ← file chính
│   ├── Tryckspec-SPO590213353056447.csv ← thông số in theo dòng
│   ├── Namnlista.csv                   ← chỉ có khi đơn có in tên/số (NWG xác nhận)
│   └── aec8004b-testklubben-logo.png   ← file in (tên khớp "Fil eller fält" trong Tryckspec)
└── SPO.../
```

(Format mới 08/10/2026: **không còn** Arbetsorder PDF và ảnh preview. Chưa xác nhận path/folder thật trên FTP, vì bộ test chỉ là file đã copy ra.)

Chưa biết: path gốc trên server, server nào (có phải `161.35.83.188` / tài khoản `integration` như Nakata/PINshirt không), và sau khi xử lý thì folder có được chuyển sang `archived/` không (xem Pending #6, #7).

### 2. Import đơn (cron, đề xuất `order:import_nwg`)

1. Liệt kê các folder con trong root NWG (bỏ `archived/`).
2. Với mỗi folder: **chỉ xử lý khi đã có `Order-{folder}.csv`**. Chưa có thì bỏ qua và để lần chạy sau (có thể NWG đang upload dở; chờ xác nhận thứ tự upload, Pending #7).
3. Đọc CSV: phân cách `;`, UTF-8, **strip BOM** (file mẫu có BOM), `trim` cả key lẫn value kể cả `\r` (bài học từ PINshirt). Đọc theo **tên cột** (header tiếng Thụy Điển, đã xác nhận). Cột thông tin đơn (`Orderdatum`...`Telefon`) lặp lại ở mọi dòng: lấy từ dòng đầu và kiểm tra các dòng còn lại giống nhau.
4. Kiểm tra: mọi dòng có cùng `Ordernummer` và trùng tên folder. Lệch thì coi là lỗi cấp folder: log critical, không import.
5. Đọc `Tryckspec-{nr}.csv` (nối theo `Rad`) và `Namnlista.csv` nếu có (nối theo `Rad`). Tên file in trong Tryckspec phải tồn tại trong folder.
6. Import 1 order trong transaction riêng (giống `PinshirtOrderImporter::import()`): tìm/tạo channel `nwg`, tìm order theo `orderNr` + `channelId`, tạo mới hoặc xử lý đơn đã tồn tại (Pending #10).
7. Đính kèm file vào Korrektur của order: file in PNG (và có thể Namnlista, chờ #12).
8. Xong thì archive folder (Pending #6) và ghi log vào file log riêng (Monolog channel `nwg`, giống `pinshirt`).

### 3. Mapping field (đề xuất; các cột CSV theo bảng trong email)

Theo quy ước đã chốt (memory "literal field mapping"): **cột mới của file import thì map 1:1 vào field mới, không diễn giải lại**. Phần cần sếp chốt được đánh dấu *(chờ #...)*.

#### `orders`

| Nguồn | Cột | Ghi chú |
|---|---|---|
| `Ordernummer` (= tên folder) | `orders.order_nr` | Khoá để map và để gọi API trạng thái. **Phải giữ nguyên chính xác**. File mẫu dùng chữ `O` (`SPO...`), cột `varchar(255)` đủ chứa mã 26 ký tự |
| hằng số `'import_nwg'` | `orders.created_from` | Dùng để rẽ nhánh trong `postOrderUpdateComplete()` |
| channel `synId = 'nwg'` | `orders.channel_id` | Tìm theo `synId`, chưa có thì tạo mới như PINshirt (đã chốt, Questions #4) |
| — | `orders.customer_id` | Customer là ai: NWG (1 customer cố định) hay người nhận? *(chờ #8)* |
| `Leveransnamn` | `orders.name` | ⚠️ **Không phải `delivery_name`**: nhãn PostNord (`PostNordService::orderCreateLabelGenerateShipmentData`) lấy tên người nhận từ `Order::getName()`, PINshirt cũng map vào `name` |
| `Adress` | `orders.delivery_address` | ✅ Chỉ là phần đường/số nhà (nhãn PostNord dùng `streets: [deliveryAddress]`) |
| `Adress 2` | `orders.delivery_address_other` | ✅ Nhãn PostNord đưa vào `careOfName` (c/o). Cần xác nhận `Adress 2` của NWG có nghĩa c/o không |
| `Postnummer`, `Ort`, `Land` | `orders.delivery_post_nr`, `delivery_city`, `delivery_country` | ✅ Nhãn nhận `SE`/`Sverige`/`Sweden` đều ra `SE`, các nước khác truyền nguyên giá trị (cần mã ISO 2 ký tự) |
| `Telefon` | `orders.mobile_nr` | ⚠️ Không phải `delivery_phone`: nhãn PostNord dùng `getMobileNr()` (`smsNo`) |
| `E-post` | `orders.contact_email` | ⚠️ Nhãn PostNord dùng `getContactEmail()`. Không dùng `customer_email` (nằm trong danh sách field không cho update, lấy từ customer) |
| `Orderdatum` | `orders.date_order` | ✅ Qua key `dateOrderString` (`Y-m-d H:i:s`), CSV chỉ có ngày nên cần ghép `00:00:00` |
| `Kommentar` | `orders.comment` | ✅ Field đã có |
| `Kundreferens` (vd `Johan Svensson`) | `orders.contact_name`? | `contactName` được nhãn PostNord dùng làm người liên hệ, nhưng đây là "tham chiếu của khách" chứ chưa chắc là tên người nhận. *(chờ #17)* |
| `Kundens ordernummer` (vd `utm-0928-1`) | ? | ❌ Chưa có field phù hợp. Theo quy ước map 1:1 thì thêm field mới *(chờ #17)* |
| `status_list` (`order_status_web_id`) | `orders.order_status_id` | Như PINshirt *(chờ #9)* |

#### `orders_products` (mỗi dòng CSV = 1 item)

| Cột CSV | Cột DB | Ghi chú |
|---|---|---|
| `Rad` | `orders_products.item_id` | Như PINshirt (`line_no`); dùng để nối với `Namnlista.csv` |
| `SKU` (vd `3410010001-100-5`, mã hàng của NWG) | ? | *(chờ #4)*: SKU của product hay field mới? |
| `Leverantör SKU` (vd `3410010-100-5`, áo phôi lấy từ kho mình) | ? | *(chờ #4)*: `modelSku` (TSHIRTORDER-1642), `supplierSku` (TSHIRTORDER-1616) hay field mới? Cũng là SKU mình gửi lại trong `lines` khi ship từng phần |
| `Produkt` (vd `Core Unify Tee`) | `orders_products.product_name` | Như PINshirt |
| `Storlek` (vd `M`, `L`) | `sub_product_id`, `product_size_id`, `product_size_text` | Tìm/tạo sub-product theo size như PINshirt |
| `Antal` | `orders_products.quantity` | |
| `Designversion` (vd `b7cadba6b5bb`) | field mới? | *(chờ #11)* |
| Namnlista `name`, `number` theo từng áo | ? | *(chờ #12)*: có đủ dữ liệu, chỉ chờ chốt nơi lưu |
| Tryckspec (kỹ thuật, mặt, kích thước, vị trí, font/màu) | ? | *(chờ #5, #11)*: có trong `Tryckspec-….csv`, lưu ở đâu chưa chốt. Bỏ qua được nếu Designversion đã đăng ký |
| — (CSV không có giá) | `orders_products.price` | Giá của product, product mới tạo thì `0` như PINshirt *(chờ #13)* |

### 4. Gửi trạng thái sang NWG

Endpoint: `POST {NWG_API_BASE_URL}/printer/orders/{orderNr}/status`, header `x-functions-key: {NWG_API_KEY}`, `Content-Type: application/json`. Base URL và key NWG sẽ gửi riêng. Đề xuất để trong `.env` (URL) và `.env.local` (key, không commit), inject qua `ParameterBagInterface`.

| Trạng thái NWG | Body | Khi nào gửi (đề xuất, *chờ #9*) |
|---|---|---|
| `InProduction` | `{ "status": "InProduction" }` | Khi order chuyển sang status sản xuất (status nào?) hoặc ngay sau khi import thành công |
| `Shipped` (cả đơn 1 kiện) | `{ "status": "Shipped", "shipment": { "carrier": "PostNord", "trackingNumber": ..., "trackingUrl": ... } }` | Trong `postOrderUpdateComplete()` khi `createdFrom == 'import_nwg'`, tức sau khi tạo label PostNord. `trackingNumber` = `postNordShipmentId`, `trackingUrl` = `postNordTrackingUrl` |
| `Shipped` (nhiều kiện) | như trên + `"lines": [ { "sku": ..., "quantity": ... } ]` | *(chờ #14)* |
| `Cancelled` | `{ "status": "Cancelled", "message": "..." }` | Khi huỷ order (luồng TSHIRTORDER-1576). Lý do lấy từ đâu? Không được huỷ khi đã gửi `Shipped` |

**Retry:** NWG nói gửi trùng không sao, nên có thể gửi lại thoải mái. Đề xuất làm giống Deco: lưu trạng thái gửi trên order (cần gửi lại không, lần gửi cuối, lỗi cuối) và có cron gửi lại các lệnh lỗi. Mọi request/response ghi vào log `nwg`.

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

- **Nhiều kolli PostNord:** `orderPostnordCreateLabel()` tạo `nr_of_kolli` label, nhưng order chỉ lưu tracking của **kolli đầu tiên** (`i == 0`); các kolli còn lại chỉ nằm trong `postNordLatestData` (serialize). Hệ thống cũng **không biết kolli nào chứa dòng nào**. Vì vậy chưa gửi được `Shipped` theo từng kiện có `lines`. Đề xuất: luôn gửi 1 lệnh `Shipped` cho cả đơn, không kèm `lines` (Pending #14).
- **`postOrderUpdateComplete()`** rẽ nhánh theo `createdFrom`. Nếu không thêm nhánh `import_nwg` trước nhánh WP, đơn NWG sẽ rơi vào nhánh WP và trả 400 `Resync url is empty` (không gây lỗi nhưng không gửi gì). Hàm này còn được gọi khi user đổi status sang Complete trên UI (`OrderService.php:1526`) và từ `ResyncWpCompletedOrderCommand`. Trường hợp complete mà **không tạo label PostNord** (không có tracking) thì gửi gì? (Pending #9)
- **Gọi HTTP trong lúc tạo label:** nếu API NWG chậm hoặc lỗi thì không được làm hỏng việc tạo label. Gọi có timeout ngắn, bắt mọi lỗi, đánh dấu để cron gửi lại.
- **Mã đơn là khoá:** `orderNr` phải giữ nguyên ký tự đúng như tên folder (xem Pending #2), không `trim`/đổi hoa thường tuỳ tiện, và phải URL-encode khi ghép vào path.
- **Bài học từ PINshirt:** `addProducts()` im lặng bỏ qua item lỗi, nên sau `add()` phải so số item đã lưu với số dòng CSV. Repository dùng `LIKE` nên phải so khớp lại bằng `strcasecmp` (`firstExact`). `FileService::uploadFile()` cần `UploadedFile` (test mode).

---

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

| # | Câu hỏi | Trả lời | Ghi chú kỹ thuật |
|---|---------|---------|-------------------|
| 1 | Mã đơn NWG dùng làm khoá? | Có: là tên folder, có trên phiếu sản xuất và trên mọi dòng CSV (email, điểm 2) | Map vào `orders.order_nr` |
| 2 | Định dạng CSV? | `;`, UTF-8, 1 dòng/sản phẩm, mã đơn lặp lại ở mỗi dòng (email, điểm 3) | |
| 3 | Gửi trùng lệnh trạng thái có sao không? | Không sao, retry được (email, điểm 4) | Cho phép cron gửi lại không cần chống trùng |
| 4 | Channel NWG? (Pending #8) | **Tạo channel mới nếu chưa có channel `synId = 'nwg'`** (Kevin, 06/10/2026) | Giống `PinshirtOrderImporter::findOrCreateChannel()`: tìm theo `synId` + `dateDeleted IS NULL`, chưa có thì `ChannelService::add()` với `synId = 'nwg'`, tên `NWG`, `active = true`. Phần customer của #8 vẫn còn chờ |
| 5 | Header CSV, mã đơn `SPO…`/`SP0…`? (Pending #1, #2) | **Đã có file mẫu cuối** (NWG, 08/10/2026): header tiếng Thụy Điển, mã đơn dùng chữ `O`, có BOM | Có thể code `NwgOrderReader` và test bằng `--local-dir` với `files/test-order-SPO59021261007093015654321/` |
| 7 | Địa chỉ giao hàng và thông tin đơn? (Pending #3) | **Đã giải quyết**: NWG thêm 12 cột vào cuối `Order-….csv`, PDF không còn dùng (NWG, 08/10/2026) | Map thẳng vào field `delivery_*` của `Order` |
| 8 | Thông tin in? (Pending #5) | **Đã giải quyết**: file `Tryckspec-{nr}.csv` mới (NWG, 08/10/2026) | Còn phải chốt nơi lưu (#11) |
| 9 | Tên/số theo từng áo? (Pending #12) | `Namnlista.csv`, 1 dòng/áo, nối theo `Rad`, chỉ có khi đơn có tên/số (NWG, 08/10/2026) | Còn phải chốt nơi lưu |
| 6 | FTP (Pending #7)? | **Chờ**, sẽ có sau (Kevin, 06/10/2026) | Trước mắt phát triển và test bằng `--local-dir`, không phụ thuộc SFTP |

---

## Pending Clarification

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

1. ~~**File `Order-….csv` mẫu**~~ — **đã có** (08/10/2026): header tiếng Thụy Điển, có BOM, không ngoặc kép.
2. ~~**Mã đơn `SPO…` hay `SP0…`**~~ — **đã có**: file mẫu và tên folder dùng chữ `O`. Email ghi số 0 nhiều khả năng là gõ nhầm; vẫn nên báo NWG xác nhận vì đây là path khi gọi API.
3. ~~**Địa chỉ giao hàng**~~ — **đã giải quyết**: CSV có đủ cột giao hàng, ngày đặt, ghi chú, email, sđt (xem phần cập nhật 08/10/2026).
4. **SKU / Leverantör SKU / product:** product trong hệ thống mình tìm theo cột nào? Lưu `SKU` (NWG, vd `3410010001-100-5`) và `Leverantör SKU` (vd `3410010-100-5`, cũng là `Plagg` trong Namnlista) vào field nào? Theo quy ước map 1:1 thì nên có 2 field riêng; có dùng lại `modelSku` (1642) hoặc `supplierSku` (1616) không?
5. **Thông tin in:** Tryckspec đã có trong CSV, còn lại: lưu ở đâu (xem #11)? Đính vào Korrektur những file nào: file in, Namnlista? Preview mặt trước/sau không còn, xưởng có cần ảnh preview không, hay dựng từ tọa độ Tryckspec?
6. **Sau khi import:** folder có được chuyển sang `archived/` không? Mình có quyền ghi/chuyển trên FTP của NWG không, hay phải tự nhớ folder nào đã xử lý (vd dựa vào order đã tồn tại)?
7. **FTP** (**chờ**, Questions #6; trước mắt test bằng `--local-dir`): "we setup FTP already" là server/tài khoản/path nào? Có dùng chung `INTEGRATION_FTP_*` (server `161.35.83.188`, tài khoản `integration`) không? Làm sao biết folder đã upload xong (vd `Order-….csv` luôn được upload cuối cùng)? Tần suất chạy cron?
8. ~~**Channel**~~ — **đã chốt: tạo channel mới nếu chưa có `synId = 'nwg'`** (Questions #4). **Còn chờ: customer** của order là NWG (1 customer cố định) hay người nhận hàng?
9. **Khi nào gửi từng trạng thái:** `InProduction` ứng với status nội bộ nào (hay gửi ngay khi import)? `Shipped` gửi lúc tạo label PostNord (order sang Complete) là đúng chưa? Đơn Complete mà không có label PostNord (giao tay, carrier khác) thì gửi gì? `Cancelled` gửi từ đâu, lý do lấy ở đâu?
10. **Đơn đã tồn tại** (cùng mã đơn, folder được upload lại hoặc chạy lại): bỏ qua, hay thay item như PINshirt/Blavitt? NWG có bao giờ sửa đơn đã gửi không?
11. **Designversion + Tryckspec:** lưu ở đâu (field mới trên `OrderProduct`? bảng riêng lưu version đã biết và thông số in?). NWG nói nếu đã đăng ký version thì bỏ qua dòng Tryckspec, nên cần chỗ nhớ "version nào đã đăng ký". Khi gặp mã mới (thiết kế in đã đổi) thì chỉ ghi nhận, cảnh báo, hay chặn đơn? "The product needs to be updated on your end" nghĩa là mình phải cập nhật product nào: product theo SKU NWG hay cấu hình in (`productionPrintFileId`...)?
12. **Namnlista (tên/số theo từng áo):** dữ liệu đã rõ (1 dòng/áo, nối theo `Rad`, số dòng khớp `Antal`). Còn phải chốt lưu ở đâu: `OrderProduct.texts` chỉ nhận tối đa 3 chuỗi cho mỗi dòng, trong khi 1 dòng có thể có nhiều áo (vd 2 áo M: ANDERSSON 10, BERG 7). Có tách mỗi áo thành 1 dòng không, hay ghi gộp vào `comment`, hay chỉ đính file vào Korrektur?
13. **Giá:** CSV không có giá. Lấy theo product (product mới thì `0`) như PINshirt, hay NWG có bảng giá riêng?
14. **Ship nhiều kiện:** hệ thống mình không biết kiện nào chứa dòng nào (xem mục bẫy). Chốt luôn gửi 1 lệnh `Shipped` cho cả đơn (dùng tracking của kolli đầu) được không? Nếu cần ship từng phần thì phải có UI chọn dòng cho từng kiện, khối lượng việc lớn hơn nhiều.
15. **API NWG:** base URL + key (NWG gửi riêng). Có môi trường test/staging không? Response thành công/lỗi trông thế nào (HTTP code, body) để log và quyết định khi nào gửi lại?
16. **Hiển thị trên UI:** có cần hiển thị trạng thái đồng bộ NWG trên trang order và nút "gửi lại" không (repo React)?
17. **Cột thông tin đơn chưa có field:** `Kundens ordernummer` (vd `utm-0928-1`), `Kundreferens` (vd `Johan Svensson`) và `E-post` lưu vào đâu? `Order` hiện có `deliveryName`, `deliveryPhone`, `comment`... nhưng chưa thấy field cho mã đơn khách, tham chiếu và email giao hàng. Theo quy ước map 1:1 thì thêm field mới. Cần sếp chốt tên.

---

## Thiết kế kỹ thuật (đề xuất, chốt sau khi có trả lời)

### Cấu trúc code (theo mẫu PINshirt)

| File | Vai trò |
|---|---|
| `src/Command/Cronjob/ImportOrderNwgCommand.php` | `order:import_nwg`, cùng bộ option với `order:import_pinshirt` |
| `src/Service/Nwg/NwgStorageInterface.php` + `SftpNwgStorage` / `LocalNwgStorage` | Liệt kê folder đơn, tải file, archive folder. Có thể tổng quát hoá từ `AbstractPinshirtStorage` |
| `src/Service/Nwg/NwgOrderReader.php` | Đọc/parse `Order-….csv` + `Namnlista.csv` của 1 folder thành 1 order |
| `src/Service/Nwg/NwgOrderImporter.php` | Tạo/cập nhật order (giống `PinshirtOrderImporter`) |
| `src/Service/Nwg/NwgStatusClient.php` | Gọi `POST /printer/orders/{nr}/status` (timeout, log, trả kết quả) |
| `src/Service/Nwg/NwgStatusService.php` | Dựng body cho từng trạng thái, lưu trạng thái đã gửi trên order, đánh dấu cần gửi lại |
| `src/Command/Cronjob/NwgResyncStatusCommand.php` | Gửi lại các lệnh trạng thái bị lỗi |
| `config/packages/monolog.yaml` | Channel `nwg` → `var/log/nwg-{Y-m-d}.log` |
| `config/services.yaml`, `.env` | `nwg_api_base_url`, `nwg_api_key` (key đặt trong `.env.local`) |

### Entity & Migration (đề xuất)

| Field | Bảng | Kiểu | Ý nghĩa |
|---|---|---|---|
| `nwgStatusSent` | `orders` | `varchar(32)`, nullable | Trạng thái cuối đã gửi thành công (`InProduction`/`Shipped`/`Cancelled`) |
| `nwgStatusNeedResync` | `orders` | `boolean`, default `false` | Lần gửi cuối lỗi, cron cần gửi lại |
| `nwgStatusPending` | `orders` | `varchar(32)`, nullable | Trạng thái đang chờ gửi lại |
| `nwgStatusDate` | `orders` | `timestamp`, nullable | Lần gửi cuối |
| `nwgStatusError` | `orders` | `text`, nullable | Lỗi cuối (để hiển thị/debug) |
| *(chờ #4, #11)* `designVersion`, field cho `SKU` NWG | `orders_products` | `varchar(255)`, nullable | Map 1:1 cột CSV |

Thay vì thêm nhiều cột vào `orders`, có thể làm 1 bảng log riêng `nwg_status_log` (order_id, status, request body, http code, response, created_at). Chọn cách nào sau khi chốt #9, #15, #16. Migration **viết tay** (không dùng `doctrine:migrations:diff`, xem `Version20260924090615`).

---

## TODO List

### Chuẩn bị (chờ thông tin)
- [x] Nhận bộ file test cuối (`Order`, `Tryckspec`, `Namnlista`, file in) — 08/10/2026, đã copy vào `files/test-order-SPO59021261007093015654321/`
- [x] Chốt channel: tạo mới nếu chưa có `synId = 'nwg'` (#8)
- [x] Địa chỉ giao hàng (#3), header (#1), mã đơn chữ `O` (#2): đã giải quyết
- [ ] Chốt các Pending còn lại, đặc biệt #4 (SKU), #8 (customer), #9 (thời điểm gửi trạng thái), #11 (Designversion + Tryckspec), #12 (Namnlista), #17 (field mới)
- [ ] Nhận thông tin FTP (#7, đang chờ) + base URL/key API NWG; đặt key vào `.env.local`

### Backend — Entity & Migration
- [ ] Thêm field trạng thái đồng bộ NWG (`Order` hoặc bảng log riêng) + field cho cột CSV mới trên `OrderProduct` (theo #4, #11, #12)
- [ ] Migration viết tay, có `down()`; chạy local → DEV → PROD trước khi deploy code

### Backend — Import
- [ ] `config/packages/monolog.yaml`: channel `nwg`
- [ ] `NwgStorageInterface` + `SftpNwgStorage` + `LocalNwgStorage`: liệt kê folder, tải file, archive (theo #6, #7)
- [x] `NwgOrderReader` (`src/Service/Nwg/NwgOrderReader.php`) + `order:import_nwg --local-dir=... --dump` (chỉ parse, chưa ghi DB): đã chạy được với bộ file test, 2 dòng, 1 file in, 0 warning. Yêu cầu gốc của mục này: đọc `Order-….csv` (kể cả 12 cột thông tin đơn), `Tryckspec-….csv`, `Namnlista.csv` theo tên cột, strip BOM/`\r`, kiểm tra mã đơn trùng tên folder, nối Tryckspec và Namnlista theo `Rad`. **Có thể bắt đầu ngay** với bộ file test
- [ ] `NwgOrderImporter`: channel/customer/product/sub-product theo mẫu `PinshirtOrderImporter`, transaction riêng cho mỗi order, kiểm tra số item đã lưu, đính file Korrektur (#5), xử lý đơn đã tồn tại (#10)
- [ ] `ImportOrderNwgCommand` (`order:import_nwg`) + đăng ký crontab (#7)

### Backend — Gửi trạng thái
- [ ] `NwgStatusClient`: `POST {base}/printer/orders/{rawurlencode(orderNr)}/status`, header `x-functions-key`, timeout, log request/response
- [ ] `NwgStatusService`: `sendInProduction()`, `sendShipped()`, `sendCancelled()`; lỗi thì đánh dấu cần gửi lại, không throw ra luồng gọi
- [ ] `OrderService::postOrderUpdateComplete()`: thêm nhánh `createdFrom == 'import_nwg'` (trước nhánh WP) → `sendShipped()` với `postNordShipmentId` / `postNordTrackingUrl`
- [ ] Móc `sendInProduction()` vào chỗ đổi status (theo #9)
- [ ] Móc `sendCancelled()` vào luồng huỷ đơn (TSHIRTORDER-1576); chặn huỷ khi đã gửi `Shipped`
- [ ] `NwgResyncStatusCommand`: gửi lại các order có `nwgStatusNeedResync = true`

### Frontend — React *(repo khác, chờ #16)*
- [ ] Hiển thị trạng thái đồng bộ NWG trên trang order + nút gửi lại

### Test / kiểm tra
- [ ] `order:import_nwg --local-dir=... --parse-only --dump` với bộ file mẫu
- [ ] `--dry-run`, rồi import thật trên local: kiểm tra order/item/size/Korrektur
- [ ] Gửi trạng thái tới môi trường test của NWG (#15): `InProduction`, `Shipped` (sau khi tạo label PostNord), `Cancelled`; thử lỗi mạng → cron gửi lại
- [ ] Test trên DEV với folder thật trên FTP

---

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

| File | Mục đích |
|------|----------|
| `src/Command/Cronjob/ImportOrderPinshirtCommand.php` | Mẫu command import qua SFTP |
| `src/Service/Pinshirt/PinshirtOrderImporter.php` | Mẫu import 1 order (channel, customer, product, size, Korrektur) |
| `src/Service/Pinshirt/*Storage.php` | Mẫu storage SFTP/local |
| `src/Service/FileService.php` | `importOrderPinshirtProcess()`, đọc CSV PINshirt |
| `src/Service/PostNordService.php` | `orderPostnordCreateLabel()`: lưu tracking, chuyển Complete, gọi `postOrderUpdateComplete()` |
| `src/Service/OrderService.php` | `postOrderUpdateComplete()` (dòng 4770), `resyncOrderToShopify` (mẫu gửi tracking ra ngoài), `addProducts()` |
| `src/Entity/Order.php` | `postNordShipmentId`, `postNordTrackingUrl`, `postNordLatestData`, mẫu `decoNeedResync` / `decoResyncDate` |
| `src/Entity/OrderProduct.php` | `modelSku`, `modelName`, `supplierSku`, `supplierColor`, `itemId`, `comment`, `texts` |
| `src/Command/Cronjob/ResyncWpCompletedOrderCommand.php` | Mẫu cron gửi lại |
| `config/services.yaml`, `.env` | `integration_ftp_*`, `order_status_complete_id`, `order_status_web_id` |
| `docs/issues/TSHIRTORDER-1627-pinshirt-import/pinshirt-import.md` | Doc import PINshirt (các quyết định và bẫy đã gặp) |
| `docs/issues/TSHIRTORDER-1642-pinshirt-model-columns/pinshirt-model-columns.md` | `modelSku` / `modelName` |
| `docs/issues/TSHIRTORDER-1576-cancellation-function.md` | Luồng huỷ đơn |
| `files/test-order-SPO59021261007093015654321/` | **Bộ file test cuối của NWG (08/10/2026, format mới, dùng cái này)** |
| `files/` | Bộ file mẫu cũ đơn `SPO590213353056447` (format cũ, có PDF) + email gốc của NWG |
