Files

2762 lines
84 KiB
Markdown

# 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](#1-authentication--jwt)
2. [Authorization Model](#2-authorization-model)
3. [Global Response Conventions](#3-global-response-conventions)
4. [Enumerations Reference](#4-enumerations-reference)
5. [Pagination](#5-pagination)
6. [Modules](#6-modules)
- [Auth](#61-auth)
- [Users](#62-users)
- [Roles](#63-roles)
- [Authorization Checks](#64-authorization-checks)
- [Regions](#65-regions)
- [Routes](#66-routes)
- [Vehicles](#67-vehicles)
- [Warehouses](#68-warehouses)
- [Stores](#69-stores)
- [Store KYC Documents](#610-store-kyc-documents)
- [Categories & Subcategories](#611-categories--subcategories)
- [Brands](#612-brands)
- [Units of Measure (UOMs)](#613-units-of-measure-uoms)
- [Items](#614-items)
- [Price Lists](#615-price-lists)
- [Schemes (Promotions)](#616-schemes-promotions)
- [Suppliers](#617-suppliers)
- [Purchase Orders (PO)](#618-purchase-orders-po)
- [Goods Receipt Notes (GRN)](#619-goods-receipt-notes-grn)
- [Purchase Returns](#620-purchase-returns)
- [Stock](#621-stock)
- [Driver Profiles](#622-driver-profiles)
- [Driver Compliance Config](#623-driver-compliance-config)
- [Rides](#624-rides)
- [Loading Sheets](#625-loading-sheets)
- [Delivery Tasks](#626-delivery-tasks)
- [Sales](#627-sales)
- [Orders](#628-orders)
- [Payments](#629-payments)
- [Sync (ERP Import)](#630-sync-erp-import)
7. [Business Flows](#7-business-flows)
- [Region → Route → Store Setup](#71-region--route--store-setup)
- [Sale Flow](#72-sale-flow-sales-run)
- [Delivery Flow](#73-delivery-flow-end-to-end-ride-lifecycle)
- [Ride Cancellation Flow](#74-ride-cancellation-flow)
- [Payment Flow](#75-payment-flow)
- [Purchase Order Flow](#76-purchase-order-flow-procurement)
- [Loading Sheet Flow](#77-loading-sheet-flow-detail)
- [Stock Flow](#78-stock-flow-how-stock-moves)
- [Scheme / Promotion Flow](#79-scheme--promotion-flow)
---
## 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:
```json
{
"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](#1-authentication) 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`
```json
{
"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}`
```json
{ "fullName": "string" }
```
#### Assign Role — `PUT /api/users/{id}/role`
```json
{ "roleId": "guid" }
```
#### Assign Scope — `PUT /api/users/{id}/scope`
```json
{
"regionIds": ["guid", ...], // list (can be empty)
"routeIds": ["guid", ...],
"storeIds": ["guid", ...]
}
```
#### User Response Object
```json
{
"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
```json
{
"id": "guid",
"name": "string",
"level": 1, // integer hierarchy level; lower = more authority
"isActive": true,
"createdAt": "datetime"
}
```
#### Role Permissions Response
Array of:
```json
{
"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:**
```json
{ "hasPermission": true }
```
**Scope check responses:**
```json
{ "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:**
```json
{ "name": "string" }
```
**Response:**
```json
{
"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**: `ManageAsync` currently allows any authenticated
> caller — the previous role-level gate (`RegionalManager`, level 50, scoped via
> `UserRegionScope`; `RegionalAgency`, level 60, any region) was deliberately stubbed out
> (`CanManageRegionAsync` always returns `true`) pending a decision on the role model for this
> endpoint. Re-enabling it is tracked as a TODO in `RegionService.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):
```json
// 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:
```json
{
"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:**
```json
{
"regionId": "guid",
"regionName": "string",
"routes": [
{ "routeId": "guid", "routeName": "string", "storeIds": ["guid", "..."] }
]
}
```
**Dashboard response:**
```json
{
"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:**
```json
{
"regionId": "guid", // required
"routeName": "string", // required
"startPoint": "string", // required
"endPoint": "string", // required
"length": 12.5 // decimal (kilometers)
}
```
**Response:**
```json
{
"id": "guid",
"regionId": "guid",
"routeName": "string",
"startPoint": "string",
"endPoint": "string",
"length": 12.5,
"ownerUserId": "guid | null",
"isActive": true,
"createdAt": "datetime"
}
```
**Assign to region request:**
```json
// 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:**
```json
// 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).
```json
{ "id": "guid", "regionId": "guid", "routeName": "string", "startPoint": "string", "endPoint": "string", "length": 12.5, "ownerUserId": "guid | null", "isActive": true, "createdAt": "datetime" }
```
**Assign owner request / response:**
```json
// 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:**
```json
// 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
}
```
```json
{
"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:**
```json
// 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](#69-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:**
```json
{
"regNumber": "string", // registration number
"numberPlate": "string", // display plate
"type": "Van" // VehicleType enum
}
```
**Response:**
```json
{
"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:**
```json
{
"name": "string",
"location": "string",
"type": "DistributionCenter", // WareHouseType enum
"vehicleId": "guid | null" // optional, link to a vehicle
}
```
**Response:**
```json
{
"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
```json
{
"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
```json
{
"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`
```json
{
"newLimit": 75000.00,
"approverId": "guid" // user ID of the approver
}
```
#### Update Outstanding Balance — `PUT /api/stores/{id}/outstanding-balance`
```json
{ "newBalance": 10000.00 }
```
#### Outstanding Balance Response — `GET /api/stores/{id}/outstanding-balance`
```json
{
"storeId": "guid",
"creditLimit": 50000.00,
"outstandingBalance": 12500.00,
"availableCredit": 37500.00
}
```
#### Verify KYC — `PUT /api/stores/{id}/kyc/verify`
```json
{
"status": "Verified", // KycStatus: Verified or Rejected
"notes": "string | null"
}
```
#### Classify Segment — `PUT /api/stores/{id}/segment`
```json
{ "segment": "Gold" }
```
#### Visit History Response — `GET /api/stores/{id}/visit-history`
Array of:
```json
{
"visitId": "guid",
"visitDate": "datetime",
"repUserId": "guid",
"outcome": "string"
}
```
#### Scheme Eligibility — `GET /api/stores/{id}/schemes/{schemeId}/eligibility`
```json
{
"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"`).
```json
{
"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:**
```json
{
"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:**
```json
{ "name": "string" }
```
**Category detail response (GET by ID):**
```json
{
"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`
```json
{
"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}`
```json
{
"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.
```json
{
"conversions": [
{ "fromUomId": "guid", "toUomId": "guid", "factor": 12.0 }
]
}
```
#### Item Summary Response (list)
```json
{
"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:
```json
{
"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
```json
{
"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.
```json
{
"price": 150.00,
"minPrice": 130.00 // optional, null = price is non-negotiable
}
```
#### Price List Response
```json
{
"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`
```json
{
"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}`
```json
{
"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`
```json
{
"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}`
```json
{ "qty": 5 }
```
#### Apply Scheme to Order — `POST /api/orders/{id}/apply-scheme`
```json
{ "schemeId": "guid" }
```
#### Scheme Response
```json
{
"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:
```json
{
"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`
```json
{
"schemeId": "guid",
"totalDiscountSpend": 15000.00,
"totalFreeGoodsCostSpend": 3000.00,
"totalSpend": 18000.00,
"redemptionCount": 45
}
```
#### Scheme ROI — `GET /api/schemes/{id}/roi`
```json
{
"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:**
```json
{
"name": "string",
"contactInfo": "string | null"
}
```
**Response:**
```json
{
"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`
```json
{
"supplierId": "guid",
"items": [
{
"itemId": "guid",
"quantity": 100,
"unitCost": 85.00
}
]
}
```
#### Update PO — `PUT /api/purchase-orders/{id}`
Only valid while status is `Issued`.
```json
{
"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`
```json
{ "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).
```json
{ "reason": "string | null" }
```
#### Match Invoice — `POST /api/purchase-orders/{id}/match-invoice`
Query params: `?invoiceId={guid}&grnId={guid}` (both optional)
**Response:**
```json
{
"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`
```json
{
"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:**
```json
{
"purchaseOrderId": "guid",
"variance": "string",
"resolution": "string",
"resolvedAt": "datetime"
}
```
#### PO Response
```json
{
"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.
```json
{
"warehouseId": "guid",
"items": [
{
"purchaseOrderItemId": "guid", // PO line being received
"acceptedQuantity": 98,
"rejectedQuantity": 2
}
]
}
```
#### GRN Response
```json
{
"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.
```json
{
"reason": "string",
"items": [
{
"itemId": "guid",
"quantity": 5
}
]
}
```
#### Purchase Return Response
```json
{
"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`:
```json
{
"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`
```json
{
"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`:
```json
{
"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`
```json
{
"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`
```json
{
"itemId": "guid",
"locationId": "guid",
"locationType": "Warehouse",
"quantity": 3.0,
"reasonCode": "string" // e.g. "WaterDamage", "ExpiredGoods"
}
```
#### Record Customer Return — `POST /api/stock/customer-return`
```json
{
"itemId": "guid",
"vehicleId": "guid", // returns go to vehicle stock
"quantity": 2.0,
"referenceId": "guid | null"
}
```
#### Record Adjustment — `POST /api/stock/adjustment`
```json
{
"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`
```json
{
"fK_UserId": "guid", // user to link this profile to
"licenseNumber": "string",
"licenseExpiry": "datetime",
"medicalClearanceExpiry": "datetime"
}
```
#### Update Driver Profile — `PUT /api/driver-profiles/{id}`
```json
{
"licenseNumber": "string",
"licenseExpiry": "datetime",
"medicalClearanceExpiry": "datetime"
}
```
#### Driver Profile Response
```json
{
"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):
```json
{
"id": "guid",
"enforceLicenseExpiry": true,
"enforceMedicalClearance": true,
"updatedAt": "datetime",
"updatedBy": "guid"
}
```
**PUT request:**
```json
{
"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`
```json
{
"rideType": "Delivery",
"fK_VehicleId": "guid",
"fK_DriverProfileId": "guid",
"fK_RouteId": "guid",
"fK_SourceWarehouseId": "guid | null"
}
```
#### Assign Driver — `PUT /api/rides/{id}/driver`
```json
{ "fK_DriverProfileId": "guid" }
```
#### Open Ride — `POST /api/rides/{id}/open`
No body required.
#### Activate Ride — `POST /api/rides/{id}/activate`
```json
{ "meterstart": 12345 } // odometer reading at start (integer)
```
#### Close Ride — `POST /api/rides/{id}/close`
```json
{ "meterend": 12567 } // odometer reading at end (integer)
```
#### Raise Cancel Request — `POST /api/rides/{id}/cancel-request`
```json
{
"typeCode": "VehicleBreakdown", // CancelRequestType enum
"reason": "string"
}
```
#### List Cancel Requests — `GET /api/rides/cancel-requests`
Query params: `?status={CancelRequestStatus}&page=1&pageSize=20`
#### Ride Response
```json
{
"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
```json
{
"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.
```json
{
"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`
```json
{ "fK_RideId": "guid" }
```
#### Set Dispatched Items — `PUT /api/loading-sheets/{id}/items`
Replaces all items on the loading sheet.
```json
{
"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`
```json
{
"items": [
{ "fK_ItemId": "guid", "quantityReceived": 98.0 }
]
}
```
#### Confirm Receipt — `POST /api/loading-sheets/{id}/confirm-receipt`
```json
{ "meterstart": 12345 } // driver's vehicle odometer at receipt
```
#### Resolve Discrepancy — `POST /api/loading-sheets/{id}/resolve`
```json
{
"items": [
{ "fK_ItemId": "guid", "quantityResolved": 98.0 }
],
"meterstart": 12345
}
```
#### Loading Sheet Response
```json
{
"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`
```json
{
"items": [
{ "itemId": "guid", "quantityFulfilled": 8 }
]
}
```
#### Fail Task — `POST /api/delivery-tasks/{id}/fail`
```json
{ "note": "string" } // reason for failure
```
#### Delivery Task Response
```json
{
"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`
```json
{
"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
```json
{
"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`
```json
{ "saleId": "guid" }
```
#### Update Order — `PUT /api/orders/{id}`
```json
{ "status": "Fulfilled" } // OrderStatus enum
```
#### Order Response
```json
{
"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`
```json
{
"amount": 25000.00,
"method": "Cash" // PaymentMethod enum
}
```
#### Payment Response
```json
{
"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`
```json
{
"items": [
{ "erpId": 101, "name": "Piece" }
]
}
```
#### Sync Categories — `POST /api/sync/categories`
```json
{
"items": [
{
"erpId": 10,
"name": "Beverages",
"subCategories": [
{ "erpId": 101, "name": "Soft Drinks", "parentErpId": 10 }
]
}
]
}
```
#### Sync Brands — `POST /api/sync/brands`
```json
{
"items": [
{ "erpId": 201, "name": "Coca-Cola" }
]
}
```
#### Sync Items — `POST /api/sync/items`
```json
{
"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)
```json
{
"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.