Field operations
Back-office field operations — delegates, zones, objectives, tour planning and live follow-up, visits and their unlocking, visit types, checklist templates and the invitation funnel.
These operations plan and measure the field activity of cahier §4.5 (« Planifier l'activité terrain et mesurer les résultats commerciaux »): delegate accounts and zones (wilayas, communes, points of sale), objectives, daily / weekly / monthly tours with imposed or recommended order, live follow-up of starts, visits, positions and closings, analysis of visit reports, and the invitation funnel. Screens: delegates (BO-16 to BO-18), zones (BO-19), objectives (BO-20), tour planner, editor and live detail (BO-21 to BO-23), visits (BO-24, BO-25), visit types and checklists (BO-26) and the field dashboard with its live map (BO-06). Tour and visit statuses are defined on Lifecycles › Tour and Visit; the delegate's side is on tours and visits.
Who uses it. The Administrateur commercial (cahier §3: « délégués, tournées ») plans and follows; the Super administrateur too; the Direction reads performance. Delegates never call these operations.
| Permission | Unlocks on this page |
|---|---|
field.view | Delegates, performance, zones (also with crm.view), objectives, tours, tracks, live map, visits, visit types, checklists, invitation funnel |
field.create | Creating delegates, objectives and tours |
field.update | Editing delegates, objectives and tours; stops; recommended order; cancelling a tour |
field.delete | Deleting objectives |
field.validate | Reopening a closed tour; unlocking a finished visit |
field.administer | Zones, visit types, checklist templates |
Settings used (settings keys): field.gpsToleranceMeters (150), field.gpsMaxAccuracyMeters (100), field.allowOutOfToleranceCheckIn (false), field.locationIntervalSeconds (300), field.locationDistanceMeters (200), field.locationRetentionDays (90), field.reportRequiredToEndVisit (true), field.closingRequiresSync (true), field.reopenMaxDays (7), field.visitUnlockHours (24), invitations.validityDays (30). GPS rules: BR-50 to BR-55.
Audit. CREATE / UPDATE / DELETE on delegates, zones, objectives, tours, visit types and templates; STATUS_CHANGE on tour cancellation; OVERRIDE on tour reopening, visit unlocking and stops added outside the delegate's scope. Domain history: TourStatusChange, DelegateZone, ResellerAssignment.
Real time. Tours started, suspended, resumed and closed, check-ins, finished and not-done visits and delegate positions reach the back-office through the SSE stream (tour.*, visit.*, delegate.position, permission field.view), so the live map and the tour detail update without polling (cahier §6.4 « La visite apparaît en temps réel dans le back-office »).
Conventions: lists return Page<T> = { items, total, page, pageSize } (responses); GeoPoint, ResellerSummary, UserSummary, FileRef are shared schemas; distances in metres (haversine, BR-50); dates YYYY-MM-DD and times of day HH:mm in Africa/Algiers; editable records carry version; a missing reason answers 422 REASON_REQUIRED. 401, 429 and 500 apply everywhere and are not repeated.
| Method | Path | Operation | Access | Apps | Release |
|---|---|---|---|---|---|
| GET · POST · PATCH | /admin/delegates[/{delegateId}] | admin.delegates.crudDelegates (account, code, zones, status) | field.* | BO | V1 |
| GET | /admin/delegates/{delegateId}/performance | admin.delegates.performanceDelegate KPIs over a period | field.view | BO | V1 |
| GET · POST · PATCH · DELETE | /admin/zones[/{zoneId}] | admin.zones.crudZones and their wilayas/communes | field.administer | BO | V1 |
| GET · POST · PATCH · DELETE | /admin/objectives[/{objectiveId}] | admin.objectives.crudObjectives per delegate and period | field.* | BO | V1 |
| GET | /admin/tours | admin.tours.listTours by delegate and date range (planner calendar) | field.view | BO | V1 |
| POST | /admin/tours | admin.tours.createPlan a tour (or a recurring series) with stops | field.create | BO | V1 |
| GET | /admin/tours/{tourId} | admin.tours.getTour detail with visits, check-ins, distances, photos, reports and closing summary | field.view | BO | V1 |
| PATCH | /admin/tours/{tourId} | admin.tours.updateEdit date, ordering mode, notes (planned tours only) | field.update | BO | V1 |
| PUT | /admin/tours/{tourId}/stops | admin.tours.setStopsReplace the ordered stops (synced to the delegate) | field.update | BO | V1 |
| POST | /admin/tours/{tourId}/recommend-order | admin.tours.recommendOrderCompute a recommended visiting order (nearest neighbour from the start point) | field.update | BO | V1 |
| POST | /admin/tours/{tourId}/cancel | admin.tours.cancelCancel a tour with a reason | field.update | BO | V1 |
| POST | /admin/tours/{tourId}/reopen | admin.tours.reopenAuthorised reopening of a closed tour | field.validate | BO | V1 |
| GET | /admin/tours/{tourId}/track | admin.tours.trackPosition trail of a tour (retention policy applies) | field.view | BO | V1 |
| GET | /admin/live/delegates | admin.live.delegatesDelegates currently on tour with last known position and progress | field.view | BO | V1 |
| GET | /admin/visits | admin.visits.listVisits with status, delegate, reseller, outcome filters | field.view | BO | V1 |
| GET | /admin/visits/{visitId} | admin.visits.getVisit with check-in distance, checklist, photos and report | field.view | BO | V1 |
| POST | /admin/visits/{visitId}/unlock | admin.visits.unlockAllow correction of a finished visit (logged) | field.validate | BO | V1 |
| GET · POST · PATCH | /admin/visit-types[/{visitTypeId}] | admin.visitTypes.crudVisit types with photo/report requirements and checklist template | field.administer | BO | V1 |
| GET · POST · PATCH | /admin/checklist-templates[/{templateId}] | admin.checklists.crudChecklist templates | field.administer | BO | V1 |
| GET | /admin/invitations | admin.invitations.listInvitation funnel by delegate (sent → installed → account → verified → catalog → first order) | field.view | BO | V1 |
Schemas
Delegate
A delegate is a User of type DELEGATE with role DELEGATE, a DelegateProfile and its DelegateZone rows; delegateId is the user id.
interface Delegate {
id: Uuid; // user id
code: string; // unique, free format (example "D014") — Assumption
firstName: string;
lastName: string;
displayName: string;
phone: string; // login, E.164
email?: string;
avatarUrl?: string;
accountStatus: AccountStatus;
zones: { id: Uuid; name: string; isPrimary: boolean }[]; // isPrimary: the delegate is the zone's primary delegate (BR-47)
wilayaCodes: string[]; // derived from the zones
homeBase?: GeoPoint; // default start point of recommended orders
manager?: UserSummary;
counts: { assignedResellers: number; prospects: number; openTasks: number };
today?: { tourId: Uuid; number: string; status: TourStatus; visitsDone: number; visitsPlanned: number };
lastSeenAt?: Iso; // last authenticated call from the delegate app
appVersion?: string;
locationConsentAt?: Iso; // BR-53
createdAt: Iso;
version: number;
}Tour
interface TourListItem {
id: Uuid;
number: string; // TRN-2026-000045
date: string; // YYYY-MM-DD
delegate: UserSummary;
status: TourStatus;
orderingMode: TourOrderingMode; // IMPOSED | RECOMMENDED
seriesId?: Uuid; // recurring series
stops: number;
visitsDone: number;
visitsNotDone: number;
plannedKm?: number; // straight-line legs of the planned order
km?: number; // travelled (BR-54), when started
startedAt?: Iso;
completedAt?: Iso;
late: boolean; // derived: TO_START after its date, or IN_PROGRESS / SUSPENDED after its date
version: number;
}
interface TourStop { // one stop = one Visit
visitId: Uuid;
visitNumber: string; // VIS-2026-001234
sequence: number; // 1-based planned order
reseller: ResellerSummary;
plannedAt?: string; // HH:mm local time slot start
priority: VisitPriority; // NORMAL | HIGH
visitType: { id: Uuid; name: string };
status: VisitStatus;
late: boolean; // derived: PLANNED and its time slot has passed
checkIn?: { at: Iso; position: GeoPoint; distanceMeters: number | null; withinTolerance: boolean; justification?: string };
startedAt?: Iso;
endedAt?: Iso;
durationMinutes?: number;
notDone?: { reasonCode: string; comment?: string; at: Iso };
photosCount: number;
hasReport: boolean;
orderId?: Uuid; // assisted order prepared during the visit
addedBy: "PLAN" | "DELEGATE"; // DELEGATE = unplanned visit added on the spot
}
interface TourDetail extends TourListItem {
notes?: string;
stopsDetail: TourStop[];
startPosition?: GeoPoint;
startPositionMissing?: boolean; // started without location permission (BR-53)
history: (StatusChange & { position?: GeoPoint })[]; // TourStatusChange
closing?: { // final report (cahier §6.9)
comment: string;
visitsPlanned: number; visitsDone: number; visitsNotDone: number;
signups: number; appInstalls: number; firstOrders: number; orders: number; revenueHt: Money;
km: number; conversionRateBps: number;
objectives: { metric: ObjectiveMetric; target: number; achieved: number }[];
version: number; // a reopened tour keeps previous final reports as versions
};
livePosition?: { position: GeoPoint; at: Iso; stale: boolean };
createdBy: UserSummary;
createdAt: Iso;
}Delegates
GET /admin/delegates
admin.delegates.crud (list) · Permission field.view · Apps BO · Release V1 · Requirements FLD-01
Purpose. Lists delegates with their zones, today's tour and counters.
Request
| Query | Type | Values / default | Notes |
|---|---|---|---|
status | AccountStatus[] | repeatable; default ACTIVE, SUSPENDED | |
zoneId | Uuid | ||
wilayaCode | string | ||
onTourToday | boolean | Tour of today IN_PROGRESS or SUSPENDED | |
q | string | min 2 chars | Name, code, phone |
sort / order / page / pageSize | lastName (default), code, lastSeenAt · asc · 1 · 25 (max 200) |
Response 200 OK
{
"items": [
{
"id": "0192a7d3-1b2c-7d4e-8f5a-6b7c8d9e0f12", "code": "D014", "firstName": "Yacine", "lastName": "Merabet", "displayName": "Yacine Merabet",
"phone": "+213770112233", "accountStatus": "ACTIVE",
"zones": [{ "id": "01928f20-2a3b-7c4d-8e5f-6a7b8c9d0e1f", "name": "Oran Est", "isPrimary": true }],
"wilayaCodes": ["31"],
"homeBase": { "lat": 35.6971, "lng": -0.6308 },
"counts": { "assignedResellers": 64, "prospects": 21, "openTasks": 7 },
"today": { "tourId": "01930a10-3c4d-7e5f-8a6b-7c8d9e0f1a2b", "number": "TRN-2026-000045", "status": "IN_PROGRESS", "visitsDone": 4, "visitsPlanned": 9 },
"lastSeenAt": "2026-11-04T10:12:40Z", "appVersion": "1.0.3", "locationConsentAt": "2026-10-28T07:02:00Z",
"createdAt": "2026-10-26T09:00:00Z", "version": 3
}
],
"total": 11,
"page": 1,
"pageSize": 25
}Errors
| Status | Code | When |
|---|---|---|
| 403 | PERMISSION_DENIED | Caller lacks field.view |
Rules & side effects
- Read-only.
Used by BO-16 · BO-21 (delegate filter) · BO-04 (filter)
GET /admin/delegates/{delegateId}
admin.delegates.crud (get) · Permission field.view · Apps BO · Release V1 · Requirements FLD-01
Purpose. One delegate (header of BO-17; performance comes from admin.delegates.performance).
Response 200 OK — a Delegate.
Errors
| Status | Code | When |
|---|---|---|
| 403 | PERMISSION_DENIED | Caller lacks field.view |
| 404 | NOT_FOUND | Unknown delegate |
Rules & side effects
- Read-only.
POST /admin/delegates
admin.delegates.crud (create) · Permission field.create · Apps BO · Release V1 · Requirements FLD-01
Purpose. Creates a delegate: the login account, the delegate profile and the zones it covers (cahier §4.5 « Création des comptes délégués et affectation des zones »).
Request
interface CreateDelegateBody {
firstName: string;
lastName: string;
phone: string; // login; unique among users
email?: string;
code: string; // unique
zoneIds: Uuid[]; // ≥ 1
primaryZoneIds?: Uuid[]; // zones where this delegate becomes the primary delegate
homeBase?: GeoPoint;
managerId?: Uuid;
sendInvitation: "SMS" | "EMAIL"; // temporary password, change forced at first sign-in
}{
"firstName": "Yacine", "lastName": "Merabet", "phone": "0770 11 22 33", "code": "D014",
"zoneIds": ["01928f20-2a3b-7c4d-8e5f-6a7b8c9d0e1f"], "primaryZoneIds": ["01928f20-2a3b-7c4d-8e5f-6a7b8c9d0e1f"],
"homeBase": { "lat": 35.6971, "lng": -0.6308 }, "sendInvitation": "SMS"
}Response 201 Created — the Delegate (accountStatus ACTIVE, phone +213770112233, version 1).
Errors
| Status | Code | When |
|---|---|---|
| 400 | VALIDATION_FAILED | Invalid phone, no zone, primaryZoneIds not a subset of zoneIds |
| 403 | PERMISSION_DENIED | Caller lacks field.create |
| 404 | NOT_FOUND | Unknown zone or manager |
| 409 | PHONE_ALREADY_EXISTS · EMAIL_ALREADY_EXISTS | Login already used |
| 409 | DUPLICATE_VALUE | code already used |
Rules & side effects
- Creates
User(typeDELEGATE, roleDELEGATE,mustChangePassword),DelegateProfileandDelegateZonerows in one transaction; a primary-zone flag moves from the zone's previous primary delegate. - Audit
CREATE(passwords are never logged). - Notifications:
user.invited→ the new delegate (SMS with the temporary password and the store link).
Used by BO-18
PATCH /admin/delegates/{delegateId}
admin.delegates.crud (update) · Permission field.update · Apps BO · Release V1 · Requirements FLD-01
Purpose. Edits a delegate's profile and zones, or suspends, reactivates or disables the account.
Request
interface UpdateDelegateBody {
firstName?: string; lastName?: string; email?: string | null; code?: string;
zoneIds?: Uuid[]; // replaces the zones
primaryZoneIds?: Uuid[];
homeBase?: GeoPoint | null;
managerId?: Uuid | null;
status?: "ACTIVE" | "SUSPENDED" | "DISABLED"; // same rules as admin.users.setStatus
statusReason?: string; // required with status
version: number;
}{ "zoneIds": ["01928f20-2a3b-7c4d-8e5f-6a7b8c9d0e1f", "01928f21-3b4c-7d5e-9f6a-7b8c9d0e1f2a"], "version": 3 }Response 200 OK — the updated Delegate.
Errors
| Status | Code | When |
|---|---|---|
| 400 | VALIDATION_FAILED | Empty zoneIds, status without statusReason |
| 403 | PERMISSION_DENIED | Caller lacks field.update |
| 404 | NOT_FOUND | Unknown delegate, zone or manager |
| 409 | DUPLICATE_VALUE | code taken |
| 409 | DELEGATE_HAS_OPEN_TOURS | DISABLED while the delegate has a tour PLANNED, TO_START, IN_PROGRESS or SUSPENDED — details: { tourIds }; reassign or cancel them first |
| 409 | VERSION_CONFLICT | Stale version |
Rules & side effects
- Zone changes change the delegate's scope at the next
sync.delta: resellers of removed zones that are not assigned to the delegate disappear from the app; assigned resellers stay (assignment is explicit, BR-45). SUSPENDEDrevokes sessions and moves anIN_PROGRESStour toSUSPENDED(reason « Compte suspendu ») — AccountStatus.- Audit
UPDATE(zones before/after) orSTATUS_CHANGE. - Notifications:
account.suspended→ the delegate on suspension (SMS + in-app).
GET /admin/delegates/{delegateId}/performance
admin.delegates.performance · Permission field.view · Apps BO · Release V1 · Requirements FLD-02 · ANA-03
Purpose. A delegate's KPIs over a period, with objectives achievement and a time series — the same definitions as the dashboard (card catalogue) and the delegate's own delegate.performance.
Request
| Query | Type | Values / default | Notes |
|---|---|---|---|
from / to | string (date) | default: current month | ≤ 366 days |
granularity | string | DAY | WEEK (default) | MONTH | Series buckets |
Response 200 OK
{
"delegate": { "id": "0192a7d3-1b2c-7d4e-8f5a-6b7c8d9e0f12", "displayName": "Yacine Merabet" },
"period": { "from": "2026-11-01", "to": "2026-11-30" },
"tours": { "planned": 19, "completed": 3, "cancelled": 0 },
"visits": { "planned": 41, "done": 35, "notDone": 6, "rateBps": 8537, "avgDurationMinutes": 27, "outOfTolerance": 1 },
"prospects": { "created": 9, "appInstalls": 6, "accountsCreated": 5, "firstOrders": 3, "conversionRateBps": 3333 },
"orders": { "count": 12, "assisted": 4, "revenueHt": 23480000 },
"km": 412.6,
"objectives": [
{ "id": "01930000-1a2b-7c3d-8e4f-5a6b7c8d9e0f", "metric": "VISITS", "period": "MONTH", "target": 180, "achieved": 35, "rateBps": 1944 },
{ "id": "01930000-2b3c-7d4e-9f5a-6b7c8d9e0f1a", "metric": "FIRST_ORDERS", "period": "MONTH", "target": 10, "achieved": 3, "rateBps": 3000 },
{ "id": "01930000-3c4d-7e5f-8a6b-7c8d9e0f1a2b", "metric": "REVENUE", "period": "MONTH", "target": 150000000, "achieved": 23480000, "rateBps": 1565 }
],
"series": [
{ "x": "2026-11-01", "visitsDone": 35, "signups": 5, "firstOrders": 3, "revenueHt": 23480000, "km": 412.6 }
],
"generatedAt": "2026-11-04T10:30:00Z"
}Field notes. Definitions: visits = visits of the delegate's tours dated in the period (planned excludes CANCELLED); prospects.* = cohort of points of sale created by the delegate in the period and the funnel steps they reached by now; conversionRateBps = firstOrders ÷ created; orders and revenueHt = orders attributed to the delegate at submission (BR-49) validated in the period (HT net of discounts); km per BR-54.
Errors
| Status | Code | When |
|---|---|---|
| 400 | VALIDATION_FAILED | Bad dates or granularity |
| 403 | PERMISSION_DENIED | Caller lacks field.view |
| 404 | NOT_FOUND | Unknown delegate |
| 422 | REPORT_RANGE_TOO_LARGE | Period over 366 days |
Rules & side effects
- Read-only; served from the analytics aggregates refreshed on field and order events.
Zones
A zone groups whole wilayas and/or communes (Zone, ZoneArea); a commune belongs to at most one zone, an explicit commune beats its wilaya (BR-46). Zones are assigned to delegates; each zone may have a primary delegate who receives unassigned sign-ups (BR-47).
interface Zone {
id: Uuid;
name: string; // unique
color?: string; // #RRGGBB on the map editor
areas: { wilayaCode: string; wilayaName: string; communeIds: Uuid[] | null }[]; // null = the whole wilaya
primaryDelegate?: UserSummary;
delegates: UserSummary[];
communeCount: number;
resellerCount: number;
createdAt: Iso; updatedAt: Iso; version: number;
}GET /admin/zones
admin.zones.crud (list) · Permission field.view or crm.view · Apps BO · Release V1 · Requirements GEO-02
Purpose. Lists zones (map editor, delegate form, CRM and dashboard filters).
Request
| Query | Type | Values / default | Notes |
|---|---|---|---|
wilayaCode | string | Zones covering the wilaya or one of its communes | |
delegateId | Uuid | ||
q | string | min 2 chars | Name |
sort / order / page / pageSize | name (default) · asc · 1 · 50 (max 200) |
Response 200 OK
{
"items": [
{
"id": "01928f20-2a3b-7c4d-8e5f-6a7b8c9d0e1f", "name": "Oran Est", "color": "#0EA5E9",
"areas": [{ "wilayaCode": "31", "wilayaName": "Oran", "communeIds": ["0191c0aa-3101-7a00-8c00-000000003107", "0191c0aa-3101-7a00-8c00-000000003112", "0191c0aa-3101-7a00-8c00-000000003119", "0191c0aa-3101-7a00-8c00-000000003124"] }],
"primaryDelegate": { "id": "0192a7d3-1b2c-7d4e-8f5a-6b7c8d9e0f12", "displayName": "Yacine Merabet" },
"delegates": [{ "id": "0192a7d3-1b2c-7d4e-8f5a-6b7c8d9e0f12", "displayName": "Yacine Merabet" }],
"communeCount": 4, "resellerCount": 96,
"createdAt": "2026-10-26T08:30:00Z", "updatedAt": "2026-10-26T08:30:00Z", "version": 1
}
],
"total": 6,
"page": 1,
"pageSize": 50
}Errors
| Status | Code | When |
|---|---|---|
| 403 | PERMISSION_DENIED | Caller has neither field.view nor crm.view |
Rules & side effects
- Read-only. Reading is relaxed to
field.vieworcrm.view(the bundle'sfield.administerapplies to writes) — Assumption.
Used by BO-19 · BO-18 · BO-09 (filter)
GET /admin/zones/{zoneId}
admin.zones.crud (get) · Permission field.view or crm.view · Apps BO · Release V1 · Requirements GEO-02
Purpose. One zone with its communes (names) for the map editor.
Response 200 OK — a Zone with areas[].communes: [{ id, name }] in addition to the ids.
Errors
| Status | Code | When |
|---|---|---|
| 403 | PERMISSION_DENIED | Caller has neither field.view nor crm.view |
| 404 | NOT_FOUND | Unknown zone |
Rules & side effects
- Read-only.
Used by BO-19
POST /admin/zones
admin.zones.crud (create) · Permission field.administer · Apps BO · Release V1 · Requirements GEO-02
Purpose. Creates a zone from wilayas and communes.
Request
interface CreateZoneBody {
name: string;
color?: string;
areas: { wilayaCode: string; communeIds?: Uuid[] | null }[]; // ≥ 1; omitted/null communeIds = whole wilaya
delegateIds?: Uuid[];
primaryDelegateId?: Uuid;
}{ "name": "Oran Ouest", "color": "#22C55E", "areas": [{ "wilayaCode": "31", "communeIds": ["0191c0aa-3101-7a00-8c00-000000003101", "0191c0aa-3101-7a00-8c00-000000003115"] }, { "wilayaCode": "46" }] }Response 201 Created — the Zone.
Errors
| Status | Code | When |
|---|---|---|
| 400 | VALIDATION_FAILED | Unknown wilaya, commune of another wilaya, primary delegate not in delegateIds |
| 403 | PERMISSION_DENIED | Caller lacks field.administer |
| 409 | DUPLICATE_VALUE | Name taken |
| 409 | ZONE_OVERLAP | A commune (explicit, or through a whole wilaya) already belongs to another zone — details: { communes: [{ communeId, zoneId }] } |
Rules & side effects
- Resellers of the covered communes get this zone as their derived zone (unless overridden); assigned delegates do not change. Audit
CREATE.
Used by BO-19
PATCH /admin/zones/{zoneId}
admin.zones.crud (update) · Permission field.administer · Apps BO · Release V1 · Requirements GEO-02
Purpose. Renames a zone, changes its areas, delegates or primary delegate.
Request
interface UpdateZoneBody { name?: string; color?: string | null; areas?: CreateZoneBody["areas"]; delegateIds?: Uuid[]; primaryDelegateId?: Uuid | null; version: number }{ "primaryDelegateId": "0192a7d3-1b2c-7d4e-8f5a-6b7c8d9e0f12", "version": 1 }Response 200 OK — the updated Zone plus impact: { resellersRezoned: number } when areas changed.
Errors
| Status | Code | When |
|---|---|---|
| 403 | PERMISSION_DENIED | Caller lacks field.administer |
| 404 | NOT_FOUND | Unknown zone or delegate |
| 409 | DUPLICATE_VALUE · ZONE_OVERLAP · VERSION_CONFLICT | As for creation · stale version |
Rules & side effects
- Area changes re-derive the zone of the resellers concerned (overrides kept) and change delegates' scopes at the next sync. Audit
UPDATE.
Used by BO-19
DELETE /admin/zones/{zoneId}
admin.zones.crud (delete) · Permission field.administer · Apps BO · Release V1 · Requirements GEO-02
Purpose. Deletes a zone that no delegate covers.
Response 204 No Content
Errors
| Status | Code | When |
|---|---|---|
| 403 | PERMISSION_DENIED | Caller lacks field.administer |
| 404 | NOT_FOUND | Unknown zone |
| 409 | RESOURCE_IN_USE | Delegates still cover it, or resellers have it as an explicit (overridden) zone, or targeting references it — details.references |
Rules & side effects
- Derived zones of resellers become
null. AuditDELETE.
Used by BO-19
Objectives
Objectives per delegate and period (cahier §4.5 « Objectifs de visites, inscriptions, installations et commandes », FLD-02). Periods: DAY (that day), WEEK (Sunday to Saturday), MONTH (calendar month), in Africa/Algiers.
interface Objective {
id: Uuid;
delegate: UserSummary;
metric: ObjectiveMetric; // VISITS, SIGNUPS, APP_INSTALLS, FIRST_ORDERS, ORDERS, REVENUE
period: ObjectivePeriod; // DAY, WEEK, MONTH
periodStart: string; // YYYY-MM-DD (a Sunday for WEEK, the 1st for MONTH)
periodEnd: string;
target: number; // count, or Money (HT) for REVENUE
achieved: number; // computed with the performance definitions
rateBps: number; // achieved ÷ target
closed: boolean; // derived: periodEnd has passed
createdBy: UserSummary;
createdAt: Iso; updatedAt: Iso; version: number;
}GET /admin/objectives
admin.objectives.crud (list) · Permission field.view · Apps BO · Release V1 · Requirements FLD-02
Purpose. Lists objectives with their achievement.
Request
| Query | Type | Values / default | Notes |
|---|---|---|---|
delegateId | Uuid[] | repeatable | |
metric | ObjectiveMetric[] | repeatable | |
period | ObjectivePeriod | ||
activeOn | string (date) | default today | Objectives whose period contains the date |
sort / order / page / pageSize | periodStart (default), rateBps · desc · 1 · 50 (max 200) |
Response 200 OK
{
"items": [
{ "id": "01930000-1a2b-7c3d-8e4f-5a6b7c8d9e0f", "delegate": { "id": "0192a7d3-1b2c-7d4e-8f5a-6b7c8d9e0f12", "displayName": "Yacine Merabet" }, "metric": "VISITS", "period": "MONTH", "periodStart": "2026-11-01", "periodEnd": "2026-11-30", "target": 180, "achieved": 35, "rateBps": 1944, "closed": false, "createdBy": { "id": "01929e55-2c3d-7e4f-9a1b-2c3d4e5f6a7b", "displayName": "Nadia Belkacem" }, "createdAt": "2026-10-29T09:00:00Z", "updatedAt": "2026-10-29T09:00:00Z", "version": 1 }
],
"total": 33,
"page": 1,
"pageSize": 50
}Errors
| Status | Code | When |
|---|---|---|
| 403 | PERMISSION_DENIED | Caller lacks field.view |
Rules & side effects
- Read-only.
GET /admin/objectives/{objectiveId}
admin.objectives.crud (get) · Permission field.view · Apps BO · Release V1 · Requirements FLD-02
Purpose. One objective.
Response 200 OK — an Objective.
Errors
| Status | Code | When |
|---|---|---|
| 403 | PERMISSION_DENIED | Caller lacks field.view |
| 404 | NOT_FOUND | Unknown objective |
Rules & side effects
- Read-only.
Used by BO-20
POST /admin/objectives
admin.objectives.crud (create) · Permission field.create · Apps BO · Release V1 · Requirements FLD-02
Purpose. Sets the same objective for one or several delegates.
Request
interface CreateObjectivesBody {
delegateIds: Uuid[]; // 1–100
metric: ObjectiveMetric;
period: ObjectivePeriod;
periodStart: string; // aligned on the period; today or later for DAY
target: number; // > 0
}{ "delegateIds": ["0192a7d3-1b2c-7d4e-8f5a-6b7c8d9e0f12"], "metric": "FIRST_ORDERS", "period": "MONTH", "periodStart": "2026-12-01", "target": 10 }Response 201 Created — { "items": [Objective…], "created": 1 }
Errors
| Status | Code | When |
|---|---|---|
| 400 | VALIDATION_FAILED | periodStart not aligned (not a Sunday for WEEK, not the 1st for MONTH), target ≤ 0 |
| 403 | PERMISSION_DENIED | Caller lacks field.create |
| 404 | NOT_FOUND | Unknown delegate |
| 409 | DUPLICATE_VALUE | An objective already exists for a delegate × metric × period × start — details: { delegateIds } (nothing is created) |
| 409 | OBJECTIVE_PERIOD_CLOSED | The period has already ended |
Rules & side effects
- All-or-nothing. Audit
CREATE. Notifications:objective.assigned→ each delegate (in-app; proposed event, not yet in the notifications matrix).
Used by BO-20
PATCH /admin/objectives/{objectiveId}
admin.objectives.crud (update) · Permission field.update · Apps BO · Release V1 · Requirements FLD-02
Purpose. Changes the target of an objective whose period has not ended.
Request
interface UpdateObjectiveBody { target: number; version: number }{ "target": 12, "version": 1 }Response 200 OK — the updated Objective (rateBps recomputed).
Errors
| Status | Code | When |
|---|---|---|
| 400 | VALIDATION_FAILED | Target ≤ 0 |
| 403 | PERMISSION_DENIED | Caller lacks field.update |
| 404 | NOT_FOUND | Unknown objective |
| 409 | OBJECTIVE_PERIOD_CLOSED · VERSION_CONFLICT | Period ended · stale version |
Rules & side effects
- Audit
UPDATEwith before/after target.
Used by BO-20
DELETE /admin/objectives/{objectiveId}
admin.objectives.crud (delete) · Permission field.delete · Apps BO · Release V1 · Requirements FLD-02
Purpose. Deletes an objective whose period has not ended.
Response 204 No Content
Errors
| Status | Code | When |
|---|---|---|
| 403 | PERMISSION_DENIED | Caller lacks field.delete |
| 404 | NOT_FOUND | Unknown objective |
| 409 | OBJECTIVE_PERIOD_CLOSED | Period ended (history is kept for reports) |
Rules & side effects
- Audit
DELETE.
Used by BO-20
Tours
One tour = one delegate × one date; weekly or monthly planning creates one tour per day (cahier §4.5 « Planification quotidienne, hebdomadaire ou mensuelle »). Only the assigned delegate can start it (cahier §4.5 « Seul le délégué affecté peut démarrer la tournée »). Every planning change reaches the delegate through sync.delta and a push (cahier §6.2 « Une visite ajoutée ou déplacée doit être synchronisée »).
GET /admin/tours
admin.tours.list · Permission field.view · Apps BO · Release V1 · Requirements FLD-03
Purpose. Tours by delegate and date range — the planner calendar (BO-21) and tour lists.
Request
| Query | Type | Values / default | Notes |
|---|---|---|---|
from / to | string (date) | required | Inclusive; at most 62 days |
delegateId | Uuid[] | repeatable | |
zoneId | Uuid | Tours of the zone's delegates | |
status | TourStatus[] | repeatable | |
late | boolean | Not started, or not closed after its date | |
seriesId | Uuid | ||
sort / order / page / pageSize | date (default), delegate · asc · 1 · 200 (max 200) | The calendar fetches a range in one page |
Response 200 OK — Page<TourListItem>
{
"items": [
{
"id": "01930a10-3c4d-7e5f-8a6b-7c8d9e0f1a2b", "number": "TRN-2026-000045", "date": "2026-11-04",
"delegate": { "id": "0192a7d3-1b2c-7d4e-8f5a-6b7c8d9e0f12", "displayName": "Yacine Merabet" },
"status": "IN_PROGRESS", "orderingMode": "RECOMMENDED", "seriesId": "01930a00-1a2b-7c3d-8e4f-5a6b7c8d9e0f",
"stops": 9, "visitsDone": 4, "visitsNotDone": 1, "plannedKm": 38.4, "km": 21.7,
"startedAt": "2026-11-04T07:58:00Z", "late": false, "version": 6
},
{
"id": "01930a10-4d5e-7f6a-9b7c-8d9e0f1a2b3c", "number": "TRN-2026-000046", "date": "2026-11-05",
"delegate": { "id": "0192a7d3-1b2c-7d4e-8f5a-6b7c8d9e0f12", "displayName": "Yacine Merabet" },
"status": "PLANNED", "orderingMode": "RECOMMENDED", "seriesId": "01930a00-1a2b-7c3d-8e4f-5a6b7c8d9e0f",
"stops": 8, "visitsDone": 0, "visitsNotDone": 0, "plannedKm": 41.2, "late": false, "version": 1
}
],
"total": 38,
"page": 1,
"pageSize": 200
}Errors
| Status | Code | When |
|---|---|---|
| 400 | VALIDATION_FAILED | Missing range, to before from |
| 403 | PERMISSION_DENIED | Caller lacks field.view |
| 422 | REPORT_RANGE_TOO_LARGE | Range over 62 days — details: { maxDays: 62 } |
Rules & side effects
- Read-only. Live status changes arrive through
tour.*SSE events.
POST /admin/tours
admin.tours.create · Permission field.create · Apps BO · Release V1 · Requirements FLD-03
Purpose. Plans a tour for one date, for an explicit list of dates, or as a recurring series, with ordered stops (point of sale, time slot, priority, visit type).
Request
interface CreateTourBody {
delegateId: Uuid;
date?: string; // exactly one of date / dates / recurrence
dates?: string[]; // 1–31 dates
recurrence?: {
frequency: "DAILY" | "WEEKLY";
weekdays?: (0 | 1 | 2 | 3 | 4 | 5 | 6)[]; // WEEKLY; 0 = Sunday
from: string; // first date
until: string; // last date; the series holds at most 31 tours
};
orderingMode: "IMPOSED" | "RECOMMENDED";
notes?: string; // shown to the delegate, ≤ 1 000
stops: { resellerId: Uuid; plannedAt?: string; priority?: "NORMAL" | "HIGH"; visitTypeId: Uuid }[]; // 1–40, in visiting order; plannedAt "HH:mm"
allowOutOfScope?: boolean; // accept stops outside the delegate's zones and assignments
outOfScopeReason?: string; // required with allowOutOfScope
}{
"delegateId": "0192a7d3-1b2c-7d4e-8f5a-6b7c8d9e0f12",
"recurrence": { "frequency": "WEEKLY", "weekdays": [0, 2, 4], "from": "2026-11-08", "until": "2026-11-26" },
"orderingMode": "RECOMMENDED",
"notes": "Priorité aux points de vente sans commande depuis 30 jours.",
"stops": [
{ "resellerId": "0192f4b8-61c2-7a3e-9b10-1f2e3d4c5b6a", "plannedAt": "09:00", "priority": "HIGH", "visitTypeId": "01928e40-1a2b-7c3d-8e4f-5a6b7c8d9e0f" },
{ "resellerId": "0192f4d2-3e4f-7a5b-9c6d-7e8f9a0b1c2d", "plannedAt": "10:00", "priority": "NORMAL", "visitTypeId": "01928e40-1a2b-7c3d-8e4f-5a6b7c8d9e0f" },
{ "resellerId": "0192f4e3-4f5a-7b6c-8d7e-8f9a0b1c2d3e", "priority": "NORMAL", "visitTypeId": "01928e40-2b3c-7d4e-9f5a-6b7c8d9e0f1a" }
]
}Response 201 Created
{
"seriesId": "01930b50-1a2b-7c3d-8e4f-5a6b7c8d9e0f",
"items": [
{ "id": "01930b50-2b3c-7d4e-9f5a-6b7c8d9e0f1a", "number": "TRN-2026-000061", "date": "2026-11-08", "delegate": { "id": "0192a7d3-1b2c-7d4e-8f5a-6b7c8d9e0f12", "displayName": "Yacine Merabet" }, "status": "PLANNED", "orderingMode": "RECOMMENDED", "seriesId": "01930b50-1a2b-7c3d-8e4f-5a6b7c8d9e0f", "stops": 3, "visitsDone": 0, "visitsNotDone": 0, "plannedKm": 14.8, "late": false, "version": 1 }
],
"created": 8,
"skippedDates": [{ "date": "2026-11-01", "reason": "PUBLIC_HOLIDAY" }],
"warnings": [{ "code": "RESELLER_NOT_INTERESTED", "resellerId": "0192f4e3-4f5a-7b6c-8d7e-8f9a0b1c2d3e" }]
}(items lists every created tour; one shown.)
Errors
| Status | Code | When |
|---|---|---|
| 400 | VALIDATION_FAILED | Not exactly one of date / dates / recurrence, more than 31 tours, a date in the past, duplicate reseller in the stops, bad plannedAt, more than 40 stops |
| 403 | PERMISSION_DENIED | Caller lacks field.create |
| 404 | NOT_FOUND | Unknown delegate, reseller or visit type |
| 409 | TOUR_DATE_TAKEN | The delegate already has a tour on one of the dates — details: { dates, existingTourIds } (nothing created) |
| 422 | DELEGATE_INACTIVE | Delegate account not ACTIVE |
| 422 | STOP_OUT_OF_SCOPE | A reseller is outside the delegate's zones and assignments and allowOutOfScope is not true — details: { resellerIds } |
| 422 | REASON_REQUIRED | allowOutOfScope without outOfScopeReason |
Rules & side effects
- All-or-nothing. Dates falling on Friday or Saturday (the working week is Sunday–Thursday) or on a public holiday are skipped and reported in
skippedDatesfor recurrences; an explicitdate/dateson such a day is accepted (weekend work happens). - Each tour gets a
TRN-number and oneVisitper stop (PLANNED,VIS-number,sequence); a tour dated today is createdTO_START, later onesPLANNED. Prospects among the stops move fromNEW_PROSPECTtoTO_CONTACT. allowOutOfScopegrants the delegate access to those resellers for the tour (see the permissions matrix) and writes anOVERRIDEaudit row with the reason.- Audit
CREATEper tour;TourStatusChange— → PLANNED/TO_START. - Notifications:
tour.assigned→ the delegate (push + in-app; one notification for a series). SSE: none.
GET /admin/tours/{tourId}
admin.tours.get · Permission field.view · Apps BO · Release V1 · Requirements FLD-13 · FLD-14
Purpose. The tour live detail: stops with check-ins, distances and tolerance, durations, not-done reasons, photos and reports, history, closing summary and the delegate's last position.
Response 200 OK — TourDetail
{
"id": "01930a10-3c4d-7e5f-8a6b-7c8d9e0f1a2b",
"number": "TRN-2026-000045",
"date": "2026-11-04",
"delegate": { "id": "0192a7d3-1b2c-7d4e-8f5a-6b7c8d9e0f12", "displayName": "Yacine Merabet" },
"status": "IN_PROGRESS",
"orderingMode": "RECOMMENDED",
"stops": 9, "visitsDone": 4, "visitsNotDone": 1, "plannedKm": 38.4, "km": 21.7,
"startedAt": "2026-11-04T07:58:00Z",
"late": false,
"notes": "Priorité aux points de vente sans commande depuis 30 jours.",
"startPosition": { "lat": 35.6969, "lng": -0.6331, "accuracy": 9, "capturedAt": "2026-11-04T07:58:00Z" },
"stopsDetail": [
{
"visitId": "01930a10-5e6f-7a8b-9c0d-1e2f3a4b5c6e", "visitNumber": "VIS-2026-001234", "sequence": 1,
"reseller": { "id": "0192f4b8-61c2-7a3e-9b10-1f2e3d4c5b6a", "number": "REV-000123", "storeName": "Phone Store El Bahia", "ownerName": "Karim Benali", "phone": "+213555123456", "wilayaCode": "31", "communeName": "Bir El Djir", "status": "ACTIVE", "location": { "lat": 35.7206, "lng": -0.544 } },
"plannedAt": "09:00", "priority": "HIGH", "visitType": { "id": "01928e40-1a2b-7c3d-8e4f-5a6b7c8d9e0f", "name": "Visite commerciale" },
"status": "DONE", "late": false,
"checkIn": { "at": "2026-11-04T08:41:12Z", "position": { "lat": 35.7209, "lng": -0.5446, "accuracy": 14 }, "distanceMeters": 63, "withinTolerance": true },
"startedAt": "2026-11-04T08:42:00Z", "endedAt": "2026-11-04T09:14:00Z", "durationMinutes": 32,
"photosCount": 2, "hasReport": true, "addedBy": "PLAN"
},
{
"visitId": "01930a10-6f7a-7b8c-8d9e-0f1a2b3c4d5f", "visitNumber": "VIS-2026-001236", "sequence": 3,
"reseller": { "id": "0192f4e3-4f5a-7b6c-8d7e-8f9a0b1c2d3e", "number": "REV-000231", "storeName": "Hassi Phone", "ownerName": "Ali Kerroum", "phone": "+213552334455", "wilayaCode": "31", "communeName": "Hassi Bounif", "status": "ACCOUNT_CREATED" },
"priority": "NORMAL", "visitType": { "id": "01928e40-2b3c-7d4e-9f5a-6b7c8d9e0f1a", "name": "Prospection" },
"status": "NOT_DONE", "late": false,
"notDone": { "reasonCode": "STORE_CLOSED", "comment": "Rideau baissé à 10 h 40.", "at": "2026-11-04T09:40:00Z" },
"photosCount": 1, "hasReport": false, "addedBy": "PLAN"
}
],
"history": [
{ "from": "TO_START", "to": "IN_PROGRESS", "at": "2026-11-04T07:58:00Z", "actor": { "id": "0192a7d3-1b2c-7d4e-8f5a-6b7c8d9e0f12", "displayName": "Yacine Merabet" }, "position": { "lat": 35.6969, "lng": -0.6331, "accuracy": 9 } },
{ "from": "PLANNED", "to": "TO_START", "at": "2026-11-03T23:05:00Z", "actor": null }
],
"livePosition": { "position": { "lat": 35.6923, "lng": -0.5871, "accuracy": 11, "capturedAt": "2026-11-04T10:09:30Z" }, "at": "2026-11-04T10:09:30Z", "stale": false },
"createdBy": { "id": "01929e55-2c3d-7e4f-9a1b-2c3d4e5f6a7b", "displayName": "Nadia Belkacem" },
"createdAt": "2026-10-29T14:00:00Z",
"version": 6
}Field notes. distanceMeters is null when the point of sale had no coordinates (the check-in position becomes a candidate location, BR-51). livePosition.stale = no ping for more than 2 × field.locationIntervalSeconds. closing appears once the tour is COMPLETED.
Errors
| Status | Code | When |
|---|---|---|
| 403 | PERMISSION_DENIED | Caller lacks field.view |
| 404 | NOT_FOUND | Unknown tour |
Rules & side effects
- Read-only. The detail page subscribes to
visit.*,tour.*anddelegate.positionfor this tour.
PATCH /admin/tours/{tourId}
admin.tours.update · Permission field.update · Apps BO · Release V1 · Requirements FLD-03
Purpose. Edits the date, the delegate, the ordering mode or the notes of a tour not yet started.
Request
interface UpdateTourBody { date?: string; delegateId?: Uuid; orderingMode?: TourOrderingMode; notes?: string | null; version: number }{ "date": "2026-11-06", "orderingMode": "IMPOSED", "version": 1 }Response 200 OK — the TourDetail (status TO_START if the new date is today).
Errors
| Status | Code | When |
|---|---|---|
| 400 | VALIDATION_FAILED | Date in the past |
| 403 | PERMISSION_DENIED | Caller lacks field.update |
| 404 | NOT_FOUND | Unknown tour or delegate |
| 409 | TOUR_NOT_EDITABLE | Status other than PLANNED or TO_START |
| 409 | TOUR_DATE_TAKEN | The (new) delegate already has a tour that day |
| 409 | VERSION_CONFLICT | Stale version |
| 422 | DELEGATE_INACTIVE · STOP_OUT_OF_SCOPE | New delegate inactive, or stops outside the new delegate's scope |
Rules & side effects
- Audit
UPDATE. Notifications:tour.updated→ the delegate (push + in-app); on a delegate change,tour.updatedto the former andtour.assignedto the new delegate.
Used by BO-22 · BO-21 (drag on the calendar)
PUT /admin/tours/{tourId}/stops
admin.tours.setStops · Permission field.update · Apps BO · Release V1 · Requirements FLD-03 · FLD-06
Purpose. Replaces the ordered list of stops; the result is synced to the delegate's app.
Request
interface SetStopsBody {
stops: { visitId?: Uuid; resellerId: Uuid; plannedAt?: string; priority?: "NORMAL" | "HIGH"; visitTypeId: Uuid }[]; // full ordered list; keep existing stops by visitId
allowOutOfScope?: boolean;
outOfScopeReason?: string;
version: number;
}{
"stops": [
{ "visitId": "01930a10-5e6f-7a8b-9c0d-1e2f3a4b5c6e", "resellerId": "0192f4b8-61c2-7a3e-9b10-1f2e3d4c5b6a", "plannedAt": "09:00", "priority": "HIGH", "visitTypeId": "01928e40-1a2b-7c3d-8e4f-5a6b7c8d9e0f" },
{ "resellerId": "0192f4f4-5a6b-7c7d-8e8f-9a0b1c2d3e4f", "plannedAt": "11:30", "priority": "NORMAL", "visitTypeId": "01928e40-2b3c-7d4e-9f5a-6b7c8d9e0f1a" }
],
"version": 1
}Response 200 OK — the TourDetail with the new stopsDetail (new visits created PLANNED with VIS- numbers), plannedKm recomputed and version + 1.
Errors
| Status | Code | When |
|---|---|---|
| 400 | VALIDATION_FAILED | Empty list, more than 40 stops, duplicate reseller, unknown visitId |
| 403 | PERMISSION_DENIED | Caller lacks field.update |
| 404 | NOT_FOUND | Unknown tour, reseller or visit type |
| 409 | TOUR_STOPS_LOCKED | Tour IN_PROGRESS or SUSPENDED and the list does not keep every existing stop unchanged and in order (only appending is allowed once started) |
| 409 | TOUR_NOT_EDITABLE | Tour COMPLETED or CANCELLED |
| 409 | VERSION_CONFLICT | Stale version |
| 422 | STOP_OUT_OF_SCOPE · REASON_REQUIRED | As for creation |
Rules & side effects
PLANNED/TO_START: full replacement — removed stops' visits are deleted (never started), kept stops are re-sequenced, new ones are created. Once started (IN_PROGRESS,SUSPENDED), the existing stops must be sent unchanged and in the same order, and new stops can only be appended at the end. Assumption — to reconcile with rule 1 of Lifecycles › Tour, which also allows removing or moving visits not yet started during the day.- In
RECOMMENDEDmode the delegate may still reorder the remaining visits in the app; inIMPOSEDmode the order sent here is final. - Prospects added move to
TO_CONTACT. AuditUPDATEwith the before/after stop list (OVERRIDEfor out-of-scope additions). - Notifications:
tour.updated→ the delegate (push + in-app, with a sync data message so the offline snapshot refreshes).
Used by BO-22 · BO-23 (append a stop during the day)
POST /admin/tours/{tourId}/recommend-order
admin.tours.recommendOrder · Permission field.update · Apps BO · Release V1 · Requirements FLD-17
Purpose. Computes a recommended visiting order with the nearest-neighbour heuristic (cahier §4.5 « Ordre manuel ou recommandé des visites »); routing-engine optimisation is V2 (FLD-18).
Request
interface RecommendOrderBody {
startFrom?: "HOME_BASE" | "CURRENT_POSITION" | "CUSTOM"; // default HOME_BASE (CURRENT_POSITION for a started tour)
customStart?: GeoPoint; // with CUSTOM
respectPriority?: boolean; // default true: HIGH stops first, then NORMAL
apply?: boolean; // default false: only propose
version?: number; // required with apply
}{ "startFrom": "HOME_BASE", "respectPriority": true }Response 200 OK
{
"start": { "kind": "HOME_BASE", "position": { "lat": 35.6971, "lng": -0.6308 } },
"proposed": [
{ "visitId": "01930a10-5e6f-7a8b-9c0d-1e2f3a4b5c6e", "resellerId": "0192f4b8-61c2-7a3e-9b10-1f2e3d4c5b6a", "storeName": "Phone Store El Bahia", "priority": "HIGH", "legKm": 8.4 },
{ "visitId": "01930a10-6f7a-7b8c-8d9e-0f1a2b3c4d5f", "resellerId": "0192f4e3-4f5a-7b6c-8d7e-8f9a0b1c2d3e", "storeName": "Hassi Phone", "priority": "NORMAL", "legKm": 6.1 },
{ "visitId": "01930a10-7a8b-7c9d-9e0f-1a2b3c4d5e70", "resellerId": "0192f4d2-3e4f-7a5b-9c6d-7e8f9a0b1c2d", "storeName": "Smart Accessoires Es Senia", "priority": "NORMAL", "legKm": 9.7 }
],
"totalKm": 24.2,
"currentKm": 31.9,
"unplaced": [],
"applied": false
}Field notes. Distances are straight-line (haversine) kilometres between consecutive points, not road distances; currentKm is the same measure for the current order. unplaced lists stops without coordinates — they are kept at the end in their current order. Visits already started or finished keep their place and are excluded.
Errors
| Status | Code | When |
|---|---|---|
| 400 | VALIDATION_FAILED | CUSTOM without customStart, apply without version |
| 403 | PERMISSION_DENIED | Caller lacks field.update |
| 404 | NOT_FOUND | Unknown tour |
| 409 | TOUR_NOT_EDITABLE | Tour COMPLETED or CANCELLED |
| 409 | TOUR_STOPS_LOCKED | apply: true on a started tour in IMPOSED mode |
| 422 | START_POSITION_UNKNOWN | No home base (or no live position for CURRENT_POSITION) |
Rules & side effects
- Nearest neighbour: from the start, repeatedly visit the closest remaining stop (HIGH stops first when
respectPriority); time slots (plannedAt) are not optimised in V1 — a proposal that breaks the slot order is flagged inwarnings. apply: falsehas no side effect.apply: truesaves the order exactly likeadmin.tours.setStops(audit,tour.updated).
Used by BO-22
POST /admin/tours/{tourId}/cancel
admin.tours.cancel · Permission field.update · Apps BO · Release V1 · Requirements FLD-04
Purpose. Cancels a tour with a reason (planned tour no longer needed; delegate sick or broken down during the day).
Request
interface CancelTourBody { reason: string; version: number }{ "reason": "Délégué en arrêt maladie — tournée reportée au 12 novembre.", "version": 1 }Response 200 OK — the TourDetail with status CANCELLED.
Errors
| Status | Code | When |
|---|---|---|
| 403 | PERMISSION_DENIED | Caller lacks field.update |
| 404 | NOT_FOUND | Unknown tour |
| 409 | TOUR_TRANSITION_NOT_ALLOWED | Status COMPLETED or CANCELLED — details: { from, to, allowed } |
| 409 | VERSION_CONFLICT | Stale version |
| 422 | REASON_REQUIRED | Empty reason |
Rules & side effects
- Allowed from
PLANNED,TO_START,IN_PROGRESS,SUSPENDED(tour transitions). Visits not started →CANCELLED; finished visits are kept; location sampling stops. TourStatusChangewith the reason; auditSTATUS_CHANGE.- Notifications:
tour.updated→ the delegate (push + in-app). SSE:tour.cancelled.
POST /admin/tours/{tourId}/reopen
admin.tours.reopen · Permission field.validate · Apps BO · Release V1 · Requirements FLD-12
Purpose. Authorised reopening of a closed tour (cahier §6.9 « La clôture est définitive sauf réouverture autorisée »), so the delegate can complete missing data and close again.
Request
interface ReopenTourBody { reason: string; version: number }{ "reason": "Rapport de visite manquant chez Hassi Phone — le délégué doit le compléter.", "version": 9 }Response 200 OK — the TourDetail with status IN_PROGRESS, the previous closing kept as version 1.
Errors
| Status | Code | When |
|---|---|---|
| 403 | PERMISSION_DENIED | Caller lacks field.validate |
| 404 | NOT_FOUND | Unknown tour |
| 409 | TOUR_TRANSITION_NOT_ALLOWED | Status not COMPLETED |
| 409 | TOUR_REOPEN_EXPIRED | More than field.reopenMaxDays (7) days after the tour date |
| 409 | VERSION_CONFLICT | Stale version |
| 422 | REASON_REQUIRED | Empty reason |
Rules & side effects
COMPLETED→IN_PROGRESS; finished visits stay locked (unlock them one by one withadmin.visits.unlock). Location sampling does not restart for a past date.TourStatusChangewith the reason; auditSTATUS_CHANGEplusOVERRIDE(exception to a definitive closing).- Notifications:
tour.reopened→ the delegate (push + in-app, with a sync data message). SSE:tour.reopened.
Used by BO-23
GET /admin/tours/{tourId}/track
admin.tours.track · Permission field.view · Apps BO · Release V1 · Requirements FLD-13 · FLD-15
Purpose. The position trail of a tour for the map, within the retention period.
Request
| Query | Type | Values / default | Notes |
|---|---|---|---|
since | string (timestamp) | Incremental fetch for a live tour | |
simplifyMeters | number | 0 (raw) – 200; default 10 | Douglas–Peucker tolerance |
Response 200 OK
{
"tourId": "01930a10-3c4d-7e5f-8a6b-7c8d9e0f1a2b",
"status": "IN_PROGRESS",
"purged": false,
"retentionUntil": "2027-02-02",
"points": [
{ "lat": 35.6969, "lng": -0.6331, "accuracy": 9, "capturedAt": "2026-11-04T07:58:00Z" },
{ "lat": 35.7012, "lng": -0.6122, "accuracy": 12, "capturedAt": "2026-11-04T08:03:00Z" },
{ "lat": 35.7209, "lng": -0.5446, "accuracy": 14, "capturedAt": "2026-11-04T08:41:12Z" }
],
"events": [
{ "kind": "TOUR_STARTED", "at": "2026-11-04T07:58:00Z", "position": { "lat": 35.6969, "lng": -0.6331 } },
{ "kind": "CHECK_IN", "at": "2026-11-04T08:41:12Z", "visitId": "01930a10-5e6f-7a8b-9c0d-1e2f3a4b5c6e", "position": { "lat": 35.7209, "lng": -0.5446 } }
],
"km": 21.7,
"pointsCount": 42
}Field notes. Points exist only while the tour was IN_PROGRESS (BR-52). After field.locationRetentionDays (90) the points are purged: purged: true, points: [], while km and check-in positions (business data) remain.
Errors
| Status | Code | When |
|---|---|---|
| 400 | VALIDATION_FAILED | simplifyMeters out of range |
| 403 | PERMISSION_DENIED | Caller lacks field.view |
| 404 | NOT_FOUND | Unknown tour |
Rules & side effects
- Read-only. Access to position trails is personal data of the delegate (cahier §10): reads are rate-limited (60 / min) and not cached by the client beyond the page.
Used by BO-23
GET /admin/live/delegates
admin.live.delegates · Permission field.view · Apps BO · Release V1 · Requirements FLD-13
Purpose. Delegates on tour now, with last known position and progress — the initial state of the live map; delegate.position events keep it current.
Request
| Query | Type | Values / default | Notes |
|---|---|---|---|
zoneId / wilayaCode | Uuid / string | ||
includeIdle | boolean | false | Also delegates with a tour TO_START or SUSPENDED |
Response 200 OK
{
"items": [
{
"delegate": { "id": "0192a7d3-1b2c-7d4e-8f5a-6b7c8d9e0f12", "displayName": "Yacine Merabet" },
"tour": { "id": "01930a10-3c4d-7e5f-8a6b-7c8d9e0f1a2b", "number": "TRN-2026-000045", "status": "IN_PROGRESS", "startedAt": "2026-11-04T07:58:00Z" },
"lastPosition": { "lat": 35.6923, "lng": -0.5871, "accuracy": 11, "capturedAt": "2026-11-04T10:09:30Z" },
"stale": false,
"progress": { "planned": 9, "done": 4, "notDone": 1, "remaining": 4, "late": 1 },
"currentVisit": null,
"nextStop": { "visitId": "01930a10-8b9c-7d0e-9f1a-2b3c4d5e6f71", "storeName": "Galaxy Phone", "plannedAt": "11:30", "distanceMeters": 2310 },
"km": 21.7
}
],
"generatedAt": "2026-11-04T10:10:00Z"
}Errors
| Status | Code | When |
|---|---|---|
| 403 | PERMISSION_DENIED | Caller lacks field.view |
Rules & side effects
- Read-only; not paginated (a few dozen delegates).
stale= no position for more than 2 ×field.locationIntervalSeconds. Suspended tours have no fresh position by design (sampling stops).
Visits
GET /admin/visits
admin.visits.list · Permission field.view · Apps BO · Release V1 · Requirements FLD-08 · FLD-09
Purpose. Visits with status, delegate, reseller and outcome filters — the visit reports analysis of cahier §4.5.
Request
| Query | Type | Values / default | Notes |
|---|---|---|---|
status | VisitStatus[] | repeatable | |
delegateId | Uuid[] | repeatable | |
resellerId / tourId | Uuid | Reseller 360° tab, tour detail | |
visitTypeId | Uuid[] | repeatable | |
from / to | string (date) | Tour date | |
interestLevel | InterestLevel[] | repeatable | From the report |
nextAction | NextAction[] | repeatable | From the report |
notDoneReasonCode | string[] | VISIT_NOT_DONE_REASON codes | |
outOfTolerance / hasReport / hasOrder | boolean | ||
q | string | min 2 chars | Visit number, store name |
sort / order / page / pageSize | checkedInAt (default), date, durationMinutes · desc · 1 · 25 (max 200) |
Response 200 OK
{
"items": [
{
"id": "01930a10-5e6f-7a8b-9c0d-1e2f3a4b5c6e", "number": "VIS-2026-001234", "date": "2026-11-04", "status": "DONE",
"tour": { "id": "01930a10-3c4d-7e5f-8a6b-7c8d9e0f1a2b", "number": "TRN-2026-000045" },
"delegate": { "id": "0192a7d3-1b2c-7d4e-8f5a-6b7c8d9e0f12", "displayName": "Yacine Merabet" },
"reseller": { "id": "0192f4b8-61c2-7a3e-9b10-1f2e3d4c5b6a", "number": "REV-000123", "storeName": "Phone Store El Bahia", "ownerName": "Karim Benali", "phone": "+213555123456", "wilayaCode": "31", "communeName": "Bir El Djir", "status": "ACTIVE" },
"visitType": { "id": "01928e40-1a2b-7c3d-8e4f-5a6b7c8d9e0f", "name": "Visite commerciale" },
"checkedInAt": "2026-11-04T08:41:12Z", "distanceMeters": 63, "outOfTolerance": false, "durationMinutes": 32,
"interestLevel": "HIGH", "nextAction": "PHONE_FOLLOW_UP", "photosCount": 2, "hasReport": true, "orderId": null, "locked": true
}
],
"total": 214,
"page": 1,
"pageSize": 25
}Errors
| Status | Code | When |
|---|---|---|
| 400 | VALIDATION_FAILED | Unknown enum value |
| 403 | PERMISSION_DENIED | Caller lacks field.view |
Rules & side effects
- Read-only; new check-ins and ends arrive through
visit.*SSE events.
GET /admin/visits/{visitId}
admin.visits.get · Permission field.view · Apps BO · Release V1 · Requirements FLD-08 · FLD-10
Purpose. A visit with check-in position and distance, timings, checklist, note, photos (with capture position) and the structured report.
Response 200 OK
{
"id": "01930a10-5e6f-7a8b-9c0d-1e2f3a4b5c6e", "number": "VIS-2026-001234", "status": "DONE", "date": "2026-11-04",
"tour": { "id": "01930a10-3c4d-7e5f-8a6b-7c8d9e0f1a2b", "number": "TRN-2026-000045" },
"delegate": { "id": "0192a7d3-1b2c-7d4e-8f5a-6b7c8d9e0f12", "displayName": "Yacine Merabet" },
"reseller": { "id": "0192f4b8-61c2-7a3e-9b10-1f2e3d4c5b6a", "number": "REV-000123", "storeName": "Phone Store El Bahia", "ownerName": "Karim Benali", "phone": "+213555123456", "wilayaCode": "31", "communeName": "Bir El Djir", "status": "ACTIVE", "location": { "lat": 35.7206, "lng": -0.544 } },
"visitType": { "id": "01928e40-1a2b-7c3d-8e4f-5a6b7c8d9e0f", "name": "Visite commerciale", "minPhotos": 1, "reportRequired": true },
"checkIn": { "at": "2026-11-04T08:41:12Z", "occurredAt": "2026-11-04T08:41:12Z", "receivedAt": "2026-11-04T08:41:15Z", "position": { "lat": 35.7209, "lng": -0.5446, "accuracy": 14 }, "distanceMeters": 63, "withinTolerance": true },
"startedAt": "2026-11-04T08:42:00Z", "endedAt": "2026-11-04T09:14:00Z", "durationMinutes": 32,
"checklist": { "templateId": "01928e50-1a2b-7c3d-8e4f-5a6b7c8d9e0f", "templateVersion": 2, "items": [
{ "id": "01928e50-2b3c-7d4e-9f5a-6b7c8d9e0f1a", "label": "Présentoir HOCO en place", "type": "YES_NO", "value": true },
{ "id": "01928e50-3c4d-7e5f-8a6b-7c8d9e0f1a2b", "label": "Nombre de références ACEFAST en rayon", "type": "NUMBER", "value": 6 }
] },
"note": "Demande la nouvelle gamme GaN.",
"photos": [{ "id": "01930a11-1a2b-7c3d-8e4f-5a6b7c8d9e0f", "file": { "id": "01930a11-2b3c-7d4e-9f5a-6b7c8d9e0f1a", "url": "https://files.hoco.dz/s/vis1234-1.webp?sig=…", "thumbnailUrl": "https://files.hoco.dz/s/vis1234-1-thumb.webp?sig=…", "mimeType": "image/webp", "size": 201334, "purpose": "VISIT_PHOTO" }, "capturedAt": "2026-11-04T08:50:00Z", "position": { "lat": 35.7208, "lng": -0.5445, "accuracy": 10 } }],
"report": {
"personMet": "Karim Benali", "personRoleCode": "OWNER", "interestLevel": "HIGH",
"productsPresented": [{ "productId": "0192b215-6f70-7a81-9b2c-3d4e5f6a7b80", "name": "Chargeur GaN 50 W 2 ports" }],
"promotionsPresented": [{ "promotionId": "01930050-2b3c-7d4e-9f5a-6b7c8d9e0f1a", "name": "ACEFAST −10 % novembre" }],
"issueTags": ["PRICE_COMPETITION"], "needs": "Veut des câbles Lightning 2 m.", "nextAction": "PHONE_FOLLOW_UP", "followUpDate": "2026-11-05",
"comment": "Commande prévue après le réassort.", "taskId": "01930b11-2c3d-7e4f-9a5b-6c7d8e9f0a1b"
},
"locked": true, "unlockedUntil": null, "history": []
}Errors
| Status | Code | When |
|---|---|---|
| 403 | PERMISSION_DENIED | Caller lacks field.view |
| 404 | NOT_FOUND | Unknown visit |
Rules & side effects
- Read-only; photo URLs signed 5 minutes.
POST /admin/visits/{visitId}/unlock
admin.visits.unlock · Permission field.validate · Apps BO · Release V1 · Requirements FLD-08
Purpose. Allows the delegate to correct a finished, locked visit (cahier §6.4 « Une visite terminée ne peut être modifiée sans permission »).
Request
interface UnlockVisitBody { reason: string; hours?: number } // hours default field.visitUnlockHours (24), 1–168{ "reason": "Photo du linéaire manquante — à ajouter." }Response 200 OK — { "id": "01930a10-5e6f-7a8b-9c0d-1e2f3a4b5c6e", "status": "DONE", "locked": false, "unlockedUntil": "2026-11-05T10:20:00Z", "unlockedBy": { "id": "01929e55-2c3d-7e4f-9a1b-2c3d4e5f6a7b", "displayName": "Nadia Belkacem" } }
Errors
| Status | Code | When |
|---|---|---|
| 403 | PERMISSION_DENIED | Caller lacks field.validate |
| 404 | NOT_FOUND | Unknown visit |
| 409 | VISIT_NOT_LOCKED | Visit not DONE, or already unlocked |
| 422 | REASON_REQUIRED | Empty reason |
Rules & side effects
- The delegate may edit checklist, note, photos and report until
unlockedUntil; the visit locks again at the nextdelegate.visits.endor when the window closes. - Audit
OVERRIDEwith the reason. - Notifications:
visit.unlocked→ the delegate (push, sync data message; proposed event, not yet in the notifications matrix).
Used by BO-25
Visit types and checklist templates
interface VisitType {
id: Uuid; code: string; name: string;
minPhotos: number; // photos required to end the visit
reportRequired: boolean; // default field.reportRequiredToEndVisit
reportFields: ("personMet" | "interestLevel" | "productsPresented" | "promotionsPresented" | "issueTags" | "needs" | "nextAction" | "comment" | "attachments")[];
checklistTemplateId?: Uuid;
defaultDurationMinutes?: number;
active: boolean; sortOrder: number; version: number;
}
interface ChecklistTemplate {
id: Uuid; name: string;
items: { id: Uuid; label: string; type: "CHECK" | "YES_NO" | "NUMBER" | "TEXT" | "CHOICE"; options?: string[]; required: boolean; sortOrder: number }[];
active: boolean;
version: number; // templates are versioned; a visit keeps the version it was done with
usedBy: number; // visit types using it
}GET /admin/visit-types
admin.visitTypes.crud (list) · Permission field.view · Apps BO · Release V1 · Requirements FLD-16
Purpose. Lists visit types (also used by the tour editor).
Request — active (boolean), page / pageSize.
Response 200 OK — Page<VisitType>, e.g. { "items": [{ "id": "01928e40-1a2b-7c3d-8e4f-5a6b7c8d9e0f", "code": "COMMERCIAL", "name": "Visite commerciale", "minPhotos": 1, "reportRequired": true, "reportFields": ["personMet", "interestLevel", "productsPresented", "promotionsPresented", "nextAction", "comment"], "checklistTemplateId": "01928e50-1a2b-7c3d-8e4f-5a6b7c8d9e0f", "defaultDurationMinutes": 30, "active": true, "sortOrder": 1, "version": 2 }], "total": 4, "page": 1, "pageSize": 25 }
Errors
| Status | Code | When |
|---|---|---|
| 403 | PERMISSION_DENIED | Caller lacks field.view |
Rules & side effects
- Read-only (reading relaxed to
field.view— Assumption).
GET /admin/visit-types/{visitTypeId}
admin.visitTypes.crud (get) · Permission field.view · Apps BO · Release V1 · Requirements FLD-16
Purpose. One visit type. Response 200 OK — a VisitType.
Errors
| Status | Code | When |
|---|---|---|
| 404 | NOT_FOUND | Unknown visit type |
Rules & side effects
- Read-only.
Used by BO-26
POST /admin/visit-types
admin.visitTypes.crud (create) · Permission field.administer · Apps BO · Release V1 · Requirements FLD-16
Purpose. Creates a visit type with its photo, report and checklist requirements (cahier §6.4 « Les photos peuvent être obligatoires selon le type de visite », §6.8).
Request
{ "code": "MERCHANDISING", "name": "Merchandising", "minPhotos": 2, "reportRequired": false, "reportFields": ["comment"], "checklistTemplateId": "01928e50-1a2b-7c3d-8e4f-5a6b7c8d9e0f", "defaultDurationMinutes": 20 }Response 201 Created — the VisitType.
Errors
| Status | Code | When |
|---|---|---|
| 400 | VALIDATION_FAILED | minPhotos over 10, unknown report field |
| 403 | PERMISSION_DENIED | Caller lacks field.administer |
| 409 | DUPLICATE_VALUE | Code taken |
Rules & side effects
- Audit
CREATE; the delegate app receives it withsync.delta.
Used by BO-26
PATCH /admin/visit-types/{visitTypeId}
admin.visitTypes.crud (update) · Permission field.administer · Apps BO · Release V1 · Requirements FLD-16
Purpose. Edits or deactivates a visit type (never deleted — visits reference it).
Request — partial VisitType fields plus version, e.g. { "minPhotos": 1, "version": 2 }.
Response 200 OK — the updated VisitType.
Errors
| Status | Code | When |
|---|---|---|
| 403 | PERMISSION_DENIED | Caller lacks field.administer |
| 404 | NOT_FOUND | Unknown visit type |
| 409 | VERSION_CONFLICT | Stale version |
Rules & side effects
- Requirements apply to visits not yet started; audit
UPDATE.
Used by BO-26
GET /admin/checklist-templates
admin.checklists.crud (list) · Permission field.view · Apps BO · Release V1 · Requirements FLD-16
Purpose. Lists checklist templates. Request — active, page / pageSize. Response 200 OK — Page<ChecklistTemplate>.
Errors
| Status | Code | When |
|---|---|---|
| 403 | PERMISSION_DENIED | Caller lacks field.view |
Rules & side effects
- Read-only.
Used by BO-26
GET /admin/checklist-templates/{templateId}
admin.checklists.crud (get) · Permission field.view · Apps BO · Release V1 · Requirements FLD-16
Purpose. One template (?version= for a past version). Response 200 OK — a ChecklistTemplate.
Errors
| Status | Code | When |
|---|---|---|
| 404 | NOT_FOUND | Unknown template or version |
Rules & side effects
- Read-only.
POST /admin/checklist-templates
admin.checklists.crud (create) · Permission field.administer · Apps BO · Release V1 · Requirements FLD-16
Purpose. Creates a checklist template.
Request
{ "name": "Contrôle linéaire", "items": [
{ "label": "Présentoir HOCO en place", "type": "YES_NO", "required": true, "sortOrder": 1 },
{ "label": "Nombre de références ACEFAST en rayon", "type": "NUMBER", "required": false, "sortOrder": 2 },
{ "label": "État du présentoir", "type": "CHOICE", "options": ["Bon", "Abîmé", "Absent"], "required": true, "sortOrder": 3 }
] }Response 201 Created — the ChecklistTemplate (version 1).
Errors
| Status | Code | When |
|---|---|---|
| 400 | VALIDATION_FAILED | No item, more than 30 items, CHOICE without options |
| 403 | PERMISSION_DENIED | Caller lacks field.administer |
Rules & side effects
- Audit
CREATE.
Used by BO-26
PATCH /admin/checklist-templates/{templateId}
admin.checklists.crud (update) · Permission field.administer · Apps BO · Release V1 · Requirements FLD-16
Purpose. Edits a template — a change of items creates a new version.
Request — { name?, items?, active?, version }.
Response 200 OK — the ChecklistTemplate with version + 1 when items changed.
Errors
| Status | Code | When |
|---|---|---|
| 403 | PERMISSION_DENIED | Caller lacks field.administer |
| 404 | NOT_FOUND | Unknown template |
| 409 | VERSION_CONFLICT | Stale version |
Rules & side effects
- Visits in progress keep their version. Audit
UPDATE.
Used by BO-26
Invitations
GET /admin/invitations
admin.invitations.list · Permission field.view · Apps BO · Release V1 · Requirements IVT-03 · ANA-04
Purpose. The invitation funnel by delegate (sent → installed → account → verified → catalog → first order, cahier §6.6) and the invitations behind it.
Request
| Query | Type | Values / default | Notes |
|---|---|---|---|
from / to | string (date) | default current month | On sentAt |
delegateId | Uuid[] | repeatable | |
zoneId | Uuid | ||
reachedStep / stuckAt | InvitationStep | Highest step reached / exactly that step | |
channel | ShareChannel[] | repeatable | |
page / pageSize | number | 1 / 25 (max 200) |
Response 200 OK
{
"funnel": { "SENT": 42, "APP_INSTALLED": 23, "ACCOUNT_CREATED": 19, "PHONE_VERIFIED": 19, "CATALOG_VIEWED": 15, "FIRST_ORDER": 7 },
"byDelegate": [
{ "delegate": { "id": "0192a7d3-1b2c-7d4e-8f5a-6b7c8d9e0f12", "displayName": "Yacine Merabet" }, "steps": { "SENT": 9, "APP_INSTALLED": 6, "ACCOUNT_CREATED": 5, "PHONE_VERIFIED": 5, "CATALOG_VIEWED": 4, "FIRST_ORDER": 3 }, "conversionBps": 3333 }
],
"items": [
{ "id": "0192f4e3-9a01-7b02-8c03-0d0e0f101112", "code": "Q4MZ-7KTR", "reseller": { "id": "0192f4e3-4f5a-7b6c-8d7e-8f9a0b1c2d3e", "number": "REV-000231", "storeName": "Hassi Phone", "ownerName": "Ali Kerroum", "phone": "+213552334455", "wilayaCode": "31", "communeName": "Hassi Bounif", "status": "ACCOUNT_CREATED" }, "delegate": { "id": "0192a7d3-1b2c-7d4e-8f5a-6b7c8d9e0f12", "displayName": "Yacine Merabet" }, "channels": ["WHATSAPP"], "sentAt": "2026-11-02T09:10:00Z", "expiresAt": "2026-12-02T09:10:00Z", "steps": [{ "step": "SENT", "at": "2026-11-02T09:10:00Z", "inferred": false }, { "step": "APP_INSTALLED", "at": "2026-11-02T09:31:00Z", "inferred": false }, { "step": "ACCOUNT_CREATED", "at": "2026-11-02T09:40:00Z", "inferred": false }, { "step": "PHONE_VERIFIED", "at": "2026-11-02T09:40:00Z", "inferred": false }], "highestStep": "PHONE_VERIFIED" }
],
"total": 42,
"page": 1,
"pageSize": 25
}Errors
| Status | Code | When |
|---|---|---|
| 400 | VALIDATION_FAILED | Unknown step or channel |
| 403 | PERMISSION_DENIED | Caller lacks field.view |
Rules & side effects
- Read-only; the funnel is monotonic (later steps imply earlier ones, back-filled
inferred: true— Lifecycles › Invitation funnel).
Promotions & loyalty
Back-office promotion operations — promotions with scope, targeting, schedule, caps and stacking, their status and simulation, banners and daily offers, and the V2 packs and loyalty programme.
Support & communication
Back-office support and messaging — ticket queue, replies and internal notes, after-sales resolutions, announcements with targeting, the notification log, templates and the event-to-channel rules.