Skip to main content

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:

  1. 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).
  2. 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.
  3. 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).
  4. 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).
  5. 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 quanNội dung
GET /swagger, GET /api-docs.htmlContract OpenAPI sinh thẳng từ code — mọi field, kiểu, enum (tag dispatch)
auth-architecture.mdCác loại token và cách guard nhận diện
Repo documentation: docs/payment/card-payment-system/ride-payment.md, card-sharing.mdThanh 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ĩaHiển thị trên portal
idUUID — dùng trong mọi URL—
ref_noSố tăng dầnMã ticket: hiển thị XP-${ref_no}
channelhotline | zalo | app | apiIcon 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
statusXem §2.4Badge trạng thái, tab
status_changed_atLần cuối đổi trạng tháiGốc của cột "Đã chờ" (§3.3)
customer_phoneSĐT người đặt, dạng +84…SĐT khách
customer_name, customer_companyCó thể nullTên, công ty (null → "Khách mới" / "Chưa xác định")
passenger_name, passenger_phoneChỉ 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_addressTê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_addressTương tự; dropoff_name null = chuyến theo giờ"Điểm đến"
pickup_atnull = Đi ngay; có giá trị = Hẹn giờChip lịch: "Đi ngay" hoặc HH:mm dd/MM
scheduleChuyế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)"
seatsSố chỗ khách yêu cầu (4/7…)"4 chỗ"
noteGhi 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_atNgười đang giữ ticket (null = chưa ai nhận)Cột Ticket: "Bạn" / tên / "Chưa ai nhận"
proposalPhương án đang chờ khách / khách đã đồng ý (§2.3)Khối phương án trong card "Xe"
vehicleXe đang gắn với ticket, null nếu chưa có (§2.2)Card "Xe"
assigned_provider_idHãng của xe hiện tạiLọc theo hãng
final_fareGiá chốt khi hoàn tất — số tiền thực thu khi chuyến được trả bằng thẻ—
paid_auth_idGiao 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_idThẻ 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_feeLý 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ạiLịch sử; chỉ hiện "Lý do huỷ" khi status = cancelled
started_at, completed_at, cancelled_atMốc thời gian"đóng lúc" / "huỷ lúc"
created_atLúc khách gửiCột "Gửi lúc"
requested_by, requested_by_typeAi 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_idDùng khi tài xế huỷ (§4.9)
provider{ id, code, name, pricing_mode, contact_name, contact_phone }
seatsSố 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_colorBiển số, mẫu, màu
driver_name, driver_phoneTà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_priceGiá 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_priceGiá mua từ hãng — nội bộ, không bao giờ hiện cho khách
pickup_eta, arrive_etaGiờ tới đón / tới nơi (ISO)
provider_refMã 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_stageHã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_atTrạ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_stageNhã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ỷ"
nullKhô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ệnHiể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" (booking awaiting_confirmation) → "Đã gửi phương án, chờ khách xác nhận".
  • state: "accepted" (booking pending) → "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​

statusNhãn (badge)ToneTab"Việc tiếp theo"
pendingChờ đặt xewarningCần xử lýĐặt xe (hoặc "Nhập thông tin xe" nếu proposal.state = accepted)
unfulfilledChờ đặt xewarningCần xử lýĐặt xe — hệ thống tự gán đã hết hãng
dispatchingChờ tài xế nhận (lượt mode: "offer") / Chờ hãng trả lờiwarningCần xử lýĐã gửi cuốc cho tài xế XPay (§4.11), hoặc luồng gửi hãng (§9)
awaiting_confirmationChờ khách xác nhậninfoĐang thực hiệnChờ khách
assignedĐã có xeprimaryĐang thực hiệnHoàn tất chuyến / Bắt đầu chuyến
ridingĐang chạyinfoĐang thực hiệnHoàn tất chuyến
completedXongpositiveĐã đóng—
cancelledHuỷ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_typemetadataCâ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ý doafter_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? }, notepaid_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ỗiCả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ỗiCả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ý doCả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
statusMột hoặc nhiều trạng thái, ngăn bằng dấu phẩy
channelhotline | zalo | app | api — dropdown "Mọi kênh"
provider_idHãng của xe hoặc của phương án đang chờ — dropdown "Tất cả hãng"
claimed_byme (của tôi) · none (chưa ai nhận) · một profile id
qÔ tìm kiếm (§3.2)
limitMặ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ệnawaiting_confirmation,assigned,riding
Đã đóngcompleted,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: 1048 hoặc XP-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 (51k12345 khớp 51K-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ị theo Asia/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, vd 2026-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. EventSource có sẵn của trình duyệt không gửi được header Authorization, 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.
eventdataFE 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ửi ready. Hụt quá 500 sự kiện thì server gửi resync thay 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ửi Last-Event-ID = id củ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ận ready.
  • event_type giố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 booking củ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 formFieldGhi chú
SĐT khách *customer_phoneBắt buộc
Điểm đón * / Địa chỉ điểm đónpickup_name / pickup_addresspickup_name bắt buộc
Điểm đến * / Địa chỉ điểm đếndropoff_name / dropoff_addressBackend cho phép bỏ trống (chuyến theo giờ); form thiết kế bắt buộc
4 chỗ / 7 chỗseatsMặc định 4
Đi ngay / Hẹn giờ + Giờ hẹnbỏ pickup_at / pickup_at ISO
Đặt cho người khác + Tên/SĐT người đipassenger_name / passenger_phoneĐặt hộ thì bắt buộc passenger_phone
  • Response 201 là ticket chi tiết, status: "pending", đã thuộc về người tạo (claimed_by = mình). Mở luôn wizard đặt xe với id trả 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 định false = để 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_modeHãng (cấu hình hiện tại)NhãnWizard
fixedXanh 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
meteredMai 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 claim trướ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ãngHãngprovider_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àuvehicle_plate, driver_phone, driver_name, vehicle_model, vehicle_color
3. Giá và giờGiá mua (từ NCC), Giá bán cho kháchcost_price, sell_price (chuỗi số, bỏ dấu chấm)
Giờ tới đón *, Giờ tới nơipickup_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ắcLỗi
vehicle_plate, driver_name, pickup_eta luôn bắt buộc422 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_eta422 validation_failed
Ticket phải đang pending / unfulfilled / dispatching409 invalid_booking_state
Hãng phải active409 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ào vehicle.driver_ref — FE không cần gửi kết quả tra.
  • Hãng fixed khô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 fixed bắt buộc sell_price (hãng có markup: gửi cost_price là đủ, sell_price tự tính); hãng metered không được gửi cost_price; seats không nhỏ hơn yêu cầu. Chỉ gửi được khi ticket pending / 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ẵn provider_id, seats, sell_price, cost_price, pickup_eta từ 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.

  • complete chạy được từ assigned hoặc riding.

  • 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_fare nế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.
  • Thẻ bị từ chối → 409 payment_declined, details.reason là 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_reason bắt buộc khi skip_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ãnreason_code
Khách huỷcustomer_cancelled
Tài xế huỷ, khách không đặt lạidriver_cancelled
Không có xeno_car
Trùng ticketduplicate
(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ượt mode: "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_eta theo tài xế;
    • từ chối → ticket về pending, lượt rejected với note = 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.
  • 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_eta có 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ốc accepted trong app và làm tiếp đón khách → thu tiền. Lịch sử ghi event accepted với actor_type = system_admin (CS nhận giùm), phân biệt với driver (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ĩaFE làm gì
users: []SĐT chưa có tài khoảnBước 3, nhánh "khách đọc số thẻ + PIN"
users 1 phần tửĐã có tài khoảnHiệ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 = active mới dùng được; not_started (chưa tới khoảng nào / đang giữa hai khoảng) hoặc expired (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 trong linkable_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ẻ (windows rỗng = không giới hạn). Khác active thì thẻ cũng không trả chuyến được, kể cả thẻ của chính khách. Thẻ trả được khi status = "active", validity.status = "active" và (link null hoặc link.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ứngBackend 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 corporateCó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ì.
  • 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.
  • Gửi cả card_id lẫn pan → 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_pin vớ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_id là một thẻ trong cards của khách, đang trả được lúc này (status active, validity.status active, và link null hoặc link.status active) — 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_id và 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.
  • 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_id phả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 ở dispatching với assignment của hãng, event dispatched có metadata.option_id và quoted_price = giá khách đã đồng ý (giá hãng + markup). sell_price = cost_price + markup. Hãng thất bại thì ticket về pending cho CS.

  • Khách chọn "Tổng đài sắp xếp" thì ticket pending như 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_failed và 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ận 409 cancel_by_hotline và 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ờ. Event cancelled củ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ệnHiể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 / rejectedLý 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ống final_fare là 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ập final_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ặc pickup_name). Toạ độ cũ của ticket bị bỏ, địa chỉ được tra lại.
  • Chọn một chỗ trong alternatives: gửi pickup_lat / pickup_lng củ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ước pickup_at 2 phút; null = đặt ngay vì giờ đón đã sát). Giá mỗi xe là ước tính: price = { kind: "estimate", amount, max_amount }. amount là giá lúc này, max_amount là giá khách cao nhất khi đặt (giá hãng +20%, cộng markup). Option có thêm max_cost_price (giá hãng +20%) cạnh cost_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_name khách nói vẫn giữ). Event dispatched có metadata.option_id, quoted_price và 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_changed kèm details.old_price, details.new_price và 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ằng option_id mớ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_id cùng provider_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), event scheduled, ticket vẫn pending (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: event dispatched có 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ì assignment rejected/failed, ticket về pending cho CS, kèm cảnh báo Telegram "Chuyến hẹn giờ chưa đặt được xe". Hãng đã ngưng (inactive) thì event schedule_failed và 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_at thì 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): event schedule_failed "Đã quá giờ đón … phút", ticket ở lại pending cho 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ị rejected như 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 metered chư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_codeSố ticketXP-1048
total_amountvehicle.sell_price, không có thì Thực tế theo đồng hồ245.000đ
payment_cardpayment_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ì XPAYThẻ XPAY •••• 4421
trip_routepickup_name → dropoff_nameSATRA Đồng Khởi → Sân bay quốc nội
trip_durationGiờ đó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ới14:06–14:35, 15/10/2026
vehicle_detailsHã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ãngXanh SM VF5 trắng · 51K-123.45
driver_detailsTên tài xế · SĐTNguyễ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 events của ticket.

5. Ánh xạ nút trên drawer​

Nút / khu vực thiết kếHiện khiAPI
"Nhận ticket"claimed_by == null và ticket đang mở§4.3 claim
"Đặt xe"pending / unfulfilled, không có proposal acceptedMở 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á).

HTTPcodeKhi nào
422validation_failedThiếu merchant_id khi tạo, hoặc gửi merchant_id: null / không phải UUID
422merchant_not_foundKhông có merchant XPay với id đó
422merchant_not_activeMerchant đang suspended — details.status
422merchant_requiredSử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:

adapterCách đặtCấu hình
manual (mặc định)CS đặt trên app hãng / gọi hotline, nhập xe tay (§4.4)—
greensmBackend đặ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:

  1. Đăng nhập tài khoản Xanh SM doanh nghiệp trên booking.greensm.com (OTP). Lấy access_token và refresh_token của phiên đó.
  2. 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": "…" } }
  3. 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):

KeyMặc địnhÝ nghĩa
service_idsMọ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_methodbusiness_cardThẻ doanh nghiệp của tài khoản. Không bao giờ tự đổi sang tiền mặt.
insurancetrueThêm bảo hiểm chuyến như web
driver_timeout_sec300Quá 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).
languagevivi / 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_km100Đ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áoMứcKhi 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 SMprovider_stageTicket
FINDINGsearchingdispatching
ASSIGNEDdriver_assignedaccepted → assigned, kèm biển số, tài xế, giá Xanh SM (cost_price)
IN PROCESS, điểm đón ARRIVING / NEAREDdriver_arrivingkhông đổi
IN PROCESS, điểm đón ARRIVEDdriver_arrivedkhông đổi
IN PROCESS, điểm đón COMPLETEDon_boardriding
COMPLETEDcompletedCó 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
CANCELLEDcancelledchư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_eta là ướ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_eta có khi khách lên xe.
  • driver_arrived gầ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_price tự cập nhật, kèm event provider_status có fare / from_fare và fare_breakdown:
    { "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" }] }
    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ì sell_price cũng đổi theo, kèm sell_price / from_sell_price trong event.

Các trường hợp khác:

  • Hết driver_timeout_sec mà vẫn FINDING: huỷ trên Xanh SM rồi rejected.
  • 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_stage quay về searching (event provider_status, from_stage là driver_* → "Tài xế bỏ chuyến, Xanh SM đang tìm tài xế khác"), ticket vẫn assigned. Khách (GET /my/rides, trợ lý) cũng thấy assigned, nhưng vehicle = null (xe cũ không tới nữa) tới khi có tài xế lại — tài xế mới (thêm vehicle_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_stage của xe đang nhận đổi (ride.provider_stage).
  • Xanh SM huỷ đơn sau khi đã có tài xế: rejected (§4.10), reason_code theo cancel_by của đơn — DRIVER → driver_cancelled, còn lại → other. CUSTOMER củ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: manual hoặc greensm. Chưa có endpoint liệt kê adapter, nên FE tự giữ danh sách này (422 unknown_adapter có trả details.available).
  • Form adapter_config của greensm theo 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 = active thì hiện secret_set_at / rotated_at. auth_lost thì báo đỏ "Hết phiên — đăng nhập lại" kèm status_reason.
    • Form chỉ ghi: hai ô access_token và 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 credentials trước, rồi PATCH adapter. Làm ngược thì nhận 422 credentials_required.
HTTPcodeKhi nào
422credentials_requiredBậ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.
422invalid_adapter_configadapter_config sai, message nói rõ key nào
422invalid_credentialsThiếu access_token / refresh_token, hoặc có key lạ
422adapter_takes_no_credentialsPUT …/credentials cho adapter không cần đăng nhập (manual)
503credentials_key_unsetServer 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 fixed có markup: chỉ cần cost_price, sell_price tự tính. CS gửi sell_price thì 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.

HTTPcodeKhi nào
422invalid_markupCó 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​

HTTPcodeKhi nàoFE xử lý
401unauthorizedThiếu/sai tokenĐăng nhập lại
403forbiddenToken không phải system admin—
403operator_requiredclaim/release bằng token không có profile idBáo lỗi cấu hình tài khoản
404not_foundSai id ticket/hãng/assignmentLàm mới danh sách
409claimed_by_otherTicket người khác đang giữ — details.claimed_byKhoá thao tác, hiện người giữ
409invalid_booking_stateThao tác không hợp trạng thái hiện tại — details.statusGET lại ticket, vẽ lại drawer
409invalid_assignment_stateXe/assignment đã bị huỷ hoặc thayGET lại ticket
409provider_inactiveHãng đã ngừngTải lại danh mục hãng
409quote_changedĐặt option mà giá hãng đã tăng (§4.14) — details.old_price, new_price, quoteHiện giá mới, hỏi lại khách, đặt bằng option_id mới trong details.quote
409option_unavailableXe 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
404not_found (option)option_id không phải của ticket nàyBáo giá lại
422place_not_foundBáo giá không tìm thấy địa điểm — details.which = pickup / dropoffHỏi khách địa chỉ cụ thể hơn, báo giá lại với pickup_address / dropoff_address
503customer_lookup_unavailableKhông tra được Store theo SĐT (§4.12)Thử lại; báo kỹ thuật nếu lặp lại
503corporate_lookup_unavailableKhông hỏi được backend corporate khi add thẻ (§4.12)Thử lại sau
422card_not_in_user_corporate, user_not_in_corporateAdd 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
429too_many_pin_attemptsOps sai số thẻ / PIN 5 lần trong 1 giờ khi add thẻ (§4.12)Thử lại sau
422card_pin_invalid, card_has_no_pin, card_not_activeNhánh khách đọc số thẻ + PIN (§4.12)Cho nhập lại; không lưu số thẻ / PIN
409card_locked_too_many_attemptsSai PIN 3 lần — thẻ khoá 15 phút, details.locked_untilBáo khách thử lại sau giờ đó
409payer_is_requesterĐổi người trả của chuyến đặt qua app (§4.12)Chỉ cho đổi thẻ của người đặt
422invalid_payment_cardGắ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
422card_outside_validity_windowThẻ 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
409payment_declinedHoà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
409no_payment_card, payment_card_ambiguous, payment_card_not_foundHoà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
422final_fare_requiredHoàn tất chuyến app mà không có final_fare lẫn giá báo của xeHỏi giá chốt
503payments_unavailableServer chưa nối phần trừ thẻBáo kỹ thuật
422validation_failedThiếu/sai field — xem message hoặc details.errorsBáo lỗi tại bước wizard tương ứng
422seat_downgradeXe ít chỗ hơn yêu cầu — details.requested, details.offeredBáo ở bước "Loại xe"
429rate_limitedGọ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ướcCó 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ánChưa cóẨn
Tab "Chờ thanh toán", trạng thái awaitpay, cờ vàng chờ thanh toán > 30 phútChư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 newChư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ênBackend chỉ có profile idTra Store
Số đếm tabChư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_ORIGINS củ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/events qua thư viện chạy trên fetch (gửi Bearer); tải danh sách khi nhận ready, GET ticket khi nhận booking, tải lại khi resync
  • Hiển thị XP-${ref_no}, "Đã chờ" từ status_changed_at, "Đi ngay" khi pickup_at null
  • 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ãn provider_stage trên card Xe (§2.2); hiện provider_ref và note củ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; event scheduled / schedule_failed trong Lịch sử, schedule_failed là cảnh báo đỏ
  • Event provider_status, vehicle_changed, provider_cancel_failed, charge_failed trong Lịch sử (§2.5); provider_cancel_failed và 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 = completed mà ticket vẫn riding → nhắc "Hoàn tất", bắt nhập final_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ạnh price.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 dispatched có metadata.option_id / quoted_price, actor_type = user → hiện "Khách đặt {label} giá {quoted_price}"; ticket không qua CS
  • Event cancelled có actor_type = user → hiện "Khách tự huỷ: {note}"
  • Event proposal_accepted / proposal_rejected có 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èm map_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