# TSHIRTORDER-1647 — Pinshirt: test import + gửi trạng thái giao hàng (callback POST) sang API Pinshirt

## Yêu cầu gốc

> TSHIRTORDER-1647
> Pinshirt - test and fix callback post
> They added 4 test files for import test in FTP
>
>  pinshirt_order_40202.csv
>  pinshirt_order_40203.csv
>  pinshirt_order_40204.csv
>  pinshirt_order_40205.csv (3 order-rader)
>
> This is the POST feedback
>
> POST https://testapi.pinshirt.dev/printery/orders/{orderId}/status
> Order-Integration-Key: fc20509e1c2460e32396931f1ea5185f3a90db4492c52fa638d39bc699762fb0
> Content-Type: application/json
>
> Delivery Status from T-shirt.se to Pinshirt — You report status per order by making a call to our API. The status is saved to the order and displayed in our admin tool. You decide yourselves which status text you use — we do not interpret them, we display them.
>
> Request: POST https://api.pinshirt.dev/printery/orders/{orderId}/status, header `Order-Integration-Key: <API key provided by us>`, `Content-Type: application/json`.
> `{orderId}` is Pinshirt's order number — the same number as in the CSV file's order_id column and in the filename pinshirt_order_{orderId}.csv. The key in the header is Order-Integration-Key. The test environment uses the same path under https://testapi.pinshirt.dev/ … with its own key. You will receive the API keys from us once the implementation is complete on our end.
>
> Request Body (JSON): `status` (required, free text 1–200 chars: "Received", "In production", "Shipped with PostNord" — whatever you prefer), `timestamp` (optional, ISO 8601 with timezone; omitted → time the request was received), `trackingNumber` (optional, max 100), `carrier` (optional, max 50), `message` (optional, max 1000, displayed to our staff).
>
> Response: `204 No Content` = saved (empty); `400 Bad Request` = status missing/empty/longer than 200 chars or body is invalid JSON (JSON body, RFC 9110 problem details with `errors.status`); `401 Unauthorized` = header missing or key incorrect (text `Unauthorized`); `404 Not Found` = order number does not exist, or the order has not been handed over to you (no CSV has been uploaded for it) (empty).
>
> Swagger: When the implementation is complete, you will receive a Swagger URL where the request is documented and can be test-run against our test environment.

Kèm file spec Pinshirt gửi (bản tiếng Thụy Điển): xem `files/printery-status-endpoint.md` (nguồn: `temp_files/printery-status-endpoint.md`). Nội dung giống bản tiếng Anh ở trên, chỉ khác ví dụ status text (`Skickad med PostNord`, `I produktion`).

---

## Tổng quan

Ticket gồm **2 việc**:

1. **Test import** — Pinshirt đã upload 4 file test lên FTP (`pinshirt_order_40202.csv` … `40205.csv`, trong đó `40205` có 3 order-line). Chạy `order:import_pinshirt` (TSHIRTORDER-1627/1642, đã có) với các file này và **sửa nếu lỗi**.
2. **Callback POST status** (mới) — khi đơn Pinshirt có tiến triển (nhận, đang sản xuất, đã gửi + tracking PostNord...), hệ thống **gọi `POST {PINSHIRT_API_BASE_URL}/printery/orders/{orderNr}/status`** sang API Pinshirt để họ hiển thị trong admin tool của họ. Pinshirt **không diễn giải** status text: mình tự chọn chữ, họ chỉ hiển thị.

Phạm vi: **chỉ backend** (Service + hook trong luồng order + cron gửi lại + migration cho trạng thái đồng bộ). Không có thay đổi API cho frontend, trừ khi sau này muốn hiển thị/gửi lại trên UI (xem Pending #9).

Tiền lệ trong codebase: TSHIRTORDER-1644 (NWG) có thiết kế gần như y hệt cho chiều "gửi trạng thái ra ngoài" (`docs/issues/TSHIRTORDER-1644-nwg-integration/nwg-integration.md`, mục "Gửi trạng thái sang NWG"). Nên **thiết kế client/service dùng chung được** cho cả 2 đối tác thay vì làm 2 bản (xem mục Thiết kế).

### ⚠️ Phát hiện quan trọng: những gì đã có / chưa có (đã kiểm tra code 08/10/2026)

| Hạng mục | File | Trạng thái |
|---|---|---|
| Import Pinshirt: command `order:import_pinshirt` (`--local-dir`, `--dry-run`, `--parse-only`, `--no-archive`, `--dump`), importer, storage SFTP/local | `src/Command/Cronjob/ImportOrderPinshirtCommand.php`, `src/Service/Pinshirt/*` | ✅ Có |
| `Order.orderNr` = `order_id` trong CSV, channel `synId = 'pinshirt'`, `createdFrom = 'import_pinshirt'` | `PinshirtOrderImporter.php:33-35, 104-108, 252-256` | ✅ Có. **Đây là khoá để nhận biết đơn Pinshirt và là `{orderId}` khi gọi API** |
| Monolog channel `pinshirt` → `var/log/pinshirt-{Y-m-d}.log`, level info, giữ 30 ngày, autowire `LoggerInterface $pinshirtLogger` | `config/packages/monolog.yaml:4-14` | ✅ Có — dùng lại cho callback |
| Lưu tracking PostNord khi tạo label: `postNordShipmentId`, `postNordTrackingUrl` (chỉ kolli đầu tiên, `i == 0`); toàn bộ kolli ở `postNordLatestData` (serialize) | `PostNordService.php:555-648`, `Order.php:1388, 1508, 2445` | ✅ Có |
| Tạo label PostNord → set Complete (`order_status_complete_id = 22`) → gọi `postOrderUpdateComplete()` | `PostNordService.php:595-620` | ✅ Có. **Đây là chỗ hợp lý nhất để gửi "Shipped"** |
| `OrderService::update()` gọi `postOrderUpdateComplete()` khi status đổi sang Complete (cả khi user đổi tay trên UI) | `OrderService.php:1516-1527` | ✅ Có. Cũng là 1 điểm hook (không có tracking nếu không qua PostNord) |
| `postOrderUpdateComplete()` rẽ nhánh theo `getSveaOrderId()` → `shopify` → `deco` → WP (cuối cùng) | `OrderService.php:4770-4830` | ✅ Có. Order Pinshirt **không** có nhánh riêng → rơi xuống nhánh WP, `getResyncUrlIntegration()` rỗng → trả 400 `Resync url is empty` (im lặng, không ai kiểm tra kết quả) |
| Mẫu "cờ cần resync + ngày resync" | `Order::$decoNeedResync`, `$decoResyncDate` (`Order.php:37-41`) | ⚠️ Chỉ làm mẫu: `resyncOrderToDeco()` hiện **vô hiệu hoá** (return 400 ngay dòng đầu, `OrderService.php:4501-4508`) |
| Cron gửi lại đơn Complete | `src/Command/Cronjob/ResyncWpCompletedOrderCommand.php` | ✅ Có mẫu |
| Hook khi đổi status đơn ngoài nhánh Complete | — | ❌ **Không có** — `update()` chỉ so `$oldStatusId`/`$newStatusId` cho Complete |
| Hook khi huỷ đơn (`cancel` false → true) | — | ❌ Không có (và `update()` chặn sửa đơn đã `isCancel()`, `OrderService.php:1452`) |
| `symfony/http-client` | `composer.json:34` | ✅ Có sẵn (code cũ dùng `curl_*` thủ công, nhưng HttpClient dùng được, có timeout + exception rõ ràng) |
| **Client gọi API status Pinshirt**, config base URL/key, field lưu trạng thái đã gửi, cron gửi lại | — | ❌ **Chưa có (grep `pinshirt` không thấy gì liên quan)**, cần làm |

Không cần entity/migration mới cho phần import. Phần callback **cần migration** nếu chọn lưu trạng thái đồng bộ (xem Thiết kế).

---

## Luồng hoạt động

### Phần A — Test import 4 file (40202–40205)

Chưa thấy 4 file này ở local (chỉ có `docs/issues/TSHIRTORDER-1627-pinshirt-import/files/pinshirt_order_10005.csv` và `…1642…/files/pinshirt_order_10005.csv`). Các bước:

1. Lấy file: từ SFTP `/home/integration/Pinshirt/new/` (cần SFTP đúng, xem Pending #1) hoặc nhờ Pinshirt/Liem đưa file, đặt vào `temp_files/pinshirt-test-import/new/` + `new/prints/`.
2. `php bin/console order:import_pinshirt --local-dir=temp_files/pinshirt-test-import --parse-only --dump` → kiểm tra parse (BOM, `;`, header, gom theo `order_id`).
3. `… --dry-run` → import thật nhưng rollback DB; xem log `var/log/pinshirt-*.log`.
4. Import thật trên local/DEV (`--no-archive` để giữ file): kiểm tra order/items/size/Korrektur/customer.
5. Với `40205` (3 order-line): xác nhận **1 order, 3 item** (không phải 3 order) và `orders_products` đúng `line_no`, `comment`, `modelSku`/`modelName` (TSHIRTORDER-1642).
6. Mọi lỗi gặp phải → sửa trong `PinshirtOrderImporter` / `FileService::importOrderPinshirt*` và ghi lại ở mục "Kết quả test import" bên dưới.

> Ghi chú: 4 order này (`40202`–`40205`) cũng chính là **4 order dùng để test callback** (Pinshirt trả 404 nếu order "chưa được bàn giao", tức chưa có CSV; 4 CSV này đã upload nên 4 id này hợp lệ ở môi trường test của họ).

### Phần B — Callback status

**Request mình gửi:**

```
POST {pinshirt_api_base_url}/printery/orders/{rawurlencode(orderNr)}/status
Order-Integration-Key: {pinshirt_api_key}
Content-Type: application/json
```

| Môi trường | Base URL | Key |
|---|---|---|
| Test | `https://testapi.pinshirt.dev` | key test (trong yêu cầu gốc). **Đặt ở `.env.local`, KHÔNG commit, không ghi vào doc/`.env`** |
| Prod | `https://api.pinshirt.dev` | Pinshirt gửi sau khi xong phía họ (Pending #2) |

Body (chỉ `status` bắt buộc):

```json
{
  "status": "Shipped with PostNord",
  "timestamp": "2026-09-08T10:15:00+02:00",
  "trackingNumber": "UA123456789SE",
  "carrier": "PostNord",
  "message": "Two parcels"
}
```

**Giới hạn phải enforce phía mình trước khi gửi** (vượt thì Pinshirt trả 400): `status` 1–200 ký tự, `trackingNumber` ≤ 100, `carrier` ≤ 50, `message` ≤ 1000. Đếm bằng **ký tự** (`mb_strlen`), cắt bằng `mb_substr` (status text tiếng Thụy Điển có `å ä ö`). Trường optional rỗng thì **bỏ hẳn key** khỏi JSON (đừng gửi `""` hay `null`).

**Xử lý response:**

| HTTP | Ý nghĩa | Hành động của mình |
|---|---|---|
| `204` | Đã lưu | Thành công: ghi log, xoá cờ cần gửi lại |
| `400` | Body sai (status rỗng/quá dài, JSON lỗi) | Lỗi **vĩnh viễn** do dữ liệu phía mình: log `error` kèm body Pinshirt trả (`errors.status`), **không retry tự động**, lưu lỗi để người vận hành xem |
| `401` | Thiếu/sai key | Lỗi **cấu hình**: log `critical`, không retry từng order (cả loạt sẽ lỗi như nhau); retry sau khi sửa key |
| `404` | Order không tồn tại / chưa bàn giao (chưa có CSV) | Có thể là tạm thời (race: mình gọi trước khi họ ghi nhận CSV) hoặc vĩnh viễn (id sai). Retry có giới hạn (vd 3 lần / 24 giờ) rồi dừng + log `warning` (Pending #6) |
| `5xx` / timeout / lỗi mạng | Phía họ | Lỗi **tạm thời**: đánh dấu cần gửi lại, cron retry |

### Khi nào gửi status nào — ĐỀ XUẤT (chưa được chốt, xem Pending #3)

Pinshirt nói "bạn tự quyết status text". Đề xuất tối thiểu, bám theo các điểm đã có trong code:

| Thời điểm | Status text (đề xuất) | Điểm móc trong code | Kèm |
|---|---|---|---|
| Import thành công, order được **tạo** (`created`) | `Received` | `PinshirtOrderImporter::doImport()` sau khi `add()` thành công và đã commit | — |
| Order chuyển sang status sản xuất | `In production` | Cần chốt status nội bộ nào (Pending #4) — **chưa có hook** trong `update()` | — |
| Tạo label PostNord thành công (order → Complete) | `Shipped with PostNord` | `PostNordService.php:617-620` → `postOrderUpdateComplete()` thêm nhánh `createdFrom == 'import_pinshirt'` | `trackingNumber = postNordShipmentId`, `carrier = 'PostNord'`, `message` nếu `nr_of_kolli > 1` (vd `2 parcels`), `timestamp = dateComplete` |
| Order Complete **không** qua PostNord (đổi tay, không có tracking) | ? (Pending #5) | `OrderService.php:1526` | Không gửi `Shipped with PostNord` khi không có tracking |
| Huỷ đơn | ? (Pending #7) | Chưa có hook | `message` = lý do |

> Vì mình chọn text nên **nên đặt text thành hằng số/cấu hình** (`PinshirtStatusService::STATUS_*`) để đổi dễ, không rải chuỗi trong code.

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

- **`orderId` phải đúng nguyên văn `Order.orderNr`** (= `order_id` CSV = số trong tên file `pinshirt_order_{orderId}.csv`). Không `trim`/đổi hoa thường; ghép vào path bằng `rawurlencode()`.
- **Chỉ gửi cho đơn Pinshirt**: điều kiện `createdFrom == 'import_pinshirt'` (hoặc channel `synId = 'pinshirt'`). Đơn tạo tay trong channel PINshirt (nếu có) không có CSV bên Pinshirt → 404. Nên kiểm tra `createdFrom`, không chỉ channel.
- **Gọi HTTP không được làm hỏng luồng chính.** Tạo label PostNord / đổi status Complete / import đơn là thao tác chính; callback lỗi phải được `try/catch` toàn bộ, timeout ngắn (vd connect 3s / total 10s), không throw ra ngoài, không rollback transaction chính. Đặc biệt **không gọi HTTP bên trong transaction import** (`PinshirtOrderImporter::import()` đang `beginTransaction`); gửi `Received` **sau `commit()`**, hoặc chỉ đánh dấu cần gửi và để cron gửi (an toàn hơn).
- **Kết quả `postOrderUpdateComplete()` bị bỏ qua** ở cả `PostNordService` lẫn `update()`, nên lỗi callback sẽ không ai thấy → bắt buộc có (a) log file riêng `pinshirt`, (b) lưu cờ/lỗi để cron retry và người vận hành tra được.
- **Nhánh WP bắt đầu bằng `getResyncUrlIntegration()`**: thêm nhánh `import_pinshirt` **trước** nhánh WP, nếu không đơn Pinshirt luôn trả 400 `Resync url is empty` mà không gửi gì.
- **Nhiều kolli PostNord:** order chỉ lưu tracking của kolli đầu. Pinshirt chỉ nhận **1** `trackingNumber` nhỏ hơn 100 ký tự cho mỗi lần gọi. Có thể gửi 1 lần `Shipped` với tracking kolli đầu + `message: "N parcels"`, hoặc gọi N lần (mỗi kolli 1 lần, đọc từ `postNordLatestData`, unserialize). Pending #8.
- **Idempotency:** spec của Pinshirt **không nói** gửi trùng có sao không (khác NWG có nói rõ). Mỗi lần gọi có thể tạo thêm 1 dòng lịch sử trong admin của họ. Vì vậy **không retry mù quáng sau 204**; chỉ retry khi chưa nhận được 204. Hỏi lại Pinshirt (Pending #10).
- **`timestamp`**: ISO 8601 **có timezone** (`2026-09-08T10:15:00+02:00`). Dùng `\DateTime::format(DATE_ATOM)` với timezone Europe/Stockholm; không gửi `Y-m-d H:i:s` trần. Bỏ qua thì họ dùng giờ nhận request — chấp nhận được cho lần gửi tức thời, nhưng với retry nên gửi `timestamp` gốc để không bị lệch giờ.
- **Không log key.** Header `Order-Integration-Key` không được xuất hiện trong log hay lỗi lưu DB.
- **Key trong yêu cầu gốc nằm trong ticket.** Không copy vào `.env` (file này được commit và đang có thay đổi cục bộ chưa commit, **không `git add .env`** khi làm ticket này); đặt ở `.env.local`.

---

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

| # | Câu hỏi | Trả lời | Ghi chú kỹ thuật |
|---|---------|---------|-------------------|
| 1 | `{orderId}` là gì? | Số order Pinshirt = cột `order_id` CSV = tên file `pinshirt_order_{orderId}.csv` (spec Pinshirt) | = `Order.orderNr` của đơn channel `pinshirt` |
| 2 | Pinshirt có diễn giải status text không? | Không, chỉ hiển thị (spec) | Mình tự đặt text |
| 3 | Test env? | `https://testapi.pinshirt.dev/printery/orders/{orderId}/status` + key test (ticket) | Key prod chưa có, Pinshirt gửi sau |
| 4 | Response mã nào? | 204/400/401/404 (spec) | Xem bảng xử lý response |

---

## Pending Clarification

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

1. **SFTP / 4 file test ở đâu?** Doc 1627 (Pending #7) ghi `/home/integration/Pinshirt/new` **không tồn tại** trên server `161.35.83.188` lúc kiểm tra 21/09/2026. Ticket này nói "added in FTP". Cần xác nhận host + path hiện tại (có phải vẫn `/home/integration/Pinshirt`? tên thư mục chữ hoa/thường?), hay Pinshirt có SFTP riêng.
2. **API key prod + Swagger URL:** chờ Pinshirt (họ nói gửi khi xong phía họ). Trước mắt làm và test hoàn toàn trên `testapi.pinshirt.dev`.
3. **Thời điểm gửi từng status:** đề xuất ở bảng trên có đúng ý không? Tối thiểu cần: có gửi `Received` ngay khi import không? Có gửi `In production` không? `Shipped` gửi lúc tạo label PostNord?
4. **`In production` ứng với status nội bộ nào?** (`status_list` type `order_status`: có `Plockad`, `Utskriven`, ...). Hiện **chưa có hook đổi status** (chỉ có nhánh Complete trong `update()`); thêm hook mới đụng vào `OrderService::update()` dùng chung cho mọi channel, nên cần chốt trước.
5. **Order Complete mà không có label PostNord (giao tay, carrier khác):** gửi gì (vd `Completed` không tracking), hay không gửi?
6. **404 retry:** retry bao nhiêu lần / bao lâu? (404 có thể do Pinshirt chưa ghi nhận CSV).
7. **Huỷ đơn:** có cần gửi status `Cancelled` không, lý do lấy ở đâu? (hiện chưa có hook huỷ).
8. **Nhiều kolli:** gửi 1 lần (tracking kolli đầu + message) hay mỗi kolli 1 lần?
9. **UI:** có cần hiển thị trạng thái đã gửi + nút "gửi lại" trên trang order không (repo React khác)? Nếu không thì chỉ log + cron.
10. **Gửi trùng có sao không?** Hỏi Pinshirt (xem bẫy Idempotency).
11. **Có cần gửi lại (backfill) cho các đơn Pinshirt đã import/đã Complete trước khi có tính năng này không?** Mặc định: không.

---

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

### Cấu hình (env / `services.yaml`)

Theo quy ước `ParameterBagInterface` (như `PINSHIRT_FTP_REMOTE_PATH`, `config/services.yaml:34-36`):

| Env | Param | Nơi đặt | Ghi chú |
|---|---|---|---|
| `PINSHIRT_API_BASE_URL` | `pinshirt_api_base_url` | `.env` (mặc định `https://testapi.pinshirt.dev` cho dev) + `.env.local` ghi đè trên prod | không có `/` cuối |
| `PINSHIRT_API_KEY` | `pinshirt_api_key` | **chỉ `.env.local`**, trong `.env` để rỗng | rỗng ⇒ client **không gọi**, log `warning` "Pinshirt API not configured" (giống cách `ImportOrderPinshirtCommand` từ chối chạy SFTP khi thiếu cấu hình) |
| `PINSHIRT_API_ENABLED` (tuỳ chọn) | `pinshirt_api_enabled` | `.env` | công tắc tắt nhanh, mặc định `false` ở mọi môi trường cho tới khi sẵn sàng |

> **Mỗi môi trường phải đặt 2 biến trước khi bật**; deploy code mà thiếu cấu hình thì chỉ log, không lỗi.

### Cấu trúc code (đề xuất)

| File | Vai trò |
|---|---|
| `src/Service/Pinshirt/PinshirtStatusClient.php` | Gọi `POST /printery/orders/{id}/status` bằng `symfony/http-client` (timeout, không throw ra ngoài), validate/cắt độ dài field, trả `['ok' => bool, 'http' => int, 'retryable' => bool, 'error' => ?string]`. Ẩn key khỏi log |
| `src/Service/Pinshirt/PinshirtStatusService.php` | `sendReceived(Order)`, `sendInProduction(Order)`, `sendShipped(Order)`, `sendCancelled(Order)`; dựng payload từ `Order` (tracking từ `postNordShipmentId`, `timestamp` từ `dateComplete`); cập nhật trạng thái gửi; chỉ chạy khi `createdFrom == 'import_pinshirt'` |
| `src/Command/Cronjob/PinshirtResyncStatusCommand.php` (`cronjob:pinshirt_resync_status`) | Gửi lại các lần gửi lỗi tạm thời; option `--dry-run`, `--order=NR`, log vào channel `pinshirt` |
| `src/Service/OrderService.php` | `postOrderUpdateComplete()`: thêm nhánh `import_pinshirt` **trước** nhánh WP → `sendShipped()`. (Hook `In production`/`Cancelled` nếu Pending #4/#7 chốt) |
| `src/Service/Pinshirt/PinshirtOrderImporter.php` | Sau `commit()` của order `created` → `sendReceived()` hoặc đánh dấu pending (nếu Pending #3 chốt gửi `Received`) |
| `config/services.yaml`, `.env` | Param ở bảng trên |

> **Cân nhắc dùng chung với NWG (TSHIRTORDER-1644):** hai bên cùng kiểu "POST status per order + key trong header + cờ resync + cron retry". Nếu NWG chưa code phần gửi status thì nên làm một `PartnerStatusClient` chung và 2 adapter mỏng (URL/header/body khác nhau). Quyết định lúc code, tránh over-engineering nếu NWG chưa bắt đầu.

### Entity & Migration (đề xuất — chốt sau Pending #3, #6, #9)

Chọn một:

**Phương án 1 — bảng log riêng (đề xuất):** `pinshirt_status_log`

| Cột | Kiểu | Ý nghĩa |
|---|---|---|
| `id` | SEQUENCE | |
| `order_id` | int, FK/index | |
| `status` | varchar(200) | status text đã/đang gửi |
| `payload` | text | JSON body đã gửi (không chứa key) |
| `state` | varchar(16) | `pending` / `sent` / `failed` / `gave_up` |
| `http_code` | int, nullable | |
| `response` | text, nullable | body lỗi Pinshirt trả |
| `attempts` | int, default 0 | |
| `date_created`, `date_sent` | timestamp | |

Ưu: giữ **lịch sử** từng status (Pinshirt cũng lưu lịch sử), retry theo từng dòng, không làm phình `orders`, tra cứu "đã gửi gì khi nào" dễ. Cron chỉ quét `state IN ('pending','failed')` có `attempts < N`.

**Phương án 2 — cột trên `orders`** (giống mẫu `decoNeedResync`): `pinshirtStatusSent`, `pinshirtStatusNeedResync`, `pinshirtStatusDate`, `pinshirtStatusError`. Nhẹ hơn nhưng chỉ nhớ **trạng thái cuối**, mất lịch sử và khó retry nhiều status khác nhau.

Migration **viết tay** theo CLAUDE.md (KHÔNG dùng `doctrine:migrations:diff`; tham chiếu `Version20260924090615`, `Version20260925130000`): cập nhật mapping entity cho khớp → `doctrine:migrations:generate` → chỉ SQL của thay đổi trong `up()`/`down()`, điền `getDescription()` + docblock ghi số ticket + "Written by hand, not via doctrine:migrations:diff (see Version20260924090615 for why)." Tạo index theo `(state, attempts)` và `order_id`.

### Quy ước log (channel `pinshirt`)

- `info`: mỗi lần gửi: `orderNr`, `status`, `http`, `attempts` (**không** log key; body log được vì không nhạy cảm).
- `warning`: 404, cấu hình thiếu, bỏ qua do không đủ điều kiện (`createdFrom` khác, không có tracking).
- `error`: 400 (kèm body lỗi của Pinshirt); `critical`: 401.

### Hành vi cron retry (đề xuất)

- Chạy mỗi 5–10 phút (crontab nằm ngoài repo, Pending cần chốt tần suất).
- Retry `5xx`/timeout/mạng/`404`: tối đa N lần (đề xuất 5), backoff tăng dần; quá giới hạn ⇒ `gave_up` + `critical` log.
- `400`: không retry (sửa dữ liệu rồi gửi lại tay bằng `--order=NR`).
- `401`: dừng cả lượt chạy, log `critical` một lần.

---

## TODO List

### Chuẩn bị (chờ thông tin)
- [ ] Xác nhận SFTP + path của 4 file test (Pending #1); lấy 4 file về `temp_files/pinshirt-test-import/new/` (+ `new/prints/`)
- [ ] Chốt Pending #3–#8 (thời điểm gửi từng status, status nội bộ ứng với `In production`, retry 404, huỷ đơn, nhiều kolli)
- [ ] Đặt `PINSHIRT_API_KEY` (test) trong `.env.local` của máy dev; **không commit**

### Test import (Phần A)
- [ ] `order:import_pinshirt --local-dir=… --parse-only --dump` với 40202–40205 → kiểm tra header/BOM/gom order
- [ ] `--dry-run`, rồi import thật `--no-archive` trên local; kiểm tra order, items (40205 = 1 order / 3 item), size, product (SKU = `print_id`), Korrektur, `modelSku`/`modelName`
- [ ] Sửa lỗi phát hiện trong `PinshirtOrderImporter` / `FileService::importOrderPinshirt*`; ghi vào mục "Kết quả test import"
- [ ] Chạy trên DEV với file thật trên SFTP

### Backend — Cấu hình
- [ ] `.env`: `PINSHIRT_API_BASE_URL`, `PINSHIRT_API_KEY=` (rỗng), `PINSHIRT_API_ENABLED=false`
- [ ] `config/services.yaml`: `pinshirt_api_base_url`, `pinshirt_api_key`, `pinshirt_api_enabled` (cạnh `pinshirt_ftp_remote_path`)

### Backend — Entity & Migration (sau khi chốt phương án)
- [ ] Entity `PinshirtStatusLog` (hoặc cột trên `Order`) + repository nếu cần query
- [ ] Migration viết tay có `down()`; chạy local → DEV → PROD **trước** khi deploy code

### Backend — Service
- [ ] `PinshirtStatusClient`: HttpClient, header `Order-Integration-Key`, timeout, `rawurlencode(orderNr)`, cắt độ dài field (`mb_substr`), bỏ key optional rỗng, map response → retryable/permanent, không log key, không throw
- [ ] `PinshirtStatusService`: dựng payload từng loại status; chỉ cho `createdFrom == 'import_pinshirt'`; lưu log/state; `timestamp` ISO 8601 có timezone
- [ ] `OrderService::postOrderUpdateComplete()`: nhánh `import_pinshirt` trước nhánh WP (`OrderService.php:4794-4815`); không gửi `Shipped with PostNord` nếu không có tracking (Pending #5)
- [ ] (nếu chốt) `PinshirtOrderImporter`: gửi/đánh dấu `Received` sau `commit()`
- [ ] (nếu chốt) Hook `In production` / `Cancelled`

### Backend — Cron
- [ ] `PinshirtResyncStatusCommand` (`cronjob:pinshirt_resync_status`) với `--dry-run`, `--order=`; đăng ký crontab

### Frontend — React *(repo khác, chỉ nếu Pending #9 chốt có)*
- [ ] Hiển thị trạng thái đồng bộ Pinshirt + nút gửi lại

### Test / kiểm tra
- [ ] Gọi tay bằng curl tới `testapi.pinshirt.dev` với order 40202: `{"status":"In production"}` → 204; thử case 400 (status rỗng / >200 ký tự), 401 (key sai), 404 (id không tồn tại)
- [ ] Import 40202 → `Received`; tạo label PostNord trên đơn Pinshirt test → `Shipped with PostNord` + tracking đúng, xem trong admin/Swagger của Pinshirt
- [ ] Đơn nhiều kolli; đơn Complete đổi tay (không tracking)
- [ ] Ngắt mạng / key sai → label PostNord và đổi status vẫn thành công, lỗi nằm trong log + được cron gửi lại
- [ ] Kiểm tra log `var/log/pinshirt-*.log` không chứa key
- [ ] Regression: đơn channel khác (Shopify/WP/Svea) vẫn đi đúng nhánh `postOrderUpdateComplete()`

---

## Kết quả test import

*(điền khi chạy xong Phần A)*

| File | Orders | Items | Kết quả | Ghi chú / lỗi |
|---|---|---|---|---|
| `pinshirt_order_40202.csv` | | | | |
| `pinshirt_order_40203.csv` | | | | |
| `pinshirt_order_40204.csv` | | | | |
| `pinshirt_order_40205.csv` | | 3 | | |

---

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

| File | Mục đích |
|------|----------|
| `docs/issues/TSHIRTORDER-1647-pinshirt-status-callback/files/printery-status-endpoint.md` | Spec API status của Pinshirt (tiếng Thụy Điển, bản gốc ở `temp_files/`) |
| `src/Command/Cronjob/ImportOrderPinshirtCommand.php` | Command import (Phần A) |
| `src/Service/Pinshirt/PinshirtOrderImporter.php` | Import 1 order; `SYN_ID`, `CREATED_FROM = 'import_pinshirt'`; điểm gửi `Received` |
| `src/Service/FileService.php` | `importOrderPinshirtProcess*`, đọc CSV Pinshirt |
| `src/Service/OrderService.php` | `update()` (1436, hook status), `postOrderUpdateComplete()` (4770, thêm nhánh `import_pinshirt`) |
| `src/Service/PostNordService.php` | `orderPostnordCreateLabel()` (555-648): tracking + set Complete + gọi `postOrderUpdateComplete()` |
| `src/Entity/Order.php` | `orderNr`, `createdFrom`, `postNordShipmentId`, `postNordTrackingUrl`, `postNordLatestData`, `dateComplete`; mẫu `decoNeedResync` |
| `src/Command/Cronjob/ResyncWpCompletedOrderCommand.php` | Mẫu cron gửi lại |
| `config/packages/monolog.yaml` | Channel/handler `pinshirt` (dùng lại) |
| `config/services.yaml`, `.env` | Thêm param API Pinshirt |
| `docs/issues/TSHIRTORDER-1627-pinshirt-import/pinshirt-import.md` | Doc import Pinshirt (quyết định + bẫy đã gặp) |
| `docs/issues/TSHIRTORDER-1642-pinshirt-model-columns/pinshirt-model-columns.md` | `modelSku` / `modelName` |
| `docs/issues/TSHIRTORDER-1644-nwg-integration/nwg-integration.md` | Thiết kế gửi status tương tự cho NWG (nên đồng bộ cách làm) |
