From 8eb11897a557672bad2a85630ba8cbfc052f3b6c Mon Sep 17 00:00:00 2001 From: MOHAN Date: Sun, 29 Mar 2026 18:01:17 +0530 Subject: [PATCH] feat: add typed retail fulfillment issue and order-ready endpoints --- docs/developer-portal/06-orders.md | 8 +++ .../06-retail-fulfillment-audit.md | 38 +++++++++++ docs/developer-portal/07-webhooks.md | 4 ++ docs/openapi/openapi.json | 46 +++++++++++++ postman/Uber_Wrapper.postman_collection.json | 68 +++++++++++++++++++ src/modules/proxy/proxy.controller.js | 54 +++++++++++++++ src/modules/proxy/proxy.service.js | 26 +++++++ src/routes/proxy.routes.js | 41 +++++++++++ 8 files changed, 285 insertions(+) create mode 100644 docs/developer-portal/06-retail-fulfillment-audit.md diff --git a/docs/developer-portal/06-orders.md b/docs/developer-portal/06-orders.md index b47fd8b..8316d4f 100644 --- a/docs/developer-portal/06-orders.md +++ b/docs/developer-portal/06-orders.md @@ -12,9 +12,17 @@ Order flow for POS: Typed routes: - `GET /api/v1/uber/orders/{orderId}` (order details) +- `POST /api/v1/uber/orders/{orderId}/fulfillment-issues` +- `POST /api/v1/uber/orders/{orderId}/ready` - `POST /api/v1/uber/orders/{orderId}/action` with action: - `accept` - `deny` - `ready` - `cancel` - `resolve` + +Retail fulfillment guidance: + +- Read customer preference (`REPLACE_FOR_ME`, `SUBSTITUTE_ME`, `REMOVE_ITEM`) from order details. +- Update issue states via fulfillment endpoint (`FOUND_ITEM`, `PARTIAL_AVAILABILITY`, `OUT_OF_ITEM`). +- On `orders.fulfillment_issues.resolved` webhook, fetch latest order and continue resolution. diff --git a/docs/developer-portal/06-retail-fulfillment-audit.md b/docs/developer-portal/06-retail-fulfillment-audit.md new file mode 100644 index 0000000..5d0ba60 --- /dev/null +++ b/docs/developer-portal/06-retail-fulfillment-audit.md @@ -0,0 +1,38 @@ +# 06 Retail Fulfillment Audit + +Source checked: Uber Eats "Retail Order Fulfillment API Guide" section shared by you. + +## Implemented Now + +- Dedicated fulfillment issues endpoint: + - `POST /api/v1/uber/orders/{orderId}/fulfillment-issues` +- Supported issue types in validation: + - `FOUND_ITEM` + - `PARTIAL_AVAILABILITY` + - `OUT_OF_ITEM` +- Supported action types in validation: + - `REPLACE_FOR_ME` + - `SUBSTITUTE_ME` + - `REMOVE_ITEM` + - `ALTERNATIVE_ITEM` + - `SUBSTITUTION_REJECTED` +- Dedicated order-ready endpoint: + - `POST /api/v1/uber/orders/{orderId}/ready` +- Existing webhook handling includes: + - `orders.fulfillment_issues.resolved` event ingestion + signature verification + dedupe + +## Existing Before + +- Get order details endpoint and order actions endpoint +- Webhook ingestion and `200` acknowledgement +- Resolve flow available through generic action path + +## Pending + +- Exact payload field-level schema parity for all quantity/weight variants from endpoint reference +- Automated workflow engine: + - fetch order after `orders.fulfillment_issues.resolved` + - inspect `customer_ack_type` + - trigger next fulfillment action automatically +- Explicit support helper for count-vs-weight conversion rules + diff --git a/docs/developer-portal/07-webhooks.md b/docs/developer-portal/07-webhooks.md index b977462..ad03af5 100644 --- a/docs/developer-portal/07-webhooks.md +++ b/docs/developer-portal/07-webhooks.md @@ -25,3 +25,7 @@ Common event types handled: - `store.provisioned` - `store.deprovisioned` - `store.status.changed` + +Retail fulfillment follow-up: + +- On `orders.fulfillment_issues.resolved`, fetch updated order details and inspect customer acknowledgment before next action. diff --git a/docs/openapi/openapi.json b/docs/openapi/openapi.json index ec51f49..1ca3dd8 100644 --- a/docs/openapi/openapi.json +++ b/docs/openapi/openapi.json @@ -347,6 +347,52 @@ } } }, + "/api/v1/uber/orders/{orderId}/fulfillment-issues": { + "post": { + "summary": "Resolve retail fulfillment issues (found/partial/out-of-stock substitutions)", + "tags": [ + "Uber Orders" + ], + "parameters": [ + { + "in": "path", + "name": "orderId", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Fulfillment issue update submitted" + } + } + } + }, + "/api/v1/uber/orders/{orderId}/ready": { + "post": { + "summary": "Mark order ready for handoff/pickup", + "tags": [ + "Uber Orders" + ], + "parameters": [ + { + "in": "path", + "name": "orderId", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Order marked ready" + } + } + } + }, "/api/v1/uber/stores": { "get": { "summary": "Retrieve all stores provisioned to developer account", diff --git a/postman/Uber_Wrapper.postman_collection.json b/postman/Uber_Wrapper.postman_collection.json index 2c74916..f9f04de 100644 --- a/postman/Uber_Wrapper.postman_collection.json +++ b/postman/Uber_Wrapper.postman_collection.json @@ -457,6 +457,74 @@ } } }, + { + "name": "Resolve Fulfillment Issues (Typed)", + "request": { + "method": "POST", + "header": [ + { + "key": "x-api-key", + "value": "{{apiKey}}" + }, + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"issues\": [\n {\n \"cart_item_id\": \"cart_item_1\",\n \"issue_type\": \"OUT_OF_ITEM\",\n \"action_type\": \"REPLACE_FOR_ME\",\n \"item_substitute\": {\n \"id\": \"sub_item_1\"\n }\n }\n ]\n}" + }, + "url": { + "raw": "{{baseUrl}}/api/v1/uber/orders/{{orderId}}/fulfillment-issues", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "api", + "v1", + "uber", + "orders", + "{{orderId}}", + "fulfillment-issues" + ] + } + } + }, + { + "name": "Mark Order Ready", + "request": { + "method": "POST", + "header": [ + { + "key": "x-api-key", + "value": "{{apiKey}}" + }, + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{}" + }, + "url": { + "raw": "{{baseUrl}}/api/v1/uber/orders/{{orderId}}/ready", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "api", + "v1", + "uber", + "orders", + "{{orderId}}", + "ready" + ] + } + } + }, { "name": "Order Response SLA Metric", "request": { diff --git a/src/modules/proxy/proxy.controller.js b/src/modules/proxy/proxy.controller.js index a52394a..ba52d3e 100644 --- a/src/modules/proxy/proxy.controller.js +++ b/src/modules/proxy/proxy.controller.js @@ -110,6 +110,58 @@ async function orderAction(req, res) { return res.json({ success: true, data }); } +async function resolveFulfillmentIssues(req, res) { + const quantityObject = z + .object({ + in_sellable_unit: z.any().optional(), + in_priceable_unit: z.any().optional() + }) + .optional(); + + const fulfillmentIssueSchema = z.object({ + cart_item_id: z.string().optional(), + issue_type: z.enum([ + "FOUND_ITEM", + "PARTIAL_AVAILABILITY", + "OUT_OF_ITEM" + ]), + action_type: z + .enum([ + "REPLACE_FOR_ME", + "SUBSTITUTE_ME", + "REMOVE_ITEM", + "ALTERNATIVE_ITEM", + "SUBSTITUTION_REJECTED" + ]) + .optional(), + item_substitute: z.any().optional(), + item_availability: z + .object({ + items_available: quantityObject + }) + .optional() + }); + + const schema = z.object({ + issues: z.array(fulfillmentIssueSchema).min(1) + }); + const payload = schema.parse(req.body || {}); + + const data = await proxyService.resolveFulfillmentIssues({ + orderId: req.params.orderId, + payload + }); + return res.json({ success: true, data }); +} + +async function markOrderReady(req, res) { + const data = await proxyService.markOrderReady({ + orderId: req.params.orderId, + payload: req.body || {} + }); + return res.json({ success: true, data }); +} + async function updateHours(req, res) { const schema = z.object({ merchantId: z.string().min(1), @@ -235,6 +287,8 @@ module.exports = { listOrders, getOrderById, orderAction, + resolveFulfillmentIssues, + markOrderReady, updateHours, listProvisionableStores, listStores, diff --git a/src/modules/proxy/proxy.service.js b/src/modules/proxy/proxy.service.js index bc5f54b..e04b658 100644 --- a/src/modules/proxy/proxy.service.js +++ b/src/modules/proxy/proxy.service.js @@ -215,6 +215,30 @@ async function orderAction({ merchantId, orderId, action, payload }) { }); } +async function resolveFulfillmentIssues({ orderId, payload }) { + const uberPath = interpolatePath(uberEndpoints.orders.resolveFulfillmentIssue, { orderId }); + return callUberApi({ + method: "POST", + uberPath, + body: payload, + wrapperRoute: "/api/v1/uber/orders/:orderId/fulfillment-issues", + authMode: "app", + scopes: AUTH_SCOPES.ORDER + }); +} + +async function markOrderReady({ orderId, payload }) { + const uberPath = interpolatePath(uberEndpoints.orders.readyForPickup, { orderId }); + return callUberApi({ + method: "POST", + uberPath, + body: payload || {}, + wrapperRoute: "/api/v1/uber/orders/:orderId/ready", + authMode: "app", + scopes: AUTH_SCOPES.ORDER + }); +} + async function updateStoreHours({ merchantId, storeId, payload }) { const uberPath = interpolatePath(uberEndpoints.stores.updateHours, { storeId }); return callUberApi({ @@ -355,6 +379,8 @@ module.exports = { ordersList, getOrderById, orderAction, + resolveFulfillmentIssues, + markOrderReady, updateStoreHours, listProvisionableStores, listStores, diff --git a/src/routes/proxy.routes.js b/src/routes/proxy.routes.js index a8bdc3f..3ce008e 100644 --- a/src/routes/proxy.routes.js +++ b/src/routes/proxy.routes.js @@ -101,6 +101,47 @@ router.get("/uber/orders", asyncHandler(controller.listOrders)); */ router.get("/uber/orders/:orderId", asyncHandler(controller.getOrderById)); +/** + * @openapi + * /api/v1/uber/orders/{orderId}/fulfillment-issues: + * post: + * summary: Resolve retail fulfillment issues (found/partial/out-of-stock substitutions) + * tags: + * - Uber Orders + * parameters: + * - in: path + * name: orderId + * required: true + * schema: + * type: string + * responses: + * 200: + * description: Fulfillment issue update submitted + */ +router.post( + "/uber/orders/:orderId/fulfillment-issues", + asyncHandler(controller.resolveFulfillmentIssues) +); + +/** + * @openapi + * /api/v1/uber/orders/{orderId}/ready: + * post: + * summary: Mark order ready for handoff/pickup + * tags: + * - Uber Orders + * parameters: + * - in: path + * name: orderId + * required: true + * schema: + * type: string + * responses: + * 200: + * description: Order marked ready + */ +router.post("/uber/orders/:orderId/ready", asyncHandler(controller.markOrderReady)); + /** * @openapi * /api/v1/uber/stores: