diff --git a/Distribution/docs/00_existing_gaps.md b/Distribution/docs/00_existing_gaps.md index 3c486e9..68dedc8 100644 --- a/Distribution/docs/00_existing_gaps.md +++ b/Distribution/docs/00_existing_gaps.md @@ -29,6 +29,10 @@ function reference (`assignAreaToRegion`, `getRegionHierarchy`) and any region-s via the API even though `Store.FK_RouteId` exists on the model. Blocks `assignOutletsToRoute` (§3) and route/region-scoped authorization for Stores and Orders. +> **Resolved** — `StoreDTOs.cs`'s `CreateStoreRequest`/`UpdateStoreRequest` now carry `RouteId`, +> and a dedicated bulk-assign endpoint (`POST /api/routes/{id}/stores`) was added in +> [11_route_sales_management.md](./11_route_sales_management.md). + ### 3. Role Controller — Missing Authorization `RoleController`'s GET endpoints carry only `[Authorize]`, with no `[Authorize(Policy = "...")]` diff --git a/Distribution/docs/09_fraud_controls.md b/Distribution/docs/09_fraud_controls.md index a1ed569..6c6389e 100644 --- a/Distribution/docs/09_fraud_controls.md +++ b/Distribution/docs/09_fraud_controls.md @@ -48,7 +48,7 @@ adds the automated pre-check (`ReturnWithoutSale`) that runs before a human appr | `logAuditEvent(userId, action, entityType, entityId)` (§18) | Internal — called by every mutating service, not directly exposed | | `checkLicenseCompliance(entityId, licenseType)` (§18) | `GET /api/compliance/{entityType}/{entityId}/license-check` | | `generateComplianceReport(period)` (§18) | `GET /api/compliance/report?period=...` | -| `sendRouteDeviationAlert(routeId, salesRepId)` (§15) | Triggered from route check-in/out data (§3 of the function reference; not yet in any numbered step — see [11_route_sales_management.md](./11_route_sales_management.md)) | +| `sendRouteDeviationAlert(routeId, salesRepId)` (§15) | Not implemented. The underlying data now exists — `POST /api/routes/{id}/visits/check-in` ([11_route_sales_management.md](./11_route_sales_management.md)) computes and stores `IsDeviationFlagged` on each `RouteVisitLog` row (Haversine distance vs. the store's registered lat/long) — but nothing reads that flag to raise an alert/anomaly flag yet. | | — | `GET /api/audit-log?entityType=...&entityId=...` | | — | `GET /api/anomaly-flags?status=Open` | @@ -57,8 +57,9 @@ adds the automated pre-check (`ReturnWithoutSale`) that runs before a human appr ## Automated Checks & Flags - Returns without a matching prior sale (`ReturnWithoutSale`). -- Sales/deliveries outside the geofenced route (`RouteDeviation`) — depends on GPS check-in/out - data from [11_route_sales_management.md](./11_route_sales_management.md). +- Sales/deliveries outside the geofenced route (`RouteDeviation`) — the GPS check-in/out data this + depends on now exists ([11_route_sales_management.md](./11_route_sales_management.md)'s + `RouteVisitLog.IsDeviationFlagged`), but no anomaly flag or alert reads it yet. - Agents with consistently high variance rates across closed rides (`HighVarianceRate`) — reads `Variance Record` history from [07_ride_reconciliation.md](./07_ride_reconciliation.md). - Cash collected below invoiced amount (`CashShortfall`) — reads `Payment Record` from diff --git a/Distribution/docs/11_route_sales_management.md b/Distribution/docs/11_route_sales_management.md index 54b369c..b35aec7 100644 --- a/Distribution/docs/11_route_sales_management.md +++ b/Distribution/docs/11_route_sales_management.md @@ -1,6 +1,6 @@ # 11 — Route & Sales Visit Management -**Status**: Not Started +**Status**: Partially Implemented **Depends On**: 00 — Existing Gap Fixes --- @@ -62,10 +62,41 @@ access grant which may cover multiple users (e.g. a Supervisor overseeing severa ## Open Questions - Is route sequence optimization (`optimizeRouteSequence`) a manual reorder endpoint, or does it - need actual distance/priority-based computation (external mapping API)? + need actual distance/priority-based computation (external mapping API)? — **Deferred.** The + endpoint is stubbed (`501 Not Implemented`) until this is decided. --- ## Implementation Notes -> Fill this section in during planning discussion before writing any code. +Implemented: `assignOutletsToRoute`, `assignRouteToSalesRep`/`reassignRoute` (same endpoint), +`recordRouteCheckIn`/`recordRouteCheckOut`, and `getStoreVisitHistory` (§4, now reads from +`RouteVisitLog`). See [BACKEND_API_REFERENCE.md §6.6](../../sharedDocs/BACKEND_API_REFERENCE.md#66-routes) +for full request/response shapes. + +Deliberately deferred, not oversights: +- **`optimizeRouteSequence`** — stubbed as `501 Not Implemented`; no distance/priority computation + exists (see Open Questions above). +- **`getRouteAdherence`** / **`getRoutePerformance`** — not built; the underlying metrics + (adherence %, strike rate, missed-visit count) were never defined, so there was nothing concrete + to implement against. +- **`sendRouteDeviationAlert`** — check-in computes and stores a basic `IsDeviationFlagged` boolean + (Haversine distance > 500m from the store's registered `Latitude`/`Longitude`) but does not call + any alert/notification pipeline, since [09_fraud_controls.md](./09_fraud_controls.md) itself is + not implemented yet. + +Data model differs slightly from the original sketch above: `RouteId`/`OwnerUserId` became a +nullable `FK_OwnerUserId` directly on `Route` (no separate ownership table needed for a +single-owner field), and `Route Visit Log`'s `CheckInGps` became two decimal columns +(`CheckInLatitude`/`CheckInLongitude`) to match the existing `Store.Latitude`/`Longitude` shape +and make the distance calculation straightforward. + +Authorization for the new endpoints follows the `RegionService.CanManageRegionAsync` pattern +(scope check via `IScopeAccessService`, no dedicated `routes.*` permission code): bulk store +assignment and owner assignment require `IScopeAccessService.HasRouteScopeAsync` plus role level +≥ 20 (Supervisor+); check-in/check-out require route scope only, no role floor, since any rep with +access to the route should be able to log their own visits. + +Frontend: implemented in `distribution_fe` — an admin "Manage" dialog on the Routes page (owner + +bulk store assignment), a read-only visit-history dialog on the Stores page, and a new rep-facing +`/sales-visits` page for GPS check-in/check-out (see `distribution_fe/app/(dashboard)/sales-visits/`). diff --git a/Distribution/docs/12_store_management.md b/Distribution/docs/12_store_management.md index daf6e56..a352f10 100644 --- a/Distribution/docs/12_store_management.md +++ b/Distribution/docs/12_store_management.md @@ -107,7 +107,7 @@ One migration needed: | PUT | /api/stores/{id}/outstanding-balance | stores.credit.update | Manual adjustment until Finance owns this; same permission as credit-limit | | GET | /api/stores/{id}/outstanding-balance | stores.view | Returns cached value + CreditLimit + available credit | | PUT | /api/stores/{id}/segment | stores.update | Body: `{ "segment": "Gold" }` | -| GET | /api/stores/{id}/visit-history | stores.view | Paginated. Empty list until Step 11 wiring lands | +| GET | /api/stores/{id}/visit-history | stores.view | Wired to Step 11 — returns a plain array (not paginated) read from `RouteVisitLog` | | GET | /api/stores/{id}/schemes/{schemeId}/eligibility | stores.view | Wired to Step 17 — returns real region/segment/date-range eligibility for the store against that scheme | | GET | /api/stores/{id}/dashboard?period=... | stores.view | Aggregates order count/value for the period from existing Order tables | diff --git a/Distribution/docs/21_notifications_alerts.md b/Distribution/docs/21_notifications_alerts.md index 6c7ea07..c339b8d 100644 --- a/Distribution/docs/21_notifications_alerts.md +++ b/Distribution/docs/21_notifications_alerts.md @@ -39,7 +39,7 @@ and trigger wiring; the underlying conditions it watches for are already defined | `sendLowStockAlert(warehouseId, sku)` | Internal — triggered when a stock movement crosses the reorder point set in [02_stock_ledger.md](./02_stock_ledger.md) | | `sendCreditLimitBreachAlert(storeId)` | Internal — triggered on order/invoice creation when `Store.OutstandingBalance` exceeds `Store.CreditLimit` | | `sendApprovalPendingNotification(approverId, requestId)` | Internal — triggered by `submitOrderForApproval` ([08_sales_agent_controls.md](./08_sales_agent_controls.md)) and return approval ([18_returns_claims.md](./18_returns_claims.md)) | -| `sendRouteDeviationAlert(routeId, salesRepId)` | Internal — triggered from [09_fraud_controls.md](./09_fraud_controls.md)'s anomaly flag | +| `sendRouteDeviationAlert(routeId, salesRepId)` | Internal — triggered from [09_fraud_controls.md](./09_fraud_controls.md)'s anomaly flag. The source data now exists (`RouteVisitLog.IsDeviationFlagged`, see [11_route_sales_management.md](./11_route_sales_management.md)), but no anomaly flag or notification reads it yet. | | — | `GET /api/notifications` — recipient's own notification inbox | | — | `PUT /api/notifications/{id}/read` | diff --git a/Distribution/docs/mainPlan.md b/Distribution/docs/mainPlan.md index 0741239..2934e21 100644 --- a/Distribution/docs/mainPlan.md +++ b/Distribution/docs/mainPlan.md @@ -55,7 +55,7 @@ Step 00 closed these. Kept here for history; all are fixed in the current codeba | 08 | Sales Agent Controls | Not Started | 07 | [08_sales_agent_controls.md](./08_sales_agent_controls.md) | | 09 | Fraud Controls & Audit Trail | Not Started | 08 | [09_fraud_controls.md](./09_fraud_controls.md) | | 10 | Region Management (Beyond CRUD) | Done | 00 | [10_region_management.md](./10_region_management.md) | -| 11 | Route & Sales Visit Management | Not Started | 00 | [11_route_sales_management.md](./11_route_sales_management.md) | +| 11 | Route & Sales Visit Management | Partially Implemented | 00 | [11_route_sales_management.md](./11_route_sales_management.md) | | 12 | Store Management (Beyond CRUD) | Done | 00 | [12_store_management.md](./12_store_management.md) | | 13 | Fleet Management (Beyond CRUD) | Not Started | 00 | [13_fleet_management.md](./13_fleet_management.md) | | 14 | Pricing & Supplier Master Data | Done | 01 | [14_pricing_and_suppliers.md](./14_pricing_and_suppliers.md) | @@ -129,14 +129,14 @@ here — see `SRS_User_Management_Module.pdf`. | Function | What it does | Status | |---|---|---| | `createRoute(name, areaId)` | Creates a new sales visit route within an area. | Partially implemented — `RouteController` supports create, but by `RegionId` not `areaId` (no `Area` entity exists). A route's region can also be changed after creation via `PUT /api/routes/{id}/region`. | -| `assignOutletsToRoute(routeId, outletIds[])` | Maps a set of stores to a route. | Partially implemented — a `Store` has a single `FK_RouteId`, set via `StoreController` create/update; no bulk-assign endpoint. | -| `assignRouteToSalesRep(routeId, salesRepId)` | Assigns ownership of a route to an SR. | Not implemented — only `UserRouteScope` exists (scope grant), not a single-owner assignment. | -| `optimizeRouteSequence(routeId)` | Reorders the visit sequence for efficiency (distance/priority-based). | Not implemented. | -| `recordRouteCheckIn(routeId, outletId, gpsCoords, timestamp)` | Logs an SR's arrival at an outlet. | Not implemented. | -| `recordRouteCheckOut(routeId, outletId, timestamp)` | Logs departure and closes out the visit. | Not implemented. | -| `getRouteAdherence(routeId, date)` | Compares planned vs. actual visits for a route/day. | Not implemented — depends on check-in/check-out above. | -| `reassignRoute(routeId, newSalesRepId)` | Moves a route to a different SR. | Not implemented. | -| `getRoutePerformance(routeId, period)` | Sales per route, strike rate, missed-visit count. | Not implemented. | +| `assignOutletsToRoute(routeId, outletIds[])` | Maps a set of stores to a route. | Implemented — `POST /api/routes/{id}/stores` bulk-replaces `FK_RouteId` for the given store ids (all-or-nothing; 404 if any id doesn't exist). | +| `assignRouteToSalesRep(routeId, salesRepId)` | Assigns ownership of a route to an SR. | Implemented — `PUT /api/routes/{id}/owner`, backed by the new `Route.FK_OwnerUserId` field, distinct from the existing `UserRouteScope` grant. | +| `optimizeRouteSequence(routeId)` | Reorders the visit sequence for efficiency (distance/priority-based). | Stubbed — `POST /api/routes/{id}/optimize` always returns `501 Not Implemented`; no distance/priority computation exists yet (see open question in [11_route_sales_management.md](./11_route_sales_management.md)). | +| `recordRouteCheckIn(routeId, outletId, gpsCoords, timestamp)` | Logs an SR's arrival at an outlet. | Implemented — `POST /api/routes/{id}/visits/check-in`, backed by the new `RouteVisitLog` entity. Sets `IsDeviationFlagged` via a basic Haversine distance check (>500m) against the store's `Latitude`/`Longitude`; does not yet call `sendRouteDeviationAlert` (§9, unimplemented). | +| `recordRouteCheckOut(routeId, outletId, timestamp)` | Logs departure and closes out the visit. | Implemented — `POST /api/routes/{id}/visits/check-out`, closes the matching open `RouteVisitLog` row (404 if none). | +| `getRouteAdherence(routeId, date)` | Compares planned vs. actual visits for a route/day. | Not implemented — metrics deferred, out of scope for the current pass. | +| `reassignRoute(routeId, newSalesRepId)` | Moves a route to a different SR. | Implemented — same endpoint as `assignRouteToSalesRep` (`PUT /api/routes/{id}/owner`); the doc treats these as one action, distinguished only by caller intent. | +| `getRoutePerformance(routeId, period)` | Sales per route, strike rate, missed-visit count. | Not implemented — metrics deferred, out of scope for the current pass. | | `planDeliveryRoute(warehouseId, orderIds[])` | Builds the warehouse-to-outlet dispatch route (separate from the sales visit route). | Not implemented — this is the `DeliveryRun` concept; see [03_ride_lifecycle.md](./03_ride_lifecycle.md). | ### 4. Store (Outlet) Management @@ -152,7 +152,7 @@ here — see `SRS_User_Management_Module.pdf`. | `getStoreOutstandingBalance(storeId)` | Returns current receivables against the store. | Partially implemented - `GET /api/stores/{id}/outstanding-balance` returns the cached `OutstandingBalance`, `CreditLimit`, and available credit. It is not yet driven by Finance events or invoice/payment records. | | `classifyStore(storeId, segment)` | Sets/updates the store's segment (key account, general trade, etc.). | Implemented - `PUT /api/stores/{id}/segment` updates the free-text `Store.Segment` field. | | `deactivateStore(storeId, reason)` | Soft-deletes a store, blocking new orders against it. | Partially implemented — `DELETE /api/stores/{id}` soft-deletes via `IsActive`, no reason code captured. | -| `getStoreVisitHistory(storeId)` | Returns visit compliance history from Route Management. | Placeholder only - endpoint exists (`GET /api/stores/{id}/visit-history`) but currently returns an empty list until Route/Sales Visit Management lands. | +| `getStoreVisitHistory(storeId)` | Returns visit compliance history from Route Management. | Implemented — `GET /api/stores/{id}/visit-history` now reads from the `RouteVisitLog` table (§3), returning each visit's date, rep, and outcome (`In Progress`/`Completed`). | | `checkStoreSchemeEligibility(storeId, schemeId)` | Evaluates whether a store qualifies for a given promotion. | Implemented — `GET /api/stores/{id}/schemes/{schemeId}/eligibility` now calls `SchemeService.CheckStoreScopeEligibilityAsync` (region/segment/date-range scope check, no order context). For full order-line rule evaluation against a specific order, use `evaluateSchemeEligibility(storeId, orderId)` instead (`GET /api/schemes/eligible?storeId=...&orderId=...`, §11). | | `getStoreSalesDashboard(storeId, period)` | Store-level sales and outstanding summary. | Partially implemented - `GET /api/stores/{id}/dashboard` aggregates order count, total order value, and last order date for the selected month. It does not yet include full sales/outstanding analytics from Finance. | diff --git a/sharedDocs/BACKEND_API_REFERENCE.md b/sharedDocs/BACKEND_API_REFERENCE.md index 6dddc51..f605908 100644 --- a/sharedDocs/BACKEND_API_REFERENCE.md +++ b/sharedDocs/BACKEND_API_REFERENCE.md @@ -619,6 +619,7 @@ role — the response body carries no distinction between "not found" and "forbi "startPoint": "string", "endPoint": "string", "length": 12.5, + "ownerUserId": "guid | null", "isActive": true, "createdAt": "datetime" } @@ -632,6 +633,81 @@ role — the response body carries no distinction between "not found" and "forbi 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