Chuyển tới nội dung chính

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ệcAi
1MCP client gọi POST {CARD_CORE}/mcp/user bằng service token (MCP_USER_S2S_TOKEN) + X-User-Id của người đang chatBackend 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 promptBackend
3Tự sinh request_id (UUID) cho mỗi yêu cầu đặt xe mới ở orchestrator, không để model tự bịa (§3)Backend
4Gọ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
5Nhậ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ý
6Ngườ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ý
7Event 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àyBackend trợ lý
8Nhậ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ý
9Trướ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
10Ngườ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ới ASSISTANT_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 /mcp và /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.

ToolVai trò
quote_ride, request_ride, list_my_rides, get_my_ride, cancel_my_ride, accept_ride_proposal, reject_ride_proposal, list_my_cardsMọ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).

ToolRouteLàm gì
quote_ridePOST /my/rides/quotesBá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_ridePOST /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_ridesGET /my/ridesCác chuyến người dùng đã yêu cầu, mới nhất trước (lọc status)
get_my_rideGET /my/rides/:idTrạng thái một chuyến; khi đã có xe thì có xe, tài xế, giờ đón, provider_stage
cancel_my_ridePOST /my/rides/:id/cancelHuỷ 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_proposalPOST /my/rides/:id/proposal/acceptNgười dùng đồng ý phương án tổng đài gửi (§2.2) — tổng đài đặt đúng xe đó
reject_ride_proposalPOST /my/rides/:id/proposal/rejectNgườ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_cardsGET /my/cardsCá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á​

  1. Người dùng: "đón ở 633 Kinh Dương Vương, đi Mega Bình Phú". Trợ lý gọi quote_ride.
  2. 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 } }
  3. 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.
  4. 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à assigned khi 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_fare củ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).
  5. 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èm details.old_price, details.new_price và details.quote (báo giá mới, có option_id mới). Trợ lý hỏi lại người dùng; không bao giờ tự đặt giá cao hơn.
  6. 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"
}
  1. Card-core báo trợ lý (§5) bằng event có ride.status = awaiting_confirmation và ride.proposal.
  2. 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.
  3. Người dùng đồng ý → accept_ride_proposal { ride_id }: chuyến về pending, proposal.state = "accepted", tổng đài đặt đúng xe đó; sau đó assigned như thường và proposal thành null.
  4. 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ùng cancel_my_ride.
  5. 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.

  1. quote_ride { …, pickup_at }: vẫn có các option xe, và báo giá có thêm book_at = lúc xe sẽ được đặt (pickup_at trừ 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" }
    amount là giá lúc này (giá hãng + markup). max_amount là mức cao nhất khi đặt: giá hãng tăng tối đa 20%, cộng markup. book_at = null nghĩa là giờ đón đã sát: chọn xe là đặt ngay như §2.1, giá là fixed, không có max_amount.
  2. 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_amount thì 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).
  3. request_ride { request_id, option_id, pickup_at } (cùng pickup_at): chuyến trả về status: pending kèm schedule, 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_price là amount / max_amount của option đã chọn. Giá giữ tới expires_at như §2.1: chọn quá hạn mà giá đã tăng thì vẫn 409 quote_changed, kèm báo giá mới cho cùng giờ đón (lại là estimate, cùng book_at). Người dùng đang ở đó để hỏi lại.
  4. Lúc book_at: card-core đặt xe với hãng. Chuyến sang dispatching (event dispatched, §5) rồi assigned khi có tài xế, schedule thành null. Giá khách (quoted_fare) theo giá hãng lúc đó cộng markup.
  5. Không đặt được (giá tăng quá 20%, hết xe, hãng lỗi): chuyến về tổng đài (pending, event rejected hoặc failed), tổng đài sắp xếp xe ngay. Trợ lý báo người dùng tổng đài đang lo.
  6. Huỷ trước book_at bằng cancel_my_ride như 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.

Xem thẻ: list_my_cards (không tham số) trả { data: [card…] }, mỗi thẻ:

TrườngNghĩa
card_idGửi vào request_ride.card_id
display_name, pan_masked, pan_last4Tên công ty đặt cho thẻ, số thẻ đã che — chỉ được hiện 4 số cuối
statusactive mới trả được; inactive (chưa kích hoạt), blocked, expired thì không
accessowner = thẻ của chính họ; linked = thẻ công ty chia sẻ cho họ
validityThờ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
linkChỉ 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.availableTiền đang có trên thẻ (đã trừ khoản đang giữ)
remaining.daily, remaining.monthlyHạn mức còn lại hôm nay / tháng này, null = không có hạn mức
limits.per_transactionTố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áo card.linked (§6.1) — trợ lý báo lại và gọi list_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_ride với đúng request_id cũ: server trả về chuyến đã tạo, không đặt thêm xe.
  • Không dùng lại request_id cho một yêu cầu khác.
  • Một option_id chỉ đặt được một chuyến: gọi lại request_ride với option đã đặt (kể cả khác request_id) thì nhận lại chuyến đã đặt. Muốn đặt thêm chuyến thì quote_ride lạ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
}
statusNói với người dùng
pending, unfulfilledTổ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_confirmationTổ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
completedXong. Đã 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_id là người đã đặt chuyến, request_id là request_id lúc gọi request_ride, ride cù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_id là "" 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ến assigned hoặc riding và hãng đã cấp link; ngoài ra không có field này (không phải null). 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). null khi chưa có xe, hoặc xe tổng đài đặt tay.

  • proposal là phương án tổng đài gửi (§2.2): có khi status = 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_fare là số tiền chuyến đi, chuỗi thập phân VND (vd "245000.00"), có khi chuyến completed; trước đó là null.

  • cancel_reason_code có khi status = 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 ghi cancel_by = CUSTOMER, tức tài khoản doanh nghiệp đặt đơn, không phải người dùng). Không dùng customer_cancelled cho trường hợp này: người dùng không huỷ.

    Mã này về null khi 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ới provider_stage = driver_assigned và vehicle củ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ế (vehicle mớ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ần provider_stage củ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ả.

  • ride là 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_id lớn nhất); trong một chuyến, event_id tă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_id sau 10 giây, 40 giây, 90 giây… (tối đa 1 giờ), bỏ sau 10 lần. Mọi 202 (kể cả duplicate, unknown_ride, stale) coi là xong. 401/422 không gửi lại, card-core ghi log lỗi; owner_mismatch ghi log cảnh báo.

  • Chưa đặt ASSISTANT_ORIGIN và ASSISTANT_INTERNAL_TOKEN thì 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ửi X-User-Id (lời gọi này không nhân danh ai). Sai/thiếu token → 401; chưa đặt MCP_USER_S2S_TOKEN → 500 assistant_auth_unconfigured.
  • user_id là thứ điền vào X-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/user phục vụ.
  • staffs: [] (vẫn 200): 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.
  • phone là E.164, null khi hồ sơ không có số. role, corporate_name có thể null.
  • zalo_oa_user_id thiế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.last4 là 4 số cuối, không bao giờ có số thẻ đầy đủ; null nếu card-core không có 4 số cuối hợp lệ.
  • occurred_at là 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ùng event_id sau 10 giây, 40 giây, 90 giây… (tối đa 1 giờ), bỏ sau 10 lần; mọi 202 coi là xong; 401/422 không gửi lại, card-core ghi log lỗi.
  • Chưa đặt ASSISTANT_ORIGIN và ASSISTANT_INTERNAL_TOKEN thì 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.

codeKhi nàoTrợ 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 StoreLỗ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 activeBáo người dùng chưa dùng được tính năng đặt xe qua trợ lý
contact_phone_requiredNgườ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_failedThiế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ùngGọi list_my_rides để lấy đúng id
upstream_timeout / upstream_unavailableLời gọi không hoàn tấtGọ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 / dropoffHỏ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ừngNhư trên, với các option mới trong details.quote
not_found (404) cho optionoption_id sai hoặc không phải của người dùngGọi quote_ride lại
invalid_booking_state (409) khi trả lời phương ánKhô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ỷ ở đâyChuyế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ã đơnChuyế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 đặtThẻ 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 xeChưa đặt gì. Báo người dùng lý do, đề nghị thẻ khác