FE: module order — đơn hàng (ADR-0073) #
Phần chung (khởi tạo client, lỗi, danh sách, ngôn ngữ) ở tài liệu FE của Core:
docs/fe/common.md (bản đọc: xforge-documentation, ADR-0102 của xforge-core).
Sửa file này CÙNG PR khi đổi hành vi FE thấy được của module.
Đơn tạo ra từ chốt giỏ ở storefront (module checkout, channel: "web") hoặc
do nhân viên tạo ở CMS (createOrder, channel: "admin", xem "Tạo đơn").
Hai trục trạng thái #
status: placed ──confirm──► confirmed ──fulfill──► fulfilled ──complete──► completed
│ │
└──────cancel─────────┴──► cancelled
paymentStatus: unpaid → paid → partially_refunded / refunded
status= vòng đời giao hàng, nhân viên chuyển (hoặc tự chuyển, xem dưới). Chuyển sai bước → 409order_invalid_transition(details: { from, to }).paymentStatus= trục TIỀN, chỉ server đổi (modulepaymentskhi tiền về, modulerefundkhi hoàn) — FE không đặt được. Giao hàng KHÔNG đòi đã trả (COD).- Tiền về đủ khi đơn
placed→ đơn tựconfirmed(paidAtcó giá trị). paymentMethod(chọn lúc đặt, không đổi được):prepaidtrả trước qua ý định thanh toán;codthu khi giao, tiền về khi hãng nộp tiền thu hộ (moduleshipment). Đơn trước khi có trường này làprepaid.- Ý định thanh toán hết hạn (mặc định 60 phút) mà đơn
prepaidcònplaced+unpaid→ đơn tựcancelled, nhả kho, khi store bậtorder.auto_cancel({ onPaymentExpiry: true }, mặc định). ĐơncodKHÔNG tự huỷ theo ý định: nhân viên xác nhận rồi giao; khi tạo vận đơn, điền sẵncodAmount=grandTotal. Store đặtorder.auto_cancel.codUnconfirmedHours(số giờ, mặc định null = không) thì đơncodcònplacedquá số giờ đó tựcancelled(cancelReason: "COD not confirmed"), nhả kho. Việc này chạy mỗi giờ nên đơn có thể bị huỷ trễ tới một giờ. - Mốc thời gian theo trạng thái:
placedAt,confirmedAt,fulfilledAt,completedAt,cancelledAt,paidAt(null = chưa tới).
Tiền là chuỗi (subtotal, discountTotal, taxTotal, shippingTotal,
offsetTotal, grandTotal) kèm currency.
CMS / admin (XforgeClient) #
Menu: key order.orders (quyền order.read).
| Việc | Method | Quyền |
|---|---|---|
| Danh sách | listOrders({ query }) | order.read |
| Chi tiết | getOrder({ params: { id } }) | order.read |
| Xác nhận | confirmOrder({ params: { id } }) | order.manage |
| Giao (xuất kho) | fulfillOrder({ params: { id } }) | order.manage |
| Hoàn tất | completeOrder({ params: { id } }) | order.manage |
| Huỷ | cancelOrder({ params: { id }, body: { reason? } }) | order.cancel |
| Xem trước tiền | quoteOrder({ body }) | order.create |
| Tạo đơn | createOrder({ body }) | order.create |
| Ghi nhận tiền | recordOrderPayment({ params: { id }, body }) | order.record_payment |
- Danh sách: mới nhất trước; lọc
status,payment_status,payment_method,channel,customer_id;qtìm theo số đơn; sắpcreated_at/updated_at/number. Phân trang theo con trỏ (phần chung §2). - Nút theo
actions: danh sách + chi tiết của nhân viên cóactions: ("confirm" | "fulfill" | "complete" | "cancel" | "record_payment")[]— thao tác người gọi làm được lúc này (theo trạng thái + quyềnorder.manage/order.cancel/order.record_payment, đơn đã trả chưa hoàn đủ không cócancel). Bật / tắt nút theo đó, đừng tự suy từstatus. Response storefront không cóactions. actionskhông phải lời hứa: server vẫn có thể trả 409 khi bấm, nên luôn xử lý lỗi rồi tải lại đơn.cancelvẫn hiện mà gặp 409order_paid_cancelkhi tiền vừa về ý định nhưng đơn chưa kịp ghi nhận (chỉ biết được sau khi khoá); module khác có thể chặn bằng 409 mã riêng (vdshipment_on_the_way); hai người cùng thao tác → 409order_invalid_transition.- Giao: hàng đã giữ rời kho của đơn (
warehouseInfo) bằng một phiếu xuất; lô / serial đã xuất ghi vàoitems[].detail. Lỗi của một dòng mangerror.at = "items.<chỉ số>"(thiếu hàng, sai lô…) → tô dòng đó. - Huỷ: nhả kho, huỷ ý định thanh toán còn chờ. Đơn đã trả (
paid) phải hoàn ĐỦ trước (modulerefund, tớipaymentStatus: refunded) — không thì 409order_paid_cancel. Đơn đãfulfilledkhông huỷ được. Module khác có thể chặn bằng 409 mã riêng (vdshipment_on_the_waykhi vận đơn đang đi) — hiệnerror.message. - Chi tiết có thêm
customerInfo(khách storefront),items,adjustments(giảm giá / phí từng dòng),shippingAddress,note,cancelReason,createdByInfo/updatedByInfo. Lịch sử thay đổi (ai đổi trạng thái lúc nào):listRecordChanges({ params: { resource: "orders", id } })(quyềncore.view_record_history+order.read).
- SMS báo khách (ADR-0104 của Core): màn cấu hình store sửa
order.sms_notify(onPlaced,onConfirmed,onFulfilled,onCancelled; mặc định tắt — tin tính phí) quaPUT /api/v1/core/configs/order.sms_notify. Tin đi tới số khách nhập cho đơn (phonelúc chốt đơn); store cần có tài khoản SMS (core.sms_sender) hoặc dùng của nền tảng.
Tạo đơn #
Màn hình gọi quoteOrder mỗi khi đổi dòng / khách / mã / phí giao để hiện tổng
tiền, rồi createOrder khi lưu. quoteOrder không giữ gì và KHÔNG kiểm tồn
kho: thiếu hàng chỉ báo lúc createOrder.
| Field | Dùng cho | Ghi chú |
|---|---|---|
lines[{ variantId, uomId?, qty }] | cả hai | 1–200 dòng; uomId vắng = đơn vị gốc |
customerId? | cả hai | khách của ĐÚNG store hiện tại: chọn bằng listCustomers({ query: { store } }) vì admin cấp account thấy khách của mọi store (Core ≥ 1.30); vắng = khách lẻ |
codes? | cả hai | mã giảm giá / thẻ quà, nhập sai nhiều lần → 429 |
shippingFee? | cả hai | do nhân viên nhập, vắng = 0 (KHÔNG tự lấy checkout.shipping) |
contact?: { name?, email?, phone? } | tạo đơn | có khách: field vắng lấy của khách. Không khách: contact.name bắt buộc (400 order_contact_required) |
shippingAddress?, note?, paymentMethod? | tạo đơn | paymentMethod: prepaid (mặc định) / cod. email của đơn là nơi nhận hoá đơn; phone là nơi nhận SMS |
createOrder→{ order, paymentCode }, một transaction: báo giá lại, giữ kho ở kho mặc định, tạo ý định thanh toán (order.paymentIntentId). Đơn 0 đồng luônprepaid, đãpaid,paymentCode: null.- Lỗi của một dòng mang
error.at = "lines.<chỉ số>"→ tô dòng đó (ngừng bán 409price_variant_unavailable, sai đơn vị / số lượng 400, thiếu hàng 409stock_insufficient_available). customerIdkhông có, thuộc store khác, đã xoá hay đã gỡ khỏi store đều trả CÙNG 404order_customer_not_found(error.at = "customerId").- Bật Idempotency-Key của client để bấm hai lần không thành hai đơn.
Thu tiền đơn prepaid: đơn chờ tiền về qua ý định thanh toán, tiền về đủ
thì đơn tự confirmed.
- Đưa khách
paymentCode/ QR:getPaymentIntent({ params: { id: paymentIntentId } })(payments.read) →code,transfer(VietQR),momo; hoặc gửi link trang trả tiền của storefront (id+paymentCode). - MoMo:
createPaymentIntentMomoCheckout(payments.write). - Tiền đã vào tài khoản mà không tự khớp (sai nội dung…):
listPaymentTransactions({ query: { status: "unmatched" } })→matchPaymentTransaction({ params: { id }, body: { intentId: paymentIntentId } })(payments.reconcile). Khớp giao dịch thiếu tiền thì ý địnhpaidnhưng đơn vẫnunpaid, người xử lý tiếp. IPN MoMo thất lạc:queryPaymentIntentMomo. - Thu tiền mặt / chuyển khoản / thẻ ngoài cổng: nút "Đã thu tiền" khi
actionscórecord_payment(quyềnorder.record_payment) →recordOrderPayment({ params: { id }, body: { method, amount, reference } }).method:cash/bank_transfer/card/other.amountphải bằnggrandTotal(409order_payment_amount_mismatch: tải lại đơn); chưa hỗ trợ thu từng phần. Đơn đổipaid+ tựconfirmedkhi job tiền về chạy, thường trong vài giây: đợi realtimeorder.changedhoặc đọc lại đơn, response có thể cònunpaid. Đơn không hợp lệ → 409order_payment_not_recordable; ý định đã trả / huỷ → 409payment_intent_not_payable. Đừng tạo ý định mới bằngcreatePaymentIntenttrỏ vào đơn (đơn chỉ nhận tiền qua ý định của chính nó). - Ý định hết hạn sau 60 phút: đơn còn
placed+unpaidtự huỷ (cấu hìnhorder.auto_cancel). Hẹn trả sau → bấm Xác nhận để giữ đơn; tiền đến sau đó nằm ở hàng chờ, khớp tay như trên.
Đơn cod: không tự huỷ theo ý định; tiền về khi hãng nộp tiền thu hộ
(remitShipmentCod, module shipment). Lệnh tạo đơn của nhân viên KHÔNG áp
checkout.payment_methods (cấu hình đó là lựa chọn của người mua lúc chốt đơn);
database chưa nâng cấp → 409 order_cod_unavailable.
Báo cáo #
Dùng màn báo cáo chung (listReports, runReport, exportReport; form dựng từ
params), đều cần order.read.
name | Tham số | Cột |
|---|---|---|
order.daily_revenue | from, to, channel? | day, currency, orders, subtotal, discount, tax, shipping, offset, total |
order.status_summary | from, to | status, paymentStatus, currency, orders, total (dashboard) |
order.top_products | from, to, sort? (revenue / qty), limit? (50) | variantId, sku, product, options, unit, currency, orders, qty, revenue |
Định nghĩa doanh thu (một nghĩa cho cả ba báo cáo): đơn có placedAt nằm
trong khoảng ngày THEO GIỜ STORE, không tính đơn cancelled, CHƯA trừ hoàn
tiền, kể cả đơn chưa thu tiền. Đơn huỷ về sau tự rời báo cáo của ngày đặt; hoàn
tiền thì không.
order.daily_revenue:total=grandTotal(số khách phải trả, gồm phí giao, đã trừoffsetlà thẻ quà / điểm).subtotallà tiền hàng trước giảm,taxcó thể đã nằm trongsubtotal(giá đã gồm thuế) nên đừng tự cộng các cột thànhtotal. Dòng tổng ởsummary;summary: nullkhi store có đơn nhiều tiền tệ.order.status_summary: CÓ đơn huỷ (dòngstatus: "cancelled") để đếm; đừng lấy tổng cộttotallàm doanh thu.order.top_products:qtytheo ĐƠN VỊ GỐC (unit),revenue= tiền dòng sau giảm giá, gồm thuế, KHÔNG gồm phí giao và chưa trừoffset, nên tổngrevenuekháctotalcủaorder.daily_revenue. Sản phẩm đã vào thùng rác cósku/productnull.- Mỗi tiền tệ một dòng; ô tiền là chuỗi, đừng đổi sang số.
Xuất file #
Tập order.orders CHỈ xuất (listDatasets, exportDataset,
startDatasetExport, quyền order.read): một dòng một đơn, sớm nhất trước, có
tên / email / điện thoại khách; không có dòng hàng, địa chỉ, ghi chú. Không có
file mẫu / nhập.
In + gửi hoá đơn #
Mẫu order.invoice: HOÁ ĐƠN BÁN HÀNG, KHÔNG phải hoá đơn VAT (hoá đơn VAT của
cấu hình payments.einvoice). Xem: printDocument({ params: { name, id } }),
quyền order.read. Gửi email: emailDocument, quyền order.send_invoice
(canEmail ở listPrintables), luôn tới customerEmail của đơn, không nhập
được địa chỉ khác; đơn không có email → 409 print_recipient_missing.
Thông báo, realtime, webhook #
- Thông báo
order.newkhi đặt đơn ở mọi kênh, tới nhân viên cóorder.manage, kèm Web Push;link: { key: "order.orders", id }. Đến trễ vài giây. - Realtime
order.changed(data: { orderId }, người cóorder.read): đặt, đổi trạng thái, huỷ, tiền về, cả khi do server tự làm. Nhận rồi đọc lại đơn qua API. Hoàn tiền chưa phát sự kiện này. - Webhook gửi đi (màn "Tích hợp",
listWebhookEvents):datachỉ cóorderId,number,status,paymentStatus,currency,grandTotal, không dữ liệu cá nhân.order.placed.order.status_changed: thêmpreviousStatus,previousPaymentStatus. Phát cả khi chỉ tiền đổi (status=previousStatus). Không phát khi huỷ và khi hoàn tiền.order.cancelled.
Storefront (StorefrontClient) #
Chỉ đơn của CHÍNH khách đang đăng nhập (đơn của người khác → 404
order_not_found); không có kho / người tạo.
listMyOrders({ query })— mới nhất trước, lọcstatus, phân trang theo con trỏ.getMyOrder({ params: { id } })— chi tiết + dòng. CópaymentIntentId+paymentCode: cònunpaidthì hiện nút "Thanh toán" mở trang trả tiền của modulepayments(getStorefrontPaymentIntent({ params: { id: paymentIntentId }, query: { code: paymentCode } })).paymentCode: null= đơn 0 đồng.cancelMyOrder({ params: { id }, body: { reason? } })— chỉ khi đơn cònplacedVÀ chưa trả (409order_invalid_transition/order_paid_cancel). Hiện nút chỉ trong điều kiện đó.
Lỗi cần xử lý #
code | Status | FE nên |
|---|---|---|
order_not_found | 404 | đơn không có / không phải của khách → trang 404 |
order_invalid_transition | 409 | trạng thái đã đổi (người khác vừa xử lý / tự huỷ) → tải lại đơn |
order_paid_cancel | 409 | đơn đã trả: CMS → hoàn tiền trước; storefront → mời liên hệ cửa hàng |
order_no_warehouse | 409 | store chưa đặt kho mặc định (cấu hình của store) |
order_customer_not_found | 404 | khách không chọn được cho store này → chọn lại khách |
order_contact_required | 400 | đơn không khách thiếu tên liên hệ → tô ô tên (error.at: "contact") |
order_cod_unavailable | 409 | database chưa nâng cấp → chọn trả trước |