# 09 — Tích hợp bên thứ ba

> Tổng hợp toàn bộ tích hợp bên ngoài: Shopify, WooCommerce/WordPress, Svea, Swish, PostNord, FTP/Garp. Mỗi mục nêu rõ: mục đích nghiệp vụ, nơi lưu credential, hướng dữ liệu, cách kích hoạt, và log.

## 0. Bảng tổng hợp nhanh

| Tích hợp | Hướng dữ liệu | Cơ chế kích hoạt | Giao thức | Phạm vi credential | Nơi ghi log |
|---|---|---|---|---|---|
| Shopify — sản phẩm/tồn kho/đơn (kéo về) | Shopify → hệ thống | Cronjob `cronjob:shopify_clone_product/_product_stock_stock/_order` | GraphQL Admin API (2025-01) qua cURL | Theo từng `Channel` | `shopify_logs/clone_{loại}_{ngày}.txt` |
| Shopify — báo fulfillment (đẩy đi) | Hệ thống → Shopify | Đồng bộ ngay khi đơn "complete"; retry bởi `cronjob:resync-wp-completed-order` | GraphQL mutation `fulfillmentCreateV2` | Theo từng `Channel` | Bảng `OrderResyncWp` (hàng đợi retry) |
| Shopify — webhook GDPR | Shopify → hệ thống | Webhook đến (`ShopifyController`) | REST + Bearer token | — | — |
| WooCommerce/WordPress — báo hoàn tất đơn | Hệ thống → WordPress | Đồng bộ ngay khi đơn "complete"; retry bởi cronjob | cURL POST tới URL webhook lưu sẵn trên từng đơn | Theo từng `Order` (URL lưu lúc import) | Bảng `OrderResyncWp` |
| WooCommerce — xuất tồn kho | Hệ thống → WooCommerce | Cronjob `cronjob:export-stock-to-woo --slot=...` | File JSON qua SFTP | Toàn cục (`services.yaml`) | `woo_stock_logs/export_{ngày}.txt` |
| Svea Checkout | 2 chiều | Đồng bộ (checkout webshop + đẩy giao hàng khi đơn hoàn tất) | SDK `sveawebpay/php-checkout`, HTTPS | Toàn cục (biến môi trường `SVEA_MERCHANT_ID/SECRET`) | Trả về mảng lỗi / `Order.sveaOrderDeliveryLog` |
| Swish | 2 chiều | Đồng bộ (checkout webshop, polling) | REST qua cURL + chứng chỉ mTLS | Toàn cục (`services.yaml`) | Không có (chỉ trả mảng kết quả) |
| PostNord | Hệ thống → PostNord | Đồng bộ (khi tạo nhãn vận chuyển từ UI/API) | REST qua cURL, JSON kiểu EDI | Toàn cục (`services.yaml`) | PSR Logger mức `critical` |
| FTP Garp | File CSV ngoài → hệ thống | Chưa rõ lịch chạy — có vẻ chưa hoàn thiện | Đọc file cục bộ (đã được đồng bộ sẵn) | Toàn cục (`services.yaml` — `garp_ftp_*`) | Không có |

---

## 1. Shopify

**Mục đích:** nhiều khách hàng của hệ thống vận hành storefront trên Shopify — tích hợp này giữ đồng bộ sản phẩm/tồn kho, kéo đơn hàng Shopify về hệ thống để sản xuất/fulfillment, và báo ngược trạng thái giao hàng khi hoàn tất.

**Credential:** lưu **theo từng `Channel`** (không phải config toàn cục): `shopifyApiKey`, `shopifySecretKey`, `shopifyAccessToken`, `shopifyUrl`. Cờ bật đồng bộ: `shopifySyncOrder`, `shopifySyncProduct`. `Channel.synId` được dùng làm `channel_id` khi map dữ liệu về định dạng nội bộ.

**API:** Shopify Admin **GraphQL API**, cố định phiên bản `2025-01` (`https://{shopifyUrl}/admin/api/2025-01/graphql.json`), xác thực bằng header `X-Shopify-Access-Token`. Gọi bằng cURL thuần, **không dùng SDK Shopify chính thức**.

### 1.1. Chiều kéo về (Shopify → hệ thống)

| Loại | Cronjob | Hàm | Ghi chú |
|---|---|---|---|
| Sản phẩm | `cronjob:shopify_clone_product` | `ShopifyService::getShopifyProducts()` → `ProductService::importWpProducts()` | Phân trang 100 sản phẩm/lần (cursor), lấy tới 250 biến thể/sản phẩm |
| Tồn kho | `cronjob:shopify_clone_product_stock_stock` ⚠️ tên lệnh có lỗi chính tả lặp từ | `getShopifyProductStocks()` → `ProductService::importWpStock()` | Truy vấn nhẹ hơn, chỉ lấy `sku`/`inventoryQuantity` |
| Đơn hàng | `cronjob:shopify_clone_order` | `getShopifyOrders()` → `OrderService::importFromWPData()` | Lọc `-status:closed -status:cancelled -fulfillment_status:fulfilled`, tạo trong 30 ngày gần nhất; đơn đã có `return` bị bỏ qua |

Cả 3 cronjob chỉ chạy cho các `Channel` thoả: `active=true`, cờ đồng bộ tương ứng bật, **và** đã cấu hình `shopifyAccessToken` + `shopifyUrl` (điều kiện `shopify_run_clone_command`).

### 1.2. Chiều đẩy đi (hệ thống → Shopify) — báo đã giao hàng

Khi 1 đơn có nguồn gốc Shopify chuyển sang "complete" (thường ngay sau khi tạo nhãn PostNord), `OrderService::postOrderUpdateComplete()` gọi `ShopifyService::fetchFulfillmentOrders()` lấy `fulfillmentOrders`, sau đó gọi mutation GraphQL **`fulfillmentCreateV2`** kèm `trackingInfo` (số vận đơn + URL tra cứu PostNord, `company: "Portal PostNord"`). Lỗi được ghi vào `OrderResyncWp` và tự động retry bởi cronjob `resync-wp-completed-order`.

### 1.3. Webhook GDPR (Shopify gọi vào hệ thống)

`ShopifyController` expose 2 endpoint: `webhook-customer-erasure-data` (xoá dữ liệu khách hàng) và `webhook-data-request` (yêu cầu xuất dữ liệu khách hàng) — cả 2 yêu cầu Bearer token hợp lệ (đây là 2 webhook Shopify **bắt buộc** phải có để được duyệt app trên App Store).

### 1.4. Log

File JSON theo dòng, mỗi ngày 1 file: `shopify_logs/clone_order_{ngày}.txt`, `clone_product_{ngày}.txt`, `clone_product_stock_{ngày}.txt`. Kênh thiếu `synId` bị bỏ qua và ghi log `'Channel missing sync id'`. **File này được `CLAUDE.md` coi là mẫu tham chiếu chuẩn** cho việc ghi log cronjob trong toàn hệ thống.

---

## 2. WooCommerce / WordPress

**Mục đích:** nhiều khách hàng khác vận hành storefront trên WooCommerce — hệ thống là nơi quản lý đơn hàng chính, cần báo ngược trạng thái hoàn tất về WordPress, và (theo thay đổi gần đây, ticket TSHIRTORDER-1548) **đẩy tồn kho từ hệ thống ra WooCommerce** (trước đây là chiều ngược lại).

**Credential:** `Channel.synId` (định danh đồng bộ), `Product.idWp` (ID sản phẩm/biến thể WooCommerce), `Product.wooSyncStock`, `Channel.wooSyncStockActive`/`wooSyncStockInterval`. Kết nối SFTP toàn cục trong `services.yaml`: `woo_ftp_host/username/password/port/remote_path`, `woo_stock_local_path`.

### 2.1. Báo hoàn tất đơn (hệ thống → WordPress)

Với đơn **không** đến từ Shopify/Svea/Deco, `OrderService::postOrderUpdateComplete()` POST JSON `{order_id, sync_id}` (`sync_id = md5(channel.synId . '__' . order.orderNr)`) tới URL webhook đã lưu sẵn trên đơn (`resyncUrlIntegration`, được gán lúc import đơn từ WordPress). Lỗi ghi vào `OrderResyncWp`, cronjob `resync-wp-completed-order` quét và thử lại định kỳ.

### 2.2. Xuất tồn kho (hệ thống → WooCommerce) — luồng hiện hành

Cronjob `cronjob:export-stock-to-woo --slot=night|noon|afternoon` → `WooStockExportService::exportForChannel()`:

1. Lấy toàn bộ `Product` của kênh có `wooSyncStock=true`, chưa xoá, có SKU.
2. Dựng JSON `{"channel_id": <synId>, "items": [...]}` — mỗi sản phẩm có `type: variable` (sản phẩm cha) hoặc `type: variation` (biến thể theo size), gồm `id` (=`idWp`), `sku` (đã bỏ hậu tố `_{sizeId}`), `stock`, `last_updated`.
3. Lưu local `public/woo_stock_export/{synId}/{ngày_giờ}.json`, sau đó **upload qua SFTP** vào `{woo_ftp_remote_path}/{synId}/`. Upload thành công → xoá file local; nếu chưa cấu hình SFTP (`woo_ftp_host` rỗng) → giữ lại file local để kiểm tra thủ công.
4. Một plugin WooCommerce (nằm ngoài repo này) được kỳ vọng tự động lấy file mới nhất trong thư mục để cập nhật tồn kho phía WooCommerce.

**Lịch chạy đề xuất trong code:** `night` (02:00 UTC, chạy cho mọi mức tần suất 1/2/3 lần/ngày), `noon` (11:00, chỉ mức 3 lần/ngày), `afternoon` (15:00, mức 2 và 3 lần/ngày).

> ⚠️ **Lưu ý lịch sử:** trước đây tồn kho đồng bộ theo chiều **ngược lại** (WooCommerce → hệ thống, qua endpoint `import-wp-stock`). Chiều này **đã được thay thế** bởi luồng xuất SFTP ở trên, nhưng các endpoint import kiểu WooCommerce (`ProductService::importWpProducts/importWpStock`, `OrderService::importFromWPData`) **vẫn giữ nguyên** vì cũng là định dạng dùng chung với luồng kéo dữ liệu Shopify.

### 2.3. Log

Xuất tồn kho: `woo_stock_logs/export_{ngày}.txt` (JSON theo dòng: bắt đầu/thành công/lỗi/số lượng/tên file/kết thúc). Đơn resync lỗi: lưu trong bảng `OrderResyncWp`, không phải file log.

---

## 3. Svea (Thanh toán/Checkout)

**Mục đích:** phương thức thanh toán chính trên webshop công khai (`/ecom/{channelEcomId}/kassa`).

**Credential:** **toàn cục**, không theo từng kênh — dù `Channel` có sẵn các field `sveaAllowImport`/`sveaAccessToken`/`sveaMerchantId`/`sveaMerchantSecret` (vẫn hiển thị qua API nhưng **không thực sự được `SveaService` đọc**). Giá trị thật lấy từ biến môi trường:

```yaml
svea_merchant_id: '%env(SVEA_MERCHANT_ID)%'
svea_merchant_secret: '%env(SVEA_MERCHANT_SECRET)%'
```

`SveaService` hiện **hard-code trỏ tới môi trường Production** của Svea (`Connector::PROD_BASE_URL`) — dòng cấu hình URL test đã bị comment sẵn trong code, cần chủ động bật lại nếu muốn test ở môi trường sandbox.

**Luồng:** `createOrder()` (tạo đơn Svea Checkout kèm callback URL sinh động theo route) → `get()` (tra trạng thái) → `adminOrderGet()`/`adminOrderDelivery()` (API Admin, dùng để **báo đã giao hàng** — được gọi tự động trong `postOrderUpdateComplete()` bất cứ khi nào đơn có `sveaOrderId`). Lỗi giao hàng lưu trực tiếp vào `Order.sveaOrderDeliveryLog`, không có file log riêng.

---

## 4. Swish (Thanh toán di động)

**Mục đích:** phương thức thanh toán QR/di động phổ biến ở Thuỵ Điển — **có code sẵn nhưng đang bị tắt** trên giao diện webshop (xem file 07 mục 5.2).

**Credential:** toàn cục trong `services.yaml`: `swish_dir` (đường dẫn chứng chỉ), `swish_payee_phone`. Xác thực bằng **chứng chỉ mTLS** (`swish.pem`/`swish.key`, mật khẩu chứng chỉ hard-code là chuỗi `'swish'`).

**API:** Swish Commerce API, **cố định endpoint live** (`https://cpc.getswish.net/...`), dòng test endpoint bị comment sẵn.

**Hàm chính:** `createPaymentRequest()` (tạo yêu cầu thanh toán, `payeeAlias` = số điện thoại cấu hình toàn cục, `payerAlias` = SĐT khách), `getPaymentById()` (polling trạng thái). 

⚠️ `refund()` **hiện bị vô hiệu hoá chủ động** — hàm trả lỗi `'This refund not work now'` ngay từ dòng đầu tiên, phần code bên dưới là code chết (còn tham chiếu tên chứng chỉ cũ, thiếu namespace). **Không dùng tính năng hoàn tiền Swish qua hệ thống này ở trạng thái hiện tại.**

---

## 5. PostNord (Vận chuyển)

**Mục đích:** nhà vận chuyển chính — tạo vận đơn, in nhãn, lấy URL tra cứu cho cả `Order` đơn lẻ và `BulkOrder` gộp.

**Credential:** toàn cục trong `services.yaml` (bộ "Test" đang active, bộ "Live" bị comment sẵn bên dưới — **cần rà soát kỹ trước khi lên production thật**): `postnord_api_key`, `postnord_api_url`, `postnord_api_pdf_url`, `postnord_consignor_party_id`. `applicationId = 2342` hard-code trong `PostNordService`.

**API:** PostNord Shipment API v3 (EDI), 2 endpoint: tạo vận đơn (trả `bookingId`, `printId`, mã vận đơn, URL tra cứu) và lấy PDF nhãn (base64).

**Luồng:** `orderPostnordCreateLabel()`/`bulkOrderPostnordCreateLabel()` — lặp theo số kiện hàng (`nr_of_kolli`), gọi API tạo nhãn cho từng kiện, lưu kết quả vào đơn. **Lần tạo nhãn thành công đầu tiên** của 1 đơn sẽ: chuyển `orderStatus` sang "complete", ghi `ActivityLog`, rồi gọi `OrderService::postOrderUpdateComplete()` — **đây chính là điểm kích hoạt** toàn bộ chuỗi đồng bộ ngược ra Shopify/WordPress mô tả ở mục 1–2. Có tuỳ chọn gửi email phiếu giao cho khách ngay sau đó.

**Log:** dùng PSR `LoggerInterface` mức `critical` (không phải file log riêng theo ngày như Shopify/Woo).

> **Ví dụ tình huống:** Nhân viên kho đóng gói xong đơn #3055 (nguồn gốc Shopify), bấm "Tạo nhãn vận chuyển" trên UI → `POST /orders/3055/postnord/create-label`. `PostNordService` gọi PostNord tạo vận đơn thành công, đơn tự động chuyển "complete", kéo theo `postOrderUpdateComplete()` gọi GraphQL Shopify báo fulfillment kèm mã vận đơn PostNord — khách hàng cuối trên Shopify storefront thấy trạng thái "Đã giao" gần như ngay lập tức mà nhân viên không cần thao tác gì thêm ở phía Shopify.

---

## 6. FTP / Garp

Có **2 tích hợp FTP khác biệt hoàn toàn**, dễ nhầm lẫn nếu chỉ đọc tên tham số — cần phân biệt rõ khi tra cứu:

### 6.1. `garp_ftp_*` — Garp (ERP kế toán)

```yaml
garp_ftp_host / garp_ftp_username / garp_ftp_password / garp_ftp_local_csv_path (public/uploads/garp_csv)
```

Dùng bởi `GarpMondayService` (xem file 08 mục 6) — **hiện chưa hoàn thiện**, chỉ đọc file CSV **đã có sẵn cục bộ** (không tự kết nối FTP tải về trong đoạn code hiện tại), có `dd()` gây dừng chương trình. Không tìm thấy cronjob nào đăng ký gọi lệnh `garp:monday` theo lịch — **không nên coi đây là tích hợp đang hoạt động**.

### 6.2. `woo_ftp_*` — Xuất tồn kho WooCommerce (thực chất là SFTP, không phải FTP thường)

```yaml
woo_ftp_host / woo_ftp_username / woo_ftp_password / woo_ftp_port (22) / woo_ftp_remote_path (/woo_stock) / woo_stock_local_path
```

Dùng bởi `WooStockExportService` (xem mục 2.2) — dùng `phpseclib3\Net\SFTP` (cổng 22), **không phải FTP/FTPS thật** dù tên tham số là `_ftp_`. Trong môi trường hiện tại các giá trị này là **chuỗi rỗng** (chờ credential thật từ đối tác hosting) — khi rỗng, service tự động bỏ qua bước upload và chỉ giữ file local để kiểm tra.

### 6.3. Thư viện dùng chung

Cả 2 tích hợp trên, cộng thêm `FileService` (nhập đơn đối tác) và `DownPaymentFileService` (nhập file ngân hàng, xem file 05), đều dùng chung `phpseclib/phpseclib` (^3.0) cho mọi thao tác SFTP — không có thư viện FTP/FTPS chuyên biệt nào khác trong hệ thống.

---

## 7. Lưu ý tổng hợp khi vận hành/bảo trì domain này

- **Thông tin đăng nhập (S)FTP của một số tích hợp (Garp, Blavitt trong `FileService`, `DownPaymentFileService`) vẫn hard-code trực tiếp trong code**, không đi qua `ParameterBagInterface` như quy ước `garp_ftp_*`/`woo_ftp_*` — xem chi tiết cảnh báo bảo mật ở file 12. **Ngoại lệ (TSHIRTORDER-1627):** các import dùng chung tài khoản SFTP `integration` trên `161.35.83.188` — Boltic, Nakata, Olamerlin, 377 Sport (`FileService`) và PINshirt — đọc từ cấu hình chung `integration_ftp_*` (biến môi trường `INTEGRATION_FTP_HOST/PORT/USERNAME` trong `.env`, `INTEGRATION_FTP_PASSWORD` trong `.env.local` hoặc biến môi trường server, không commit); thiếu cấu hình thì báo lỗi rõ và không kết nối. 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 chưa chuyển.
- Bộ credential PostNord "Live" đang bị comment, bộ "Test" đang active — **cần xác nhận với đội vận hành** trước khi tài liệu này được dùng để đối chiếu môi trường production thật.
- `cronjob:shopify_clone_product_stock_stock` có lỗi chính tả trong tên lệnh (`stock_stock` lặp từ) — cần giữ nguyên đúng tên này khi cấu hình crontab, sửa lại sẽ làm vỡ lịch chạy hiện có.
- Swish `refund()` không hoạt động — nếu nghiệp vụ cần hoàn tiền Swish, phải làm thủ công ngoài hệ thống hoặc viết lại hàm này.
