EzyDelivery có sẵn một số đơn vị vận chuyển như Giao Hàng Tiết Kiệm, Viettel Post, SPX, 247Express. Khi cần dùng một đơn vị khác, bạn không phải sửa mã nguồn EzyDelivery. Bạn chỉ cần viết một plugin riêng có một lớp cài đặt interface dịch vụ vận chuyển. Khi khởi động, hệ thống tự tìm thấy lớp đó, và dịch vụ mới sẽ hiện trong trang quản trị như các dịch vụ có sẵn.
Bài viết này giải thích cơ chế đó và từng bước để viết một plugin như vậy. Ví dụ xuyên suốt bài là đơn vị vận chuyển giả định ACME Express, mã dịch vụ ACME_EXPRESS.

Kiến trúc tổng quát

Hệ thống chia việc giữa ba bên như sau:
  • Lõi thương mại điện tử: quản lý đơn giao hàng, cấu hình dịch vụ vận chuyển, gửi đơn và nhận webhook. Lõi chỉ làm việc qua interface DeliveryService và không biết bên dưới là đơn vị vận chuyển nào.
  • EzyDelivery: nghiệp vụ vận chuyển, gồm yêu cầu giao hàng, tạo vận đơn, in tem, công nợ... Khi cần gửi đơn đi, EzyDelivery chuyển việc đó cho lõi.
  • Plugin của bạn: cài đặt DeliveryService cho một đơn vị vận chuyển cụ thể.
flowchart LR
    subgraph QT["Trang quản trị (admin)"]
        A1["Màn hình tạo vận đơn<br/>(EzyDelivery)"] --> A2["Bộ xử lý gửi đơn<br/>(lõi thương mại điện tử)"]
        A2 --> M1["Bộ quản lý dịch vụ vận chuyển"]
    end
    subgraph WEB["Trang web (web)"]
        W1["Endpoint /callback/{mã-dịch-vụ}"] --> M2["Bộ quản lý dịch vụ vận chuyển"]
    end
    M1 -->|"tìm theo mã"| P1["AcmeExpressDeliveryService<br/>(bản admin)"]
    M2 -->|"tìm theo mã"| P2["AcmeExpressDeliveryService<br/>(bản web)"]
    P1 -->|"gọi API tạo đơn"| ACME[("API ACME Express")]
    ACME -->|"webhook trạng thái"| W1
Dịch vụ vận chuyển chạy ở cả hai phía:
PhíaViệc của dịch vụ vận chuyển
AdminĐăng nhập hoặc kiểm tra khoá khi lưu cấu hình, gửi đơn sang đơn vị vận chuyển, tạo đường dẫn tra cứu hành trình.
WebNhận webhook, xác thực, đọc dữ liệu trạng thái đơn và chuyển sang trạng thái của hệ thống.
Vì vậy plugin của bạn thường có ba phần: phần SDK chứa logic dùng chung, phần admin plugin và phần web plugin. Mỗi phần chỉ cần một lớp con mỏng để đăng ký bean.

Cơ chế tự phát hiện dịch vụ

Hệ thống không có danh sách dịch vụ viết cứng. Khi khởi động, bộ quản lý dịch vụ vận chuyển lấy tất cả bean cài đặt DeliveryService trong ngữ cảnh của plugin, rồi lập bảng tra cứu theo getCode().
flowchart TD
    S["Khởi động admin / web"] --> B["Quét bean của các plugin"]
    B --> C{"Bean có cài đặt<br/>DeliveryService?"}
    C -->|"Có"| D["Đưa vào bảng tra cứu<br/>khoá = getCode()"]
    C -->|"Không"| E["Bỏ qua"]
    D --> F["Trang Các dịch vụ vận chuyển<br/>liệt kê mọi mã trong bảng"]
    D --> G["Gửi đơn / nhận webhook<br/>tìm dịch vụ theo mã"]
Hệ quả:
  • Mã do getCode() trả về là định danh duy nhất của dịch vụ. Mã này được dùng làm khoá cấu hình, làm giá trị phương thức giao hàng khi tạo vận đơn, và làm đoạn cuối của đường dẫn webhook /callback/<mã>. Khi tra cứu, hệ thống thử khớp chính xác trước, nếu không thấy thì so sánh không phân biệt hoa thường.
  • Chỉ cần bean xuất hiện là dịch vụ mới hiện trong Thương mại điện tử → Các dịch vụ vận chuyển với trạng thái INACTIVATED. Quản trị viên điền cấu hình và bật ACTIVATED thì dịch vụ mới dùng được.
  • Nếu chỉ đăng ký bean ở phía admin mà quên phía web, đơn vẫn gửi đi được, nhưng webhook sẽ nhận lỗi deliveryService: notFound.

Interface cần cài đặt

Interface gốc là DeliveryService của SDK thương mại điện tử. EzyDelivery mở rộng nó thành EzyDeliveryDeliveryService, có thêm một hàm tuỳ chọn để kéo danh sách đơn từ đơn vị vận chuyển về đối soát. Nên cài đặt EzyDeliveryDeliveryService để dùng được các tính năng đó sau này.
classDiagram
    class DeliveryService {
        <<interface>>
        +getCode() String
        +deliver(setting, order) DeliveryOrderResultModel
        +verifyDeliveryCallbackParameters(...) DeliveryVerifyResponseModel
        +extractDeliveryOrder(params, body) DeliveryOrderCallbackModel
        +verifyDeliveryResult(order, params, body) DeliveryVerifyResultModel
        +getDeliveryOrderNotFoundResponse() DeliveryVerifyResponseModel
        +convertVerifyExceptionToResponse(e) DeliveryVerifyResponseModel
        +getTrackingDeliveryOrderUrl(serviceUrl, trackingId) String
        +isAuthenticatedByUsernamePassword() boolean
        +login(settingId, setting, username, password)
        +loginWithSecretKey(settingId, setting, secretKey)
        +cancelDeliveryOrderById(id)
    }
    class EzyDeliveryDeliveryService {
        <<interface>>
        +getOrderList(request, limit) TrackingDeliveryOrderPaginationModel
    }
    class AcmeExpressDeliveryService
    class AdminAcmeExpressDeliveryService
    class WebAcmeExpressDeliveryService
    DeliveryService <|-- EzyDeliveryDeliveryService
    EzyDeliveryDeliveryService <|.. AcmeExpressDeliveryService
    AcmeExpressDeliveryService <|-- AdminAcmeExpressDeliveryService
    AcmeExpressDeliveryService <|-- WebAcmeExpressDeliveryService
Chỉ có hai hàm bắt buộc: getCode() và deliver(...). Các hàm còn lại có sẵn cài đặt mặc định, bạn ghi đè khi cần:
HàmKhi nào cần ghi đèMặc định
getCode()Luôn luôn—
deliver(...)Luôn luôn—
verifyDeliveryCallbackParameters(...)Khi đơn vị vận chuyển có webhookBáo lỗi unimplemented
extractDeliveryOrder(...)Khi đơn vị vận chuyển có webhookBáo lỗi unimplemented
verifyDeliveryResult(...)Khi muốn kiểm tra thêm hoặc chọn dữ liệu cần lưuKhông kiểm tra, không chọn
getDeliveryOrderNotFoundResponse()Khi đơn vị vận chuyển cần một mã HTTP hoặc nội dung trả về riêng400 + {"deliveryOrder":"notFound"}
convertVerifyExceptionToResponse(e)Khi muốn biến exception thành phản hồi thay vì lỗi 500null (ném tiếp exception)
getTrackingDeliveryOrderUrl(...)Khi đơn vị vận chuyển có trang tra cứu hành trìnhnull
isAuthenticatedByUsernamePassword() + login(...)Khi API cần đăng nhập bằng tên đăng nhập và mật khẩu để lấy tokenfalse, không làm gì
loginWithSecretKey(...)Khi cần kiểm tra khoá bí mật ngay lúc lưu cấu hìnhKhông làm gì
cancelDeliveryOrderById(...)Khi hỗ trợ huỷ đơn từ hệ thốngBáo lỗi unsupported
getOrderList(...)Khi cần kéo danh sách đơn về đối soátTrả về danh sách rỗng

Luồng gửi đơn

Khi nhân viên tạo vận đơn và chọn dịch vụ ACME Express, EzyDelivery tạo đơn giao hàng rồi giao việc gửi cho lõi:
sequenceDiagram
    autonumber
    actor NV as Nhân viên
    participant ED as EzyDelivery (admin)
    participant LOI as Bộ xử lý gửi đơn (lõi)
    participant SV as AcmeExpressDeliveryService
    participant API as API ACME Express

    NV->>ED: Tạo vận đơn, chọn ACME_EXPRESS
    ED->>LOI: Gửi đơn với mã ACME_EXPRESS
    LOI->>LOI: Tìm dịch vụ theo mã<br/>Kiểm tra cấu hình đang ACTIVATED
    LOI->>LOI: Dựng ForDeliveryOrderModel<br/>(người gửi, kho, người nhận, sản phẩm, COD...)
    LOI->>SV: deliver(cấu hình, đơn)
    SV->>API: Gọi API tạo đơn
    API-->>SV: Kết quả + mã vận đơn
    SV-->>LOI: DeliveryOrderResultModel
    alt SUCCESS
        LOI->>LOI: Lưu mã vận đơn, trạng thái "Đã gửi yêu cầu"
    else FAILED hoặc có exception
        LOI->>LOI: Trạng thái "Gửi yêu cầu thất bại"<br/>Lưu nội dung lỗi
    end
    LOI->>LOI: Lưu properties vào dữ liệu phụ của đơn
Dữ liệu vào của deliver(...):
  • DeliveryServiceSettingModel: cấu hình quản trị viên đã nhập, gồm đường dẫn API, phiên bản, khoá máy khách, tên đăng nhập... Khoá bí mật lưu riêng và đọc qua service cấu hình theo mã dịch vụ.
  • ForDeliveryOrderModel: đơn đã được chuẩn bị sẵn, gồm id đơn, tiền tệ, kho gửi, thông tin người gửi, người nhận (ForDeliveryOrderRecipientModel, có tỉnh, quận, phường đã ghép tiền tố), danh sách sản phẩm (ForDeliveryProductModel có khối lượng, số lượng và các cờ dễ vỡ, tài liệu, hàng giá trị cao...), tiền thu hộ, giá trị hàng, người trả phí ship, ngày lấy hàng.
Dữ liệu ra là DeliveryOrderResultModel:
TrườngÝ nghĩa
resultSUCCESS hoặc FAILED.
trackingIdMã vận đơn của đơn vị vận chuyển. Phải có khi SUCCESS, vì webhook về sau được đối chiếu bằng mã này.
propertiesDữ liệu tuỳ ý (phí, thời gian giao dự kiến, mã lỗi, thông báo...). Được lưu kèm đơn để xem lại.
Một số lưu ý khi viết deliver(...):
  • Mã đơn gửi sang đơn vị vận chuyển nên có dạng <id-đơn>-<timestamp>. Khi webhook gọi về, bạn tách lại id-đơn để hệ thống tìm đúng đơn. Phần timestamp giúp gửi lại một đơn đã thất bại mà không bị đơn vị vận chuyển báo trùng mã.
  • Exception ném ra trong deliver không làm hỏng luồng. Đơn được chuyển sang "Gửi yêu cầu thất bại" và nội dung exception được lưu làm thông báo lỗi. Nhưng nếu API trả lỗi nghiệp vụ, nên trả FAILED kèm properties chi tiết thay vì ném exception.
  • Tiền tệ: nếu đơn vị vận chuyển chỉ nhận một loại tiền, hãy quy đổi codAmount và goodsAmountMoney từ currencyId của đơn sang loại tiền đó bằng service tỉ giá.
  • Thu hộ: chỉ thu khi người nhận chưa thanh toán. Kết hợp thêm shippingFeePayer để quyết định ai trả phí vận chuyển.

Luồng nhận webhook

Mọi đơn vị vận chuyển dùng chung một endpoint trên trang web: GET hoặc POST /callback/<mã-dịch-vụ>. Phần lõi lo các bước chung, còn dịch vụ của bạn chỉ trả lời ba câu hỏi: request có hợp lệ không, request nói về đơn nào và trạng thái gì, và cần lưu thêm dữ liệu gì.
sequenceDiagram
    autonumber
    participant API as ACME Express
    participant CB as Endpoint /callback/ACME_EXPRESS (lõi)
    participant SV as AcmeExpressDeliveryService (web)
    participant DB as Đơn giao hàng

    API->>CB: Webhook (header, query, body)
    CB->>CB: Tìm dịch vụ theo mã<br/>(không có thì 404)
    CB->>SV: verifyDeliveryCallbackParameters(...)
    alt Có lỗi
        SV-->>CB: DeliveryVerifyResponseModel (mã + dữ liệu lỗi)
        CB-->>API: Trả lỗi, dừng xử lý
    end
    CB->>SV: extractDeliveryOrder(params, body)
    SV-->>CB: DeliveryOrderCallbackModel
    CB->>DB: Tìm đơn theo deliveryOrderId
    alt Không có đơn hoặc trackingId không khớp
        CB->>SV: getDeliveryOrderNotFoundResponse()
        CB-->>API: Trả "không tìm thấy"
    end
    CB->>SV: verifyDeliveryResult(đơn, params, body)
    CB->>DB: Cập nhật trạng thái (nếu status khác rỗng)
    CB->>DB: Lưu properties (rỗng thì lưu toàn bộ params + body)
    CB-->>API: 200 OK
Vai trò của từng hàm:
  • verifyDeliveryCallbackParameters: xác thực nguồn gọi (token trong header hoặc query, chữ ký HMAC...) và kiểm tra các trường bắt buộc. Trả null nếu hợp lệ. Nếu không hợp lệ, trả DeliveryVerifyResponseModel chứa mã HTTP và dữ liệu lỗi. Tham số secretKey là khoá bí mật đã lưu của dịch vụ. Nếu đơn vị vận chuyển gửi kèm một chuỗi bí mật do bạn tự đặt, nên so với khoá máy khách trong cấu hình dịch vụ.
  • extractDeliveryOrder: chuyển dữ liệu webhook thành DeliveryOrderCallbackModel:
    TrườngÝ nghĩa
    deliveryOrderIdId đơn trong hệ thống, thường tách từ mã đơn đã gửi đi (<id-đơn>-<timestamp>).
    trackingIdMã vận đơn, phải trùng với mã đã lưu lúc gửi đơn, nếu không webhook bị bỏ qua.
    statusTên một trạng thái DeliveryOrderStatus (PICKED_UP, IN_TRANSIT, DELIVERED, RETURNING_TO_SENDER...). Mã lạ thì trả về UNKNOWN. Để rỗng thì đơn không đổi trạng thái.
    shipperName, shipperPhoneNumber, location, expectedDeliveryDate, notes, issuedAtThông tin bổ sung hiển thị kèm đơn.
    dataDữ liệu gốc của webhook.
    TrườngÝ nghĩa
    deliveryOrderIdId đơn trong hệ thống, thường tách từ mã đơn đã gửi đi (<id-đơn>-<timestamp>).
    trackingIdMã vận đơn, phải trùng với mã đã lưu lúc gửi đơn, nếu không webhook bị bỏ qua.
    statusTên một trạng thái DeliveryOrderStatus (PICKED_UP, IN_TRANSIT, DELIVERED, RETURNING_TO_SENDER...). Mã lạ thì trả về UNKNOWN. Để rỗng thì đơn không đổi trạng thái.
    shipperName, shipperPhoneNumber, location, expectedDeliveryDate, notes, issuedAtThông tin bổ sung hiển thị kèm đơn.
    dataDữ liệu gốc của webhook.
  • verifyDeliveryResult: chạy sau khi đã tìm thấy đơn. Hàm này dùng để kiểm tra chéo với dữ liệu đơn và chọn các trường cần lưu (properties), ví dụ chỉ lưu mã trạng thái gốc của đơn vị vận chuyển.
Cách làm thường gặp là khai báo một bảng ánh xạ cố định từ mã trạng thái của đơn vị vận chuyển sang DeliveryOrderStatus:
flowchart LR
    subgraph ACME["Mã trạng thái ACME"]
        a1["CREATED"]
        a2["PICKED"]
        a3["SHIPPING"]
        a4["DONE"]
        a5["RETURN"]
        a6["Mã khác"]
    end
    subgraph EZ["DeliveryOrderStatus"]
        e1["REQUEST_RECEIVED"]
        e2["PICKED_UP"]
        e3["IN_TRANSIT"]
        e4["DELIVERED"]
        e5["RETURNING_TO_SENDER"]
        e6["UNKNOWN"]
    end
    a1 --> e1
    a2 --> e2
    a3 --> e3
    a4 --> e4
    a5 --> e5
    a6 --> e6

Luồng lưu cấu hình và đăng nhập

Khi quản trị viên bấm Lưu ở trang sửa dịch vụ, hệ thống có thể gọi dịch vụ của bạn để kiểm tra thông tin đăng nhập ngay lúc đó:
flowchart TD
    A["Lưu cấu hình dịch vụ"] --> B["Lưu các ô cấu hình<br/>+ mẫu ánh xạ tiền tố"]
    B --> C{"Có nhập khoá bí mật?"}
    C -->|"Có"| D["loginWithSecretKey(...)"]
    D -->|"Exception"| E["Báo lỗi ô Khoá bí mật"]
    C -->|"Không"| F{"isAuthenticatedByUsernamePassword()?"}
    D -->|"OK"| F
    F -->|"true, có đủ tên đăng nhập + mật khẩu"| G["login(...)"]
    G -->|"Exception"| H["Báo lỗi ô Tên đăng nhập / Mật khẩu"]
    G -->|"OK"| I["Lưu thành công"]
    F -->|"false"| I
  • Nếu API dùng token cố định, bạn chỉ cần đọc khoá bí mật trong deliver. Có thể ghi đè loginWithSecretKey để gọi thử một API nhẹ, nhờ đó phát hiện khoá sai ngay lúc lưu.
  • Nếu API cần đăng nhập để lấy token có hạn, hãy cho isAuthenticatedByUsernamePassword() trả về true và cài đặt login(...). Hàm này gọi API đăng nhập, lưu token và thời điểm hết hạn. Trong deliver, kiểm tra token sắp hết hạn thì tự đăng nhập lại bằng tên đăng nhập và mật khẩu đã lưu.
  • Ném exception trong hai hàm này để báo thông tin sai. Hệ thống sẽ hiện lỗi ở đúng ô tương ứng.

Ánh xạ tiền tố đơn vị hành chính

Nhiều đơn vị vận chuyển nhận diện địa chỉ bằng văn bản và cần tiền tố như TP., Q., P.. Lúc dựng ForDeliveryOrderModel, lõi ghép tiền tố vào tên tỉnh, quận, phường theo mẫu ánh xạ tiền tố của từng mã dịch vụ. Mẫu này được lưu dưới dạng content template kiểu TEMPLATE_TYPE_ADMINISTRATIVE_TYPE_PREFIX_MAPPING, tên template chính là mã dịch vụ.
Nếu đơn vị vận chuyển của bạn cần tiền tố, hãy cho admin plugin tạo mẫu mặc định khi khởi động, chỉ tạo khi chưa có để không ghi đè chỉnh sửa của quản trị viên:
[
  { "type": "CITY", "prefix": "TP." },
  { "type": "PROVINCE", "prefix": "T." },
  { "type": "DISTRICT_IN_CITY", "prefix": "Q." },
  { "type": "DISTRICT_IN_PROVINCE", "prefix": "H." },
  { "type": "WARD_IN_CITY", "prefix": "P." },
  { "type": "WARD", "prefix": "X." }
]
Quản trị viên xem và sửa mẫu này ở ô Ánh xạ giữa kiểu và tiền tố địa giới hành chính trên trang sửa dịch vụ.

Các bước viết plugin

Cấu trúc dự án

flowchart TD
    R["acme-delivery (pom cha)"] --> SDK["acme-delivery-sdk<br/>AcmeExpressDeliveryService"]
    R --> AD["acme-delivery-admin-plugin<br/>AdminAcmeExpressDeliveryService<br/>+ mẫu tiền tố mặc định"]
    R --> WB["acme-delivery-web-plugin<br/>WebAcmeExpressDeliveryService"]
    AD --> SDK
    WB --> SDK
    SDK -.->|"phụ thuộc"| E1["ecommerce-sdk<br/>ezydelivery-sdk"]
Mỗi plugin khai báo trong module.properties, gồm loại plugin, package gốc để quét bean, và danh sách plugin phụ thuộc:
type=admin-plugin
version=0.0.1
package=com.example.acmedelivery
project_name=AcmeDelivery
description=ACME Express delivery service for EzyDelivery
dependencies=ecommerce,ezydelivery
Phía web làm tương tự với type=web-plugin. Các thư viện mà EzyPlatform và các plugin phụ thuộc đã cung cấp sẵn (ecommerce-sdk, ezydelivery-sdk, ezyplatform-*) nên khai báo provided hoặc loại khỏi gói build, tránh nạp trùng.

Viết lớp dịch vụ trong SDK

@Setter
public class AcmeExpressDeliveryService
    extends EzyLoggable
    implements EzyDeliveryDeliveryService {

    public static final String CODE = "ACME_EXPRESS";

    private static final Map<String, DeliveryOrderStatus> STATUS_BY_ACME_VALUE =
        Collections.unmodifiableMap(
            EzyMapBuilder.mapBuilder()
                .put("CREATED", DeliveryOrderStatus.REQUEST_RECEIVED)
                .put("PICKED", DeliveryOrderStatus.PICKED_UP)
                .put("SHIPPING", DeliveryOrderStatus.IN_TRANSIT)
                .put("DONE", DeliveryOrderStatus.DELIVERED)
                .put("RETURN", DeliveryOrderStatus.RETURNING_TO_SENDER)
                .toMap()
        );

    @EzyAutoBind
    private HttpClient httpClient;

    @EzyAutoBind
    private EcommerceSettingService ecommerceSettingService;

    @EzyAutoBind
    private DeliveryServiceSettingService deliveryServiceSettingService;

    @Override
    public String getCode() {
        return CODE;
    }

    @Override
    public DeliveryOrderResultModel deliver(
        DeliveryServiceSettingModel setting,
        ForDeliveryOrderModel order
    ) throws Exception {
        String apiUrl = setting.getServiceApiUrl();
        String secretKey = ecommerceSettingService
            .getDeliveryServiceSecretKey(CODE);
        if (isBlank(apiUrl) || isBlank(secretKey)) {
            throw new IllegalStateException("deliveryServiceNotSetup");
        }
        ResponseEntity response = httpClient.request(
            new PostRequest()
                .setURL(apiUrl + "/orders")
                .setEntity(
                    RequestEntity.builder()
                        .header("Authorization", "Bearer " + secretKey)
                        .contentType("application/json")
                        .body(toCreateOrderBody(order))
                        .build()
                )
        );
        Map<String, Object> body = (Map<String, Object>) response.getBody();
        if (response.getStatus() == StatusCodes.OK && body != null) {
            return DeliveryOrderResultModel.builder()
                .result(DeliveryOrderResult.SUCCESS)
                .trackingId((String) body.get("tracking_code"))
                .properties(EzyMaps.getValues(body, Sets.newHashSet("fee", "eta")))
                .build();
        }
        return DeliveryOrderResultModel.builder()
            .result(DeliveryOrderResult.FAILED)
            .properties(singletonMap("message", String.valueOf(body)))
            .build();
    }

    private Map<String, Object> toCreateOrderBody(ForDeliveryOrderModel order) {
        ForDeliveryOrderRecipientModel recipient = order.getOrderRecipient();
        return EzyMapBuilder.mapBuilder()
            .put("partner_order_id", order.getId() + "-" + System.currentTimeMillis())
            .put("sender_name", order.getSenderOrShopFullName())
            .put("sender_phone", order.getSenderOrFromWarehousePhoneNumber())
            .put("sender_address", order.getSenderOrFromWarehouseAddress())
            .put("receiver_name", recipient.getFullName())
            .put("receiver_phone", recipient.getPhoneNumber())
            .put("receiver_address", recipient.getFullAddress())
            .put("cod", order.getCodAmount())
            .put("value", order.getGoodsAmountMoney())
            .toMap();
    }

    @Override
    public DeliveryVerifyResponseModel verifyDeliveryCallbackParameters(
        Function<String, String> headerFetcher,
        Enumeration<String> parameterNames,
        Map<String, String> parameterValueMap,
        Map<String, Object> requestBody,
        String secretKey
    ) {
        Map<String, String> errors = new HashMap<>();
        String clientKey = deliveryServiceSettingService
            .getDeliveryClientKeyByCode(CODE);
        String token = headerFetcher.apply("X-Acme-Token");
        if (isBlank(token) || !token.equals(clientKey)) {
            errors.put("token", "invalid");
        }
        if (requestBody.get("partner_order_id") == null) {
            errors.put("partnerOrderId", "required");
        }
        return errors.isEmpty()
            ? null
            : DeliveryVerifyResponseModel.builder()
                .code(StatusCodes.BAD_REQUEST)
                .data(errors)
                .build();
    }

    @Override
    public DeliveryOrderCallbackModel extractDeliveryOrder(
        Map<String, String> parameterValueMap,
        Map<String, Object> requestBody
    ) {
        String partnerOrderId = (String) requestBody.get("partner_order_id");
        DeliveryOrderStatus status = STATUS_BY_ACME_VALUE.getOrDefault(
            (String) requestBody.get("status"),
            DeliveryOrderStatus.UNKNOWN
        );
        return DeliveryOrderCallbackModel.builder()
            .deliveryOrderId(extractDeliveryOrderId(partnerOrderId))
            .trackingId((String) requestBody.get("tracking_code"))
            .status(status.toString())
            .shipperName((String) requestBody.get("shipper_name"))
            .shipperPhoneNumber((String) requestBody.get("shipper_phone"))
            .issuedAt((String) requestBody.get("updated_at"))
            .data(requestBody)
            .build();
    }

    @Override
    public String getTrackingDeliveryOrderUrl(
        String deliveryServiceUrl,
        String trackingId
    ) {
        return deliveryServiceUrl + "/tracking/" + trackingId;
    }
}
extractDeliveryOrderId là hàm tiện ích của SDK EzyDelivery. Hàm này tách phần đứng trước dấu - đầu tiên của mã đơn và trả về id đơn.

Đăng ký bean ở admin và web

Lớp trong SDK không gắn annotation. Mỗi phía tạo một lớp con mỏng gắn @Service để được quét thành bean:
@Service
public class AdminAcmeExpressDeliveryService
    extends AcmeExpressDeliveryService {}
@Service
public class WebAcmeExpressDeliveryService
    extends AcmeExpressDeliveryService {}
Lớp con phải nằm trong package đã khai báo ở module.properties của plugin tương ứng.

Cài đặt và cấu hình

flowchart LR
    A["Build và cài<br/>admin + web plugin"] --> B["Khởi động lại<br/>admin và web"]
    B --> C["ACME_EXPRESS xuất hiện<br/>trong Các dịch vụ vận chuyển"]
    C --> D["Điền cấu hình<br/>bật ACTIVATED"]
    D --> E["Khai báo webhook<br/>trên ACME Express"]
    E --> F["Tạo vận đơn thử<br/>chọn ACME Express"]
Ở trang sửa dịch vụ, các ô thường dùng gồm:
ÔDùng cho
TênTên hiển thị khi chọn dịch vụ, không được trùng tên dịch vụ khác.
Phiên bảnPhiên bản API, nếu đơn vị vận chuyển có.
Đường dẫn dịch vụTham số deliveryServiceUrl truyền vào getTrackingDeliveryOrderUrl.
Đường dẫn API dịch vụĐịa chỉ gốc của API, đọc bằng getServiceApiUrl().
Khoá máy kháchChuỗi bí mật dùng để xác thực webhook.
Tên đăng nhập / Mật khẩuChỉ hiện khi isAuthenticatedByUsernamePassword() trả về true.
Khoá bí mậtToken API, đọc trong deliver.
Giá trị hàng hoá mặc địnhGiá trị hàng được điền sẵn khi EzyDelivery tạo đơn giao hàng cho dịch vụ này.
Đường dẫn webhook khai báo với đơn vị vận chuyển là https://<tên-miền-web>/callback/ACME_EXPRESS.
Để thử luồng mà chưa có tài khoản thật, có thể dùng dịch vụ SANDBOX có sẵn làm mẫu tham khảo. Dịch vụ này trả về SUCCESS ngay khi có khoá máy khách, không gọi ra ngoài.

Giới hạn cần lưu ý

  • Bảng tra cứu dịch vụ chỉ được lập một lần lúc khởi động. Cài thêm hoặc gỡ plugin thì phải khởi động lại admin và web.
  • Hai dịch vụ có cùng getCode() sẽ ghi đè lẫn nhau. Hãy chọn mã đủ riêng.
  • Lõi chỉ chấp nhận webhook của đơn do chính hệ thống gửi đi và có trackingId khớp. Đơn tạo thẳng trên trang của đơn vị vận chuyển sẽ bị bỏ qua.
  • Hệ thống không tự gửi lại đơn khi deliver thất bại. Nhân viên cần sửa nguyên nhân rồi gửi lại.
  • Huỷ đơn và kéo danh sách đơn về đối soát là tính năng tuỳ chọn. Nếu không cài đặt cancelDeliveryOrderById hoặc getOrderList, các thao tác tương ứng sẽ báo không hỗ trợ hoặc trả về rỗng.
  • Lõi không tự giới hạn số lần gọi webhook. Nếu đơn vị vận chuyển gửi trùng một sự kiện nhiều lần, trạng thái sẽ được cập nhật lại nhiều lần. Hãy dùng verifyDeliveryResult nếu cần chặn sự kiện cũ.

Kết luận

Thêm một đơn vị vận chuyển vào EzyDelivery là cài đặt một interface, không phải sửa hệ thống. Bạn viết logic gửi đơn và đọc webhook một lần trong SDK, sau đó đăng ký một lớp con ở admin và một lớp con ở web. Lõi lo phần còn lại: tìm dịch vụ theo mã, quản lý cấu hình, đổi trạng thái đơn, lưu lịch sử và hiển thị trong trang quản trị.