Files
ERP_Distribution/sharedDocs/BACKEND_API_REFERENCE.md
Kalana B. Thilakarathna bb33928661 auth clean up
2026-08-25 16:11:37 +05:30

76 KiB

Distribution ERP — Backend API Reference

Stack: ASP.NET Core (.NET 9) · MySQL (Entity Framework Core)
Base URL: https://<host> (configure per environment)
Swagger UI: available at /swagger in Development mode
Content-Type: application/json for all requests unless noted (multipart/form-data for KYC uploads)


Table of Contents

  1. Authentication & JWT
  2. Authorization Model
  3. Global Response Conventions
  4. Enumerations Reference
  5. Pagination
  6. Modules
  7. Business Flows

1. Authentication

⚠️ YET TO IMPLEMENT

Authentication is handled by a dedicated external Auth Service (separate microservice). This backend acts as a connector/proxy — the Next.js frontend never calls the Auth Service directly; all auth traffic flows through this backend's /api/auth/* endpoints.

The exact contract (token format, claims, endpoints, session strategy) is not yet finalized. The /api/auth routes are scaffolded in the backend as pass-through stubs pending Auth Service integration.

What is decided:

  • The frontend calls this backend for all auth operations, not the Auth Service directly.
  • This backend forwards the request to the Auth Service and relays the response.
  • All other endpoints on this backend will validate whatever credential the Auth Service issues (token format TBD).

Do not implement auth in the frontend until this section is updated.


2. Authorization Model

The system uses two orthogonal authorization concepts:

2.1 Permission-Based Access Control

Every protected endpoint is guarded by a policy string (e.g. "orders.view"). The JWT's role_id claim is used to look up the role's granted permissions in the database. The frontend should gracefully hide/disable UI elements the user lacks permission for (use the /api/authorization/permissions/{code} endpoint to check at runtime).

Full Permission Code List

Permission Code What it Controls
items.view Read items, brands, UOMs
items.create Create items, brands, UOMs; also gate for Sync endpoints
items.update Update items, brands, UOMs
items.delete Delete items, brands, UOMs
categories.view Read categories/subcategories
categories.create Create categories/subcategories
categories.update Update categories/subcategories
categories.delete Delete categories/subcategories
regions.view Read regions
regions.create Create regions
regions.update Update regions
regions.delete Delete regions
routes.view Read routes
routes.create Create routes
routes.update Update routes
routes.delete Delete routes
vehicles.view Read vehicles
vehicles.create Create vehicles
vehicles.update Update vehicles
vehicles.delete Delete vehicles
warehouses.view Read warehouses
warehouses.create Create warehouses
warehouses.update Update warehouses
warehouses.delete Delete warehouses
stores.view Read stores, visit history, dashboard
stores.create Create stores
stores.update Update stores, upload KYC docs
stores.delete Delete stores
stores.credit.update Update credit limit / outstanding balance
stores.kyc.verify Change a store's KYC status
orders.view Read orders, sales
orders.create Create orders, sales
orders.update Update orders
orders.delete Delete orders
users.view Read users
users.create Create users
users.update Update users
users.assign_role Change a user's role
users.assign_scope Set a user's region/route/store scope
users.deactivate Deactivate users
driver_profiles.view Read driver profiles
driver_profiles.create Create driver profiles
driver_profiles.update Update driver profiles
driver_profiles.deactivate Deactivate driver profiles
compliance.manage Read/update driver compliance config
rides.view Read rides, cancel requests
rides.create Create rides
rides.manage Assign driver, open, activate, close rides
rides.cancel.request Raise a cancel request on a ride
rides.cancel.approve Approve or reject a cancel request
loading.view Read loading sheets
loading.dispatch Create loading sheets, set items, confirm dispatch
loading.receive Set receipt items, confirm receipt
loading.resolve Resolve discrepancy on a loading sheet

2.2 Scope-Based Data Filtering

Users can be restricted to specific regions, routes, or stores. Certain read operations (orders/sales by store, etc.) enforce this scope. Use the scope-check endpoints to determine what data a user can access:

  • GET /api/authorization/scopes/stores/{storeId} — can the current user access this store?
  • GET /api/authorization/scopes/routes/{routeId} — can the current user access this route?
  • GET /api/authorization/scopes/regions/{regionId} — can the current user access this region?
  • GET /api/authorization/scopes/stores — list all store IDs the current user can access

3. Global Response Conventions

HTTP Status Meaning
200 OK Success (read / update / action)
201 Created Resource created; Location header + body returned
204 No Content Delete successful
400 Bad Request Validation error or invalid state; body: { "error": "message" }
401 Unauthorized Missing or invalid JWT
403 Forbidden Authenticated but access denied (scope)
404 Not Found Resource not found
409 Conflict Business rule violation (duplicate, already exists, etc.)

All timestamps are ISO 8601 UTC strings (e.g. "2024-01-15T09:30:00Z").
All IDs are GUIDs in the format "3fa85f64-5717-4562-b3fc-2c963f66afa6".


4. Enumerations Reference

KycStatus

Value Description
0 / "Pending" Awaiting review
1 / "Verified" KYC approved
2 / "Rejected" KYC rejected

LocationType

Value Description
0 / "Warehouse" Stock held in a warehouse
1 / "Vehicle" Stock loaded on a vehicle

MovementType

Value Description
0 / "Sale" Stock reduced by a sale
1 / "CustomerReturn" Stock returned by customer
2 / "Transfer" Moved between locations
3 / "Damage" Written off as damaged
4 / "Adjustment" Manual stock correction
5 / "Load" Vehicle loading event
6 / "PurchaseReceipt" GRN stock addition
7 / "PurchaseReturn" Returned to supplier

LoadingSheetStatus

Value Description
0 / "Pending" Created, not yet dispatched
1 / "DispatchConfirmed" Warehouse confirmed dispatch
2 / "ReceiptConfirmed" Driver confirmed receipt
3 / "DiscrepancyFlagged" Quantities differ; needs resolution
4 / "Resolved" Discrepancy settled

RideType

Value Description
0 / "Delivery" Delivery-only run
1 / "Sale" Sales run (driver can take orders)

RideStatus

Value Description
0 / "Draft" Created, not yet started
1 / "Open" Route opened, ready to activate
2 / "Active" Currently in progress
3 / "CancelRequested" Cancel requested, pending approval
4 / "Cancelled" Cancelled
5 / "Closed" Completed

CancelRequestType

Value Description
0 / "VehicleBreakdown" Vehicle broke down
1 / "DriverUnavailable" Driver unavailable
2 / "RouteIssue" Route-related issue
3 / "Other" Other reason

CancelRequestStatus

Value Description
0 / "Pending" Awaiting approval decision
1 / "Approved" Cancel request approved
2 / "Rejected" Cancel request rejected

PurchaseOrderStatus

Value Description
0 / "Issued" PO created, not yet received
1 / "PartiallyReceived" Some items received via GRN
2 / "Received" All items received
3 / "Closed" Manually closed
4 / "Cancelled" PO cancelled

SaleStatus

Value Description
0 / "Pending" Sale not yet completed
1 / "Completed" Sale fulfilled
2 / "Cancelled" Sale cancelled

OrderStatus

Value Description
0 / "Pending" Order awaiting fulfilment
1 / "PartiallyFulfilled" Some items fulfilled
2 / "Fulfilled" Fully fulfilled
3 / "Cancelled" Order cancelled

PaymentStatus

Value Description
0 / "Pending" No payment received
1 / "PartiallyPaid" Partial payment received
2 / "Paid" Fully paid

PaymentMethod

Value Description
0 / "Cash" Cash payment
1 / "Credit" Credit payment

DeliveryTaskStatus

Value Description
0 / "Pending" Not yet actioned
1 / "Delivered" Fully delivered
2 / "PartiallyDelivered" Partially delivered
3 / "Failed" Delivery failed

VehicleType

Value Description
0 / "Car" Car
1 / "Truck" Truck
2 / "Van" Van
3 / "Motorcycle" Motorcycle
4 / "Bicycle" Bicycle

WareHouseType

Value Description
0 / "DistributionCenter" Distribution center
1 / "FulfillmentCenter" Fulfilment center
2 / "CrossDocking" Cross-docking facility
3 / "ColdStorage" Cold storage
4 / "BondedWarehouse" Bonded warehouse
5 / "AutomatedWarehouse" Automated warehouse

SchemeRuleType

Value Description
0 / "BuyXGetY" Buy X quantity of trigger item, get Y free
1 / "SlabDiscount" Tiered order-value discount
2 / "FlatPercent" Flat percentage discount
3 / "FreeBundle" Buy a bundle of required items, get free item

SyncSource (on Items)

Value Description
0 / "Manual" Created directly in this system
1 / "Erp" Imported via the Sync API

5. Pagination

Endpoints that support pagination accept these query parameters:

Parameter Type Default Description
page int 1 1-based page number
pageSize int 20 Records per page

Paginated responses use this wrapper:

{
  "page": 1,
  "pageSize": 20,
  "total": 150,
  "items": [ ... ]
}

Endpoints with pagination: Driver Profiles, Rides, Loading Sheets, Stock Balances, Stock Movements, Ride Cancel Requests.

All other list endpoints return a plain array — implement client-side pagination or virtual scrolling for large datasets.


6. Modules


6.1 Auth

⚠️ YET TO IMPLEMENT — See Section 1 for context.

The /api/auth routes are stubs. Shape of requests and responses will be defined once the Auth Service contract is agreed.

Base path: /api/auth


6.2 Users

Base path: /api/users
Required: JWT token

Method Path Permission Description
GET /api/users users.view List all users
GET /api/users/{id} users.view Get user by ID
POST /api/users users.create Create user
PUT /api/users/{id} users.update Update user full name
PUT /api/users/{id}/role users.assign_role Assign role to user
PUT /api/users/{id}/scope users.assign_scope Set user's data scope
DELETE /api/users/{id} users.deactivate Deactivate user

Create User — POST /api/users

{
  "username": "string",        // required, unique login name
  "password": "string",        // required, plaintext (hashed server-side)
  "fullName": "string",        // required
  "roleId": "guid",            // required
  "reportsToUserId": "guid"    // optional, parent user in hierarchy
}

Update User — PUT /api/users/{id}

{ "fullName": "string" }

Assign Role — PUT /api/users/{id}/role

{ "roleId": "guid" }

Assign Scope — PUT /api/users/{id}/scope

{
  "regionIds": ["guid", ...],  // list (can be empty)
  "routeIds": ["guid", ...],
  "storeIds": ["guid", ...]
}

User Response Object

{
  "id": "guid",
  "username": "string",
  "fullName": "string",
  "roleId": "guid",
  "roleName": "string",
  "reportsToUserId": "guid | null",
  "isActive": true,
  "createdAt": "datetime",
  "updatedAt": "datetime | null"
}

6.3 Roles

Base path: /api/roles
Required: JWT token (no specific permission — any authenticated user)

Method Path Description
GET /api/roles List all roles
GET /api/roles/{id} Get role by ID
GET /api/roles/{id}/permissions Get permissions assigned to role

Role Response

{
  "id": "guid",
  "name": "string",
  "level": 1,          // integer hierarchy level; lower = more authority
  "isActive": true,
  "createdAt": "datetime"
}

Role Permissions Response

Array of:

{
  "permissionId": "guid",
  "code": "orders.view",       // use this code for Policy checks
  "description": "string"
}

6.4 Authorization Checks

Base path: /api/authorization
Required: JWT token

Method Path Description
GET /api/authorization/permissions/{permissionCode} Check if current user's role has a permission
GET /api/authorization/scopes/stores/{storeId} Check if current user can access a store
GET /api/authorization/scopes/routes/{routeId} Check if current user can access a route
GET /api/authorization/scopes/regions/{regionId} Check if current user can access a region
GET /api/authorization/scopes/stores Get all store IDs accessible to current user

Permission check response:

{ "hasPermission": true }

Scope check responses:

{ "hasScope": true }                          // stores/routes/regions
{ "storeIds": ["guid", "guid", ...] }         // accessible stores list

6.5 Regions

Base path: /api/regions

Method Path Permission Description
GET /api/regions regions.view List all regions
GET /api/regions/{id} regions.view Get region by ID
POST /api/regions regions.create Create region
PUT /api/regions/{id} regions.update Update region
DELETE /api/regions/{id} regions.delete Delete region

Create/Update request:

{ "name": "string" }

Response:

{
  "id": "guid",
  "name": "string",
  "isActive": true,
  "createdAt": "datetime"
}

6.6 Routes

Base path: /api/routes

Method Path Permission Description
GET /api/routes routes.view List all routes
GET /api/routes/{id} routes.view Get route by ID
POST /api/routes routes.create Create route
PUT /api/routes/{id} routes.update Update route
DELETE /api/routes/{id} routes.delete Delete route

Create/Update request:

{
  "regionId": "guid",       // required
  "routeName": "string",    // required
  "startPoint": "string",   // required
  "endPoint": "string",     // required
  "length": 12.5            // decimal (kilometers)
}

Response:

{
  "id": "guid",
  "regionId": "guid",
  "routeName": "string",
  "startPoint": "string",
  "endPoint": "string",
  "length": 12.5,
  "isActive": true,
  "createdAt": "datetime"
}

6.7 Vehicles

Base path: /api/vehicles

Method Path Permission Description
GET /api/vehicles vehicles.view List all vehicles
GET /api/vehicles/{id} vehicles.view Get vehicle by ID
POST /api/vehicles vehicles.create Create vehicle
PUT /api/vehicles/{id} vehicles.update Update vehicle
DELETE /api/vehicles/{id} vehicles.delete Delete vehicle

Create/Update request:

{
  "regNumber": "string",          // registration number
  "numberPlate": "string",        // display plate
  "type": "Van"                   // VehicleType enum
}

Response:

{
  "id": "guid",
  "regNumber": "string",
  "numberPlate": "string",
  "type": "Van",
  "isActive": true,
  "createdAt": "datetime"
}

6.8 Warehouses

Base path: /api/warehouses

Method Path Permission Description
GET /api/warehouses warehouses.view List all warehouses
GET /api/warehouses/{id} warehouses.view Get warehouse by ID
POST /api/warehouses warehouses.create Create warehouse
PUT /api/warehouses/{id} warehouses.update Update warehouse
DELETE /api/warehouses/{id} warehouses.delete Delete warehouse

Create/Update request:

{
  "name": "string",
  "location": "string",
  "type": "DistributionCenter",   // WareHouseType enum
  "vehicleId": "guid | null"      // optional, link to a vehicle
}

Response:

{
  "id": "guid",
  "name": "string",
  "location": "string",
  "type": "DistributionCenter",
  "vehicleId": "guid | null",
  "isActive": true,
  "createdAt": "datetime"
}

6.9 Stores

Base path: /api/stores

Method Path Permission Description
GET /api/stores stores.view List all stores
GET /api/stores/{id} stores.view Get store by ID
POST /api/stores stores.create Create store
PUT /api/stores/{id} stores.update Update store
DELETE /api/stores/{id} stores.delete Delete store
PUT /api/stores/{id}/credit-limit stores.credit.update Update credit limit
PUT /api/stores/{id}/outstanding-balance stores.credit.update Update outstanding balance
GET /api/stores/{id}/outstanding-balance stores.view Get outstanding balance details
PUT /api/stores/{id}/kyc/verify stores.kyc.verify Verify or reject KYC
PUT /api/stores/{id}/segment stores.update Classify store segment
GET /api/stores/{id}/visit-history stores.view Get store visit history
GET /api/stores/{id}/schemes/{schemeId}/eligibility stores.view Check scheme eligibility
GET /api/stores/{id}/dashboard stores.view Get store dashboard metrics

Create/Update Store

{
  "routeId": "guid",
  "ownerName": "string",
  "storeName": "string",
  "storeAddress": "string",
  "latitude": 6.927079,
  "longitude": 79.861244,
  "oneTimeAmount": 0.00,    // one-time registration/setup fee
  "creditAmount": 0.00      // assigned credit amount
}

Store Response

{
  "id": "guid",
  "routeId": "guid",
  "ownerName": "string",
  "storeName": "string",
  "storeAddress": "string",
  "latitude": 6.927079,
  "longitude": 79.861244,
  "oneTimeAmount": 0.00,
  "creditAmount": 0.00,
  "creditLimit": 50000.00,
  "outstandingBalance": 12500.00,
  "segment": "Gold | null",       // Gold, Silver, Key Account, High Risk, etc.
  "kycStatus": "Pending",
  "isActive": true,
  "createdAt": "datetime"
}

Update Credit Limit — PUT /api/stores/{id}/credit-limit

{
  "newLimit": 75000.00,
  "approverId": "guid"    // user ID of the approver
}

Update Outstanding Balance — PUT /api/stores/{id}/outstanding-balance

{ "newBalance": 10000.00 }

Outstanding Balance Response — GET /api/stores/{id}/outstanding-balance

{
  "storeId": "guid",
  "creditLimit": 50000.00,
  "outstandingBalance": 12500.00,
  "availableCredit": 37500.00
}

Verify KYC — PUT /api/stores/{id}/kyc/verify

{
  "status": "Verified",     // KycStatus: Verified or Rejected
  "notes": "string | null"
}

Classify Segment — PUT /api/stores/{id}/segment

{ "segment": "Gold" }

Visit History Response — GET /api/stores/{id}/visit-history

Array of:

{
  "visitId": "guid",
  "visitDate": "datetime",
  "repUserId": "guid",
  "outcome": "string"
}

Scheme Eligibility — GET /api/stores/{id}/schemes/{schemeId}/eligibility

{
  "eligible": true,
  "reason": "string | null"
}

Store Dashboard — GET /api/stores/{id}/dashboard?period=monthly

period query param is a string (e.g. "daily", "weekly", "monthly").

{
  "storeId": "guid",
  "period": "monthly",
  "orderCount": 12,
  "totalOrderValue": 145000.00,
  "lastOrderDate": "datetime | null"
}

6.10 Store KYC Documents

Base path: /api/stores/{storeId}/kyc-documents

Method Path Permission Description
GET /api/stores/{storeId}/kyc-documents stores.view Get all KYC documents for a store
POST /api/stores/{storeId}/kyc-documents stores.update Upload a KYC document

Upload KYC Document — POST /api/stores/{storeId}/kyc-documents

Content-Type: multipart/form-data

Form field Type Required Description
documentType string Yes Document type label (e.g. "NIC", "Business Registration")
file File Yes The document file

Response:

{
  "id": "guid",
  "storeId": "guid",
  "documentType": "NIC",
  "fileReference": "string",   // server-side file reference/path
  "uploadedAt": "datetime"
}

6.11 Categories & Subcategories

Method Path Permission Description
GET /api/categories categories.view List all categories (with subcategories)
GET /api/categories/{id} categories.view Get category by ID
POST /api/categories categories.create Create category
PUT /api/categories/{id} categories.update Update category
DELETE /api/categories/{id} categories.delete Delete category
POST /api/categories/{categoryId}/subcategories categories.create Create subcategory
PUT /api/subcategories/{id} categories.update Update subcategory
DELETE /api/subcategories/{id} categories.delete Delete subcategory

Create/Update category or subcategory:

{ "name": "string" }

Category detail response (GET by ID):

{
  "id": "guid",
  "name": "string",
  "isActive": true,
  "createdAt": "datetime",
  "subCategories": [
    { "id": "guid", "name": "string", "isActive": true, "createdAt": "datetime" }
  ]
}

Category list response (GET all): Array of above objects.


6.12 Brands

Base path: /api/brands

Method Path Permission Description
GET /api/brands items.view List all brands
GET /api/brands/{id} items.view Get brand by ID
POST /api/brands items.create Create brand
PUT /api/brands/{id} items.update Update brand
DELETE /api/brands/{id} items.delete Delete brand

Create/Update: { "name": "string" }

Response: { "id": "guid", "name": "string", "isActive": true, "createdAt": "datetime" }


6.13 Units of Measure (UOMs)

Base path: /api/uoms

Method Path Permission Description
GET /api/uoms items.view List all UOMs
GET /api/uoms/{id} items.view Get UOM by ID
POST /api/uoms items.create Create UOM
PUT /api/uoms/{id} items.update Update UOM
DELETE /api/uoms/{id} items.delete Delete UOM

Create/Update: { "name": "string" }

Response: { "id": "guid", "name": "string", "isActive": true, "createdAt": "datetime" }


6.14 Items

Base path: /api/items

Method Path Permission Description
GET /api/items items.view List items (filterable)
GET /api/items/{id} items.view Get item detail
POST /api/items items.create Create item
PUT /api/items/{id} items.update Update item
DELETE /api/items/{id} items.delete Delete item
PUT /api/items/{id}/uom-conversions items.update Replace all UOM conversions

List Items — GET /api/items

Optional query filters:

Param Type Description
categoryId guid Filter by category
subCategoryId guid Filter by subcategory
brandId guid Filter by brand

Create Item — POST /api/items

{
  "sku": "string",            // required, unique SKU
  "name": "string",           // required
  "description": "string",    // optional
  "categoryId": "guid",       // required
  "subCategoryId": "guid",    // optional
  "brandId": "guid",          // optional
  "baseUomId": "guid",        // required, base unit of measure
  "salePrice": 150.00,        // optional default sale price
  "conversions": [            // optional UOM conversion rules
    {
      "fromUomId": "guid",
      "toUomId": "guid",
      "factor": 12.0          // 1 fromUom = factor toUom
    }
  ]
}

Returns 409 Conflict if SKU already exists.

Update Item — PUT /api/items/{id}

{
  "name": "string",
  "description": "string | null",
  "categoryId": "guid",
  "subCategoryId": "guid | null",
  "brandId": "guid | null",
  "baseUomId": "guid",
  "salePrice": 150.00
}

Replace UOM Conversions — PUT /api/items/{id}/uom-conversions

Replaces all existing conversions for the item.

{
  "conversions": [
    { "fromUomId": "guid", "toUomId": "guid", "factor": 12.0 }
  ]
}

Item Summary Response (list)

{
  "id": "guid",
  "sku": "string",
  "name": "string",
  "categoryId": "guid",
  "categoryName": "string",
  "subCategoryId": "guid | null",
  "subCategoryName": "string | null",
  "brandId": "guid | null",
  "brandName": "string | null",
  "baseUomId": "guid",
  "baseUomName": "string",
  "salePrice": 150.00,
  "syncSource": "Manual | Erp",
  "isActive": true,
  "createdAt": "datetime"
}

Item Detail Response (single)

Same as summary plus:

{
  "description": "string | null",
  "conversions": [
    {
      "id": "guid",
      "fromUomId": "guid",
      "fromUomName": "string",
      "toUomId": "guid",
      "toUomName": "string",
      "factor": 12.0
    }
  ]
}

6.15 Price Lists

Base path: /api/price-lists
Required: JWT token (no specific permission policy — any authenticated user)

Method Path Description
GET /api/price-lists List price lists (filterable by region)
GET /api/price-lists/{id} Get price list with all items
POST /api/price-lists Create price list
PUT /api/price-lists/{id} Update price list metadata
DELETE /api/price-lists/{id} Delete price list
PUT /api/price-lists/{id}/items/{sku} Upsert an item's price in this list
DELETE /api/price-lists/{id}/items/{sku} Remove an item from this list

List Price Lists — GET /api/price-lists

Optional query: ?regionId={guid}

Create/Update Price List

{
  "name": "string",
  "regionId": "guid | null",      // null = applies to all regions
  "effectiveFrom": "datetime",
  "effectiveTo": "datetime"
}

Returns 409 Conflict if overlapping active price list for the same region exists.

Upsert Item Price — PUT /api/price-lists/{id}/items/{sku}

sku is the item's SKU string in the URL path.

{
  "price": 150.00,
  "minPrice": 130.00    // optional, null = price is non-negotiable
}

Price List Response

{
  "id": "guid",
  "name": "string",
  "regionId": "guid | null",
  "regionName": "string | null",
  "effectiveFrom": "datetime",
  "effectiveTo": "datetime",
  "isActive": true,
  "createdAt": "datetime",
  "items": [
    {
      "id": "guid",
      "itemId": "guid",
      "sku": "string",
      "itemName": "string",
      "price": 150.00,
      "minPrice": 130.00
    }
  ]
}

6.16 Schemes (Promotions)

Base path: /api/schemes
Required: JWT token (no specific permission policy)

Method Path Description
GET /api/schemes List schemes (optional ?regionId={guid})
GET /api/schemes/{id} Get scheme detail
POST /api/schemes Create scheme
PUT /api/schemes/{id} Update scheme metadata
DELETE /api/schemes/{id} Delete scheme
PUT /api/schemes/{id}/free-goods-rule Set free-goods rule
DELETE /api/schemes/{id}/free-goods-rule Clear free-goods rule
PUT /api/schemes/{id}/bundle-components/{itemId} Upsert bundle component
DELETE /api/schemes/{id}/bundle-components/{itemId} Remove bundle component
GET /api/schemes/eligible?storeId={guid}&orderId={guid} Get eligible schemes for a store+order
GET /api/schemes/{id}/spend Get scheme spend analytics
GET /api/schemes/{id}/roi Get scheme ROI analytics
POST /api/orders/{id}/apply-scheme Apply a scheme to an order

Scheme Rule Types

  • BuyXGetY: Store buys triggerQty of triggerItemId, gets freeQty of freeItemId for free.
  • SlabDiscount: Tiered discount based on order value. ruleConfig JSON: { "tiers": [ { "minOrderAmount": 10000, "discountPercent": 5.0 }, ... ] } (ordered ascending).
  • FlatPercent: Flat percentage off. ruleConfig JSON: { "discountPercent": 10.0 }.
  • FreeBundle: Store buys all required bundle components, gets freeQty of freeItemId free.

Create Scheme — POST /api/schemes

{
  "name": "string",
  "ruleType": "SlabDiscount",           // SchemeRuleType enum
  "ruleConfig": "{...}",                // JSON string; required for SlabDiscount/FlatPercent, null for BuyXGetY/FreeBundle
  "regionId": "guid | null",            // null = all regions
  "eligibleSegment": "Gold | null",     // null = all segments
  "dateRangeFrom": "datetime",
  "dateRangeTo": "datetime",
  "agentLimitExempt": false,            // if true, exempt from agent discount ceiling
  "baselineExpectedSales": 500000.00,   // optional, used for ROI calc
  // Optional one-shot fields (can also use separate endpoints later):
  "triggerItemId": "guid | null",       // BuyXGetY: item store must buy
  "triggerQty": 10,                     // BuyXGetY: quantity trigger
  "freeItemId": "guid | null",          // BuyXGetY/FreeBundle: item given free
  "freeQty": 2.0,                       // quantity of free item
  "bundleComponents": [                 // FreeBundle: required purchase items
    { "itemId": "guid", "qty": 5 }
  ]
}

Update Scheme — PUT /api/schemes/{id}

{
  "name": "string",
  "ruleConfig": "{...} | null",
  "regionId": "guid | null",
  "eligibleSegment": "string | null",
  "dateRangeFrom": "datetime",
  "dateRangeTo": "datetime",
  "agentLimitExempt": false,
  "baselineExpectedSales": 500000.00
}

Set Free Goods Rule — PUT /api/schemes/{id}/free-goods-rule

{
  "triggerItemId": "guid | null",   // required for BuyXGetY; ignored for FreeBundle
  "triggerQty": 10,                 // required for BuyXGetY; ignored for FreeBundle
  "freeItemId": "guid",             // required
  "freeQty": 2.0                    // required
}

Upsert Bundle Component — PUT /api/schemes/{id}/bundle-components/{itemId}

{ "qty": 5 }

Apply Scheme to Order — POST /api/orders/{id}/apply-scheme

{ "schemeId": "guid" }

Scheme Response

{
  "id": "guid",
  "name": "string",
  "ruleType": "BuyXGetY",
  "ruleConfig": "string | null",
  "triggerItemId": "guid | null",
  "triggerItemName": "string | null",
  "triggerQty": 10,
  "freeItemId": "guid | null",
  "freeItemName": "string | null",
  "freeQty": 2.0,
  "bundleComponents": [
    { "itemId": "guid", "sku": "string", "itemName": "string", "qty": 5 }
  ],
  "regionId": "guid | null",
  "regionName": "string | null",
  "eligibleSegment": "Gold | null",
  "dateRangeFrom": "datetime",
  "dateRangeTo": "datetime",
  "agentLimitExempt": false,
  "baselineExpectedSales": 500000.00,
  "isActive": true,
  "createdAt": "datetime"
}

Eligible Schemes Response — GET /api/schemes/eligible?storeId=...&orderId=...

Array of:

{
  "schemeId": "guid",
  "schemeName": "string",
  "ruleType": "SlabDiscount",
  "eligible": true,
  "reason": "string | null",
  "discountAmount": 1500.00,
  "freeItemId": "guid | null",
  "freeQty": 2.0
}

Scheme Spend — GET /api/schemes/{id}/spend

{
  "schemeId": "guid",
  "totalDiscountSpend": 15000.00,
  "totalFreeGoodsCostSpend": 3000.00,
  "totalSpend": 18000.00,
  "redemptionCount": 45
}

Scheme ROI — GET /api/schemes/{id}/roi

{
  "schemeId": "guid",
  "baselineExpectedSales": 500000.00,
  "actualSales": 650000.00,
  "incrementalSales": 150000.00,
  "totalSpend": 18000.00,
  "roi": 7.33          // (incrementalSales - totalSpend) / totalSpend; null if baseline not set
}

6.17 Suppliers

Base path: /api/suppliers
Required: JWT token (no specific permission policy)

Method Path Description
GET /api/suppliers List all suppliers
GET /api/suppliers/{id} Get supplier by ID
POST /api/suppliers Create supplier
PUT /api/suppliers/{id} Update supplier
DELETE /api/suppliers/{id} Delete supplier (returns 409 if has active POs)
POST /api/suppliers/{id}/evaluate Trigger supplier performance evaluation

Create/Update:

{
  "name": "string",
  "contactInfo": "string | null"
}

Response:

{
  "id": "guid",
  "name": "string",
  "contactInfo": "string | null",
  "performanceScore": 4.5,   // decimal | null; computed by evaluate
  "isActive": true,
  "createdAt": "datetime"
}

6.18 Purchase Orders (PO)

Base path: /api/purchase-orders
Required: JWT token (no specific permission policy)

Method Path Description
GET /api/purchase-orders List POs (optional filters)
GET /api/purchase-orders/{id} Get PO detail
POST /api/purchase-orders Create PO
PUT /api/purchase-orders/{id} Update PO lines (while Issued)
POST /api/purchase-orders/{id}/close Manually close PO
POST /api/purchase-orders/{id}/cancel Cancel entire PO
POST /api/purchase-orders/{id}/items/{itemId}/cancel Cancel a single PO line
POST /api/purchase-orders/{id}/match-invoice Match PO to GRN/Invoice
POST /api/purchase-orders/{id}/resolve-variance Resolve invoice variance

List POs — GET /api/purchase-orders

Optional query filters:

Param Type Description
supplierId guid Filter by supplier
status PurchaseOrderStatus Filter by status

Create PO — POST /api/purchase-orders

{
  "supplierId": "guid",
  "items": [
    {
      "itemId": "guid",
      "quantity": 100,
      "unitCost": 85.00
    }
  ]
}

Update PO — PUT /api/purchase-orders/{id}

Only valid while status is Issued.

{
  "items": [
    {
      "purchaseOrderItemId": "guid | null",  // null = new line, guid = update existing
      "itemId": "guid",
      "quantity": 120,
      "unitCost": 83.00
    }
  ]
}

Cancel PO — POST /api/purchase-orders/{id}/cancel

{ "reason": "string" }

Cancel PO Line — POST /api/purchase-orders/{id}/items/{itemId}/cancel

itemId here is the PurchaseOrderItem.Id (not the item's SKU ID).

{ "reason": "string | null" }

Match Invoice — POST /api/purchase-orders/{id}/match-invoice

Query params: ?invoiceId={guid}&grnId={guid} (both optional)

Response:

{
  "purchaseOrderId": "guid",
  "goodsReceiptId": "guid | null",
  "invoiceId": "guid | null",
  "isMatched": true,
  "lines": [
    {
      "purchaseOrderItemId": "guid",
      "itemId": "guid",
      "orderedQuantity": 100,
      "acceptedQuantity": 98,
      "unitCost": 85.00,
      "quantityMatches": false
    }
  ],
  "note": "string"
}

Resolve Variance — POST /api/purchase-orders/{id}/resolve-variance

{
  "variance": "string",            // description of the variance
  "resolution": "string",          // resolution action taken
  "adjustedUnitCost": 84.00,       // optional adjusted cost
  "purchaseOrderItemId": "guid"    // optional, which line was adjusted
}

Response:

{
  "purchaseOrderId": "guid",
  "variance": "string",
  "resolution": "string",
  "resolvedAt": "datetime"
}

PO Response

{
  "id": "guid",
  "supplierId": "guid",
  "supplierName": "string",
  "createdByUserId": "guid",
  "status": "Issued",
  "cancelReason": "string | null",
  "isActive": true,
  "createdAt": "datetime",
  "items": [
    {
      "id": "guid",
      "itemId": "guid",
      "sku": "string",
      "itemName": "string",
      "quantity": 100,
      "unitCost": 85.00,
      "receivedQuantity": 50,
      "cancelledQuantity": 0,
      "isCancelled": false
    }
  ]
}

6.19 Goods Receipt Notes (GRN)

Method Path Description
GET /api/goods-receipts/{id} Get GRN by ID
GET /api/purchase-orders/{id}/goods-receipts List GRNs for a PO
POST /api/purchase-orders/{id}/goods-receipts Create GRN for PO

Create GRN — POST /api/purchase-orders/{id}/goods-receipts

Returns 409 Conflict if the PO is not in a receivable status.

{
  "warehouseId": "guid",
  "items": [
    {
      "purchaseOrderItemId": "guid",   // PO line being received
      "acceptedQuantity": 98,
      "rejectedQuantity": 2
    }
  ]
}

GRN Response

{
  "id": "guid",
  "purchaseOrderId": "guid",
  "receivedByUserId": "guid",
  "warehouseId": "guid",
  "warehouseName": "string",
  "receivedAt": "datetime",
  "createdAt": "datetime",
  "items": [
    {
      "id": "guid",
      "purchaseOrderItemId": "guid",
      "itemId": "guid",
      "sku": "string",
      "itemName": "string",
      "acceptedQuantity": 98,
      "rejectedQuantity": 2
    }
  ]
}

6.20 Purchase Returns

Method Path Description
GET /api/purchase-returns/{id} Get purchase return by ID
GET /api/purchase-orders/{id}/returns List returns for a PO
POST /api/purchase-orders/{id}/returns Create purchase return for PO

Create Purchase Return — POST /api/purchase-orders/{id}/returns

Returns 409 Conflict on business rule violations.

{
  "reason": "string",
  "items": [
    {
      "itemId": "guid",
      "quantity": 5
    }
  ]
}

Purchase Return Response

{
  "id": "guid",
  "purchaseOrderId": "guid",
  "reason": "string",
  "isActive": true,
  "createdAt": "datetime",
  "items": [
    {
      "id": "guid",
      "itemId": "guid",
      "sku": "string",
      "itemName": "string",
      "quantity": 5
    }
  ]
}

6.21 Stock

Base path: /api/stock
Required: JWT token (no specific permission policy)

Method Path Description
GET /api/stock/balances Get stock balances (paginated, filterable)
GET /api/stock/balances/{itemId}/total Get total stock across all locations for item
GET /api/stock/movements Get stock movement history (paginated, filterable)
POST /api/stock/transfer Transfer stock between locations
POST /api/stock/damage Record damaged stock write-off
POST /api/stock/customer-return Record customer return to vehicle
POST /api/stock/adjustment Record stock adjustment

Get Stock Balances — GET /api/stock/balances

Query params (all optional):

Param Type Description
locationId guid Filter by specific location (warehouse or vehicle ID)
locationType LocationType Filter by location type (Warehouse or Vehicle)
itemId guid Filter by item
page int Default: 1
pageSize int Default: 20

Response: Paginated list of StockBalanceResponse:

{
  "page": 1, "pageSize": 20, "total": 100,
  "items": [
    {
      "itemId": "guid",
      "itemName": "string",
      "locationId": "guid",
      "locationType": "Warehouse",
      "quantity": 250.0,
      "updatedAt": "datetime"
    }
  ]
}

Get Item Total — GET /api/stock/balances/{itemId}/total

{
  "itemId": "guid",
  "itemName": "string",
  "totalQuantity": 500.0,
  "breakdown": [
    {
      "itemId": "guid",
      "itemName": "string",
      "locationId": "guid",
      "locationType": "Vehicle",
      "quantity": 250.0,
      "updatedAt": "datetime"
    }
  ]
}

Get Stock Movements — GET /api/stock/movements

Query params (all optional):

Param Type Description
locationId guid Filter by location
itemId guid Filter by item
type MovementType Filter by movement type
from datetime Start date filter
to datetime End date filter
page int Default: 1
pageSize int Default: 20

Response: Paginated list of StockMovementResponse:

{
  "items": [
    {
      "id": "guid",
      "itemId": "guid",
      "itemName": "string",
      "fromLocationId": "guid | null",
      "fromLocationType": "Warehouse | null",
      "toLocationId": "guid | null",
      "toLocationType": "Vehicle | null",
      "quantity": 100.0,
      "type": "Transfer",
      "reasonCode": "string | null",
      "referenceId": "guid | null",   // e.g. SaleId, RideId, etc.
      "createdAt": "datetime",
      "createdBy": "guid"
    }
  ]
}

Transfer Stock — POST /api/stock/transfer

{
  "itemId": "guid",
  "fromLocationId": "guid",
  "fromLocationType": "Warehouse",
  "toLocationId": "guid",
  "toLocationType": "Vehicle",
  "quantity": 50.0,
  "referenceId": "guid | null"   // e.g. loading sheet ID
}

Record Damage — POST /api/stock/damage

{
  "itemId": "guid",
  "locationId": "guid",
  "locationType": "Warehouse",
  "quantity": 3.0,
  "reasonCode": "string"    // e.g. "WaterDamage", "ExpiredGoods"
}

Record Customer Return — POST /api/stock/customer-return

{
  "itemId": "guid",
  "vehicleId": "guid",       // returns go to vehicle stock
  "quantity": 2.0,
  "referenceId": "guid | null"
}

Record Adjustment — POST /api/stock/adjustment

{
  "itemId": "guid",
  "locationId": "guid",
  "locationType": "Warehouse",
  "quantity": -5.0,           // negative = reduce stock, positive = add stock
  "reasonCode": "string"
}

6.22 Driver Profiles

Base path: /api/driver-profiles

Method Path Permission Description
GET /api/driver-profiles driver_profiles.view List driver profiles (paginated)
GET /api/driver-profiles/{id} driver_profiles.view Get driver profile by ID
POST /api/driver-profiles driver_profiles.create Create driver profile
PUT /api/driver-profiles/{id} driver_profiles.update Update driver profile
DELETE /api/driver-profiles/{id} driver_profiles.deactivate Deactivate driver profile

List — GET /api/driver-profiles?page=1&pageSize=20

Create Driver Profile — POST /api/driver-profiles

{
  "fK_UserId": "guid",             // user to link this profile to
  "licenseNumber": "string",
  "licenseExpiry": "datetime",
  "medicalClearanceExpiry": "datetime"
}

Update Driver Profile — PUT /api/driver-profiles/{id}

{
  "licenseNumber": "string",
  "licenseExpiry": "datetime",
  "medicalClearanceExpiry": "datetime"
}

Driver Profile Response

{
  "id": "guid",
  "userId": "guid",
  "userFullName": "string",
  "licenseNumber": "string",
  "licenseExpiry": "datetime",
  "medicalClearanceExpiry": "datetime",
  "isActive": true,
  "createdAt": "datetime"
}

6.23 Driver Compliance Config

Base path: /api/driver-compliance-config

Method Path Permission Description
GET /api/driver-compliance-config compliance.manage Get current config
PUT /api/driver-compliance-config compliance.manage Create or update config

This is a singleton resource — only one config record exists system-wide.

GET response (returns 204 No Content if not yet configured):

{
  "id": "guid",
  "enforceLicenseExpiry": true,
  "enforceMedicalClearance": true,
  "updatedAt": "datetime",
  "updatedBy": "guid"
}

PUT request:

{
  "enforceLicenseExpiry": true,
  "enforceMedicalClearance": false
}

6.24 Rides

Base path: /api/rides

Method Path Permission Description
GET /api/rides rides.view List rides (paginated, filterable)
GET /api/rides/{id} rides.view Get ride detail
POST /api/rides rides.create Create ride
PUT /api/rides/{id}/driver rides.manage Assign/change driver
POST /api/rides/{id}/open rides.manage Transition to Open status
POST /api/rides/{id}/activate rides.manage Activate ride (record meter start)
POST /api/rides/{id}/close rides.manage Close ride (record meter end)
POST /api/rides/{id}/cancel-request rides.cancel.request Raise a cancel request
GET /api/rides/cancel-requests rides.view List all cancel requests
POST /api/rides/cancel-requests/{id}/approve rides.cancel.approve Approve cancel request
POST /api/rides/cancel-requests/{id}/reject rides.cancel.approve Reject cancel request
GET /api/rides/{id}/cancel-request rides.view Get cancel request for a ride
GET /api/rides/breakdown-assist/{rideId} rides.view Get breakdown assist data

Ride Lifecycle

Draft → Open → Active → Closed
             ↓
      CancelRequested → Cancelled

List Rides — GET /api/rides

Query params (all optional):

Param Type Description
status RideStatus Filter by status
type RideType Filter by ride type
driverProfileId guid Filter by driver
vehicleId guid Filter by vehicle
page int Default: 1
pageSize int Default: 20

Create Ride — POST /api/rides

{
  "rideType": "Delivery",
  "fK_VehicleId": "guid",
  "fK_DriverProfileId": "guid",
  "fK_RouteId": "guid",
  "fK_SourceWarehouseId": "guid | null"
}

Assign Driver — PUT /api/rides/{id}/driver

{ "fK_DriverProfileId": "guid" }

Open Ride — POST /api/rides/{id}/open

No body required.

Activate Ride — POST /api/rides/{id}/activate

{ "meterstart": 12345 }   // odometer reading at start (integer)

Close Ride — POST /api/rides/{id}/close

{ "meterend": 12567 }   // odometer reading at end (integer)

Raise Cancel Request — POST /api/rides/{id}/cancel-request

{
  "typeCode": "VehicleBreakdown",   // CancelRequestType enum
  "reason": "string"
}

List Cancel Requests — GET /api/rides/cancel-requests

Query params: ?status={CancelRequestStatus}&page=1&pageSize=20

Ride Response

{
  "id": "guid",
  "rideType": "Delivery",
  "status": "Active",
  "vehicleId": "guid",
  "vehicleNumberPlate": "ABC-1234",
  "driverProfileId": "guid",
  "driverFullName": "string",
  "routeId": "guid",
  "routeName": "string",
  "sourceWarehouseId": "guid",
  "sourceWarehouseName": "string",
  "meterstart": 12345,
  "meterend": 0,
  "startedAt": "datetime | null",
  "activatedAt": "datetime | null",
  "closedAt": "datetime | null",
  "createdAt": "datetime"
}

Cancel Request Response

{
  "id": "guid",
  "rideId": "guid",
  "typeCode": "VehicleBreakdown",
  "reason": "string",
  "requestedBy": "guid",
  "requestedAt": "datetime",
  "status": "Pending",
  "approvedBy": "guid | null",
  "approvedAt": "datetime | null"
}

Breakdown Assist Response — GET /api/rides/breakdown-assist/{rideId}

Returns active rides on the same route with stock levels, and the open orders for the broken-down ride.

{
  "rideId": "guid",
  "activeRides": [
    {
      "rideId": "guid",
      "vehicleId": "guid",
      "vehicleNumberPlate": "string",
      "driverProfileId": "guid",
      "driverFullName": "string",
      "routeId": "guid",
      "routeName": "string",
      "stockLevels": [
        { "itemId": "guid", "itemName": "string", "locationId": "guid", "locationType": "Vehicle", "quantity": 50.0, "updatedAt": "datetime" }
      ]
    }
  ],
  "openOrders": [
    {
      "orderId": "guid",
      "storeId": "guid",
      "storeName": "string",
      "orderDate": "datetime",
      "totalAmount": 25000.00
    }
  ]
}

6.25 Loading Sheets

Base path: /api/loading-sheets

Method Path Permission Description
GET /api/loading-sheets loading.view List loading sheets (paginated, filterable)
GET /api/loading-sheets/{id} loading.view Get loading sheet detail
POST /api/loading-sheets loading.dispatch Create loading sheet for a ride
PUT /api/loading-sheets/{id}/items loading.dispatch Set dispatched items
POST /api/loading-sheets/{id}/confirm-dispatch loading.dispatch Confirm dispatch from warehouse
PUT /api/loading-sheets/{id}/receipt-items loading.receive Set received item quantities
POST /api/loading-sheets/{id}/confirm-receipt loading.receive Driver confirms receipt
POST /api/loading-sheets/{id}/resolve loading.resolve Resolve quantity discrepancy

Loading Sheet Lifecycle

Pending → DispatchConfirmed → ReceiptConfirmed
                                    ↓ (if quantities differ)
                           DiscrepancyFlagged → Resolved

List Loading Sheets — GET /api/loading-sheets

Query params (all optional):

Param Type Description
rideId guid Filter by ride
status LoadingSheetStatus Filter by status
page int Default: 1
pageSize int Default: 20

Create Loading Sheet — POST /api/loading-sheets

{ "fK_RideId": "guid" }

Set Dispatched Items — PUT /api/loading-sheets/{id}/items

Replaces all items on the loading sheet.

{
  "items": [
    { "fK_ItemId": "guid", "quantity": 100.0 }
  ]
}

Confirm Dispatch — POST /api/loading-sheets/{id}/confirm-dispatch

No body required. Transitions status to DispatchConfirmed.

Set Receipt Items — PUT /api/loading-sheets/{id}/receipt-items

{
  "items": [
    { "fK_ItemId": "guid", "quantityReceived": 98.0 }
  ]
}

Confirm Receipt — POST /api/loading-sheets/{id}/confirm-receipt

{ "meterstart": 12345 }   // driver's vehicle odometer at receipt

Resolve Discrepancy — POST /api/loading-sheets/{id}/resolve

{
  "items": [
    { "fK_ItemId": "guid", "quantityResolved": 98.0 }
  ],
  "meterstart": 12345
}

Loading Sheet Response

{
  "id": "guid",
  "fK_RideId": "guid",
  "fK_SourceWarehouseId": "guid",
  "warehouseName": "string",
  "status": "Pending",
  "dispatchConfirmedAt": "datetime | null",
  "dispatchConfirmedBy": "guid | null",
  "receiptConfirmedAt": "datetime | null",
  "receiptConfirmedBy": "guid | null",
  "createdAt": "datetime",
  "items": [
    {
      "id": "guid",
      "fK_ItemId": "guid",
      "itemName": "string",
      "quantityDispatched": 100.0,
      "quantityReceived": 98.0,
      "quantityResolved": 98.0
    }
  ]
}

6.26 Delivery Tasks

Base path: /api (mixed routes — see below)

Method Path Description
POST /api/rides/{rideId}/tasks/generate Auto-generate delivery tasks from ride's orders
GET /api/rides/{rideId}/tasks List all tasks for a ride
GET /api/delivery-tasks/{id} Get delivery task by ID
PUT /api/delivery-tasks/{id}/quantities Set fulfilled quantities per item
POST /api/delivery-tasks/{id}/confirm Mark task as delivered
POST /api/delivery-tasks/{id}/fail Mark task as failed

Generate Tasks — POST /api/rides/{rideId}/tasks/generate

No body. Generates one DeliveryTask per store/order on the ride.
Returns 400 Bad Request if ride is not in a valid state, 404 Not Found if ride not found.

Response: Array of DeliveryTaskResponse.

Set Quantities — PUT /api/delivery-tasks/{id}/quantities

{
  "items": [
    { "itemId": "guid", "quantityFulfilled": 8 }
  ]
}

Fail Task — POST /api/delivery-tasks/{id}/fail

{ "note": "string" }   // reason for failure

Delivery Task Response

{
  "id": "guid",
  "fK_RideId": "guid",
  "fK_OrderId": "guid",
  "storeId": "guid",
  "storeName": "string",
  "sequence": 1,
  "status": "Pending",
  "customerConfirmed": false,
  "confirmedAt": "datetime | null",
  "note": "string | null",
  "items": [
    {
      "itemId": "guid",
      "itemName": "string",
      "quantity": 10,
      "quantityFulfilled": 8,
      "unitPrice": 150.00
    }
  ],
  "createdAt": "datetime"
}

6.27 Sales

Base path: /api/sales

Method Path Permission Description
GET /api/sales orders.view List all sales
GET /api/sales/{id} orders.view Get sale detail
GET /api/sales/store/{storeId} orders.view Get sales for a store (scope-enforced)
POST /api/sales orders.create Create a new sale

GET /api/sales/store/{storeId} returns 403 Forbidden if current user's scope does not include that store.

Create Sale — POST /api/sales

{
  "storeId": "guid",
  "saleDate": "datetime",
  "totalAmount": 45000.00,
  "fulfillImmediately": false,    // if true, immediately fulfills the linked order
  "rideId": "guid | null",        // associate with a ride (for mobile sales)
  "items": [
    {
      "itemId": "guid",
      "quantity": 10,
      "unitPrice": null             // null = use price list; decimal = override price
    }
  ]
}

Returns 403 Forbidden if current user's scope doesn't include the store.

Sale Response

{
  "id": "guid",
  "saleDate": "datetime",
  "storeId": "guid",
  "createdByUserId": "guid",
  "rideId": "guid | null",
  "totalAmount": 45000.00,
  "status": "Pending",
  "paymentStatus": "Pending",
  "fulfillImmediately": false,
  "completedAt": "datetime | null",
  "orderId": "guid",               // the linked Order record
  "orderStatus": "Pending",
  "orderFulfilledAt": "datetime | null",
  "items": [
    {
      "itemId": "guid",
      "quantity": 10,
      "quantityFulfilled": 0,
      "unitPrice": 150.00
    }
  ],
  "isActive": true,
  "createdAt": "datetime"
}

6.28 Orders

Base path: /api/orders

Method Path Permission Description
GET /api/orders orders.view List all orders
GET /api/orders/{id} orders.view Get order by ID
GET /api/orders/store/{storeId} orders.view Get orders for a store (scope-enforced)
POST /api/orders orders.create Create order (linked to a sale)
PUT /api/orders/{id} orders.update Update order status
DELETE /api/orders/{id} orders.delete Delete order
POST /api/orders/{id}/apply-scheme — Apply a scheme to order

GET /api/orders/store/{storeId} and POST /api/orders return 403 Forbidden if scope is not satisfied.

Create Order — POST /api/orders

{ "saleId": "guid" }

Update Order — PUT /api/orders/{id}

{ "status": "Fulfilled" }   // OrderStatus enum

Order Response

{
  "id": "guid",
  "saleId": "guid",
  "orderDate": "datetime",
  "storeId": "guid",
  "totalAmount": 45000.00,
  "status": "Pending",
  "fulfilledAt": "datetime | null",
  "items": [
    {
      "itemId": "guid",
      "quantity": 10,
      "quantityFulfilled": 0,
      "unitPrice": 150.00
    }
  ],
  "isActive": true,
  "createdAt": "datetime"
}

6.29 Payments

Base path: /api/sales/{saleId}/payments

Method Path Description
GET /api/sales/{saleId}/payments Get all payments for a sale
POST /api/sales/{saleId}/payments Record a payment for a sale

GET returns 404 Not Found if the sale doesn't exist.
POST returns 404 Not Found if the sale doesn't exist, 400 Bad Request if payment already complete.

Create Payment — POST /api/sales/{saleId}/payments

{
  "amount": 25000.00,
  "method": "Cash"    // PaymentMethod enum
}

Payment Response

{
  "id": "guid",
  "saleId": "guid",
  "storeId": "guid",
  "amount": 25000.00,
  "method": "Cash",
  "collectedAt": "datetime",
  "collectedBy": "guid",     // user who recorded the payment
  "createdAt": "datetime"
}

6.30 Sync (ERP Import)

Base path: /api/sync
Required permission: items.create

These endpoints are for bulk import from an external ERP system. Each payload is idempotent — existing records (matched by erpId) are updated; new records are inserted.

Method Path Description
POST /api/sync/uoms Bulk upsert units of measure
POST /api/sync/categories Bulk upsert categories + subcategories
POST /api/sync/brands Bulk upsert brands
POST /api/sync/items Bulk upsert items

Sync UOMs — POST /api/sync/uoms

{
  "items": [
    { "erpId": 101, "name": "Piece" }
  ]
}

Sync Categories — POST /api/sync/categories

{
  "items": [
    {
      "erpId": 10,
      "name": "Beverages",
      "subCategories": [
        { "erpId": 101, "name": "Soft Drinks", "parentErpId": 10 }
      ]
    }
  ]
}

Sync Brands — POST /api/sync/brands

{
  "items": [
    { "erpId": 201, "name": "Coca-Cola" }
  ]
}

Sync Items — POST /api/sync/items

{
  "items": [
    {
      "erpId": 5001,
      "sku": "CC-330ML",
      "name": "Coca-Cola 330ml",
      "description": "string | null",
      "categoryErpId": 10,
      "subCategoryErpId": 101,
      "brandErpId": 201,
      "baseUomErpId": 101,
      "salePrice": 65.00,
      "isActive": true
    }
  ]
}

Sync Result Response (all sync endpoints)

{
  "inserted": 5,
  "updated": 12,
  "skipped": 0
}

7. Business Flows

End-to-end sequences showing which API calls to make, in order, for each major workflow. Every step maps directly to an endpoint documented in Section 6.


7.1 Region → Route → Store Setup

This is the master data foundation. Everything else (rides, sales, delivery) depends on it existing first.

1. POST /api/regions              → creates a region (geographic area)
2. POST /api/routes               → creates a route inside that region
                                    (requires regionId)
3. POST /api/stores               → creates a store on that route
                                    (requires routeId)
4. POST /api/stores/{id}/kyc-documents   → upload KYC docs for the store
5. PUT  /api/stores/{id}/kyc/verify      → approve/reject KYC
                                           (kycStatus: Verified unlocks full ordering)

Key rules:

  • A store belongs to exactly one route; a route belongs to exactly one region.
  • Stores with KycStatus = Pending can only place orders up to the configured threshold (default 0 — meaning blocked). Verify KYC before the store can transact.
  • Users are scoped to regions/routes/stores via PUT /api/users/{id}/scope. Scoped users only see and operate on data within their assigned scope.

7.2 Sale Flow (Sales Run)

Used when a sales rep visits a store and records a sale on the spot. The sale and its linked order are created atomically.

1. POST /api/sales
   body: { storeId, saleDate, totalAmount, fulfillImmediately, rideId?, items[] }
   → creates Sale + Order in one call
   → Sale.status = Pending, Order.status = Pending (if fulfillImmediately: false)
   → Sale.status = Completed, Order.status = Fulfilled (if fulfillImmediately: true)

2. (Optional) GET /api/schemes/eligible?storeId=...&orderId=...
   → find promotions the store qualifies for

3. (Optional) POST /api/orders/{orderId}/apply-scheme
   body: { schemeId }
   → applies discount or free-goods scheme to the order

4. POST /api/sales/{saleId}/payments
   body: { amount, method: "Cash"|"Credit" }
   → records a payment; Sale.paymentStatus updates automatically:
     Pending → PartiallyPaid → Paid (based on cumulative total vs totalAmount)
   → repeat this step for partial payments

Price resolution (automatic on sale creation):

  • If items[].unitPrice is null, the system looks up the active price list for the store's region and uses that price.
  • If unitPrice is supplied, it must fall within [minPrice, price] from the price list. Prices outside this range cause the sale to be rejected (null response → treat as validation error).

Stock deduction (when fulfillImmediately: true + rideId supplied):

  • The system checks that the vehicle has sufficient stock for every item.
  • Stock is deducted from the vehicle immediately and a Sale movement is recorded.
  • If any item lacks stock, the entire sale creation fails.

Scope enforcement:

  • The creating user must have scope access to the store, otherwise 403 Forbidden is returned.

7.3 Delivery Flow (End-to-End Ride Lifecycle)

The full sequence for a delivery run — from creating the ride to closing it out.

Step 1 — Set up the ride (Draft)

POST /api/rides
body: { rideType: "Delivery", fK_VehicleId, fK_DriverProfileId, fK_RouteId, fK_SourceWarehouseId? }
→ Ride.status = Draft

PUT /api/rides/{id}/driver        ← optional, change driver while still Draft
body: { fK_DriverProfileId }

Step 2 — Open the ride (Draft → Open)

POST /api/rides/{id}/open
→ Ride.status = Open

Blocked if:

  • The vehicle already has another Open or Active ride.
  • Driver compliance is enforced and the driver's license or medical clearance has expired.

Step 3 — Create & confirm the loading sheet (Open ride required)

POST /api/loading-sheets
body: { fK_RideId }
→ LoadingSheet.status = Pending
  (For Delivery rides: items are auto-populated from pending orders on the route.
   For Sale rides: items must be added manually in the next step.)

PUT /api/loading-sheets/{id}/items          ← set/adjust dispatched quantities
body: { items: [{ fK_ItemId, quantity }] }

POST /api/loading-sheets/{id}/confirm-dispatch
→ LoadingSheet.status = DispatchConfirmed
  (Warehouse side confirms the goods left the warehouse)

Step 4 — Driver receives stock & ride goes Active

PUT /api/loading-sheets/{id}/receipt-items  ← driver records what they actually received
body: { items: [{ fK_ItemId, quantityReceived }] }

POST /api/loading-sheets/{id}/confirm-receipt
body: { meterstart: 12345 }
→ if quantities match dispatched:
    LoadingSheet.status = ReceiptConfirmed
    Stock transfers from Warehouse → Vehicle (Load movement recorded)
    Ride.status = Active, Ride.meterstart set

→ if quantities differ:
    LoadingSheet.status = DiscrepancyFlagged
    (Ride stays Open; must resolve before activation)

POST /api/loading-sheets/{id}/resolve       ← if DiscrepancyFlagged
body: { items: [{ fK_ItemId, quantityResolved }], meterstart }
→ LoadingSheet.status = Resolved
  Stock transfers using resolved quantities
  Ride.status = Active

Step 5 — Generate delivery tasks & execute deliveries (Active ride, Delivery type only)

POST /api/rides/{rideId}/tasks/generate
→ creates one DeliveryTask per pending order on the route
  (idempotent — skips orders that already have a task for this ride)

GET /api/rides/{rideId}/tasks               ← list tasks to display the delivery sequence

For each task:
  PUT /api/delivery-tasks/{id}/quantities
  body: { items: [{ itemId, quantityFulfilled }] }
  → set how much was actually delivered per item

  POST /api/delivery-tasks/{id}/confirm
  → DeliveryTask.status = Delivered (all qty) or PartiallyDelivered
    Order.status = Fulfilled or PartiallyFulfilled
    Sale.status = Completed (if fully delivered)
    Stock deducted from Vehicle (Sale movement recorded)

  — OR —

  POST /api/delivery-tasks/{id}/fail
  body: { note: "reason" }
  → DeliveryTask.status = Failed (stock not deducted)

Step 6 — Close the ride (Active → Closed)

POST /api/rides/{id}/close
body: { meterend: 12567 }
→ Ride.status = Closed

Blocked if: The vehicle still has stock with quantity > 0. All stock must be returned (via transfer, damage, or customer-return endpoints) before closing.


7.4 Ride Cancellation Flow

Applies when a ride is Active and something goes wrong (breakdown, driver issue, etc.).

POST /api/rides/{id}/cancel-request
body: { typeCode: "VehicleBreakdown", reason: "string" }
→ Ride.status = CancelRequested
  RideCancelRequest.status = Pending

GET /api/rides/cancel-requests?status=Pending    ← supervisor polls for pending requests
GET /api/rides/{id}/cancel-request               ← check status for a specific ride

POST /api/rides/cancel-requests/{requestId}/approve
→ RideCancelRequest.status = Approved
  Ride.status = Cancelled
  (Blocked if vehicle still has stock)

— OR —

POST /api/rides/cancel-requests/{requestId}/reject
→ RideCancelRequest.status = Rejected
  Ride.status = Active (restored)

Breakdown Assist — when a ride is cancelled mid-route, use this to find a replacement:

GET /api/rides/breakdown-assist/{cancelledRideId}
→ returns: other Active rides on the same route with their current vehicle stock levels
           + open orders from the cancelled ride's route

7.5 Payment Flow

Payments are recorded against a Sale after it is created. Multiple partial payments are supported.

1. POST /api/sales                            ← sale created; paymentStatus = Pending

2. POST /api/sales/{saleId}/payments
   body: { amount: 25000.00, method: "Cash" }
   → payment recorded; sale.paymentStatus updated:
     sum(payments) == 0              → Pending
     sum(payments) <  totalAmount    → PartiallyPaid
     sum(payments) >= totalAmount    → Paid

3. Repeat step 2 for additional instalments.

4. GET /api/sales/{saleId}/payments           ← list all payments for the sale

Rules:

  • Cannot record payment against a Cancelled sale — returns 400 Bad Request.
  • No upper limit enforced — overpayment is allowed (handle reconciliation in the UI).
  • collectedBy is automatically set from the authenticated user's ID.

7.6 Purchase Order Flow (Procurement)

1. POST /api/purchase-orders
   body: { supplierId, items: [{ itemId, quantity, unitCost }] }
   → PO.status = Issued

2. (Optional) PUT /api/purchase-orders/{id}    ← edit lines while Issued only
   body: { items: [{ purchaseOrderItemId?, itemId, quantity, unitCost }] }
   (purchaseOrderItemId null = add new line, guid = update existing)

3. POST /api/purchase-orders/{id}/goods-receipts    ← goods arrive at warehouse
   body: { warehouseId, items: [{ purchaseOrderItemId, acceptedQuantity, rejectedQuantity }] }
   → GRN created; stock added to Warehouse (PurchaseReceipt movement)
   → PO.status = PartiallyReceived or Received (if all lines fully received)

4. POST /api/purchase-orders/{id}/match-invoice     ← optional 3-way match
   query: ?grnId=...&invoiceId=...
   → returns line-by-line comparison of ordered vs received quantities

5. (if discrepancy) POST /api/purchase-orders/{id}/resolve-variance
   body: { variance, resolution, adjustedUnitCost?, purchaseOrderItemId? }

6. POST /api/purchase-orders/{id}/close              ← manually close when done
   → PO.status = Closed

   — OR —

   POST /api/purchase-orders/{id}/cancel             ← cancel entire PO (Issued only)
   body: { reason }

   POST /api/purchase-orders/{id}/items/{itemId}/cancel   ← cancel one line (Issued only)
   body: { reason? }

Returns: GET /api/purchase-orders/{id}/returns / POST /api/purchase-orders/{id}/returns
Record supplier return: { reason, items: [{ itemId, quantity }] } → stock deducted from Warehouse (PurchaseReturn movement).


7.7 Loading Sheet Flow (Detail)

The loading sheet controls the physical movement of stock from warehouse to vehicle. It sits between "ride opened" and "ride active".

Status machine:
Pending ──► DispatchConfirmed ──► ReceiptConfirmed (clean)
                                └──► DiscrepancyFlagged ──► Resolved
Status Who acts What happens to stock
Pending Warehouse staff No stock movement yet; edit items freely
DispatchConfirmed Warehouse staff Goods physically leave warehouse; no stock change yet
ReceiptConfirmed Driver Stock transferred Warehouse → Vehicle; ride goes Active
DiscrepancyFlagged System Quantities differ; ride stays Open; waiting for resolution
Resolved Supervisor Resolved quantities transferred; ride goes Active

Important: confirming receipt (or resolving discrepancy) is what actually activates the ride and loads stock onto the vehicle. The meterstart odometer reading is recorded at this point.


7.8 Stock Flow (How Stock Moves)

Stock is tracked per item per location (Warehouse or Vehicle). Every write operation creates an immutable StockMovement record.

Trigger API call Movement type Direction
GRN received POST /api/purchase-orders/{id}/goods-receipts PurchaseReceipt → Warehouse
Loading sheet confirmed POST /api/loading-sheets/{id}/confirm-receipt Load Warehouse → Vehicle
Sale fulfilled (immediate) POST /api/sales (fulfillImmediately: true) Sale Vehicle → (out)
Delivery task confirmed POST /api/delivery-tasks/{id}/confirm Sale Vehicle → (out)
Customer returns goods POST /api/stock/customer-return CustomerReturn → Vehicle
Supplier return POST /api/purchase-orders/{id}/returns PurchaseReturn Warehouse → (out)
Manual transfer POST /api/stock/transfer Transfer Any → Any
Damage write-off POST /api/stock/damage Damage Location → (out)
Manual correction POST /api/stock/adjustment Adjustment Location ± delta

Closing a ride requires zero vehicle stock. Any remaining stock must be transferred back to a warehouse, recorded as damage, or returned by the customer before POST /api/rides/{id}/close will succeed.


7.9 Scheme / Promotion Flow

1. POST /api/schemes              ← create a promotion
   body: { name, ruleType, ruleConfig?, regionId?, eligibleSegment?,
           dateRangeFrom, dateRangeTo, agentLimitExempt, baselineExpectedSales?,
           ...optional free-goods fields }

2. (if BuyXGetY or FreeBundle)
   PUT /api/schemes/{id}/free-goods-rule
   PUT /api/schemes/{id}/bundle-components/{itemId}

3. At order time:
   GET /api/schemes/eligible?storeId=...&orderId=...
   → returns which schemes the store qualifies for, with discount/free-goods amounts

4. POST /api/orders/{orderId}/apply-scheme
   body: { schemeId }
   → scheme applied to the order; SchemeRedemption recorded

5. Analytics:
   GET /api/schemes/{id}/spend    ← total cost of scheme so far
   GET /api/schemes/{id}/roi      ← ROI vs baseline expected sales

Rule types quick reference:

Type ruleConfig JSON Trigger Payout
FlatPercent {"discountPercent": 10.0} Any order % off total
SlabDiscount {"tiers":[{"minOrderAmount":10000,"discountPercent":5},...]} Order value reaches slab % off total
BuyXGetY null Buy N of trigger item Free qty of free item
FreeBundle null Buy all bundle components Free qty of free item

Appendix A — Common Business Rules

  1. Store KYC threshold: A store with KycStatus = Pending has an order threshold of 0 (configurable via StoreManagement:UnverifiedKycOrderThreshold). Orders for unverified stores may be blocked or limited.

  2. Credit enforcement: On sale creation, the system checks OutstandingBalance + saleAmount ≤ CreditLimit. Exceeding this will block the sale.

  3. Driver compliance: When EnforceLicenseExpiry = true, a ride cannot be opened/activated if the assigned driver's license has expired. Same for EnforceMedicalClearance.

  4. Ride state machine: Transitions are strictly enforced — calling open on a non-Draft ride, activate on a non-Open ride, etc. returns 400 Bad Request.

  5. Loading sheet discrepancy: When ConfirmReceipt is called and received quantities differ from dispatched quantities, the sheet automatically transitions to DiscrepancyFlagged instead of ReceiptConfirmed.

  6. PO line cancellation: Individual lines can be cancelled only while the PO is Issued. The entire PO can only be cancelled while Issued.

  7. Scheme eligibility: A scheme applies to a store only if the store's segment matches EligibleSegment (or the scheme has no segment restriction), the scheme is within its date range, and the region matches (or scheme has no region restriction).

  8. Agent discount ceiling: A role's Level field controls the maximum discount an agent can apply. Schemes with AgentLimitExempt = true stack on top regardless of this ceiling.

  9. Scope enforcement: Users with an empty scope list for a scope type (regions/routes/stores) are assumed to have no restriction for that type (system-wide access). Only explicitly listed IDs restrict access.


Appendix B — Frontend Implementation Notes

Token / Session Strategy

⚠️ YET TO IMPLEMENT — Auth mechanism not yet decided. Do not implement token storage until Section 1 is finalized.

Permission Gating

Once auth is implemented, call GET /api/roles/{roleId}/permissions on login to pre-fetch all permissions for the session. Cache them client-side and use them to show/hide UI elements. The role ID will come from whatever credential the Auth Service issues.

Enum Serialization

The API serializes enums as strings by default in ASP.NET Core System.Text.Json — verify with your actual deployment. If numbers are returned, map them using the tables in Section 4.

GUID Format

Always send GUIDs as lowercase hyphenated strings: "3fa85f64-5717-4562-b3fc-2c963f66afa6".

Date Format

Send all dates as ISO 8601 strings. For date-only fields (license expiry), include a time component: "2026-12-31T00:00:00Z".

Pagination

For paginated endpoints, always implement page / pageSize query params in the UI. Default pageSize is 20.

File Upload

KYC document upload uses multipart/form-data. Do not set Content-Type manually when using browser FormData — the browser sets it with the boundary automatically.

Error Handling

  • 400 errors include { "error": "descriptive message" } — surface this to the user.
  • 404 — show "not found" state.
  • 409 — show the conflict message from { "error": "..." }.
  • 401 — redirect to login.
  • 403 — show "access denied" / hide the action.