feat: expand order integration with order details, fulfillment resolution, and SLA metrics

This commit is contained in:
MOHAN 2026-03-29 17:59:11 +05:30
parent 8fb333918c
commit 6b768bfbf5
12 changed files with 380 additions and 10 deletions

View File

@ -0,0 +1,27 @@
# 06 Order Integration Audit
Source checked: Uber Eats "Order Integration" section shared by you.
## Implemented Now
- Retrieve full order details after webhook:
- `GET /api/v1/uber/orders/{orderId}`
- Core order actions:
- accept / deny / ready / cancel
- Resolve fulfillment issues action:
- `POST /api/v1/uber/orders/{orderId}/action` with `action=resolve`
- SLA metric for 11.5 minute response window:
- `GET /api/v1/metrics/order-response-sla`
## Existing Before
- Order listing by store
- Webhook ingestion for order events
- Injection success metric
## Pending
- Confirmation of exact upstream path for fulfillment-issue resolution endpoint (verify against final API reference)
- Scheduled-order specific business rules and timers
- Courier handoff update endpoint coverage once reference section is shared

View File

@ -5,6 +5,16 @@ Order flow for POS:
- Receive order event
- Fetch full order payload
- Accept/deny
- Resolve fulfillment issues when item(s) cannot be fulfilled
- Ready/handoff updates
- Completion/cancellation reconciliation
Typed routes:
- `GET /api/v1/uber/orders/{orderId}` (order details)
- `POST /api/v1/uber/orders/{orderId}/action` with action:
- `accept`
- `deny`
- `ready`
- `cancel`
- `resolve`

View File

@ -215,6 +215,37 @@
}
}
},
"/api/v1/metrics/order-response-sla": {
"get": {
"summary": "Get response SLA metric for order accept/deny timing",
"tags": [
"Metrics"
],
"parameters": [
{
"in": "query",
"name": "merchantId",
"required": false,
"schema": {
"type": "string"
}
},
{
"in": "query",
"name": "windowDays",
"required": false,
"schema": {
"type": "integer"
}
}
],
"responses": {
"200": {
"description": "SLA metrics"
}
}
}
},
"/api/v1/uber/request": {
"post": {
"summary": "Generic Uber passthrough for any Uber endpoint",
@ -293,6 +324,29 @@
}
}
},
"/api/v1/uber/orders/{orderId}": {
"get": {
"summary": "Retrieve full order details by order ID",
"tags": [
"Uber Orders"
],
"parameters": [
{
"in": "path",
"name": "orderId",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "Order details retrieved"
}
}
}
},
"/api/v1/uber/stores": {
"get": {
"summary": "Retrieve all stores provisioned to developer account",
@ -442,7 +496,7 @@
},
"/api/v1/uber/orders/{orderId}/action": {
"post": {
"summary": "Trigger order action (accept, deny, ready, cancel)",
"summary": "Trigger order action (accept, deny, ready, cancel, resolve)",
"tags": [
"Uber Orders"
],

View File

@ -398,6 +398,89 @@
}
}
},
{
"name": "Get Order Details",
"request": {
"method": "GET",
"header": [
{
"key": "x-api-key",
"value": "{{apiKey}}"
}
],
"url": {
"raw": "{{baseUrl}}/api/v1/uber/orders/{{orderId}}",
"host": [
"{{baseUrl}}"
],
"path": [
"api",
"v1",
"uber",
"orders",
"{{orderId}}"
]
}
}
},
{
"name": "Resolve Fulfillment Issue",
"request": {
"method": "POST",
"header": [
{
"key": "x-api-key",
"value": "{{apiKey}}"
},
{
"key": "Content-Type",
"value": "application/json"
}
],
"body": {
"mode": "raw",
"raw": "{\n \"merchantId\": \"{{merchantId}}\",\n \"action\": \"resolve\",\n \"payload\": {\n \"issues\": []\n }\n}"
},
"url": {
"raw": "{{baseUrl}}/api/v1/uber/orders/{{orderId}}/action",
"host": [
"{{baseUrl}}"
],
"path": [
"api",
"v1",
"uber",
"orders",
"{{orderId}}",
"action"
]
}
}
},
{
"name": "Order Response SLA Metric",
"request": {
"method": "GET",
"header": [
{
"key": "x-api-key",
"value": "{{apiKey}}"
}
],
"url": {
"raw": "{{baseUrl}}/api/v1/metrics/order-response-sla",
"host": [
"{{baseUrl}}"
],
"path": [
"api",
"v1",
"metrics",
"order-response-sla"
]
}
}
},
{
"name": "Set Holiday Hours",
"request": {
@ -615,6 +698,10 @@
{
"key": "storeId",
"value": ""
},
{
"key": "orderId",
"value": ""
}
]
}

View File

@ -7,6 +7,7 @@ module.exports = {
orders: {
list: "/v1/eats/stores/{storeId}/orders",
getById: "/v1/eats/orders/{orderId}",
resolveFulfillmentIssue: "/v1/eats/orders/{orderId}/resolve_fulfillment_issues",
accept: "/v1/eats/orders/{orderId}/accept_pos_order",
deny: "/v1/eats/orders/{orderId}/deny_pos_order",
readyForPickup: "/v1/eats/orders/{orderId}/pos_order_ready_for_pickup",

View File

@ -5,6 +5,14 @@ function nowIso() {
return new Date().toISOString();
}
function extractOrderIdFromPath(uberPath) {
if (!uberPath) {
return null;
}
const match = uberPath.match(/\/orders\/([^/]+)/i);
return match ? decodeURIComponent(match[1]) : null;
}
const merchantRepository = {
upsert({ id, name, externalRef }) {
const timestamp = nowIso();
@ -163,9 +171,11 @@ const webhookRepository = {
const apiLogRepository = {
insert({ merchantId, method, wrapperRoute, uberPath, responseStatus, requestBody, responseBody }) {
const orderId = extractOrderIdFromPath(uberPath);
const row = {
id: uuidv4(),
merchant_id: merchantId || null,
order_id: orderId,
http_method: method,
wrapper_route: wrapperRoute,
uber_path: uberPath,
@ -177,11 +187,11 @@ const apiLogRepository = {
const stmt = db.prepare(`
INSERT INTO api_logs (
id, merchant_id, http_method, wrapper_route, uber_path,
id, merchant_id, order_id, http_method, wrapper_route, uber_path,
response_status, request_json, response_json, created_at
)
VALUES (
@id, @merchant_id, @http_method, @wrapper_route, @uber_path,
@id, @merchant_id, @order_id, @http_method, @wrapper_route, @uber_path,
@response_status, @request_json, @response_json, @created_at
)
`);
@ -227,6 +237,91 @@ const apiLogRepository = {
failed: total - success,
successRate
};
},
getOrderResponseSlaStats({ merchantId, sinceIso }) {
const webhookFilters = [
"provider = 'uber'",
"(event_type = 'orders.notification' OR event_type = 'orders.scheduled.notification')",
"resource_id IS NOT NULL"
];
const webhookParams = [];
if (merchantId) {
webhookFilters.push("merchant_id = ?");
webhookParams.push(merchantId);
}
if (sinceIso) {
webhookFilters.push("received_at >= ?");
webhookParams.push(sinceIso);
}
const webhookRows = db
.prepare(
`
SELECT resource_id, merchant_id, received_at
FROM webhook_events
WHERE ${webhookFilters.join(" AND ")}
ORDER BY received_at ASC
`
)
.all(...webhookParams);
const uniqueOrders = new Map();
webhookRows.forEach((row) => {
if (!uniqueOrders.has(row.resource_id)) {
uniqueOrders.set(row.resource_id, row);
}
});
let withinSla = 0;
let responded = 0;
uniqueOrders.forEach((row, orderId) => {
const action = db
.prepare(
`
SELECT created_at
FROM api_logs
WHERE order_id = ?
AND (
uber_path LIKE '%/accept_pos_order'
OR uber_path LIKE '%/deny_pos_order'
)
AND response_status >= 200
AND response_status < 300
ORDER BY created_at ASC
LIMIT 1
`
)
.get(orderId);
if (!action) {
return;
}
responded += 1;
const receivedAtMs = new Date(row.received_at).getTime();
const actionAtMs = new Date(action.created_at).getTime();
const elapsedMs = actionAtMs - receivedAtMs;
if (!Number.isNaN(elapsedMs) && elapsedMs <= 11.5 * 60 * 1000) {
withinSla += 1;
}
});
const totalNotifications = uniqueOrders.size;
const withinSlaRate =
totalNotifications === 0 ? 0 : Number(((withinSla / totalNotifications) * 100).toFixed(2));
return {
totalNotifications,
responded,
noResponseYet: totalNotifications - responded,
withinSla,
breachedSla: responded - withinSla,
withinSlaRate
};
}
};

View File

@ -65,6 +65,7 @@ function initSchema() {
CREATE TABLE IF NOT EXISTS api_logs (
id TEXT PRIMARY KEY,
merchant_id TEXT,
order_id TEXT,
http_method TEXT NOT NULL,
wrapper_route TEXT NOT NULL,
uber_path TEXT NOT NULL,
@ -115,6 +116,10 @@ function initSchema() {
CREATE UNIQUE INDEX IF NOT EXISTS idx_webhook_events_dedupe_key
ON webhook_events(dedupe_key);
`);
if (!tableHasColumn("api_logs", "order_id")) {
db.exec("ALTER TABLE api_logs ADD COLUMN order_id TEXT");
}
}
module.exports = {

View File

@ -38,7 +38,32 @@ async function getInjectionSuccess(req, res) {
});
}
module.exports = {
getInjectionSuccess
};
async function getOrderResponseSla(req, res) {
const schema = z.object({
merchantId: z.string().optional(),
windowDays: z.string().optional()
});
const query = schema.parse(req.query);
const sinceIso = toSinceIso(query.windowDays);
const stats = apiLogRepository.getOrderResponseSlaStats({
merchantId: query.merchantId,
sinceIso
});
return res.json({
success: true,
data: {
metric: "order_response_within_11_5_minutes",
merchantId: query.merchantId || "all",
windowDays: query.windowDays ? Number(query.windowDays) : "all_time",
targetMinutes: 11.5,
...stats
}
});
}
module.exports = {
getInjectionSuccess,
getOrderResponseSla
};

View File

@ -86,10 +86,17 @@ async function listOrders(req, res) {
return res.json({ success: true, data });
}
async function getOrderById(req, res) {
const data = await proxyService.getOrderById({
orderId: req.params.orderId
});
return res.json({ success: true, data });
}
async function orderAction(req, res) {
const schema = z.object({
merchantId: z.string().min(1),
action: z.enum(["accept", "deny", "ready", "cancel"]),
action: z.enum(["accept", "deny", "ready", "cancel", "resolve"]),
payload: z.any().optional()
});
const parsed = schema.parse(req.body);
@ -226,6 +233,7 @@ module.exports = {
updateMenuItems,
getMenu,
listOrders,
getOrderById,
orderAction,
updateHours,
listProvisionableStores,

View File

@ -177,8 +177,20 @@ async function ordersList({ merchantId, storeId, query }) {
});
}
async function getOrderById({ orderId }) {
const uberPath = interpolatePath(uberEndpoints.orders.getById, { orderId });
return callUberApi({
method: "GET",
uberPath,
wrapperRoute: "/api/v1/uber/orders/:orderId",
authMode: "app",
scopes: AUTH_SCOPES.ORDER
});
}
async function orderAction({ merchantId, orderId, action, payload }) {
const routeMap = {
resolve: uberEndpoints.orders.resolveFulfillmentIssue,
accept: uberEndpoints.orders.accept,
deny: uberEndpoints.orders.deny,
ready: uberEndpoints.orders.readyForPickup,
@ -341,6 +353,7 @@ module.exports = {
menuGet,
updateMenuItems,
ordersList,
getOrderById,
orderAction,
updateStoreHours,
listProvisionableStores,

View File

@ -1,6 +1,9 @@
const express = require("express");
const asyncHandler = require("../middleware/asyncHandler");
const { getInjectionSuccess } = require("../modules/metrics/metrics.controller");
const {
getInjectionSuccess,
getOrderResponseSla
} = require("../modules/metrics/metrics.controller");
const router = express.Router();
@ -28,5 +31,28 @@ const router = express.Router();
*/
router.get("/metrics/injection-success", asyncHandler(getInjectionSuccess));
module.exports = router;
/**
* @openapi
* /api/v1/metrics/order-response-sla:
* get:
* summary: Get response SLA metric for order accept/deny timing
* tags:
* - Metrics
* parameters:
* - in: query
* name: merchantId
* required: false
* schema:
* type: string
* - in: query
* name: windowDays
* required: false
* schema:
* type: integer
* responses:
* 200:
* description: SLA metrics
*/
router.get("/metrics/order-response-sla", asyncHandler(getOrderResponseSla));
module.exports = router;

View File

@ -82,6 +82,25 @@ router.get("/uber/menu", asyncHandler(controller.getMenu));
*/
router.get("/uber/orders", asyncHandler(controller.listOrders));
/**
* @openapi
* /api/v1/uber/orders/{orderId}:
* get:
* summary: Retrieve full order details by order ID
* tags:
* - Uber Orders
* parameters:
* - in: path
* name: orderId
* required: true
* schema:
* type: string
* responses:
* 200:
* description: Order details retrieved
*/
router.get("/uber/orders/:orderId", asyncHandler(controller.getOrderById));
/**
* @openapi
* /api/v1/uber/stores:
@ -203,7 +222,7 @@ router.post("/uber/stores/:storeId/holiday-hours", asyncHandler(controller.setHo
* @openapi
* /api/v1/uber/orders/{orderId}/action:
* post:
* summary: Trigger order action (accept, deny, ready, cancel)
* summary: Trigger order action (accept, deny, ready, cancel, resolve)
* tags:
* - Uber Orders
* parameters: