84 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/swaggerin Development mode
Content-Type:application/jsonfor all requests unless noted (multipart/form-datafor KYC uploads)
Table of Contents
- Authentication & JWT
- Authorization Model
- Global Response Conventions
- Enumerations Reference
- Pagination
- Modules
- Auth
- Users
- Roles
- Authorization Checks
- Regions
- Routes
- Vehicles
- Warehouses
- Stores
- Store KYC Documents
- Categories & Subcategories
- Brands
- Units of Measure (UOMs)
- Items
- Price Lists
- Schemes (Promotions)
- Suppliers
- Purchase Orders (PO)
- Goods Receipt Notes (GRN)
- Purchase Returns
- Stock
- Driver Profiles
- Driver Compliance Config
- Rides
- Loading Sheets
- Delivery Tasks
- Sales
- Orders
- Payments
- Sync (ERP Import)
- 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/authroutes 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/authroutes 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"
}
Beyond CRUD — manage (targets/budgets/agency mapping/tax rules, consolidated), hierarchy, and
dashboard. Not gated by the regions.* permission codes above.
⚠️ Authorization temporarily disabled:
ManageAsynccurrently allows any authenticated caller — the previous role-level gate (RegionalManager, level 50, scoped viaUserRegionScope;RegionalAgency, level 60, any region) was deliberately stubbed out (CanManageRegionAsyncalways returnstrue) pending a decision on the role model for this endpoint. Re-enabling it is tracked as a TODO inRegionService.cs.
| Method | Path | Description |
|---|---|---|
PUT |
/api/regions/{id}/manage |
Set target/budget/agency-map/tax-rule in one call — replaces the old separate targets/budgets/agencies/tax-rule endpoints |
GET |
/api/regions/{id}/hierarchy |
Region's routes and each route's store ids |
GET |
/api/regions/{id}/dashboard?period=yyyy-MM |
Real-time order count/sales/target/budget for the period |
Manage request — every field is optional; only the sub-actions whose fields are present get
applied, so a caller can update just one thing or several in a single request. period is
required alongside targetValue and/or budgetAmount (both are silently skipped without it):
// PUT /api/regions/{id}/manage
{
"period": "2026-08", // required for targetValue/budgetAmount
"targetValue": 500000, // optional
"budgetAmount": 200000, // optional
"agencyUserId": "guid", // optional — must be a User with the RegionalAgency role
"ruleConfig": "{\"rate\": 0.15}" // optional — arbitrary JSON stored as a string, not yet consumed by pricing
}
Manage response — each field is null if that sub-action wasn't requested:
{
"regionId": "guid",
"target": { "regionId": "guid", "period": "2026-08", "targetValue": 500000 } | null,
"budget": { "regionId": "guid", "period": "2026-08", "budgetAmount": 200000 } | null,
"agencyMap": { "regionId": "guid", "agencyUserId": "guid" } | null,
"taxRule": { "regionId": "guid", "ruleConfig": "{\"rate\": 0.15}" } | null
}
Hierarchy response:
{
"regionId": "guid",
"regionName": "string",
"routes": [
{ "routeId": "guid", "routeName": "string", "storeIds": ["guid", "..."] }
]
}
Dashboard response:
{
"regionId": "guid",
"period": "2026-08",
"targetValue": 500000, // null if no target set for the period
"budgetAmount": 200000, // null if no budget set for the period
"orderCount": 42,
"actualSales": 187500.00,
"lastOrderDate": "datetime | null",
"taxRuleConfig": "{\"rate\": 0.15}" | null, // current tax rule, independent of period
"agencyUserIds": ["guid", "..."] // all agency users currently mapped to this region
}
taxRuleConfig and agencyUserIds are not period-scoped — they reflect current state regardless
of the period query param, added so the frontend's region-manage dialog has something to render
after calling manage (previously the dashboard only echoed back target/budget, leaving tax-rule
and agency-mapping changes invisible in the UI after saving).
All three of these endpoints return 404 if the region doesn't exist, or (for manage, when
agencyUserId is supplied) the target user doesn't hold the RegionalAgency role — the response
body carries no distinction between "not found" and "invalid agency user". (The former
"forbidden due to region scope" case doesn't currently apply — see the authorization note above.)
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 |
PUT |
/api/routes/{id}/region |
routes.update |
Assign/reassign the route to a region |
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,
"ownerUserId": "guid | null",
"isActive": true,
"createdAt": "datetime"
}
Assign to region request:
// PUT /api/routes/{id}/region
{ "regionId": "guid" }
Returns the same RouteResponse shape as above. 404 if the route or the target region doesn't
exist (or either is inactive).
Route & Sales Visit Management
Bulk store assignment, owner assignment, and GPS check-in/out for a SalesRep's daily visit
schedule. Not gated by the routes.* permission codes above — access is checked in
RouteService via IScopeAccessService.HasRouteScopeAsync, with a RouteManagerRoleLevel (20,
i.e. Supervisor and above) floor for the assignment endpoints. All endpoints below return 404
if the route doesn't exist, is inactive, or the caller lacks route scope — the response body
carries no distinction between "not found" and "forbidden".
| Method | Path | Description |
|---|---|---|
POST |
/api/routes/{id}/stores |
Bulk-assign a set of stores to this route (replaces each store's routeId) |
PUT |
/api/routes/{id}/owner |
Assign or reassign the route's owning SalesRep (OwnerUserId) |
POST |
/api/routes/{id}/optimize |
Route sequence optimization — not yet implemented, always returns 501 Not Implemented |
POST |
/api/routes/{id}/visits/check-in |
Record a GPS check-in at a store on this route |
POST |
/api/routes/{id}/visits/check-out |
Record check-out for an open visit at a store on this route |
Assign stores request / response:
// POST /api/routes/{id}/stores
{ "storeIds": ["guid", "guid", "..."] }
Returns the RouteResponse shape. All IDs must reference existing stores or the request is
rejected with 404 (no partial assignment).
{ "id": "guid", "regionId": "guid", "routeName": "string", "startPoint": "string", "endPoint": "string", "length": 12.5, "ownerUserId": "guid | null", "isActive": true, "createdAt": "datetime" }
Assign owner request / response:
// PUT /api/routes/{id}/owner
{ "salesRepId": "guid" } // must be an active User
Returns the same RouteResponse shape. 404 if the target user doesn't exist or is inactive.
Check-in request / response:
// POST /api/routes/{id}/visits/check-in
{
"storeId": "guid", // must currently be assigned to this route
"latitude": 6.927079,
"longitude": 79.861244,
"timestamp": "datetime | null" // optional, defaults to server time
}
{
"id": "guid",
"routeId": "guid",
"storeId": "guid",
"userId": "guid", // caller's user ID — the visiting rep
"checkInAt": "datetime",
"isDeviationFlagged": false, // true if GPS is > 500m (Haversine) from the store's registered lat/long
"checkOutAt": "datetime | null"
}
isDeviationFlagged is a basic distance check only — it does not yet trigger any alert/notification
(see [docs/09_fraud_controls.md] sendRouteDeviationAlert, which is not implemented).
Check-out request / response:
// POST /api/routes/{id}/visits/check-out
{
"storeId": "guid",
"timestamp": "datetime | null" // optional, defaults to server time
}
Returns the same shape as check-in with checkOutAt populated. 404 if there's no open
(checked-in, not yet checked-out) visit for that route+store.
There is no endpoint to list a route's open/in-progress visits — check-out requires the caller to
already know which store they checked into. A store's full visit history is available via
GET /api/stores/{id}/visit-history (see §6.9 Stores), which reads from this same
visit log.
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 buystriggerQtyoftriggerItemId, getsfreeQtyoffreeItemIdfor free.SlabDiscount: Tiered discount based on order value.ruleConfigJSON:{ "tiers": [ { "minOrderAmount": 10000, "discountPercent": 5.0 }, ... ] }(ordered ascending).FlatPercent: Flat percentage off.ruleConfigJSON:{ "discountPercent": 10.0 }.FreeBundle: Store buys all required bundle components, getsfreeQtyoffreeItemIdfree.
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}returns403 Forbiddenif 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}andPOST /api/ordersreturn403 Forbiddenif 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 |
GETreturns404 Not Foundif the sale doesn't exist.
POSTreturns404 Not Foundif the sale doesn't exist,400 Bad Requestif 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 = Pendingcan 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[].unitPriceisnull, the system looks up the active price list for the store's region and uses that price. - If
unitPriceis supplied, it must fall within[minPrice, price]from the price list. Prices outside this range cause the sale to be rejected (nullresponse → 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
Salemovement 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 Forbiddenis 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
Cancelledsale — returns400 Bad Request. - No upper limit enforced — overpayment is allowed (handle reconciliation in the UI).
collectedByis 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
-
Store KYC threshold: A store with
KycStatus = Pendinghas an order threshold of0(configurable viaStoreManagement:UnverifiedKycOrderThreshold). Orders for unverified stores may be blocked or limited. -
Credit enforcement: On sale creation, the system checks
OutstandingBalance + saleAmount ≤ CreditLimit. Exceeding this will block the sale. -
Driver compliance: When
EnforceLicenseExpiry = true, a ride cannot be opened/activated if the assigned driver's license has expired. Same forEnforceMedicalClearance. -
Ride state machine: Transitions are strictly enforced — calling
openon a non-Draft ride,activateon a non-Open ride, etc. returns400 Bad Request. -
Loading sheet discrepancy: When
ConfirmReceiptis called and received quantities differ from dispatched quantities, the sheet automatically transitions toDiscrepancyFlaggedinstead ofReceiptConfirmed. -
PO line cancellation: Individual lines can be cancelled only while the PO is
Issued. The entire PO can only be cancelled whileIssued. -
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). -
Agent discount ceiling: A role's
Levelfield controls the maximum discount an agent can apply. Schemes withAgentLimitExempt = truestack on top regardless of this ceiling. -
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
400errors 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.