XPay MCP cho người dùng — đặt xe qua trợ lý AI
Tài liệu dành cho đội xây trợ lý AI trong app XPay (phía người dùng, không phải app
merchant). Phía card-core đã xong endpoint POST /mcp/user với các tool đặt xe trên route
/my/rides và tool xem thẻ trên /my/cards. Trợ lý gọi thay cho người đang chat (X-User-Id): báo giá, cho người dùng xác nhận địa
điểm, chọn xe và thẻ trả tiền, rồi đặt. Xe của hãng tích hợp API (Xanh SM) được đặt ngay; option "Tổng đài sắp
xếp" vào hàng đợi của tổng đài (CS Portal) như trước.
Cùng kiến trúc với MCP merchant (merchant-mcp.md): mỗi tool là một lời
gọi loopback tới đúng route REST, nhân danh chính người dùng đó — trợ lý không làm được gì
nhiều hơn người dùng tự làm trong app. Trợ lý gọi server-to-server bằng service token và
nói rõ đang phục vụ người dùng nào (X-User-Id); card-core chỉ phục vụ nhân viên corporate
đang active và trả bộ tool theo vai trò của họ (§2).
0. Tóm tắt — việc phải làm
| # | Việc | Ai |
|---|---|---|
| 1 | MCP client gọi POST {CARD_CORE}/mcp/user bằng service token (MCP_USER_S2S_TOKEN) + X-User-Id của người đang chat | Backend trợ lý |
| 2 | Đưa tools/list vào LLM; dùng chỉ dẫn server trả về (instructions) làm một phần system prompt | Backend |
| 3 | Tự sinh request_id (UUID) cho mỗi yêu cầu đặt xe mới ở orchestrator, không để model tự bịa (§3) | Backend |
| 4 | Gọi quote_ride, cho người dùng xác nhận địa điểm (address, map_url) và chọn option, rồi mới gọi request_ride với option_id (§2.1) | Backend + app |
| 5 | Nhận POST /internal/v1/ride-events — card-core báo mỗi lần chuyến đổi trạng thái (§5) | Backend trợ lý |
| 6 | Người nhắn qua Zalo OA: tra user_id bằng GET /assistant/staffs?zalo_oa_user_id=… rồi mới điền X-User-Id (§6) | Backend trợ lý |
| 7 | Event có ride.status = awaiting_confirmation: hiện ride.proposal cho người dùng và hỏi; họ trả lời thì gọi accept_ride_proposal / reject_ride_proposal (§2.2). Hiện trợ lý trả ignored_status cho trạng thái này | Backend trợ lý |
| 8 | Nhận POST /internal/v1/card-events — card-core báo khi người dùng link thẻ qua nút "Link thẻ" trong OA (§6.1) | Backend trợ lý |
| 9 | Trước request_ride: list_my_cards, chọn thẻ trả được (hỏi người dùng nếu có nhiều) và gửi card_id (§2.4) | Backend + model |
| 10 | Người dùng muốn link thêm thẻ → gửi nút "Link thẻ" (card_link_invite qua Gateway, card-link-zalo-gateway.md §3). Không bao giờ hỏi / nhận số thẻ hay PIN trong chat (§2.4) | Backend trợ lý + Gateway |
1. Kết nối
POST /mcp/user
Authorization: Bearer <MCP_USER_S2S_TOKEN>
X-User-Id: 4242
Content-Type: application/json
Accept: application/json, text/event-stream
- Service token (
MCP_USER_S2S_TOKEN): một secret dùng chung giữa card-core và backend trợ lý, khác vớiASSISTANT_INTERNAL_TOKEN(chiều ngược lại, §5). Token này nhân danh được bất kỳ người dùng nào: chỉ để ở backend, không bao giờ đưa xuống app hay trình duyệt. Sai hoặc thiếu →401 unauthorized. X-User-Id: id Store (số nguyên) của người đang chat. Card-core tin giá trị này — backend trợ lý phải tự xác thực người dùng (phiên đăng nhập của họ) trước khi điền. Thiếu hoặc sai dạng →400 validation_failed. Gửi trên mọi request, kể cảinitialize.- Store token của người dùng không còn được nhận ở
/mcp/user(→401). - Người dùng phải là nhân viên corporate đang active, nếu không →
403 not_corporate_staff. Kiểm lại ở mỗi request, nên người đã nghỉ mất tool ngay ở lần gọi sau. - Streamable HTTP, stateless: mỗi POST một message JSON-RPC, trả về một JSON. Không có
session, không có stream (
GET/DELETE→405). Không nhận batch (400 batch_not_supported). - Request có header
Origin(từ trình duyệt) →403. - Giới hạn 120 request/phút theo người dùng (không theo token, không theo IP).
- Bật/tắt bằng
MCP_ENABLED(chung cho/mcpvà/mcp/user). Production mặc định tắt.
2. Tool theo vai trò
Vai trò là role trong hồ sơ nhân viên corporate của người dùng (STAFF, ADMIN, OWNER,
ACCOUNTING, BUDGETER, DEPARTMENT_APPROVER, OPERATOR). tools/list trả đúng bộ tool của
vai trò đó; luôn lấy danh sách tool từ tools/list, đừng hard-code.
| Tool | Vai trò |
|---|---|
quote_ride, request_ride, list_my_rides, get_my_ride, cancel_my_ride, accept_ride_proposal, reject_ride_proposal, list_my_cards | Mọi vai trò |
Card-core lấy vai trò từ bản sao hồ sơ nhân viên nó lưu khi người dùng được cấp hoặc chia sẻ thẻ;
đổi vai trò bên corporate thì card-core thấy ở lần đồng bộ sau đó. Chủ corporate (OWNER) không có
hồ sơ nhân viên bên corporate, nên chưa dùng được /mcp/user (→ 403 not_corporate_staff).
| Tool | Route | Làm gì |
|---|---|---|
quote_ride | POST /my/rides/quotes | Báo giá, không đặt gì: đặt điểm đón/trả lên bản đồ, các xe có thể gửi ngay kèm giá và giờ đón, và option "Tổng đài sắp xếp" |
request_ride | POST /my/rides | Đặt xe: theo option_id người dùng chọn, hoặc gửi tổng đài như trước |
list_my_rides | GET /my/rides | Các chuyến người dùng đã yêu cầu, mới nhất trước (lọc status) |
get_my_ride | GET /my/rides/:id | Trạng thái một chuyến; khi đã có xe thì có xe, tài xế, giờ đón, provider_stage |
cancel_my_ride | POST /my/rides/:id/cancel | Huỷ chuyến, được tới khi khách lên xe, và chỉ khi lệnh huỷ 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), hoặc cuốc của tài xế XPay. Tổng đài đang đặt tay thì không huỷ được |
accept_ride_proposal | POST /my/rides/:id/proposal/accept | Người dùng đồng ý phương án tổng đài gửi (§2.2) — tổng đài đặt đúng xe đó |
reject_ride_proposal | POST /my/rides/:id/proposal/reject | Người dùng không lấy phương án đó (reason: muốn gì thay) — tổng đài tìm phương án khác. Không huỷ chuyến |
list_my_cards | GET /my/cards | Các thẻ người dùng đang có — thẻ của mình và thẻ công ty chia sẻ cho họ — kèm số dư, hạn mức, và thẻ chia sẻ dùng được lúc nào (§2.4) |
quote_ride — tham số: pickup_name (bắt buộc), pickup_address, pickup_lat/pickup_lng
(nếu người dùng chia sẻ vị trí: có toạ độ thì không tra địa chỉ), dropoff_name, dropoff_address,
dropoff_lat/dropoff_lng, seats (mặc định 4), pickup_at (hẹn giờ: vẫn báo giá xe theo giá
lúc này, xe được tự đặt trước giờ đón 2 phút — §2.3).
request_ride — tham số: request_id (UUID, bắt buộc), option_id (option người dùng chọn;
khi có thì điểm đón/trả/số chỗ lấy theo option, các trường địa điểm bị bỏ qua), hoặc (không có
option) pickup_name (bắt buộc), pickup_address, pickup_lat/pickup_lng, dropoff_name,
dropoff_address, dropoff_lat/dropoff_lng, seats (mặc định 4); và pickup_at (ISO có
+07:00; bỏ trống = đi ngay; có option_id thì gửi đúng giờ đã đưa cho quote_ride), note (gửi
tài xế), passenger_name + passenger_phone (đặt hộ), contact_phone (chỉ khi server báo
contact_phone_required), card_id (thẻ trả tiền, lấy từ list_my_cards — §2.4).
2.1 Luồng đặt xe có báo giá
- Người dùng: "đón ở 633 Kinh Dương Vương, đi Mega Bình Phú". Trợ lý gọi
quote_ride. - Kết quả có
pickup/dropoff(địa chỉ đầy đủ,lat/lng,map_url),alternatives(chỗ khác cùng tên) vàoptions, rẻ nhất trước:{ "option_id": "…", "kind": "provider", "provider": { "name": "Xanh SM" }, "label": "Green Car - 4 seater","seats": 4, "price": { "kind": "fixed", "amount": "55000.00", "currency": "VND" },"pickup_eta_min": 5, "expires_at": "…" }{ "option_id": "…", "kind": "hotline", "label": "Tổng đài sắp xếp", "price": { "kind": "on_request", "amount": null } } - Trợ lý đọc lại địa điểm và các option, rồi hỏi người dùng. Địa điểm sai thì dùng
alternatives, hoặc hỏi địa chỉ cụ thể hơn, và báo giá lại. - Người dùng chọn →
request_ride { request_id, option_id, note? }.- Option xe: đặt ngay trên hãng, chuyến trả về
status: dispatching, sau đó làassignedkhi có tài xế. Giá là giá khách trả (giá hãng cộng markup của hãng, đã làm tròn), và làquoted_farecủa chuyến. Khi hãng hoàn tất, thẻ bị trừ theo giá cuối của hãng cộng markup, nên có thể khác giá báo nếu tài xế thêm phụ phí. - Option "Tổng đài sắp xếp": vào hàng đợi tổng đài như trước (
pending).
- Option xe: đặt ngay trên hãng, chuyến trả về
- Giá giữ tới
expires_at(khoảng 5 phút với Xanh SM). Đặt sau đó thì server báo giá lại:- bằng hoặc rẻ hơn: đặt luôn với giá mới;
- đắt hơn:
409 quote_changed, kèmdetails.old_price,details.new_pricevàdetails.quote(báo giá mới, cóoption_idmới). Trợ lý hỏi lại người dùng; không bao giờ tự đặt giá cao hơn.
- Option xe đặt bên hãng mà thất bại (không có tài xế, hãng từ chối): chuyến chuyển về tổng đài
(
pending), tổng đài sắp xếp tiếp.
2.2 Tổng đài gửi phương án — người dùng xác nhận
Với chuyến vào hàng đợi tổng đài, CS có thể gửi người dùng một phương án để xác nhận trước khi
đặt (vd hết xe 4 chỗ, đổi sang xe 7 chỗ giá khác). Chuyến chuyển awaiting_confirmation và có
proposal:
"proposal": {
"state": "sent",
"provider_name": "Xanh SM",
"seats": 7,
"sell_price": "620000",
"pickup_eta": "2026-10-12T03:15:00.000Z",
"note": "Không còn xe 4 chỗ gần, em điều xe 7 chỗ",
"sent_at": "2026-10-12T03:02:00.000Z"
}
- Card-core báo trợ lý (§5) bằng event có
ride.status = awaiting_confirmationvàride.proposal. - Trợ lý đọc phương án cho người dùng (hãng, số chỗ, giá, giờ đón, ghi chú của tổng đài) và hỏi.
- Người dùng đồng ý →
accept_ride_proposal { ride_id }: chuyến vềpending,proposal.state = "accepted", tổng đài đặt đúng xe đó; sau đóassignednhư thường vàproposalthànhnull. - Người dùng không đồng ý →
reject_ride_proposal { ride_id, reason? }: chuyến vềpending,proposal = null, tổng đài tìm phương án khác. Muốn huỷ hẳn thì dùngcancel_my_ride. - Chỉ gọi khi người dùng đã trả lời rõ ràng — không tự đồng ý thay người dùng.
CS cũng có thể ghi nhận câu trả lời thay người dùng (khách trả lời qua điện thoại), nên khi trợ lý
gọi, phương án có thể đã được trả lời → 409 invalid_booking_state; gọi get_my_ride để xem chuyến
đang ở đâu. sell_price với taxi đồng hồ chỉ là ước tính.
2.3 Chuyến hẹn giờ
Xanh SM qua API chỉ đặt đi ngay, nên với chuyến hẹn giờ card-core giữ option người dùng chọn và tự đặt xe trước giờ đón 2 phút.
quote_ride { …, pickup_at }: vẫn có các option xe, và báo giá có thêmbook_at= lúc xe sẽ được đặt (pickup_attrừ 2 phút). Giá mỗi xe là ước tính kèm mức tối đa:"price": { "kind": "estimate", "amount": "55000", "max_amount": "66000", "currency": "VND" }amountlà giá lúc này (giá hãng + markup).max_amountlà mức cao nhất khi đặt: giá hãng tăng tối đa 20%, cộng markup.book_at = nullnghĩa là giờ đón đã sát: chọn xe là đặt ngay như §2.1, giá làfixed, không cómax_amount.- Trợ lý đọc cả hai số trước khi người dùng chọn, vd "khoảng 55.000đ, lúc đặt tối đa 66.000đ,
chưa gồm phụ phí phát sinh trong chuyến". Giá lúc đặt cao hơn
max_amountthì tổng đài sắp xếp xe. Phụ phí tài xế thêm khi chạy vẫn tính như mọi chuyến (thẻ bị trừ giá cuối hãng + markup). request_ride { request_id, option_id, pickup_at }(cùngpickup_at): chuyến trả vềstatus: pendingkèmschedule, chưa có xe:"schedule": { "provider_name": "Xanh SM", "label": "Green Car - 4 seater", "seats": 4,"price": "55000", "max_price": "66000", "book_at": "2026-10-24T00:58:00.000Z" }price/max_pricelàamount/max_amountcủa option đã chọn. Giá giữ tớiexpires_atnhư §2.1: chọn quá hạn mà giá đã tăng thì vẫn409 quote_changed, kèm báo giá mới cho cùng giờ đón (lại làestimate, cùngbook_at). Người dùng đang ở đó để hỏi lại.- Lúc
book_at: card-core đặt xe với hãng. Chuyến sangdispatching(eventdispatched, §5) rồiassignedkhi có tài xế,schedulethànhnull. Giá khách (quoted_fare) theo giá hãng lúc đó cộng markup. - Không đặt được (giá tăng quá 20%, hết xe, hãng lỗi): chuyến về tổng đài (
pending, eventrejectedhoặcfailed), tổng đài sắp xếp xe ngay. Trợ lý báo người dùng tổng đài đang lo. - Huỷ trước
book_atbằngcancel_my_ridenhư thường (chưa có gì bên hãng để huỷ).
Danh tính lấy từ hồ sơ, không từ tham số: SĐT để tổng đài gọi lại, tên, công ty lấy từ hồ sơ nhân viên của người dùng (dữ liệu corporate). Tool không có tham số nào để đổi các thông tin này; tham số lạ bị bỏ qua.
2.4 Thẻ của người dùng — thẻ nào dùng được, link thêm thẻ
Xem thẻ: list_my_cards (không tham số) trả { data: [card…] }, mỗi thẻ:
| Trường | Nghĩa |
|---|---|
card_id | Gửi vào request_ride.card_id |
display_name, pan_masked, pan_last4 | Tên công ty đặt cho thẻ, số thẻ đã che — chỉ được hiện 4 số cuối |
status | active mới trả được; inactive (chưa kích hoạt), blocked, expired thì không |
access | owner = thẻ của chính họ; linked = thẻ công ty chia sẻ cho họ |
validity | Thời gian công ty cho thẻ được dùng, áp cho mọi người dùng thẻ: status (active / not_started / expired) và windows. windows rỗng = không giới hạn |
link | Chỉ thẻ linked, khi công ty giới hạn thời gian dùng: status (active / not_started / expired) và windows (các khoảng valid_from–valid_until). null hoặc không có = không giới hạn |
balance.available | Tiền đang có trên thẻ (đã trừ khoản đang giữ) |
remaining.daily, remaining.monthly | Hạn mức còn lại hôm nay / tháng này, null = không có hạn mức |
limits.per_transaction | Tối đa một lần thanh toán |
Thẻ trả được khi status = active và validity.status = active và (link là null/không có
hoặc link.status = active). validity là hạn của chính thẻ (ai dùng cũng vậy); link là hạn riêng của
người dùng này với thẻ được chia sẻ — phải thoả cả hai. Thẻ ngoài thời gian cho phép vẫn nằm trong danh sách
(để người dùng biết mình có thẻ đó) nhưng không trả được — model nói rõ "thẻ •••• 4421 chỉ dùng được từ …",
lấy từ windows. Số tiền tối đa tiêu được lúc
này là số nhỏ nhất trong balance.available, remaining.daily, remaining.monthly.
Chọn thẻ khi đặt xe: gọi list_my_cards trước request_ride. Một thẻ trả được → gửi card_id
của nó; nhiều thẻ → hỏi người dùng (theo display_name + 4 số cuối); không có thẻ nào → báo người dùng,
chuyến không trừ thẻ qua trợ lý được. Bỏ trống card_id thì lúc hoàn tất chuyến card-core trừ thẻ
active duy nhất của người dùng; có nhiều thẻ thì không trừ được (payment_card_ambiguous) và tổng đài
phải xử lý — nên luôn gửi card_id.
Link thêm thẻ: được — người dùng link được mọi thẻ của công ty mình mà họ đang cầm, không giới
hạn số thẻ; cần số thẻ (hoặc mã QR in trên thẻ) và PIN in kèm thẻ. Thẻ của công ty khác bị từ chối
(409 staff_in_another_corporate).
- Không có tool link thẻ qua MCP, và model không bao giờ hỏi, nhắc lại hay nhận số thẻ / PIN trong chat: PIN in trên thẻ là thứ dùng để trả tiền tại quầy, lộ trong lịch sử chat Zalo hay log của LLM là lộ thẻ.
- Người dùng muốn link thẻ → backend trợ lý báo Gateway gửi nút "Link thẻ" (
card_link_invite, card-link-zalo-gateway.md §3). Người dùng nhập số thẻ + PIN trên trang đó; xong card-core báocard.linked(§6.1) — trợ lý báo lại và gọilist_my_cardsđể thấy thẻ mới. - Trong app XPay: màn "Link thẻ" (
POST /my/cards/link).
3. request_id — chống đặt trùng
- Mỗi yêu cầu đặt xe mới = một UUID mới, do orchestrator sinh.
- Lỗi mạng / timeout → gọi lại
request_ridevới đúngrequest_idcũ: server trả về chuyến đã tạo, không đặt thêm xe. - Không dùng lại
request_idcho một yêu cầu khác. - Một
option_idchỉ đặt được một chuyến: gọi lạirequest_ridevới option đã đặt (kể cả khácrequest_id) thì nhận lại chuyến đã đặt. Muốn đặt thêm chuyến thìquote_ridelại.
4. Kết quả
{
"id": "2b1e…",
"ref": "XP-1048",
"status": "assigned",
"pickup_name": "SATRA Đồng Khởi",
"dropoff_name": "Sân bay Tân Sơn Nhất",
"pickup_at": null,
"seats": 7,
"vehicle": {
"provider_name": "Xanh SM",
"vehicle_plate": "51K-123.45",
"vehicle_model": "VinFast VF8",
"vehicle_color": "Trắng",
"driver_name": "Minh",
"driver_phone": "+84901234567",
"pickup_eta": "2026-10-12T03:08:00.000Z",
"arrive_eta": null
},
"quoted_fare": "620000.00",
"provider_stage": "driver_arriving",
"final_fare": null,
"payment_card_id": null,
"paid_auth_id": null
}
status | Nói với người dùng |
|---|---|
pending, unfulfilled | Tổng đài đang tìm xe — chưa có xe. pending mà có schedule: chuyến hẹn giờ, hệ thống sẽ tự đặt schedule.label lúc schedule.book_at (§2.3). pending mà trước đó đã có xe: mất xe, chuyến về tổng đài; cancel_reason_code nói vì sao |
awaiting_confirmation | Tổng đài gửi một phương án, chờ người dùng xác nhận (proposal, §2.2) — chưa có xe |
dispatching | Đang đặt xe với hãng (option xe vừa chọn, hoặc tổng đài đã gửi hãng) — chưa có xe |
assigned | Đã có xe: biển số, tài xế, giờ tới đón. Riêng provider_stage = searching (vehicle = null): tài xế đã bỏ chuyến, hãng đang tìm tài xế khác trên cùng đơn — chưa có xe; có tài xế (kể cả tài xế cũ) thì vehicle có lại |
riding | Đang trên xe |
completed | Xong. Đã trừ final_fare vào thẻ của người dùng (paid_auth_id = giao dịch, xem /my/payments) |
cancelled | Đã huỷ (cancel_reason_code) |
Thanh toán: khi tổng đài hoàn tất chuyến, backend tự trừ tiền vào thẻ XCard của người dùng,
người dùng không phải làm gì. Thẻ bị trừ là payment_card_id — thẻ trợ lý gửi qua request_ride.card_id
(§2.4); không gửi thì là thẻ active duy nhất người dùng dùng được. Nếu thẻ bị từ chối, tổng đài xử lý
với người dùng.
provider_stage (chỉ có khi xe đặt qua API hãng, vd Xanh SM): chuyến đang ở bước nào theo hãng —
searching (đang tìm tài xế) · driver_assigned · driver_arriving (tài xế đang tới) ·
driver_arrived · on_board (đã lên xe) · completed (hãng xong, chờ tổng đài chốt và trừ thẻ) ·
cancelled; null với xe tổng đài đặt tay. quoted_fare là giá người dùng đã đồng ý ở option;
final_fare có thể khác nếu hãng tính thêm phụ phí và tổng đài chốt theo giá đó.
proposal: phương án tổng đài gửi chờ người dùng xác nhận (§2.2); null khi không có, và
khi đã có xe.
ref (vd XP-1048) là mã người dùng đọc cho hotline khi cần. Kết quả không chứa thông tin
nội bộ (giá mua, tổng đài viên xử lý, ghi chú nội bộ).
Không cần hỏi lại theo chu kỳ để biết đã có xe: card-core tự báo cho trợ lý (§5). get_my_ride
vẫn dùng khi người dùng hỏi.
5. Card-core báo trạng thái chuyến
Mỗi lần một chuyến đặt qua /my/rides đổi trạng thái, đổi xe, hoặc xe của hãng đổi bước
(provider_stage), card-core gọi trợ lý:
POST {ASSISTANT_ORIGIN}/internal/v1/ride-events
X-Internal-Token: <ASSISTANT_INTERNAL_TOKEN>
Content-Type: application/json
{
"type": "ride.status_changed",
"event_id": "9123",
"occurred_at": "2026-10-15T08:31:30.309Z",
"user_id": "4242",
"corporate_id": "corp_123",
"request_id": "6f1c2a9e-3b4d-4e5f-8a7b-9c0d1e2f3a4b",
"ride": { "id": "2b1e…", "ref": "XP-1048", "status": "assigned", "pickup_name": "SATRA Đồng Khởi",
"dropoff_name": "Sân bay Tân Sơn Nhất", "pickup_at": null, "seats": 7, "final_fare": null,
"vehicle": { "provider_name": "Xanh SM", "vehicle_plate": "51K-123.45", "…": "…" },
"proposal": null, "cancel_reason_code": null, "provider_stage": "driver_arriving",
"tracking_url": "https://tracking.greensm.com/vnm-vi/uFDVgU" }
}
-
user_idlà người đã đặt chuyến,request_idlàrequest_idlúc gọirequest_ride,ridecùng dạng với kết quảget_my_ride(§4), chỉ gồm các field trên — trừtracking_url, chỉ có ở đây.corporate_idlà""khi người dùng không thuộc công ty nào. -
tracking_url: link theo dõi xe trực tiếp trên trang của hãng (Xanh SM:https://tracking.greensm.com/…). Chỉ có khi chuyếnassignedhoặcridingvà hãng đã cấp link; ngoài ra không có field này (không phảinull). Thường có ngay từ sự kiện báo có xe; nếu lúc đó hãng chưa cấp được thì có thể có ở sự kiện sau của chuyến, hoặc không có (card-core chỉ hỏi hãng vài lần). -
provider_stage: xe đặt qua API hãng (Xanh SM) đang ở bước nào theo hãng —searching(đang tìm tài xế, kể cả tìm lại khi tài xế bỏ chuyến) ·driver_assigned·driver_arriving·driver_arrived·on_board·completed·cancelled(§4).nullkhi chưa có xe, hoặc xe tổng đài đặt tay. -
proposallà phương án tổng đài gửi (§2.2): có khistatus = awaiting_confirmation(state: "sent") — trợ lý cần hỏi người dùng — và sau khi họ đồng ý (state: "accepted") tới khi có xe. -
final_farelà số tiền chuyến đi, chuỗi thập phân VND (vd"245000.00"), có khi chuyếncompleted; trước đó lànull. -
cancel_reason_codecó khistatus = cancelled:customer_cancelled(người dùng tự huỷ, hoặc admin công ty / hotline huỷ theo yêu cầu khách),driver_cancelled(tài xế / hãng huỷ),no_car(không có xe),duplicate(trùng yêu cầu khác của người dùng),other. Chỉ có mã — không kèm ghi chú của tổng đài hay phí huỷ. Chuyến đang có xe mà mất xe, về tổng đài xếp xe khác (assigned→pending), cũng có mã, cho biết vì sao:driver_cancelled: tài xế huỷ — tổng đài báo, hoặc đơn bên hãng bị huỷ bởi tài xế.other: đơn bên hãng bị huỷ không phải do tài xế — vd. bị huỷ tay trên hệ thống hãng (Xanh SM ghicancel_by = CUSTOMER, tức tài khoản doanh nghiệp đặt đơn, không phải người dùng). Không dùngcustomer_cancelledcho trường hợp này: người dùng không huỷ.
Mã này về
nullkhi có xe lại. Còn lại lànull. -
Tài xế bỏ chuyến mà hãng tự tìm tài xế khác trên cùng đơn (Xanh SM): chuyến vẫn
assigned,provider_stage = searching,vehicle = null— tài xế cũ không tới nữa, không đưa SĐT của họ cho người dùng. Báo người dùng tài xế đã huỷ, đang tìm tài xế khác. Có tài xế (mới, hoặc chính tài xế cũ nhận lại) thì gửi tiếp vớiprovider_stage = driver_assignedvàvehiclecủa tài xế đó. -
Gửi khi: tổng đài gửi phương án, khách trả lời phương án, gửi cuốc cho hãng / tài xế (kể cả khi người dùng đặt bằng option), có xe, hãng đổi xe / tài xế (
vehiclemới), tài xế huỷ, không còn hãng nào, đón khách, hoàn tất, huỷ (cả khi người dùng tự huỷ), và mỗi lầnprovider_stagecủa xe đang nhận đổi — gồm tài xế bỏ chuyến, hãng tìm tài xế khác (searching) và có tài xế lại (kể cả tài xế cũ). Chỉ trạng thái của hãng đổi mà bước không đổi, hay chỉ giá / giờ dự kiến đổi, thì không gửi. Không gửi khi tổng đài nhận / nhả ticket, gắn người trả. -
ridelà chuyến lúc gửi, không phải lúc xảy ra. Nhiều thay đổi của cùng một chuyến còn chờ gửi thì chỉ gửi cái mới nhất (event_idlớn nhất); trong một chuyến,event_idtăng dần theo thứ tự thay đổi. -
Ít nhất một lần: mỗi thay đổi được ghi vào hàng đợi cùng transaction với chính thay đổi đó, rồi gửi trong vài giây. Timeout hoặc 5xx thì gửi lại cùng
event_idsau 10 giây, 40 giây, 90 giây… (tối đa 1 giờ), bỏ sau 10 lần. Mọi202(kể cảduplicate,unknown_ride,stale) coi là xong.401/422không gửi lại, card-core ghi log lỗi;owner_mismatchghi log cảnh báo. -
Chưa đặt
ASSISTANT_ORIGINvàASSISTANT_INTERNAL_TOKENthì không gửi gì.
6. Người nhắn qua Zalo OA — tra user_id
Trên Zalo, trợ lý chỉ biết Zalo user id của người đang nhắn, chưa biết họ là ai trong XPay. Card-core trả lời câu đó:
GET /assistant/staffs?zalo_oa_user_id=8414426538466712345
Authorization: Bearer <MCP_USER_S2S_TOKEN>
{
"zalo_oa_user_id": "8414426538466712345",
"staffs": [
{
"user_id": "4242",
"corporate_id": "corp_123",
"corporate_name": "Acme",
"role": "STAFF",
"first_name": "Tuấn",
"last_name": "Lê Hoàng",
"phone": "+84937828401"
}
]
}
- Cùng service token với
/mcp/user, không gửiX-User-Id(lời gọi này không nhân danh ai). Sai/thiếu token →401; chưa đặtMCP_USER_S2S_TOKEN→500 assistant_auth_unconfigured. user_idlà thứ điền vàoX-User-Idở/mcp/user(§1).- Zalo id được gắn vào hồ sơ khi người dùng link thẻ qua nút "Link thẻ" trong OA: Gateway ký Zalo id vào link, card-core lưu sau khi thẻ được xác thực bằng PIN (card-link-zalo-gateway.md). Không ai tự khai được Zalo id của người khác; chỉ khi chính chủ chuyển tiếp link còn hạn (15 phút) thì người nhận link mới gắn được Zalo id đó vào tài khoản của họ.
- Chỉ trả nhân viên đang active — đúng tập người
/mcp/userphục vụ. staffs: [](vẫn200): Zalo user này chưa link thẻ, hoặc không còn là nhân viên active → mời họ link thẻ (Gateway gửi nút "Link thẻ").- Hơn một phần tử: cùng một Zalo đã link thẻ cho nhiều tài khoản XPay → hỏi người dùng đang dùng tài khoản nào (theo tên công ty / SĐT), đừng tự chọn.
phonelà E.164,nullkhi hồ sơ không có số.role,corporate_namecó thểnull.zalo_oa_user_idthiếu, rỗng hoặc dài quá 64 ký tự →422 validation_failed.- Giới hạn 120 request/phút theo Zalo user. Request có header
Origin→403. - Không phụ thuộc
MCP_ENABLED.
6.1 Card-core báo thẻ đã liên kết Zalo
Người dùng link thẻ qua nút "Link thẻ" trong OA (POST /card-link/cards có zalo_token) thành công
thì card-core gọi trợ lý để trợ lý báo lại trên Zalo:
POST {ASSISTANT_ORIGIN}/internal/v1/card-events
X-Internal-Token: <ASSISTANT_INTERNAL_TOKEN>
Content-Type: application/json
{
"type": "card.linked",
"event_id": "5b0e9f7a-1c2d-4e3f-8a9b-0c1d2e3f4a5b",
"occurred_at": "2026-10-22T03:15:00.000Z",
"user_id": "4242",
"corporate_id": "corp_123",
"zalo_oa_user_id": "8414426538466712345",
"card": { "last4": "4421" }
}
- Gửi sau khi thẻ đã được link vào tài khoản, kể cả thẻ người dùng đang giữ sẵn. Link không kèm
zalo_token(app,POST /my/cards/link) thì không gửi. - Một event cho mỗi cặp link Zalo + thẻ: gửi lại cùng link cho cùng thẻ (mất mạng, bấm lại) không tạo event mới; link thẻ thứ hai bằng cùng link thì có event riêng.
card.last4là 4 số cuối, không bao giờ có số thẻ đầy đủ;nullnếu card-core không có 4 số cuối hợp lệ.occurred_atlà lúc thẻ được link. Gửi lại giống §5: timeout hoặc 5xx (kể cả503 card_events_disabled) thì gửi lại cùngevent_idsau 10 giây, 40 giây, 90 giây… (tối đa 1 giờ), bỏ sau 10 lần; mọi202coi là xong;401/422không gửi lại, card-core ghi log lỗi.- Chưa đặt
ASSISTANT_ORIGINvàASSISTANT_INTERNAL_TOKENthì không gửi gì.
7. Lỗi
Lỗi trả về dạng tool error (isError: true), nội dung { "error": { http_status, code, message, details? } }
— model đọc được và tự sửa.
code | Khi nào | Trợ lý làm gì |
|---|---|---|
unauthorized (401) | Service token sai/thiếu (HTTP, không phải tool error) | Lỗi cấu hình — không cho model gọi lại |
validation_failed (400, HTTP) | X-User-Id thiếu hoặc không phải id Store | Lỗi của backend trợ lý |
not_corporate_staff (403, HTTP) | Người dùng không phải nhân viên corporate đang active | Báo người dùng chưa dùng được tính năng đặt xe qua trợ lý |
contact_phone_required | Người dùng chưa có SĐT trong hồ sơ | Hỏi SĐT liên hệ, gọi lại với contact_phone (cùng request_id) |
validation_failed | Thiếu/sai tham số (message nói rõ) | Hỏi lại người dùng phần còn thiếu |
not_found (404) | Chuyến không tồn tại hoặc không phải của người dùng | Gọi list_my_rides để lấy đúng id |
upstream_timeout / upstream_unavailable | Lời gọi không hoàn tất | Gọi lại y nguyên (cùng request_id) |
place_not_found (422) | quote_ride không tìm thấy địa điểm, details.which = pickup / dropoff | Hỏi địa chỉ cụ thể hơn (số nhà, đường, quận) |
quote_changed (409) | Giá option đã tăng khi đặt (§2.1) | Đọc giá mới trong details.quote, hỏi lại người dùng, đặt bằng option_id mới |
option_unavailable (409) | Xe đó không còn, hoặc hãng ngừng | Như trên, với các option mới trong details.quote |
not_found (404) cho option | option_id sai hoặc không phải của người dùng | Gọi quote_ride lại |
invalid_booking_state (409) khi trả lời phương án | Không còn phương án nào chờ: đã được trả lời (CS ghi thay người dùng), hoặc chuyến đã huỷ | Gọi get_my_ride, báo người dùng chuyến đang ở đâu |
invalid_booking_state (409) khi huỷ | Khách đã lên xe, hoặc chuyến đã xong / đã huỷ | Báo người dùng gọi tổng đài |
cancel_by_hotline (409) | Tổng đài đang đặt xe tay với một hãng (gọi điện, nhập tay), hoặc đang xử lý chuyến mà chưa nhập xe (details.claimed) — hãng sẽ không biết nếu huỷ ở đây | Chuyến chưa huỷ. Báo người dùng gọi tổng đài để huỷ |
provider_cancel_failed (502) | Hãng (Xanh SM) không nhận lệnh huỷ — details.provider_ref là mã đơn | Chuyến chưa huỷ, xe có thể vẫn tới. Thử lại một lần; vẫn lỗi thì báo người dùng gọi tổng đài |
invalid_payment_card (422) | request_ride.card_id không phải thẻ người dùng trả được lúc này (không phải của họ, chưa active, ngoài hạn của thẻ — validity — hoặc ngoài hạn link) | Gọi list_my_cards, hỏi lại người dùng chọn thẻ |
payment_declined (409) khi đặt | Thẻ không giữ được giá chuyến (details.reason: insufficient_funds, daily_limit_exceeded…) — có từ khi card-core giữ tiền lúc đặt xe | Chưa đặt gì. Báo người dùng lý do, đề nghị thẻ khác |