CS Portal "Yêu cầu đặt xe" — Spec tích hợp frontend
Tài liệu dành cho đội frontend xây CS Portal v2 (portal tổng đài viên điều xe
XPAY, thiết kế CS Portal v2.dc.html). Phía card-core đã xong backend điều xe
(module dispatch, các route /dispatch/*). Tài liệu này nói màn hình nào gọi API
nào, dữ liệu trả về đọc ra sao, và phần nào của thiết kế chưa có backend để
FE ẩn đi thay vì giả lập.
Trước khi đọc tiếp, năm điều quyết định phần lớn cách gắn:
- Thanh toán XPAY mới có một phần: "Hoàn tất" tự trừ thẻ cho chuyến khách tự đặt qua app và chuyến hotline CS đã gắn người trả (§4.7, §4.12). Giữ tiền trước, hội thoại Zalo, cờ đỏ/vàng, tab "Chờ thanh toán", "Cần chú ý" — chưa có, ẩn đi (§8).
- Backend chỉ tự gửi một tin Zalo cho khách: ZNS báo có xe (§4.15), và không gửi cho SĐT đã liên kết Zalo OA XPay. Gửi phương án, báo không có xe chỉ ghi nhận trên hệ thống; CS phải tự báo khách.
- Ticket có khoá theo người nhận. Thao tác đầu tiên của tổng đài viên lên
ticket chưa ai nhận sẽ tự nhận nó; ticket người khác đang giữ trả
409 claimed_by_other(§4.3). - Tiền luôn là chuỗi thập phân (
"165000"), không bao giờ là số. Thời gian luôn là ISO-8601 — ô "14:05" trên form phải đổi sang ISO trước khi gửi (§3.4). - Cập nhật realtime qua SSE (
GET /dispatch/events): server báo "ticket X vừa đổi", FE gọi lại REST (§3.5). Không poll.
| Tài liệu liên quan | Nội dung |
|---|---|
GET /swagger, GET /api-docs.html | Contract OpenAPI sinh thẳng từ code — mọi field, kiểu, enum (tag dispatch) |
| auth-architecture.md | Các loại token và cách guard nhận diện |
Repo documentation: docs/payment/card-payment-system/ride-payment.md, card-sharing.md | Thanh toán chuyến xe và chia sẻ / tự link thẻ, viết cho các nhóm FE |
1. Kết nối
1.1 Base URL, CORS
Gọi thẳng card-core: {CARD_CORE}/dispatch/.... Origin của portal phải có trong
biến môi trường CORS_ORIGINS của card-core (production không nhận *) — báo
backend thêm origin trước khi deploy.
1.2 Xác thực — token Store của tài khoản system admin
Authorization: Bearer <access token Store của tổng đài viên (is_admin = true)>
Content-Type: application/json
- Backend xác minh token qua Store, lấy Store profile id làm danh tính người
thao tác. Đây là giá trị xuất hiện ở
claimed_by,events[].actor,requested_by,dispatched_by… - Backend không trả tên/avatar tổng đài viên. FE tự đổi profile id → tên từ Store (portal đã có profile của người đang đăng nhập; với người khác thì tra Store).
- "Bạn" trên UI =
booking.claimed_by === <profile id của mình>. - Token Store không trả về profile id thì không nhận ticket được (
403 operator_required).
1.3 Format lỗi
Mọi lỗi đều có dạng:
{ "code": "claimed_by_other", "message": "Another operator is handling this booking", "details": { "claimed_by": "1532" } }
Lỗi validate request (422 validation_failed) có details.errors[] gồm
{ property, constraints }; lỗi nghiệp vụ cùng mã thì chỉ có message. Bảng đầy đủ ở §7.
2. Mô hình dữ liệu
2.1 Booking = một ticket
GET /dispatch/bookings trả mảng booking; GET /dispatch/bookings/:id trả booking
cộng assigned_provider, assignments[], events[]. Mọi API thao tác ticket đều trả
lại bản chi tiết mới nhất — FE thay state bằng response, không cần gọi lại GET.
| Field | Ý nghĩa | Hiển thị trên portal |
|---|---|---|
id | UUID — dùng trong mọi URL | — |
ref_no | Số tăng dần | Mã ticket: hiển thị XP-${ref_no} |
channel | hotline | zalo | app | api | Icon kênh; lọc "Mọi kênh". app = người dùng tự đặt trong app XPay hoặc qua trợ lý AI (xpay-user-mcp.md); ticket này chưa ai nhận, SĐT/tên/công ty lấy từ hồ sơ người dùng |
status | Xem §2.4 | Badge trạng thái, tab |
status_changed_at | Lần cuối đổi trạng thái | Gốc của cột "Đã chờ" (§3.3) |
customer_phone | SĐT người đặt, dạng +84… | SĐT khách |
customer_name, customer_company | Có thể null | Tên, công ty (null → "Khách mới" / "Chưa xác định") |
passenger_name, passenger_phone | Chỉ có khi đặt hộ | Khối "Đặt hộ. Người đi: …"; dòng danh sách hiện "<tên người đi> (đặt hộ)" |
pickup_name, pickup_address | Tên điểm đón khách nói + địa chỉ (có thể null) | Dòng trên/dưới của "Điểm đón" |
dropoff_name, dropoff_address | Tương tự; dropoff_name null = chuyến theo giờ | "Điểm đến" |
pickup_at | null = Đi ngay; có giá trị = Hẹn giờ | Chip lịch: "Đi ngay" hoặc HH:mm dd/MM |
schedule | Chuyến hẹn giờ đã chọn xe của hãng có API (Xanh SM), chưa đặt: { book_at, option_id, provider, label, seats, price, cost_price, max_cost_price }. Hệ thống tự đặt xe đó lúc book_at (trước pickup_at 2 phút); tới lúc đó ticket vẫn pending. null khi không có hẹn, hoặc hẹn đã dùng (đã đặt, CS đặt tay, gửi phương án, huỷ) (§4.14) | Chip "Tự đặt {label} lúc {HH:mm book_at}" · "{price}đ (tối đa {max_cost_price} giá hãng)" |
seats | Số chỗ khách yêu cầu (4/7…) | "4 chỗ" |
note | Ghi chú lúc tạo. Với hãng đặt qua API (Xanh SM, §4.13) đây là ghi chú gửi tài xế, và là thứ duy nhất được gửi kèm, mã chuyến XP-… không được gửi. Chỉ ghi dặn dò cho tài xế (vd. "Chuyến bay VN123, đón cửa A3"), không ghi thông tin nội bộ | Ô "Ghi chú cho tài xế" khi tạo ticket |
claimed_by, claimed_at | Người đang giữ ticket (null = chưa ai nhận) | Cột Ticket: "Bạn" / tên / "Chưa ai nhận" |
proposal | Phương án đang chờ khách / khách đã đồng ý (§2.3) | Khối phương án trong card "Xe" |
vehicle | Xe đang gắn với ticket, null nếu chưa có (§2.2) | Card "Xe" |
assigned_provider_id | Hãng của xe hiện tại | Lọc theo hãng |
final_fare | Giá chốt khi hoàn tất — số tiền thực thu khi chuyến được trả bằng thẻ | — |
paid_auth_id | Giao dịch XPAY đã đóng chuyến: thẻ khách bị trừ khi hoàn tất (§4.7) hoặc tài xế XPay thu qua app (§4.11) | "Đã thanh toán" |
payment_card_id | Thẻ khách chọn khi tự đặt qua app; null = thẻ active duy nhất khách dùng được (của mình, hoặc thẻ bearer được chia sẻ cho khách) | — |
cancel_reason_code, cancel_reason, cancel_fee | Lý do huỷ, ghi chú, phí huỷ. Ticket chưa huỷ mà có cancel_reason_code: vì sao mất xe trước (rejected sau khi đã nhận, §4.10) — về null khi có xe lại | Lịch sử; chỉ hiện "Lý do huỷ" khi status = cancelled |
started_at, completed_at, cancelled_at | Mốc thời gian | "đóng lúc" / "huỷ lúc" |
created_at | Lúc khách gửi | Cột "Gửi lúc" |
requested_by, requested_by_type | Ai tạo (system_admin, platform, corporate_admin, user = khách tự đặt qua app) | user → chuyến sẽ trừ thẻ khi hoàn tất (§4.7) |
2.2 vehicle — card "Xe"
Phẳng hoá từ assignment đang được nhận:
| Field | Ý nghĩa |
|---|---|
assignment_id | Dùng khi tài xế huỷ (§4.9) |
provider | { id, code, name, pricing_mode, contact_name, contact_phone } |
seats | Số chỗ của xe thực tế đi — có thể lớn hơn yêu cầu (điều xe 7 chỗ thay 4 chỗ) |
vehicle_plate, vehicle_model, vehicle_color | Biển số, mẫu, màu |
driver_name, driver_phone | Tài xế (driver_phone dạng +84…) |
driver_ref | { merchant_id, merchant_name, sub_account_id } nếu tài xế có trong XPay Merchant, null nếu không |
sell_price | Giá bán cho khách (hãng đồng hồ: chỉ là ước tính, có thể null). Hãng có markup (§6.2): luôn = cost_price + markup, làm tròn 1.000đ, tự đổi khi cost_price đổi |
cost_price | Giá mua từ hãng — nội bộ, không bao giờ hiện cho khách |
pickup_eta, arrive_eta | Giờ tới đón / tới nơi (ISO) |
provider_ref | Mã chuyến bên hãng. Với hãng API là mã đơn Xanh SM (vd. 01M548K98ZX72PNF9D8J9GSWWZ), dùng để đối soát ticket với đơn bên hãng và tìm đơn trên app Xanh SM |
provider_stage | Hãng tích hợp API (Xanh SM, §6.1): chuyến đang ở bước nào theo hãng. null với hãng manual. Bảng nhãn bên dưới |
provider_status, provider_status_at | Trạng thái gốc hãng gửi (vd. IN PROCESS) và lúc nó đổi lần cuối |
provider_stage → nhãn gợi ý:
provider_stage | Nhãn |
|---|---|
searching | "Hãng đang tìm tài xế". Gặp sau khi đã có xe nghĩa là tài xế bỏ, hãng đang tìm người khác |
driver_assigned | "Đã có tài xế" |
driver_arriving | "Tài xế đang tới điểm đón" |
driver_arrived | "Tài xế đã tới điểm đón" |
on_board | "Khách đã lên xe" |
completed | "Hãng đã hoàn tất chuyến". Chuyến có người trả thì backend tự hoàn tất và trừ thẻ (§6.1). Ticket còn riding (không có người trả, hoặc có event charge_failed) → thêm "— chờ CS bấm Hoàn tất" |
cancelled | "Hãng đã huỷ" |
null | Không hiện (hãng manual, hoặc trạng thái backend chưa biết; khi đó vẫn có provider_status) |
Dòng "XPay Merchant" trong card Xe:
| Điều kiện | Hiển thị |
|---|---|
provider.pricing_mode === "fixed" | "Tích hợp app tài xế" |
metered và driver_ref != null | "Có trong danh sách" |
metered và driver_ref == null | "Không có" (màu đỏ) |
2.3 proposal — "Gửi khách xác nhận trước"
{
"state": "sent",
"provider_id": "6f1c…",
"provider_name": "Xanh SM",
"seats": 7,
"sell_price": "620000",
"cost_price": "560000",
"pickup_eta": "2026-10-09T07:05:00.000Z",
"note": "Không còn xe 4 chỗ gần, em điều xe 7 chỗ",
"sent_by": "1532",
"sent_at": "2026-10-09T06:58:12.000Z"
}
state: "sent"(bookingawaiting_confirmation) → "Đã gửi phương án, chờ khách xác nhận".state: "accepted"(bookingpending) → "Khách đã đồng ý phương án" — mở wizard điền sẵn từ đây (§4.6).null→ không có phương án. Phương án bị xoá khi khách muốn đổi, và khi đã có xe.
2.4 Trạng thái → nhãn, tab
status | Nhãn (badge) | Tone | Tab | "Việc tiếp theo" |
|---|---|---|---|---|
pending | Chờ đặt xe | warning | Cần xử lý | Đặt xe (hoặc "Nhập thông tin xe" nếu proposal.state = accepted) |
unfulfilled | Chờ đặt xe | warning | Cần xử lý | Đặt xe — hệ thống tự gán đã hết hãng |
dispatching | Chờ tài xế nhận (lượt mode: "offer") / Chờ hãng trả lời | warning | Cần xử lý | Đã gửi cuốc cho tài xế XPay (§4.11), hoặc luồng gửi hãng (§9) |
awaiting_confirmation | Chờ khách xác nhận | info | Đang thực hiện | Chờ khách |
assigned | Đã có xe | primary | Đang thực hiện | Hoàn tất chuyến / Bắt đầu chuyến |
riding | Đang chạy | info | Đang thực hiện | Hoàn tất chuyến |
completed | Xong | positive | Đã đóng | — |
cancelled | Huỷ | neutral | Đã đóng | — |
Trạng thái new (AI đang hỏi thông tin) và awaitpay (chờ thanh toán) trong thiết kế
không tồn tại ở backend.
2.5 events[] — "Lịch sử"
Cũ trước, mới sau (portal hiển thị ngược lại thì tự đảo). Mỗi event:
{ id, event_type, actor, actor_type, note, metadata, assignment_id, created_at }.
Backend không sinh câu chữ — FE dựng câu từ event_type + metadata:
event_type | metadata | Câu gợi ý |
|---|---|---|
created | { channel } | "Tạo từ cuộc gọi hotline" / "Khách nhắn Zalo OA" |
claimed | { auto: true } · { takeover: true, previous } · {} | "{actor} nhận ticket" / "{actor} tiếp quản từ {previous}" |
released | — | "{actor} nhả ticket" |
proposal_sent | { provider_id, seats, sell_price, pickup_eta } | "Gửi phương án {hãng} {seats} chỗ · {giá} · đón {giờ}" |
proposal_accepted | { provider_id } | "Khách đồng ý phương án" |
proposal_rejected | { provider_id }, note = lý do | "Khách muốn đổi phương án" |
dispatched | { provider_id, provider_code, attempt_no, mode, sub_account_id? } | mode = "offer" → "Gửi cuốc cho tài xế {tên}"; bỏ qua khi mode = "direct" (luôn đi kèm accepted) |
accepted | { provider_id, mode, vehicle_plate, seats, upgraded_from?, xpay_driver } | "Nhập xe {plate}"; mode = "offer" → actor_type = driver: "Tài xế {tên} nhận cuốc", actor_type = system_admin: "{actor} nhận cuốc giùm tài xế {tên}" (§4.11); có upgraded_from → thêm "Điều xe {seats} chỗ thay cho {upgraded_from} chỗ" |
rejected | { provider_id, after_accept?, reason_code? }, note = lý do | after_accept → "Tài xế huỷ: {note}" (reason_code = ai huỷ: driver_cancelled, hoặc với Xanh SM other khi cancel_by không phải DRIVER); không có → "Hãng từ chối: {note}" |
withdrawn | { provider_id, was } | "Rút yêu cầu khỏi {hãng}" |
started | — | "Tài xế đã đón khách" |
completed | { final_fare?, auth_id?, paid_via?, card_id?, charge_skipped? }, note | paid_via = "card" → "Trừ {final_fare} vào thẻ khách, chuyến đóng"; paid_via = "merchant_payment" → "Tài xế thu {final_fare} qua XPAY, chuyến đóng"; charge_skipped → "Hoàn tất, không trừ thẻ: {note}"; còn lại → "Hoàn tất chuyến". actor = adapter:greensm → thêm "(tự động theo giá cuối Xanh SM + markup)" |
cancelled | { reason_code, fee? }, note | "Huỷ chuyến: {nhãn lý do}. Phí huỷ {fee}" (§4.8) |
payer_set | { via, user_id?, card_id?, pan_last4? } | via = "account" → "Gắn người trả {user_id}, thẻ •••• {pan_last4}"; "card_pin" → "Khách đọc thẻ •••• {pan_last4}, đã kiểm PIN" (§4.12) |
payer_cleared | — | "Bỏ người trả" |
provider_status | { provider_id, provider_status, stage, from_stage, fare?, from_fare?, fare_breakdown?, sell_price?, from_sell_price? }, note = lý do khi hãng huỷ | Câu theo stage như bảng §2.2. Có fare thì thêm "· giá hãng {from_fare} → {fare}" (có sell_price — hãng có markup — thì thêm "· giá khách {from_sell_price} → {sell_price}"), và nếu fare_breakdown.surcharges có dòng thì liệt kê từng dòng "{label} +{amount}" (vd. "Phí Khác +10.000đ"). actor = adapter:greensm |
vehicle_changed | { provider_id, from: { vehicle_plate, driver_name }, to: { … } } | "Hãng đổi xe: {from.vehicle_plate} → {to.vehicle_plate} ({to.driver_name})" |
provider_cancel_failed | { provider_id, provider_ref }, note = lỗi | Cảnh báo đỏ, không tự tắt: "Không huỷ được đơn {provider_ref} trên {hãng} — huỷ tay trên app {hãng}, xe có thể vẫn tới" |
charge_failed | { provider_id, provider_ref, final_fare, provider_fare?, code?, reason? }, note = lỗi | Cảnh báo đỏ tới khi ticket đóng: "{hãng} đã hoàn tất nhưng chưa trừ được thẻ khách ({final_fare}): {note} — bấm Hoàn tất". final_fare = số định trừ khách (đã cộng markup); có markup thì provider_fare = giá cuối của hãng. code = "payment_declined" → thẻ bị từ chối, reason là lý do; final_fare = null → hãng không báo giá cuối, CS nhập giá trừ khách |
scheduled | { option_id, provider_id, label, seats, quoted_price, cost_price, max_cost_price, pickup_at, book_at } | "Hẹn tự đặt {hãng} {label} lúc {book_at} · {quoted_price}" (§4.14) |
schedule_failed | { option_id }, note = lý do | Cảnh báo đỏ: "Tới giờ nhưng không đặt được xe đã hẹn: {note} — đặt tay ngay". actor = system:scheduler |
failed, unfulfilled | — | Chỉ ở luồng tự gán (§9) |
provider_id trong metadata → tên hãng: tra trong assignments[].provider hoặc danh sách hãng.
3. Hàng đợi (màn hình chính)
3.1 Danh sách
GET /dispatch/bookings?status=pending,unfulfilled,dispatching&limit=200
| Query | Ý nghĩa |
|---|---|
status | Một hoặc nhiều trạng thái, ngăn bằng dấu phẩy |
channel | hotline | zalo | app | api — dropdown "Mọi kênh" |
provider_id | Hãng của xe hoặc của phương án đang chờ — dropdown "Tất cả hãng" |
claimed_by | me (của tôi) · none (chưa ai nhận) · một profile id |
q | Ô tìm kiếm (§3.2) |
limit | Mặc định 50, tối đa 200 |
Kết quả mới nhất trước (created_at giảm dần). Mỗi dòng đã có vehicle để hiện biển
số trong dòng meta ("4 chỗ · Đi ngay · 51K-123.45").
Tab → status:
| Tab thiết kế | status= |
|---|---|
| Tất cả yêu cầu | (bỏ trống) |
| Cần xử lý | pending,unfulfilled,dispatching |
| Đang thực hiện | awaiting_confirmation,assigned,riding |
| Đã đóng | completed,cancelled |
| Chờ thanh toán, Cần chú ý | Ẩn — chưa có backend |
Số đếm trên tab: chưa có endpoint đếm. Cách đề xuất: một lời gọi lấy toàn bộ ticket
đang mở
(status=pending,unfulfilled,dispatching,awaiting_confirmation,assigned,riding&limit=200)
rồi đếm và chia tab phía client; tab "Đã đóng" gọi riêng khi người dùng bấm vào. Header
"Tổng đài viên · N ticket" = số ticket đang mở có claimed_by là mình.
3.2 Ô tìm kiếm (q)
Một ô cho mọi thứ CS nghe được qua điện thoại:
- Mã ticket:
1048hoặcXP-1048 - SĐT khách hoặc người đi, gõ kiểu nào cũng được (
0912 345 678,+84912…,912345678) — tối thiểu 4 số - Tên khách, tên người đi, công ty, tên/địa chỉ điểm đón, điểm đến (không phân biệt hoa thường)
- Biển số, bỏ qua dấu (
51k12345khớp51K-123.45) — gồm cả xe đã bị huỷ trên ticket
Khối "khách hàng" phía trên danh sách khi tìm theo SĐT (tên, công ty, "N yêu cầu · M đang xử lý") dựng phía client từ kết quả tìm kiếm.
3.3 Cột "Đã chờ"
- Ticket đang mở:
now - status_changed_at. Mốc này chỉ đặt lại khi trạng thái thật sự đổi (nhận ticket, sửa không đổi trạng thái thì không reset). completed/cancelled: hiện giờcompleted_at/cancelled_at.- Ngưỡng màu do FE quyết theo thiết kế (vd.
pending≥ 3 phút vàng, ≥ 5 phút đỏ).
3.4 Thời gian
- Mọi timestamp trả về là ISO UTC (
…Z); hiển thị theoAsia/Ho_Chi_Minh. - Ô giờ dạng
14:05(giờ tới đón, tới nơi, giờ hẹn) → FE dựng ISO có offset trước khi gửi, vd2026-10-09T14:05:00+07:00. Đề xuất: lấy ngày hôm nay theo giờ VN; nếu giờ nhập sớm hơn hiện tại quá 1 tiếng thì coi là ngày mai. - Backend chỉ ràng buộc
arrive_eta >= pickup_eta; không chặn giờ trong quá khứ.
3.5 Làm mới dữ liệu — SSE
Giữ một kết nối SSE suốt ca làm việc:
GET /dispatch/events
Authorization: Bearer <Store token system admin>
Accept: text/event-stream
- Dùng thư viện SSE chạy trên
fetch, ví dụ@microsoft/fetch-event-source.EventSourcecó sẵn của trình duyệt không gửi được headerAuthorization, và không đưa token lên query string (token sẽ nằm trong log). - Server chỉ báo "ticket nào vừa đổi"; FE tự gọi REST để lấy dữ liệu mới. Stream không bao giờ mang nội dung ticket.
event | data | FE làm gì |
|---|---|---|
ready | { last_event_id } | Stream đã chạy. Tải (lại) danh sách lúc này — nhận ready rồi mới tải để không hụt thay đổi nào |
booking | { event_id, booking_id, assignment_id, event_type, at } | Gọi GET /dispatch/bookings/{booking_id}, thay dòng đó trong danh sách (thêm nếu chưa có); drawer đang mở đúng ticket đó thì vẽ lại |
resync | { reason } | Có thể đã hụt sự kiện → tải lại danh sách và ticket đang mở |
ping | {} | Heartbeat ~25 giây, bỏ qua |
- Mất kết nối: thư viện tự kết nối lại sau ~3 giây và gửi header
Last-Event-ID; server phát lại các sự kiện bị hụt rồi gửiready. Hụt quá 500 sự kiện thì server gửiresyncthay cho phát lại. - Mọi message (kể cả
ping,resync) đều cóid, luôn là điểm nối lại mới nhất. Nếu FE tự mở lại stream (ví dụ sau khi 401 → renew token), gửiLast-Event-ID=idcủa message cuối cùng đã nhận; không có thì bỏ header, server bắt đầu từ "bây giờ" và FE tải lại khi nhậnready. event_typegiống cột "Lịch sử" (§2.5): FE có thể dùng để hiện toast ("Tài xế đã nhận cuốc XP-1048") mà không cần chờ GET xong.- Admin công ty chỉ nhận sự kiện của ticket thuộc công ty mình.
- Mỗi lần deploy, server chủ động đóng stream; client kết nối lại sang pod mới và không mất gì.
- Sau mỗi thao tác của chính mình, dùng luôn response (đã là bản mới nhất); sự kiện
bookingcủa thao tác đó sẽ tới sau, GET lại cũng không sao.
4. Drawer ticket và các thao tác
Mở drawer: GET /dispatch/bookings/:id.
4.1 Tạo yêu cầu từ hotline ("Tạo yêu cầu đặt xe")
POST /dispatch/bookings
{
"channel": "hotline",
"customer_phone": "0912 345 678",
"customer_name": "Lê Hoàng Tuấn",
"customer_company": "Công ty ABC",
"pickup_name": "SATRA Đồng Khởi",
"pickup_address": "58 Đồng Khởi, Phường Sài Gòn, TP.HCM",
"dropoff_name": "Sân bay Tân Sơn Nhất, ga quốc nội",
"dropoff_address": "Đường Trường Sơn, Phường Tân Sơn Hòa, TP.HCM",
"seats": 4
}
| Ô trên form | Field | Ghi chú |
|---|---|---|
| SĐT khách * | customer_phone | Bắt buộc |
| Điểm đón * / Địa chỉ điểm đón | pickup_name / pickup_address | pickup_name bắt buộc |
| Điểm đến * / Địa chỉ điểm đến | dropoff_name / dropoff_address | Backend cho phép bỏ trống (chuyến theo giờ); form thiết kế bắt buộc |
| 4 chỗ / 7 chỗ | seats | Mặc định 4 |
| Đi ngay / Hẹn giờ + Giờ hẹn | bỏ pickup_at / pickup_at ISO | |
| Đặt cho người khác + Tên/SĐT người đi | passenger_name / passenger_phone | Đặt hộ thì bắt buộc passenger_phone |
- Response
201là ticket chi tiết,status: "pending", đã thuộc về người tạo (claimed_by= mình). Mở luôn wizard đặt xe vớiidtrả về. - Gợi ý "khách quen" khi gõ SĐT: backend chưa có tra cứu khách hàng. FE có thể gọi
GET /dispatch/bookings?q=<sđt>&limit=1để điền sẵn tên/công ty từ ticket trước của số đó. Trạng thái "đã link Zalo", số thẻ XCard không có (§8). - Không gửi
auto_dispatch(mặc địnhfalse= để CS tự chọn hãng).
4.2 Danh mục hãng cho wizard
GET /dispatch/providers?status=active&seats=7
Trả theo thứ tự ưu tiên. Mỗi hãng có pricing_mode:
pricing_mode | Hãng (cấu hình hiện tại) | Nhãn | Wizard |
|---|---|---|---|
fixed | Xanh SM, Be | "Trả trước" | Bắt buộc đủ thông tin xe + giá mua + giá bán. Hãng có markup (markup_type ≠ null, §6.2): giá bán bỏ trống thì backend tự tính từ giá mua |
metered | Mai Linh | "Trả sau · đồng hồ" | Không có giá mua; giá bán chỉ là ước tính (tuỳ chọn) |
Thêm/sửa hãng là việc của ops (§6), không phải màn hình CS.
Hãng có markup: FE nên tự điền sẵn giá bán khi CS gõ giá mua, theo cùng công thức backend (§6.2), để CS thấy giá trước khi gửi. CS sửa giá bán thì backend lấy đúng số CS nhập.
4.3 Nhận ticket / tiếp quản / nhả
POST /dispatch/bookings/:id/claim { } → nhận
POST /dispatch/bookings/:id/claim { "takeover": true } → trưởng ca tiếp quản
POST /dispatch/bookings/:id/release → nhả (chỉ người đang giữ)
- Không cần gọi
claimtrước khi thao tác: thao tác đầu tiên (đặt xe, gửi phương án, huỷ…) của tổng đài viên lên ticket chưa ai nhận sẽ tự nhận. - Ticket người khác đang giữ → mọi thao tác trả
409 claimed_by_other,details.claimed_by= người giữ. UI: khoá nút, hiện "{tên} đang xử lý". takeoverđược ghi vào lịch sử kèm người giữ trước. Backend chưa phân quyền trưởng ca — FE nên chỉ hiện nút tiếp quản cho trưởng ca.- Ticket đã đóng không nhận được (
409 invalid_booking_state). - Admin công ty và hệ thống (Zalo, app tài xế) không bao giờ bị khoá, cũng không giữ ticket.
- Giữ ticket chặn khách tự huỷ: ticket đang có người giữ mà chưa có assignment nào thì khách
huỷ qua app/trợ lý nhận
409 cancel_by_hotline(CS có thể đã đặt xe trên app hãng mà chưa nhập). Không còn xử lý thì nhả ticket, kẻo khách phải gọi tổng đài chỉ để huỷ.
4.4 Wizard "Đặt xe trước" → "Gửi thông tin xe"
CS đặt trên app/portal của hãng xong, nhập xe vào đây. Một lời gọi duy nhất ở bước Review:
POST /dispatch/bookings/:id/assign
{
"provider_id": "6f1c…",
"seats": 4,
"vehicle_plate": "51K-123.45",
"driver_name": "Nguyễn Văn Minh",
"driver_phone": "0901 234 567",
"vehicle_model": "VinFast VF5",
"vehicle_color": "Trắng",
"cost_price": "150000",
"sell_price": "165000",
"pickup_eta": "2026-10-09T14:05:00+07:00",
"arrive_eta": "2026-10-09T14:35:00+07:00",
"provider_ref": "XSM-88213"
}
Bước wizard → field:
| Bước | Ô | Field |
|---|---|---|
| 1. Cách đặt và hãng | Hãng | provider_id |
| Loại xe (4/7 chỗ) | seats (bỏ trống = đúng yêu cầu) | |
| 2. Xe và tài xế | Biển số *, SĐT tài xế, Tài xế *, Mẫu xe, Màu | vehicle_plate, driver_phone, driver_name, vehicle_model, vehicle_color |
| 3. Giá và giờ | Giá mua (từ NCC), Giá bán cho khách | cost_price, sell_price (chuỗi số, bỏ dấu chấm) |
| Giờ tới đón *, Giờ tới nơi | pickup_eta, arrive_eta (ISO, §3.4) |
Quy tắc backend kiểm (FE nên kiểm trước để báo lỗi đúng bước):
| Quy tắc | Lỗi |
|---|---|
vehicle_plate, driver_name, pickup_eta luôn bắt buộc | 422 validation_failed |
Hãng fixed: thêm driver_phone, vehicle_model, vehicle_color, cost_price > 0, sell_price > 0 (hãng có markup: sell_price bỏ trống thì = cost_price + markup) | 422 validation_failed |
Hãng metered: không được gửi cost_price; sell_price tuỳ chọn (ước tính) | 422 validation_failed |
seats không được nhỏ hơn số chỗ khách yêu cầu (7 chỗ không điều xe 4 chỗ; 4 → 7 được) | 422 seat_downgrade |
arrive_eta không trước pickup_eta | 422 validation_failed |
Ticket phải đang pending / unfulfilled / dispatching | 409 invalid_booking_state |
Hãng phải active | 409 provider_inactive |
Kết quả: ticket assigned, có vehicle; phương án (nếu có) bị xoá. Cảnh báo "Giá bán thấp
hơn giá mua" là việc của FE — backend không chặn.
Tra XPay Merchant (bước 2, hãng metered): khi CS gõ biển số hoặc SĐT tài xế,
GET /dispatch/drivers/lookup?plate=51G-246.80&provider_id=<mai linh id>
GET /dispatch/drivers/lookup?phone=0909876543&provider_id=<mai linh id>
{ "available": true, "found": true, "merchant_id": "…", "merchant_name": "Mai Linh Taxi",
"sub_account_id": "…", "driver_name": "Lê Văn Tâm", "driver_phone": "+84909876543",
"vehicle_plate": "51G-246.80", "vehicle_model": "Toyota Vios" }
found: true→ tự điền tên, SĐT, mẫu xe (màu không có), hiện "Tài xế có trong XPay Merchant".found: false→ "Không tìm thấy trong XPay Merchant…", vẫn cho nhập tay.- Biển số so khớp bỏ dấu; SĐT cần đủ số (≥ 8 chữ số), gõ một phần không khớp.
- Khi
assign, backend tự tra lại và lưu vàovehicle.driver_ref— FE không cần gửi kết quả tra. - Hãng
fixedkhông cần tra (thiết kế: tích hợp sẵn app tài xế).
4.5 Wizard "Gửi khách xác nhận trước"
POST /dispatch/bookings/:id/proposal
{
"provider_id": "6f1c…",
"seats": 7,
"sell_price": "620000",
"cost_price": "560000",
"pickup_eta": "2026-10-09T14:05:00+07:00",
"note": "Không còn xe 4 chỗ gần, em điều xe 7 chỗ"
}
- Hãng
fixedbắt buộcsell_price(hãng có markup: gửicost_pricelà đủ,sell_pricetự tính); hãngmeteredkhông được gửicost_price;seatskhông nhỏ hơn yêu cầu. Chỉ gửi được khi ticketpending/unfulfilled. - Kết quả:
awaiting_confirmation,proposal.state = "sent". - ⚠️ Backend không gửi tin Zalo cho khách. Khung preview "Khách sẽ nhận trên Zalo" trên thiết kế hiện chưa thành sự thật — CS phải tự báo khách.
4.6 Khách trả lời phương án
POST /dispatch/bookings/:id/proposal/accept
POST /dispatch/bookings/:id/proposal/reject { "reason": "Khách muốn đi Xanh SM" }
CS bấm thay khi khách trả lời qua điện thoại / Zalo. Chuyến khách tự đặt qua app/trợ lý thì khách
tự trả lời được (POST /my/rides/:id/proposal/accept|reject, xem
xpay-user-mcp.md §2.2): event proposal_accepted / proposal_rejected có
actor_type = user, ticket không đổi người giữ. Ai trả lời trước thì được; người sau nhận
409 invalid_booking_state. Cả hai đưa ticket về pending:
accept→proposal.state = "accepted". "Việc tiếp theo" = "Nhập thông tin xe": mở wizard §4.4 bắt đầu từ bước 2, điền sẵnprovider_id,seats,sell_price,cost_price,pickup_etatừproposal.reject→proposal = null, quay lại chọn phương án.
4.7 Bắt đầu chuyến, hoàn tất
POST /dispatch/bookings/:id/start → riding
POST /dispatch/bookings/:id/complete { "final_fare": "165000" } → completed
-
start: thiết kế coi là tự động từ app tài xế; hiện chưa có, nên nút demo "Tài xế bắt đầu chuyến" có thể thành nút thật. -
completechạy được từassignedhoặcriding. -
Chuyến khách tự đặt qua app (
requested_by_type = "user"): hoàn tất = trừ thẻ khách rồi mới đóng ticket.- Số tiền:
final_farenếu gửi, không thìvehicle.sell_price. Không có cả hai →422 final_fare_required. - Thẻ: thẻ khách chọn lúc đặt (
payment_card_id), không có thì thẻ active duy nhất khách dùng được — tính cả thẻ bearer công ty chia sẻ cho khách. - Trừ như một giao dịch taxi bình thường: qua đủ các cổng thẻ (trạng thái, hạn mức, số dư, chính sách) và trừ cả ví Xperise.
- Thành công →
completed,final_fare= số đã trừ,paid_auth_id= giao dịch. - Nếu tài xế XPay đã thu chuyến này qua app thì backend đóng theo giao dịch đó, không trừ lần hai.
- Bấm hai lần hay gọi lại cũng chỉ trừ một lần.
- Số tiền:
-
Thẻ bị từ chối →
409 payment_declined,details.reasonlà lý do (insufficient_funds,card_blocked,service_payment_insufficient_funds…). Ticket giữ nguyên trạng thái. -
Khách trả cách khác (tiền mặt, chuyển khoản…) → đóng mà không trừ thẻ:
POST /dispatch/bookings/:id/complete { "final_fare": "165000", "skip_charge": true, "skip_reason": "Khách trả tiền mặt" }skip_reasonbắt buộc khiskip_charge, được lưu vào lịch sử. -
Chuyến hotline có người trả (CS đã gắn ở §4.12): hoàn tất trừ thẻ y như chuyến app ở trên. Thẻ là thẻ CS đã chọn, hoặc thẻ active duy nhất của tài khoản đó, hoặc thẻ khách đọc số + PIN.
-
Chuyến hotline/API chưa gắn người trả: hoàn tất = đóng ticket như cũ, không trừ thẻ.
4.8 Huỷ chuyến
POST /dispatch/bookings/:id/cancel
{ "reason_code": "customer_cancelled", "reason": "Khách đổi lịch", "fee": "20000" }
Dropdown "Lý do huỷ" của thiết kế → reason_code:
| Nhãn | reason_code |
|---|---|
| Khách huỷ | customer_cancelled |
| Tài xế huỷ, khách không đặt lại | driver_cancelled |
| Không có xe | no_car |
| Trùng ticket | duplicate |
| (khác) | other |
- Tổng đài viên bắt buộc gửi
reason_code.fee(phí huỷ) tuỳ chọn, chuỗi số. - Được huỷ khi ticket chưa đóng và chưa ở
riding(đang chạy thì chỉ hoàn tất được). - Nếu đã có xe: CS vẫn phải tự huỷ trên app của hãng (thiết kế: "Huỷ trên app NCC trước").
4.9 Báo khách không có xe
Cùng endpoint huỷ, mã no_car; lý do chi tiết và lời nhắn gộp vào reason:
{ "reason_code": "no_car", "reason": "Không có xe 7 chỗ phù hợp. Khoảng 30 phút nữa sẽ có xe" }
Ticket chuyển cancelled. Lời nhắn Zalo và nút "Đặt lại" cho khách chưa có (backend không gửi tin).
4.10 Tài xế huỷ sau khi đã có xe
POST /dispatch/assignments/{vehicle.assignment_id}/reject { "reason": "Tài xế huỷ, xe hỏng" }
Ticket quay về pending, vehicle = null, xe cũ còn trong assignments[] và lịch sử
(rejected có after_accept: true). CS mở lại wizard §4.4 để đặt xe khác. Không làm được khi
ticket đang riding hoặc đã đóng.
Ticket giữ cancel_reason_code = "driver_cancelled" (vì sao mất xe) tới khi có xe khác được nhận;
chuyến khách tự đặt qua trợ lý thì trợ lý báo khách tài xế đã huỷ. Đây không phải lý do huỷ
chuyến — chỉ hiện "Lý do huỷ" khi status = cancelled.
4.11 Chọn tài xế XPay — gửi cuốc vào app tài xế
Với hãng có tài xế là XPay Merchant (hãng có merchant_id, vd Mai Linh), CS chọn thẳng một
tài xế thay vì tự đặt trên app hãng. Tài xế nhận push, nhận/từ chối trong app XPay Merchant
(hoặc CS nhận giùm), thu tiền qua app — ticket tự đóng. Phía app: dispatch-driver-app.md.
Danh sách tài xế (ô chọn tài xế trong wizard):
GET /dispatch/drivers?provider_id=<id hãng>&q=<tên | biển số | SĐT>
[{ "sub_account_id": "…", "merchant_id": "…", "merchant_name": "Mai Linh Taxi",
"driver_name": "Lê Văn Tâm", "driver_phone": "+84909876543",
"vehicle_plate": "51G-246.80", "vehicle_model": "Toyota Vios", "busy": false }]
busy: true = tài xế đang giữ một cuốc khác chưa xong (vẫn chọn được, nên cảnh báo).
Hãng tạo từ trước khi bắt buộc link mà chưa có merchant_id (§6) → 422 provider_has_no_fleet.
Gửi cuốc:
POST /dispatch/bookings/:id/offer
{ "provider_id": "…", "sub_account_id": "…", "pickup_eta": "2026-10-09T14:05:00+07:00",
"sell_price": "200000", "note": "Khách đứng sảnh B" }
pickup_eta,sell_price(giá ước tính báo tài xế/khách),noteđều tuỳ chọn.- Ticket
dispatching;assignments[]có lượtmode: "offer",status: "pending", xe và tài xế điền sẵn từ hồ sơ tài xế. Tài xế nhận push. - Mỗi ticket chỉ một cuốc đang chờ: gửi cho tài xế khác sẽ rút cuốc cũ (tài xế cũ nhận push "đã huỷ"). Đặt xe tay (§4.4) hoặc huỷ ticket cũng rút cuốc và báo tài xế.
- Tài xế trả lời trong app (FE nhận qua SSE, §3.5):
- nhận → ticket
assigned,vehicle= xe của tài xế,pickup_etatheo tài xế; - từ chối → ticket về
pending, lượtrejectedvớinote= lý do; - đón khách →
riding; - thu tiền xong →
completed,final_fare= số tiền thực thu,paid_auth_id= giao dịch.
- nhận → ticket
- Tài xế không phải của hãng đó →
422 driver_not_in_fleet. - Chưa có hạn chờ tự động: tài xế không trả lời thì CS gửi cho tài xế khác, đặt xe tay (§4.4), hoặc gọi xác nhận với tài xế rồi nhận giùm (bên dưới).
Nhận giùm tài xế — tài xế đã đồng ý qua điện thoại nhưng chưa bấm "Nhận cuốc" trong app:
POST /dispatch/assignments/{id lượt offer}/accept
{ "pickup_eta": "2026-10-09T14:05:00+07:00" }
- Chỉ dùng cho lượt
mode: "offer",status: "pending"(nút "Nhận giùm" cạnh lượt đang chờ tài xế). - Body tuỳ chọn hết: xe, tài xế lấy từ hồ sơ tài xế như lúc gửi cuốc;
pickup_etacó thì ghi, không có thì để trống (khác §4.4, không bắt nhập đủ thông tin xe). - Kết quả như tài xế tự nhận: ticket
assigned, khách được báo có xe (ZNS), tài xế thấy cuốcacceptedtrong app và làm tiếp đón khách → thu tiền. Lịch sử ghi eventacceptedvớiactor_type = system_admin(CS nhận giùm), phân biệt vớidriver(tài xế tự nhận). - Tài xế không nhận push riêng cho việc này — app thấy khi tải lại cuốc. Nên gọi báo tài xế.
- Tài xế vẫn trả lại cuốc được trong app → ticket về
pending, như §4.10. - Lượt đã bị rút / đã có người nhận →
409 invalid_assignment_state.
4.12 Khách gọi hotline — tra SĐT, add thẻ, gắn người trả
Ba bước, đều do tổng đài viên làm trên drawer ticket hotline.
1. Tra SĐT khách
GET /dispatch/customers?phone=0901234567
{
"phone": "+84901234567",
"users": [
{
"user_id": "4242",
"name": "Lê Hoàng Tuấn",
"email": "tuan@acme.vn",
"corporate": { "corporate_id": "2anPOV…", "name": "ACME" },
"corporate_status": "member",
"cards": [
{ "card_id": "…", "display_name": "Thẻ taxi KD", "pan_last4": "4421", "status": "active",
"corporate_id": "2anPOV…", "access": "linked",
"link": { "status": "active", "windows": [{ "valid_from": "2026-11-01T00:00:00.000Z", "valid_until": "2026-11-06T00:00:00.000Z" }] },
"validity": { "status": "active", "windows": [] },
"available": "1200000.00" }
],
"linkable_cards": [
{ "card_id": "…", "display_name": "Thẻ taxi KT", "pan_last4": "7788", "status": "active",
"corporate_id": "2anPOV…", "access": null, "link": null, "available": "800000.00" }
]
}
]
}
| Kết quả | Nghĩa | FE làm gì |
|---|---|---|
users: [] | SĐT chưa có tài khoản | Bước 3, nhánh "khách đọc số thẻ + PIN" |
users 1 phần tử | Đã có tài khoản | Hiện thẻ đang dùng (cards); không có thẻ nào → bước 2 |
users > 1 phần tử | Nhiều tài khoản cùng SĐT (có thật — đã gặp trên staging) | Cho CS chọn đúng người theo tên / email |
corporate_status: "member" | Nhân viên active hoặc owner của corporate (owner không có bản ghi staff) | Cho add thẻ từ linkable_cards |
"none" | Không thuộc công ty nào (khách lẻ) | Không có thẻ để add; dùng nhánh số thẻ + PIN nếu khách có thẻ |
"inactive" | Đã nghỉ ở công ty đó | Không add được |
"unavailable" | Không hỏi được backend corporate (token bị từ chối / backend lỗi) | Hiện thẻ đang có, ẩn nút add |
access:owner= thẻ của chính họ;linked= thẻ công ty đã chia sẻ cho họ.link(chỉ thẻlinked): các khoảng thời gian admin cho phép dùng thẻ (windows, rỗng = không giới hạn).status=activemới dùng được;not_started(chưa tới khoảng nào / đang giữa hai khoảng) hoặcexpired(mọi khoảng đã qua) thì thẻ vẫn hiện nhưng không chọn để trả chuyến được (422 invalid_payment_card) — hiện xám, không add lại được (nó không nằm tronglinkable_cards).validity(mọi thẻ): các khoảng thời gian admin công ty cho thẻ được dùng — áp cho mọi người dùng thẻ (windowsrỗng = không giới hạn). Khácactivethì thẻ cũng không trả chuyến được, kể cả thẻ của chính khách. Thẻ trả được khistatus = "active",validity.status = "active"và (linknull hoặclink.status = "active").available= số dư còn tiêu được của thẻ, để CS chọn thẻ đủ tiền.- Backend hỏi Store và backend corporate bằng chính token system admin của tổng đài viên. Gọi
bằng PlatformKey thì không có token đó →
503 customer_lookup_unavailable. - SĐT gõ kiểu nào cũng được; Store tìm khớp chính xác cả số (thiếu/thừa một chữ số là không ra).
2. Add thẻ cho khách — một trong hai cách:
POST /dispatch/customers/:user_id/cards { "card_id": "…" } → chọn từ linkable_cards
POST /dispatch/customers/:user_id/cards { "pan": "9686860012345678", "pin": "246810" } → khách đọc số thẻ + PIN
Kết quả: khách (như một phần tử của users), thẻ đã sang cards.
card_id (chọn từ linkable_cards) | pan + pin (khách đọc) | |
|---|---|---|
| Bằng chứng | Backend corporate xác nhận khách là nhân viên của công ty sở hữu thẻ | Khách cầm thẻ và biết PIN |
| Thẻ khác công ty, khách lẻ | ❌ | ✅ |
| Cần hỏi backend corporate | Có | Không |
card_id:- Thẻ khác công ty →
422 card_not_in_user_corporate. - Khách không còn là nhân viên active →
422 user_not_in_corporate. - Không hỏi được backend corporate →
503 corporate_lookup_unavailable, không link gì.
- Thẻ khác công ty →
pan+pin:- Sai số thẻ hay sai PIN đều trả
422 card_pin_invalid. - Sai 3 lần trên một thẻ thì khoá thẻ 15 phút (
409 card_locked_too_many_attempts). - Một ops sai 5 lần trong 1 giờ thì bị tạm chặn (
429 too_many_pin_attempts). - Số thẻ và PIN không lưu, không ghi log; FE xoá ô nhập ngay sau khi gửi.
- Sai số thẻ hay sai PIN đều trả
- Gửi cả
card_idlẫnpan→422 validation_failed. - Link rồi thì khách cũng thấy thẻ trong app và tự dùng được (QR, đặt xe qua app). Công ty nhận
event
card.user_linked(via: card_pinvới cách đọc số thẻ + PIN).
3. Gắn người trả vào ticket — complete (§4.7) sẽ trừ thẻ này
PUT /dispatch/bookings/:id/payer { "user_id": "4242", "card_id": "…" } → có tài khoản
PUT /dispatch/bookings/:id/payer { "pan": "9686860012345678", "pin": "246810" } → chưa có tài khoản
DELETE /dispatch/bookings/:id/payer → bỏ, chuyến đóng không trừ thẻ
- Có tài khoản:
card_idlà một thẻ trongcardscủa khách, đang trả được lúc này (statusactive,validity.statusactive, vàlinknull hoặclink.statusactive) — khác đi →422 invalid_payment_card. Bỏ trống thì lúc complete dùng thẻ active duy nhất của họ đang trong hạn. - Chưa có tài khoản: khách đọc số thẻ đầy đủ + mã PIN in kèm thẻ, CS nhập vào.
- Backend kiểm ngay; sai số thẻ hay sai PIN đều trả
422 card_pin_invalid. - Sai PIN 3 lần trong 15 phút thì thẻ bị khoá 15 phút (
409 card_locked_too_many_attempts,details.locked_until). Khoá này dùng chung với luồng nhập PIN ở merchant. - Thẻ không có PIN in kèm →
422 card_has_no_pin. Thẻ bị khoá / hết hạn →422 card_not_active. Thẻ đang ngoài khoảng thời gian công ty cho phép (validity) →422 card_outside_validity_window. - Số thẻ và PIN không được lưu ở đâu cả, không ghi vào log; ticket chỉ giữ
payment_card_idvà 4 số cuối. FE không được lưu hay log 2 giá trị này; xoá ô nhập ngay sau khi gửi.
- Backend kiểm ngay; sai số thẻ hay sai PIN đều trả
- Ticket trả về có
payer_user_id(null với nhánh số thẻ + PIN),payment_card_id,payment_card_last4→ hiện badge "XCard •••• 4421". - Chuyến khách tự đặt qua app: người trả luôn là người đặt; CS chỉ được đổi thẻ của họ
(
user_idphải là người đặt). Khác đi →409 payer_is_requester. - Ticket đã đóng →
409 invalid_booking_state.
4.13 Hãng đặt qua API (Xanh SM)
Chuyến khách tự đặt qua app/trợ lý có báo giá (xpay-user-mcp.md §2.1):
-
Khách chọn option xe thì chuyến được gửi thẳng cho hãng, không qua CS. Ticket hiện ở
dispatchingvới assignment của hãng, eventdispatchedcómetadata.option_idvàquoted_price= giá khách đã đồng ý (giá hãng + markup).sell_price=cost_price+ markup. Hãng thất bại thì ticket vềpendingcho CS. -
Khách chọn "Tổng đài sắp xếp" thì ticket
pendingnhư mọi yêu cầu. -
Khách tự huỷ được (
POST /my/rides/:id/cancel) tới khi lên xe, nhưng chỉ khi lệnh huỷ tự tới được bên đặt xe:- chưa gửi hãng nào;
- xe đặt qua API hãng: huỷ bên hãng trước, hãng không nhận thì khách nhận
502 provider_cancel_failedvà ticket giữ nguyên; - cuốc của tài xế XPay.
Ticket CS đang đặt tay (hãng
manual, xe nhập bằng/assign) thì khách nhận409 cancel_by_hotlinevà phải gọi tổng đài. Ticket CS đang giữ (claimed_by) mà chưa có assignment nào cũng vậy (details.claimed = true): CS có thể đã đặt xe trên app hãng mà chưa kịp nhập. Ticket không đổi, CS không bị bất ngờ. Eventcancelledcủa khách cóactor_type = user. -
Lệnh huỷ của CS vẫn như cũ: huỷ ở đây trước, báo hãng sau. Hãng không nhận thì có event
provider_cancel_failed, để CS huỷ được cả khi API hãng đang lỗi.
Với hãng có adapter khác manual (xem GET /dispatch/providers hoặc assignments[].provider), CS
không cần mở app hãng. Backend tự đặt xe, tự theo dõi, tự điền thông tin xe.
Đặt xe — nút "Đặt qua Xanh SM" thay cho wizard nhập xe (§4.4):
POST /dispatch/bookings/:id/dispatch
{ "provider_ids": ["<id hãng Xanh SM>"] }
Ticket chuyển dispatching, có một assignment pending. Sau đó mọi thứ đến qua SSE như §3.5:
provider_status → accepted (có xe) → started → provider_status (completed). FE chỉ cần GET lại
ticket khi có event, như mọi event khác.
Wizard nhập tay (§4.4, /assign) vẫn dùng được cho hãng này, khi CS đã tự đặt trên app Xanh SM.
Khi đó backend không gọi API.
Trong lúc chờ (vehicle == null, assignment pending của hãng API):
| Điều kiện | Hiển thị |
|---|---|
assignments[].provider_ref có giá trị | "Đã đặt trên Xanh SM · mã {provider_ref} · {nhãn provider_stage}" (§2.2). note cho biết Xanh SM hiểu địa chỉ thành chỗ nào: Xanh SM: {loại xe} · đón "…" → trả "…". CS đối chiếu với lời khách; nếu sai thì reject (backend huỷ luôn đơn Xanh SM) rồi sửa địa chỉ |
provider_ref == null và note có giá trị | Cảnh báo: hiện nguyên note. Backend không rõ đơn đã tạo chưa. CS mở app Xanh SM kiểm tra, rồi accept / reject assignment bằng tay (§9) |
Assignment failed / rejected | Lý do ở note (vd. "Xanh SM không có xe từ 7 chỗ", "business card has … left"). Auto-dispatch tự chuyển hãng, còn hãng CS chọn tay thì ticket về pending |
Đang chạy: card Xe hiện thêm nhãn provider_stage (§2.2): tài xế đang tới / đã tới / khách đã lên xe.
Hoàn tất: chuyến có người trả được backend tự hoàn tất và trừ thẻ khi Xanh SM báo xong (§6.1).
Ticket vẫn riding khi vehicle.provider_stage == "completed" (không có người trả, hoặc có event
charge_failed) thì nhắc CS bấm "Hoàn tất chuyến" (§4.7):
- Hãng có markup:
sell_price= giá cuối Xanh SM + markup, nên bỏ trốngfinal_farelà trừ đúng số đó. - Hãng không markup và chuyến CS đặt (không qua báo giá): không có
sell_price, phải nhậpfinal_fare. Có thể gợi ýcost_price(giá Xanh SM thu) làm tham chiếu chỉ cho CS.
Huỷ / reject: như cũ (§4.8, §4.10). Backend huỷ luôn đơn bên Xanh SM. Nếu huỷ bên đó thất bại,
ticket có event provider_cancel_failed (§2.5). Phải hiện cảnh báo đỏ để CS huỷ tay trên app.
Bất thường: vehicle.provider_stage == "cancelled" trong khi ticket riding nghĩa là Xanh SM huỷ
khi khách đã lên xe. Ticket không tự đổi. Hiện cảnh báo; CS hoàn tất với giá thực tế, hoặc dùng
skip_charge.
4.14 Báo giá trên ticket (hãng có API)
Ticket phải có trước: báo giá luôn gắn với một ticket. Không có báo giá chỉ để hỏi giá khi chưa có ticket. FE chưa gắn phần này; backend đã có.
1. Báo giá — mặc định lấy điểm đón, điểm trả, số chỗ và khu vực của ticket:
POST /dispatch/bookings/:id/quotes
{ }
Chỉ gửi field khi cần sửa một điểm:
- Khách đọc lại địa chỉ khác: gửi
pickup_address(hoặcpickup_name). Toạ độ cũ của ticket bị bỏ, địa chỉ được tra lại. - Chọn một chỗ trong
alternatives: gửipickup_lat/pickup_lngcủa chỗ đó. - Tương tự với
dropoff_*, vàseats,service_area.
Kết quả cùng dạng báo giá của app (xpay-user-mcp.md §2.1). pickup / dropoff
là chỗ bản đồ Xanh SM đặt điểm (địa chỉ đầy đủ, lat/lng, map_url) để CS đọc lại cho khách.
alternatives là các chỗ trùng tên. options là xe rẻ nhất trước (theo giá khách trả):
{ option_id, kind: "provider", provider, label, seats, price, cost_price, pickup_eta_min, expires_at }.
price.amount là giá báo khách (giá hãng + markup, làm tròn 1.000đ); cost_price là giá hãng, chỉ
có trong báo giá của CS, không bao giờ có trong báo giá của app.
Không có option "Tổng đài sắp xếp", vì CS chính là tổng đài; hãng manual xem ở candidates.
options: []nghĩa là không hãng API nào báo giá được lúc này. Lý do có thể là: chuyến theo giờ (không có điểm trả), không đủ chỗ (Xanh SM tối đa 6), hoặc hãng lỗi. Khi đó CS đặt tay như §4.4.- Ticket hẹn giờ cũng được báo giá, và báo giá có thêm
book_at= lúc xe sẽ được đặt (trướcpickup_at2 phút;null= đặt ngay vì giờ đón đã sát). Giá mỗi xe là ước tính:price = { kind: "estimate", amount, max_amount }.amountlà giá lúc này,max_amountlà giá khách cao nhất khi đặt (giá hãng +20%, cộng markup). Option có thêmmax_cost_price(giá hãng +20%) cạnhcost_price. CS đọc khách cả hai: "khoảng {amount}, tối đa {max_amount}, chưa gồm phụ phí trong chuyến". Xem mục 3 dưới. - Báo giá không đổi ticket, không nhận ticket, và gọi lại được nhiều lần.
2. Đặt option khách đồng ý:
POST /dispatch/bookings/:id/dispatch
{ "option_id": "…" }
- Ticket được gửi cho hãng của option, đặt đúng xe đó, và địa chỉ cùng toạ độ đã tra được ghi vào
ticket (
pickup_namekhách nói vẫn giữ). Eventdispatchedcómetadata.option_id,quoted_pricevàplaces_confirmed: true. Phần sau như §4.13 (poller tự điền xe, theo dõi trạng thái). - Thao tác này nhận ticket như mọi thao tác khác (§4.3). Trưởng ca tiếp quản vẫn đặt được option người trước đã báo giá.
- Giá được giữ tới
expires_at(khoảng 5 phút). Đặt sau đó thì backend báo giá lại (so theo giá khách trả, đã cộng markup): bằng hoặc rẻ hơn thì đặt luôn; đắt hơn thì409 quote_changedkèmdetails.old_price,details.new_pricevàdetails.quote(báo giá mới, cùng giờ đón của ticket). CS báo khách giá mới rồi đặt bằngoption_idmới. Xe không còn thì409 option_unavailable, cũng kèm báo giá mới. - Gửi một option hai lần thì trả lại ticket, không đặt thêm. Đặt hỏng (vd
claimed_by_other) thì option được giải phóng để thử lại. - Không gửi
option_idcùngprovider_ids.
3. Ticket hẹn giờ — hệ thống tự đặt trước giờ đón 2 phút. Xanh SM qua API chỉ đặt đi ngay, nên
với ticket có pickup_at còn hơn 2 phút, POST …/dispatch { option_id } chưa gửi cho hãng:
- Option được giữ trên ticket (
schedule, §2.1), eventscheduled, ticket vẫnpending(và CS thao tác thì nhận ticket như thường). Địa chỉ, toạ độ của báo giá được ghi vào ticket ngay. - Lúc
book_at(trước giờ đón 2 phút) backend tự gửi ticket cho hãng của option, như CS bấm đặt: eventdispatchedcóactor = system:scheduler,metadata.scheduled: true,max_cost_price. Phần sau như §4.13. - Giá lúc đặt: hãng báo giá lại. Giá hãng tăng không quá 20% so với lúc báo (
max_cost_price) thì vẫn đặt, và giá khách (sell_price) theo giá mới + markup. Tăng hơn 20%, hết xe, hoặc hãng lỗi thì assignmentrejected/failed, ticket vềpendingcho CS, kèm cảnh báo Telegram "Chuyến hẹn giờ chưa đặt được xe". Hãng đã ngưng (inactive) thì eventschedule_failedvà cũng báo Telegram. Lúc này chỉ còn khoảng 2 phút tới giờ đón: CS cần đặt tay ngay. - Server lỗi hoặc tắt đúng lúc
book_atthì backend thử lại mỗi 10 giây (Telegram báo một lần). Quá giờ đón 10 phút mà vẫn chưa đặt được thì không tự đặt nữa (xe tới cũng không còn ai): eventschedule_failed"Đã quá giờ đón … phút", ticket ở lạipendingcho CS. - Chọn option khác cho ticket (
POST …/dispatch { option_id }lần nữa) thì thay option cũ. - Hẹn tự mất khi ticket rời
pending: CS đặt tay (provider_ids, wizard §4.4, gửi tài xế XPay), gửi phương án, hoặc huỷ. Nhờ vậy không bao giờ đặt hai xe. Muốn bỏ hẹn mà chưa đặt gì thì chưa có API riêng: đặt tay khi tới lúc, hoặc huỷ ticket. - Ticket hẹn giờ mà CS gửi thẳng cho Xanh SM bằng
provider_ids(không qua báo giá) khi còn hơn 5 phút tới giờ đón thì vẫn bịrejectednhư trước ("chuyến hẹn giờ: báo giá ticket rồi đặt option…").
4.15 Zalo báo khách có xe (tự động)
Không có API, FE không cần làm gì. Khi ticket có xe, backend tự gửi ZNS "Mobility - báo user khi có
xe" (template MOBILITY.VEHICLE_FOUND) từ OA XPay qua notification-center. Mỗi tin Zalo đều tính
phí.
- Khi nào gửi: ngay khi có xe trên ticket. Đó là lúc CS "Gửi thông tin xe" (§4.4), hãng hoặc tài
xế XPay nhận chuyến (§4.11, §4.13), hoặc hãng API báo xe mới hay báo thêm biển số / tài xế
(
vehicle_changed). Đổi trạng thái thường thì không gửi. - Gửi tới:
passenger_phone, không có thìcustomer_phone. - Không gửi khi SĐT đó đã liên kết Zalo OA XPay (nhân viên đã link thẻ qua Zalo). Khi đó trợ lý trên OA đã báo khách.
- Chưa biết giá, thẻ hoặc giờ đón thì tham số đó ghi chữ mặc định (bảng dưới), tin vẫn gửi. Ví
dụ: hãng
meteredchưa có giá, chuyến hotline chưa gắn người trả, tài xế XPay chưa báo giờ đón. Sau đó có thông tin cũng không gửi lại: gắn người trả, có giá hay có giờ đón đều không gửi tin mới. Chỉ khi đổi xe (biển số hoặc tài xế khác) mới gửi tin mới, kèm những gì đã biết lúc đó. - Chưa có biển số hoặc tài xế thì chưa gửi, chỉ ghi log. Khi hãng báo thêm (
vehicle_changed) sẽ gửi.
| Tham số | Lấy từ | Ví dụ |
|---|---|---|
trip_code | Số ticket | XP-1048 |
total_amount | vehicle.sell_price, không có thì Thực tế theo đồng hồ | 245.000đ |
payment_card | payment_card_last4. Chuyến app hoặc có người trả mà chưa chọn thẻ thì chỉ ghi Thẻ XPAY. Chuyến hotline chưa gắn người trả thì XPAY | Thẻ XPAY •••• 4421 |
trip_route | pickup_name → dropoff_name | SATRA Đồng Khởi → Sân bay quốc nội |
trip_duration | Giờ đón thật (started_at), không có thì pickup_eta, rồi pickup_at; kèm arrive_eta nếu có. Không có giờ nào (đi ngay, chưa báo giờ đón) thì Trong vài phút tới | 14:06–14:35, 15/10/2026 |
vehicle_details | Hãng, xe, màu · biển số (bắt buộc có biển số). Quá 30 ký tự thì bỏ màu, rồi bỏ tên hãng | Xanh SM VF5 trắng · 51K-123.45 |
driver_details | Tên tài xế · SĐT | Nguyễn Văn Long · 0905 123 456 |
- Chống gửi trùng: notification-center giữ một tin cho mỗi xe, SĐT và nội dung. Đổi xe hoặc đổi thông tin xe thì thành tin mới.
- Gửi lỗi (notification-center không trả lời, SĐT sai) không làm hỏng thao tác của CS. Lỗi chỉ ghi log, không thử lại. Tin đã vào hàng đợi mà SĐT không dùng Zalo hoặc khách chặn OA thì lỗi chỉ ghi ở notification-center.
- Không ghi vào
eventscủa ticket.
5. Ánh xạ nút trên drawer
| Nút / khu vực thiết kế | Hiện khi | API |
|---|---|---|
| "Nhận ticket" | claimed_by == null và ticket đang mở | §4.3 claim |
| "Đặt xe" | pending / unfulfilled, không có proposal accepted | Mở wizard §4.4 / §4.5 |
| "Nhập thông tin xe" | pending và proposal.state == "accepted" | Wizard §4.4 từ bước 2 |
| "Không tìm được xe? Báo khách không có xe" | pending / unfulfilled | §4.9 |
| Demo "khách đồng ý / muốn đổi" | awaiting_confirmation | §4.6 |
| Demo "tài xế bắt đầu chuyến" | assigned | §4.7 start |
| "Hoàn tất chuyến" | assigned / riding | §4.7 complete |
| (mới) Khối "Thanh toán XPAY": tra SĐT, add thẻ, gắn người trả | Ticket hotline đang mở | §4.12 |
| "Huỷ chuyến" | Mở, không phải riding | §4.8 |
| (mới) "Tài xế huỷ" | assigned | §4.10 |
| (mới) "Chọn tài xế XPay" | pending / unfulfilled / dispatching, hãng có merchant_id | §4.11 |
| (mới) "Nhận giùm tài xế" | dispatching, lượt mode: "offer" đang pending | §4.11 assignments/:id/accept |
| (mới) "Đặt qua {hãng}" | pending / unfulfilled, hãng có adapter != "manual" | §4.13 |
| (mới) "Báo giá" → danh sách xe → "Đặt" | pending / unfulfilled / dispatching, có hãng API đang hoạt động | §4.14 |
| "Đổi xe", "Sửa xe", "Báo xe trễ" | Thiết kế v2 đang ẩn | — |
Mọi nút thao tác: chỉ bật khi claimed_by là mình hoặc null.
6. Danh mục hãng — ops cấu hình một lần
Không phải màn hình CS; ops (system admin hoặc PlatformKey) tạo bằng API/Swagger:
POST /dispatch/providers
{ "code": "xanh_sm", "name": "Xanh SM", "pricing_mode": "fixed", "seat_options": [3, 4, 6], "priority": 10,
"merchant_id": "<merchant_id của Xanh SM trong XPay>" }
{ "code": "be", "name": "Be", "pricing_mode": "fixed", "seat_options": [4, 7], "priority": 20,
"merchant_id": "<merchant_id của Be trong XPay>" }
{ "code": "mai_linh", "name": "Mai Linh", "pricing_mode": "metered", "seat_options": [4, 7], "priority": 30,
"merchant_id": "<merchant_id của Mai Linh trong XPay>", "contact_phone": "02838383838" }
Mỗi hãng bắt buộc link với một merchant XPay (merchant_id): tài xế của merchant đó là đội xe
của hãng (tra cứu tài xế §4.4, chọn tài xế §4.11), và chuyến CS hoàn tất được trả cho merchant đó.
Tạo merchant cho hãng trước, rồi mới tạo hãng. Đổi được sang merchant khác bằng PATCH, không xoá
được về null. Ngừng một hãng: PATCH /dispatch/providers/:id { "status": "inactive" } (không xoá).
| HTTP | code | Khi nào |
|---|---|---|
| 422 | validation_failed | Thiếu merchant_id khi tạo, hoặc gửi merchant_id: null / không phải UUID |
| 422 | merchant_not_found | Không có merchant XPay với id đó |
| 422 | merchant_not_active | Merchant đang suspended — details.status |
| 422 | merchant_required | Sửa một hãng tạo từ trước khi bắt buộc link mà chưa có merchant_id — gửi kèm merchant_id trong cùng PATCH |
Hãng tạo trước khi bắt buộc link mà chưa có merchant_id vẫn đặt xe được như cũ (chuyến CS hoàn
tất trả cho platform), nhưng không sửa được gì khác nếu chưa gửi merchant_id.
6.1 Hãng nào dùng bộ API nào — adapter
Mỗi hãng (= một merchant) chọn cách đặt xe bằng adapter:
adapter | Cách đặt | Cấu hình |
|---|---|---|
manual (mặc định) | CS đặt trên app hãng / gọi hotline, nhập xe tay (§4.4) | — |
greensm | Backend đặt thẳng trên Xanh SM bằng tài khoản Xanh SM của hãng đó, tự theo dõi tới khi có tài xế | adapter_config + đăng nhập (dưới đây) |
Nhiều hãng có thể cùng greensm, mỗi hãng một tài khoản Xanh SM và một adapter_config. Ví dụ hai
merchant đặt Xanh SM bằng hai thẻ doanh nghiệp khác nhau.
Chuyển một hãng sang greensm:
- Đăng nhập tài khoản Xanh SM doanh nghiệp trên booking.greensm.com (OTP). Lấy
access_tokenvàrefresh_tokencủa phiên đó. - Lưu đăng nhập cho hãng. Backend mã hoá khi lưu, không route nào trả lại:
PUT /dispatch/providers/:id/credentials{ "adapter": "greensm", "credentials": { "access_token": "…", "refresh_token": "…" } }
- Chuyển hãng:
PATCH /dispatch/providers/:id{ "adapter": "greensm", "adapter_config": { "service_ids": { "4": 1128, "7": 1131 } } }
adapter_config của greensm (đều không bắt buộc):
| Key | Mặc định | Ý nghĩa |
|---|---|---|
service_ids | Mọi dịch vụ ô tô, số chỗ lấy theo max_passenger Xanh SM trả về (không bao giờ đặt xe máy) | Số chỗ → service_id Xanh SM, khi muốn giới hạn loại xe. Ví dụ { "4": 1128, "6": 2274 } để chỉ đặt Green Car và Green Limo, bỏ Green Mini 3 chỗ. |
payment_method | business_card | Thẻ doanh nghiệp của tài khoản. Không bao giờ tự đổi sang tiền mặt. |
insurance | true | Thêm bảo hiểm chuyến như web |
driver_timeout_sec | 300 | Quá thời gian này chưa có tài xế thì huỷ trên Xanh SM, ở đây coi như hãng từ chối. Xanh SM tự huỷ sau 600 giây (auto_cancel_interval). |
language | vi | vi / en |
area_centers | {"hcm": [10.7769, 106.7009], "hn": [21.0285, 105.8542]} | service_area → toạ độ dùng làm tâm tìm địa chỉ khi ticket không có toạ độ |
max_trip_km | 100 | Điểm đón và trả tìm được cách nhau xa hơn số km này (đường chim bay) → coi là hiểu sai địa chỉ, từ chối |
Ticket không có toạ độ (form hotline và trợ lý hiện chỉ gửi chữ): backend tìm điểm đón trên bản
đồ Xanh SM quanh area_centers[service_area], tìm điểm trả quanh điểm đón vừa tìm, rồi lấy kết quả
đầu tiên. Vì vậy CS nên nhập địa chỉ cụ thể (số nhà, đường, quận) và chọn khu vực. Gửi kèm
pickup_lat/pickup_lng (từ ô tìm địa chỉ có bản đồ) là chính xác nhất. Có toạ độ thì backend
không tìm trên bản đồ Xanh SM nữa: gửi thẳng toạ độ kèm chữ địa chỉ, xe tới đúng chỗ ghim, và mỗi lần
đặt bớt 2 lần gọi API.
Xe Xanh SM (tháng 10/2026): Green Mini 3 chỗ, Green Car / Premium / GF-VIP 4 chỗ, Green Limo 6 chỗ.
Không có xe 7 chỗ, nên hãng greensm nên để seat_options: [3, 4, 6] để auto-dispatch không chọn
Xanh SM cho chuyến 7 chỗ. Nếu vẫn chọn, chuyến bị từ chối ngay và chuyển hãng khác.
Thẻ doanh nghiệp Xanh SM là trả trước. Nếu số dư còn lại (card_credit) không đủ giá báo, chuyến
failed, chuyển hãng kế tiếp, kèm cảnh báo Telegram (dưới đây). Cần nạp thẻ trên cổng doanh nghiệp Xanh SM.
Cảnh báo Telegram, group riêng với báo giao dịch: service key card-dispatch
(TELEGRAM_NOTIFY_DISPATCH_SERVICE), map sang group bằng TELEGRAM_CHAT_ID_CARD_DISPATCH bên
notification worker; chưa map thì vào group mặc định của worker. Tắt bằng TELEGRAM_NOTIFY_DISPATCH=off.
Cùng một lỗi chỉ báo lại sau 15 phút:
| Cảnh báo | Mức | Khi nào |
|---|---|---|
| Không đặt được xe / Không báo giá được / Không tìm được địa chỉ | ⚠️ | Gọi Xanh SM lỗi (HTTP lỗi, timeout, chưa đăng nhập, thẻ hết tiền, không có phương thức thanh toán). Gộp theo lỗi, không theo chuyến |
| Không rõ đã tạo đơn chưa | 🚨 | create-order timeout / 5xx / không có mã đơn. Mỗi chuyến một lần |
| Không huỷ được đơn — xe có thể vẫn tới | 🚨 | Xanh SM không nhận lệnh huỷ. Mỗi đơn một lần |
| Tài khoản bị đăng xuất | 🚨 | Xanh SM từ chối refresh token |
| Không đọc được đơn | ⚠️ | Đơn đọc lỗi liên tục quá 1 phút |
| Chuyến hẹn giờ chưa đặt được xe | 🚨 | Tới giờ tự đặt (trước giờ đón 2 phút) nhưng ticket về CS: giá tăng quá 20%, hết xe, hãng lỗi hoặc ngưng (§4.14). Tiêu đề không có tiền tố "Xanh SM ·". Mỗi chuyến một lần |
| Trạng thái đơn lạ | ⚠️ | Xanh SM trả trạng thái backend chưa hiểu |
| Huỷ chuyến khi khách đã lên xe | 🚨 | Xanh SM huỷ khi ticket đang riding |
| Chuyến đã xong nhưng chưa trừ được thẻ | ⚠️ | Đi kèm event charge_failed |
Xanh SM trả lời "không" (hết xe, chuyến hẹn giờ, giá tăng, không tìm thấy địa chỉ) là rejected, không
cảnh báo.
Khi đặt: backend báo giá, chọn xe nhỏ nhất đủ chỗ (rẻ nhất trong số đó), tạo đơn, rồi để
assignment ở pending với provider_ref = mã đơn Xanh SM. Mỗi 10 giây backend đọc lại đơn cho
tới khi Xanh SM xong chuyến. Mỗi lần trạng thái đổi, backend ghi provider_stage lên xe (§2.2)
kèm một event provider_status (§2.5), nên CS thấy qua SSE ngay:
| Xanh SM | provider_stage | Ticket |
|---|---|---|
FINDING | searching | dispatching |
ASSIGNED | driver_assigned | accepted → assigned, kèm biển số, tài xế, giá Xanh SM (cost_price) |
IN PROCESS, điểm đón ARRIVING / NEARED | driver_arriving | không đổi |
IN PROCESS, điểm đón ARRIVED | driver_arrived | không đổi |
IN PROCESS, điểm đón COMPLETED | on_board | riding |
COMPLETED | completed | Có người trả (khách tự đặt, hoặc CS đã gắn người trả) → backend tự Hoàn tất với final_fare = giá cuối Xanh SM (không phải giá báo), trừ thẻ, ticket completed. Không có người trả, hoặc trừ thẻ lỗi (event charge_failed), hoặc Xanh SM không báo giá cuối → vẫn riding, CS bấm Hoàn tất. cost_price = giá cuối Xanh SM |
CANCELLED | cancelled | chưa đón khách → rejected (auto-dispatch chuyển hãng kế tiếp); khách đã lên xe → CS xử lý |
Đã chạy thật trọn một chuyến (17/10/2026, Green Car Bình Tân → Quận 6):
- Có tài xế 5 giây sau khi đặt. Card Xe tự điền biển số, tài xế, loại xe (
VFE34), màu xe. pickup_etalà ước tính Xanh SM đưa ra một lần lúc nhận chuyến: báo 17:16, xe tới 17:24. FE nên hiện là "dự kiến".arrive_etacó khi khách lên xe.driver_arrivedgần như không bao giờ thấy: Xanh SM chuyển "đã tới" sang "đã đón" trong khoảng 3 giây. FE không nên chờ nhãn này.- Giá cuối có thể cao hơn giá báo: báo 55.000đ, khi xong là 65.000đ. Tài xế thêm 10.000đ "Phí Khác"
lúc bấm hoàn tất, Xanh SM không ghi lý do.
cost_pricetự cập nhật, kèm eventprovider_statuscófare/from_farevàfare_breakdown:Phụ phí tính cho khách: tự hoàn tất trừ thẻ theo giá cuối này cộng markup (với markup %, phụ phí cũng bị cộng %). Hãng có markup thì{ "base": "53000.00", "surge_multiplier": 1.2, "discount": null,"surcharges": [{ "code": "surcharge_other_tax", "label": "Phí Khác", "amount": "10000.00" }],"addons": [{ "code": "INSURANCE", "label": "OPES", "amount": "2000.00" }] }sell_pricecũng đổi theo, kèmsell_price/from_sell_pricetrong event.
Các trường hợp khác:
- Hết
driver_timeout_secmà vẫnFINDING: huỷ trên Xanh SM rồirejected. - Xanh SM đổi tài xế hoặc xe: cập nhật card Xe, ghi event
vehicle_changed, và báo trợ lý của khách. - Tài xế bỏ, Xanh SM tìm lại trên cùng đơn:
provider_stagequay vềsearching(eventprovider_status,from_stagelàdriver_*→ "Tài xế bỏ chuyến, Xanh SM đang tìm tài xế khác"), ticket vẫnassigned. Khách (GET /my/rides, trợ lý) cũng thấyassigned, nhưngvehicle = null(xe cũ không tới nữa) tới khi có tài xế lại — tài xế mới (thêmvehicle_changed) hoặc chính tài xế cũ. - Trợ lý của khách tự đặt được báo mỗi lần
provider_stagecủa xe đang nhận đổi (ride.provider_stage). - Xanh SM huỷ đơn sau khi đã có tài xế:
rejected(§4.10),reason_codetheocancel_bycủa đơn —DRIVER→driver_cancelled, còn lại →other.CUSTOMERcủa Xanh SM là tài khoản doanh nghiệp của mình (có người huỷ tay trên web/app Xanh SM), không phải khách, nên không làcustomer_cancelled.
Hoàn tất khi Xanh SM báo xong: chuyến có người trả được backend tự hoàn tất, trừ thẻ giá cuối Xanh SM + markup của chuyến (§6.2), không phải giá báo. Còn lại CS bấm "Hoàn tất" (§4.13).
Nếu Xanh SM huỷ khi khách đã lên xe, ticket kẹt ở riding (không quay lại được); CS tự quyết:
hoàn tất với giá thực tế, hoặc skip_charge.
Xanh SM qua API chỉ đặt đi ngay, và không nhận chuyến không có điểm trả. Chuyến hẹn giờ chỉ được nhận khi còn tối đa 5 phút tới giờ đón; sớm hơn thì bị từ chối ngay để chuyển hãng khác hoặc trả về CS. Muốn Xanh SM chở chuyến hẹn giờ: báo giá trên ticket rồi đặt option, hệ thống tự đặt trước giờ đón 2 phút (§4.14 mục 3). Huỷ ticket, hoặc CS reject một assignment Xanh SM, sẽ huỷ luôn đơn trên Xanh SM.
Khi không rõ đơn đã tạo chưa (create-order timeout hoặc 5xx, hoặc trả lời mà không có mã đơn),
assignment vẫn pending, không có provider_ref, và note ghi "Kiểm tra app Xanh SM…". Backend
không tự chuyển hãng khác, để tránh hai xe cùng tới. CS mở app Xanh SM của tài khoản đó để xem, rồi
accept / reject tay.
Đăng nhập hết hạn: khi Xanh SM từ chối refresh token, đăng nhập chuyển auth_lost; mọi chuyến gửi
tới hãng đó failed và chuyển hãng kế tiếp. Xem trạng thái ở GET /dispatch/providers/:id/credentials.
Đăng nhập lại rồi PUT lần nữa.
Nếu làm màn hình cấu hình hãng (thay cho Swagger):
- Chọn
adapter:manualhoặcgreensm. Chưa có endpoint liệt kê adapter, nên FE tự giữ danh sách này (422 unknown_adaptercó trảdetails.available). - Form
adapter_configcủagreensmtheo bảng trên. Gửi số, không gửi chuỗi; số chỗ là key chuỗi. - Khối "Đăng nhập Xanh SM":
GET …/credentials: 404 thì "Chưa đăng nhập".status = activethì hiệnsecret_set_at/rotated_at.auth_lostthì báo đỏ "Hết phiên — đăng nhập lại" kèmstatus_reason.- Form chỉ ghi: hai ô
access_tokenvàrefresh_token→PUT …/credentials. Không bao giờ hiện lại token (API cũng không trả về). Xoá ô nhập sau khi gửi.
- Thứ tự bắt buộc khi chuyển hãng sang API:
PUT credentialstrước, rồiPATCH adapter. Làm ngược thì nhận422 credentials_required.
| HTTP | code | Khi nào |
|---|---|---|
| 422 | credentials_required | Bật (status: active) hoặc chuyển hãng sang greensm khi chưa có đăng nhập active cho greensm. Tạo hãng mới trên greensm thì tạo inactive trước. |
| 422 | invalid_adapter_config | adapter_config sai, message nói rõ key nào |
| 422 | invalid_credentials | Thiếu access_token / refresh_token, hoặc có key lạ |
| 422 | adapter_takes_no_credentials | PUT …/credentials cho adapter không cần đăng nhập (manual) |
| 503 | credentials_key_unset | Server chưa có DISPATCH_CREDENTIALS_KEY |
6.2 Markup — giá bán = giá hãng + markup
Hãng fixed có thể có markup, là phần merchant cộng thêm vào giá hãng. Khách trả giá hãng + markup;
tiền trừ thẻ vào merchant của hãng. Hãng metered không có markup (giá là giá đồng hồ).
PATCH /dispatch/providers/:id
{ "markup_type": "percent", "markup_value": "10" } // +10 %
{ "markup_type": "fixed", "markup_value": "5000" } // +5.000đ mỗi chuyến
{ "markup_type": null } // bỏ markup
Công thức: giá hãng + markup, làm tròn tới 1.000đ gần nhất (từ 500đ trở lên thì làm tròn lên). Nếu làm tròn xuống làm giá bán thấp hơn giá hãng thì làm tròn lên. Ví dụ với +10 %: 87.500đ → 96.250đ → 96.000đ; 55.000đ → 60.500đ → 61.000đ. Với +5.000đ: 87.400đ → 92.400đ → 92.000đ. Không có markup thì giá bán = giá hãng, không làm tròn.
Áp dụng ở:
- Báo giá (§4.14, app):
price.amount= giá hãng + markup; xe xếp theo giá khách trả. - Xe hãng API:
sell_price=cost_price+ markup, đổi theo khi giá hãng đổi (phụ phí cuối chuyến). - Tự hoàn tất khi Xanh SM báo xong: trừ giá cuối + markup.
- CS nhập xe / gửi phương án cho hãng
fixedcó markup: chỉ cầncost_price,sell_pricetự tính. CS gửisell_pricethì backend lấy đúng số đó.
Đổi markup không đổi giá chuyến đang chạy: markup được chụp vào assignment lúc gửi hãng
(assignments[].markup_type / markup_value), và chuyến đó tính theo markup đã chụp.
| HTTP | code | Khi nào |
|---|---|---|
| 422 | invalid_markup | Có markup_type mà không có markup_value (hoặc ngược lại), hãng metered, giá trị ≤ 0, % lớn hơn 100, hoặc đổi hãng đang có markup sang metered (bỏ markup trước) |
7. Bảng lỗi
| HTTP | code | Khi nào | FE xử lý |
|---|---|---|---|
| 401 | unauthorized | Thiếu/sai token | Đăng nhập lại |
| 403 | forbidden | Token không phải system admin | — |
| 403 | operator_required | claim/release bằng token không có profile id | Báo lỗi cấu hình tài khoản |
| 404 | not_found | Sai id ticket/hãng/assignment | Làm mới danh sách |
| 409 | claimed_by_other | Ticket người khác đang giữ — details.claimed_by | Khoá thao tác, hiện người giữ |
| 409 | invalid_booking_state | Thao tác không hợp trạng thái hiện tại — details.status | GET lại ticket, vẽ lại drawer |
| 409 | invalid_assignment_state | Xe/assignment đã bị huỷ hoặc thay | GET lại ticket |
| 409 | provider_inactive | Hãng đã ngừng | Tải lại danh mục hãng |
| 409 | quote_changed | Đặt option mà giá hãng đã tăng (§4.14) — details.old_price, new_price, quote | Hiện giá mới, hỏi lại khách, đặt bằng option_id mới trong details.quote |
| 409 | option_unavailable | Xe của option không còn, hoặc hãng ngừng (§4.14) | Như trên, với option mới trong details.quote |
| 404 | not_found (option) | option_id không phải của ticket này | Báo giá lại |
| 422 | place_not_found | Báo giá không tìm thấy địa điểm — details.which = pickup / dropoff | Hỏi khách địa chỉ cụ thể hơn, báo giá lại với pickup_address / dropoff_address |
| 503 | customer_lookup_unavailable | Không tra được Store theo SĐT (§4.12) | Thử lại; báo kỹ thuật nếu lặp lại |
| 503 | corporate_lookup_unavailable | Không hỏi được backend corporate khi add thẻ (§4.12) | Thử lại sau |
| 422 | card_not_in_user_corporate, user_not_in_corporate | Add thẻ khác công ty / khách không còn là nhân viên (§4.12) | Chọn thẻ khác trong linkable_cards, hoặc cho khách đọc số thẻ + PIN |
| 429 | too_many_pin_attempts | Ops sai số thẻ / PIN 5 lần trong 1 giờ khi add thẻ (§4.12) | Thử lại sau |
| 422 | card_pin_invalid, card_has_no_pin, card_not_active | Nhánh khách đọc số thẻ + PIN (§4.12) | Cho nhập lại; không lưu số thẻ / PIN |
| 409 | card_locked_too_many_attempts | Sai PIN 3 lần — thẻ khoá 15 phút, details.locked_until | Báo khách thử lại sau giờ đó |
| 409 | payer_is_requester | Đổi người trả của chuyến đặt qua app (§4.12) | Chỉ cho đổi thẻ của người đặt |
| 422 | invalid_payment_card | Gắn người trả có tài khoản với thẻ không trả được lúc này: không phải thẻ của khách, chưa active, ngoài hạn thẻ (validity) hoặc ngoài hạn link (§4.12) | Chọn thẻ khác trong cards |
| 422 | card_outside_validity_window | Thẻ khách đọc số + PIN đúng nhưng đang ngoài khoảng thời gian công ty cho phép (§4.12) | Báo khách dùng thẻ khác |
| 409 | payment_declined | Hoàn tất chuyến app: thẻ khách bị từ chối — details.reason, details.auth_id (§4.7) | Hiện lý do; ticket vẫn mở. Khách trả cách khác → hoàn tất với skip_charge |
| 409 | no_payment_card, payment_card_ambiguous, payment_card_not_found | Hoàn tất chuyến app: khách không có thẻ active / có nhiều thẻ mà lúc đặt không chọn / thẻ đã chọn không còn của khách (hoặc đã bị bỏ chia sẻ) | Như trên |
| 422 | final_fare_required | Hoàn tất chuyến app mà không có final_fare lẫn giá báo của xe | Hỏi giá chốt |
| 503 | payments_unavailable | Server chưa nối phần trừ thẻ | Báo kỹ thuật |
| 422 | validation_failed | Thiếu/sai field — xem message hoặc details.errors | Báo lỗi tại bước wizard tương ứng |
| 422 | seat_downgrade | Xe ít chỗ hơn yêu cầu — details.requested, details.offered | Báo ở bước "Loại xe" |
| 429 | rate_limited | Gọi quá dày (giới hạn theo IP; cả phòng CS chung IP văn phòng tính chung) | Dùng SSE thay vì poll; chỉ GET ticket nào vừa đổi |
8. Phần thiết kế chưa có backend — ẩn hoặc để placeholder
| Thiết kế | Tình trạng | Đề xuất cho FE |
|---|---|---|
| Card "Thanh toán XPAY": trả trước/trả sau, giữ tiền, chờ tài xế nhập giá, PIN, các bước | Có hai phần. (1) Trả sau cho chuyến khách tự đặt qua app: "Hoàn tất" tự trừ thẻ (§4.7). (2) Tài xế XPay thu qua app (§4.11). Cả hai đều cho final_fare và paid_auth_id. Giữ tiền XCard trước cho hãng trả trước: chưa có | Hiện "Đã thanh toán {final_fare}" khi có paid_auth_id; còn lại ẩn |
| "Thanh toán hộ", ghép giao dịch, các nút demo thanh toán | Chưa có | Ẩn |
Tab "Chờ thanh toán", trạng thái awaitpay, cờ vàng chờ thanh toán > 30 phút | Chưa có | Ẩn |
| Hội thoại Zalo, "AI đang trả lời", ô nhắn khách, "Trả lại AI", "Đóng ticket" theo lý do xử lý cờ | Chưa có | Ẩn |
Cờ đỏ E2/E3, banner "N request có cờ đỏ", tab "Cần chú ý", trạng thái new | Chưa có | Ẩn |
| Gửi tin OA/ZNS khi gửi xe / phương án / không có xe, "Gửi lại tin xe" | Backend không gửi | Ẩn preview hoặc ghi rõ "CS tự báo khách" |
| Badge "Đã link Zalo · tin OA" | Chưa có dữ liệu | Ẩn |
| Badge "XCard •••• 4421" | Có: payment_card_last4 trên ticket (§4.12) | Hiện khi có |
| Tên/avatar tổng đài viên | Backend chỉ có profile id | Tra Store |
| Số đếm tab | Chưa có endpoint đếm | Đếm phía client (§3.1) |
9. Luồng tự gán hãng (không dùng trong CS Portal v2)
Backend còn một luồng cho hãng tích hợp API (hiện có Xanh SM qua greensm, §6.1): auto_dispatch: true khi tạo ticket, hoặc
POST /dispatch/bookings/:id/dispatch → ticket dispatching, chờ hãng trả lời qua
/dispatch/assignments/:id/accept|reject; hãng từ chối thì tự chuyển hãng kế tiếp, hết hãng thì
unfulfilled. Portal v2 dùng luồng CS tự đặt (§4.4) nên chỉ cần hiển thị đúng các trạng thái này
nếu gặp (§2.4). Chi tiết trong Swagger tag dispatch.
10. Checklist gắn
- Origin portal có trong
CORS_ORIGINScủa card-core - Gửi
Authorization: Bearer <Store token system admin>; đổi profile id → tên qua Store - Hàng đợi: tab theo §3.1, tìm kiếm
q, lọc kênh/hãng - SSE
GET /dispatch/eventsqua thư viện chạy trênfetch(gửi Bearer); tải danh sách khi nhậnready, GET ticket khi nhậnbooking, tải lại khiresync - Hiển thị
XP-${ref_no}, "Đã chờ" từstatus_changed_at, "Đi ngay" khipickup_atnull - Xử lý
409 claimed_by_otherở mọi thao tác - Wizard: kiểm tra trước theo
pricing_mode(§4.4), đổi giờHH:mm→ ISO có+07:00 - Tiền gửi dạng chuỗi số, bỏ dấu chấm phân cách
- Tra XPay Merchant cho hãng
metered - Huỷ / không có xe gửi
reason_code - Ẩn mọi phần §8; không hứa với CS là khách nhận được tin Zalo, trừ tin báo có xe (§4.15)
- Hãng API (§4.13): nút "Đặt qua {hãng}" →
POST …/dispatch { provider_ids }; nhãnprovider_stagetrên card Xe (§2.2); hiệnprovider_refvànotecủa assignment đang chờ - Ticket hẹn giờ (§4.14 mục 3): chip
schedule"Tự đặt … lúc {book_at}" trên hàng đợi và drawer; eventscheduled/schedule_failedtrong Lịch sử,schedule_failedlà cảnh báo đỏ - Event
provider_status,vehicle_changed,provider_cancel_failed,charge_failedtrong Lịch sử (§2.5);provider_cancel_failedvànote"Kiểm tra app Xanh SM…" là cảnh báo đỏ không tự tắt;charge_failedđỏ tới khi ticket đóng -
provider_stage = completedmà ticket vẫnriding→ nhắc "Hoàn tất", bắt nhậpfinal_fare(chuyến có người trả thường đã được backend tự hoàn tất) - (Nếu có màn cấu hình hãng) adapter +
adapter_config+ khối đăng nhập Xanh SM chỉ ghi (§6.1) - Hãng có markup (§6.2): wizard nhập xe / gửi phương án tự điền giá bán từ giá mua; báo giá trên ticket
hiện
cost_price(giá hãng) cạnhprice.amount(giá khách); (nếu có màn cấu hình hãng) ô markup % / cố định - Chuyến khách tự đặt từ báo giá (§4.13): event
dispatchedcómetadata.option_id/quoted_price,actor_type = user→ hiện "Khách đặt {label} giá {quoted_price}"; ticket không qua CS - Event
cancelledcóactor_type = user→ hiện "Khách tự huỷ: {note}" - Event
proposal_accepted/proposal_rejectedcóactor_type = user→ hiện "Khách xác nhận qua app" / "Khách muốn đổi phương án: {note}"; ticket đang mở thì tải lại (§4.6) - Nhả ticket khi không còn xử lý (§4.3) — ticket đang giữ chặn khách tự huỷ
- Báo giá trên ticket (§4.14): đọc lại
pickup/dropoff(kèmmap_url) cho khách;alternatives→ báo giá lại với toạ độ đã chọn; "Đặt" →POST …/dispatch { option_id }; xử lýquote_changed/option_unavailable(hiện option mới);options: []→ đặt tay