Tours & visits
Every operation of a delegate's day in the field: the offline snapshot of today's tour, start/suspend/resume, reordering, unplanned visits, GPS check-in, checklist, note, photos, structured report, end, not-done and tour closing.
A tour (tournée) is one delegate on one date with an ordered list of visits to points of sale (cahier §4.5, §6.1–§6.4, §6.8, §6.9). The back-office plans it (admin.tours.create); the delegate app works it — usually with a weak or absent network. The state machines and their guards are owned by Lifecycles — Tour and Lifecycles — Visit; this page specifies the exact requests, responses and checks.
Offline behaviour. The app downloads today's snapshot every morning (and whenever the tour changes) and can then work the whole day without network: every mutation on this page is offline-capable except closing. Offline, the app applies the action to its local database at once, stamps it with occurredAt (device time corrected by the server offset), gives it an Idempotency-Key and appends it to its outbox; the outbox replays the items in order through sync.batch as soon as a connection exists, and sends critical items (check-in, end, not-done) immediately when it already has one (SYN-03). A direct call and a replay of the same action use the same key, so a lost response never duplicates anything. Records created offline (unplanned visits, photos, reports) carry a client-generated UUIDv7 id.
Scope. A delegate sees and acts only on their own tours (Tour.delegateId = caller) and the visits of those tours. Each stop's point of sale is readable through the visit even when it is not otherwise in the delegate's scope (a store planned in one of the delegate's tours is accessible from 7 days before to 30 days after the tour date — permissions matrix); the general rule for points of sale is on Prospects & points of sale. A tour of another delegate answers 404 NOT_FOUND; a tour that was the caller's and has been reassigned answers 403 NOT_ASSIGNED_DELEGATE, so a replayed offline action can explain itself.
| Method | Path | Operation | Access | Apps | Release |
|---|---|---|---|---|---|
| GET | /delegate/tours | delegate.tours.listMy tours in a date range (calendar and history) | delegate | DA | V1 |
| GET | /delegate/tours/today | delegate.tours.todayToday's tour with ordered visits, points of sale, distances and statuses (offline snapshot) | delegate | DA | V1 |
| GET | /delegate/tours/{tourId} | delegate.tours.getA tour with its visits | delegate | DA | V1 |
| POST | /delegate/tours/{tourId}/start | delegate.tours.startStart the tour (time and position recorded) | delegateIdempotency-Key | DA | V1 |
| POST | /delegate/tours/{tourId}/suspend | delegate.tours.suspendSuspend the tour with a reason | delegateIdempotency-Key | DA | V1 |
| POST | /delegate/tours/{tourId}/resume | delegate.tours.resumeResume a suspended tour | delegateIdempotency-Key | DA | V1 |
| PUT | /delegate/tours/{tourId}/visit-order | delegate.tours.reorderReorder remaining visits when the tour's order is 'recommended' (not 'imposed') | delegateIdempotency-Key | DA | V1 |
| POST | /delegate/tours/{tourId}/visits | delegate.tours.addVisitAdd an unplanned visit (client-generated id) | delegateIdempotency-Key | DA | V1 |
| GET | /delegate/tours/{tourId}/closing-preview | delegate.tours.closingPreviewDay summary and blocking items (unvisited stops without reason, unsynced data) | delegate | DA | V1 |
| POST | /delegate/tours/{tourId}/close | delegate.tours.closeClose the tour with an end-of-day comment; returns the final report | delegateIdempotency-Key | DA | V1 |
| GET | /delegate/visits/{visitId} | delegate.visits.getVisit detail: point of sale, checklist template, report config, current state | delegate | DA | V1 |
| POST | /delegate/visits/{visitId}/arrive | delegate.visits.arriveCheck-in: position and time; returns distance and whether the GPS tolerance is met | delegateIdempotency-Key | DA | V1 |
| POST | /delegate/visits/{visitId}/start | delegate.visits.startStart the visit timer | delegateIdempotency-Key | DA | V1 |
| PUT | /delegate/visits/{visitId}/checklist | delegate.visits.checklistSave the checklist state | delegateIdempotency-Key | DA | V1 |
| PUT | /delegate/visits/{visitId}/note | delegate.visits.noteSave the quick note (≤ 300 chars) | delegateIdempotency-Key | DA | V1 |
| POST | /delegate/visits/{visitId}/photos | delegate.visits.addPhotoAttach an uploaded photo with its capture position and time | delegateIdempotency-Key | DA | V1 |
| PUT | /delegate/visits/{visitId}/report | delegate.visits.reportSave the structured visit report; creates or updates the follow-up task | delegateIdempotency-Key | DA | V1 |
| POST | /delegate/visits/{visitId}/end | delegate.visits.endFinish the visit (requires report/photos when the visit type demands them); the visit locks | delegateIdempotency-Key | DA | V1 |
| POST | /delegate/visits/{visitId}/not-done | delegate.visits.notDoneMark the visit as not carried out with a reason | delegateIdempotency-Key | DA | V1 |
Conventions on this page
- Caller. Delegate session (
Authorization: Bearer …), user typeDELEGATE, accountACTIVE. Mobile headersX-App-Version,X-App-Platform,X-Device-Idare sent on every call. - Every mutation requires
Idempotency-Key(UUID). Same key and same body → original response withIdempotent-Replayed: true; same key, different body →409 IDEMPOTENCY_KEY_REUSED; same key still running →409 IDEMPOTENCY_IN_PROGRESS. occurredAt(required on every mutation) is the device time of the action. The server keeps it next toreceivedAtand refuses422 OCCURRED_AT_OUT_OF_RANGEwhen it is more than 5 minutes in the future, older than 72 hours, or earlier than the previous step of the same record (a visit cannot end before its check-in). Time-based guards (such as "the tour date is today") use theAfrica/Algiersdate ofoccurredAt, not ofreceivedAt.- Errors common to every operation, not repeated below:
400 VALIDATION_FAILED,400 IDEMPOTENCY_KEY_REQUIRED(mutation without the header),401 UNAUTHENTICATED,403 ACCOUNT_SUSPENDED,403 PERMISSION_DENIED(caller is not a delegate),404 NOT_FOUND,409 IDEMPOTENCY_KEY_REUSED,409 IDEMPOTENCY_IN_PROGRESS,422 OCCURRED_AT_OUT_OF_RANGE(mutations),426 UPGRADE_REQUIRED,429 RATE_LIMITED,500 INTERNAL_ERROR,503 MAINTENANCE. - Settings used here (defaults, tunable in BO-64, see the settings catalogue):
field.gpsToleranceMeters= 150,field.gpsMaxAccuracyMeters= 100,field.allowOutOfToleranceCheckIn=false,field.reportRequiredToEndVisit=true(the defaultrequiresReportof new visit types),field.closingRequiresSync=true,field.visitUnlockHours= 24,field.locationIntervalSeconds= 300,field.locationDistanceMeters= 200, and the proposedfield.maxPhotosPerVisit= 10 (Assumption — not yet in the catalogue).
Shared shapes of this page
ResellerCard, ResellerSheet and TaskView are defined on Prospects & points of sale; ObjectiveProgress and ConversionStat on Delegate home.
type TourStatus = "PLANNED" | "TO_START" | "IN_PROGRESS" | "SUSPENDED" | "COMPLETED" | "CANCELLED";
type VisitStatus = "PLANNED" | "CHECKED_IN" | "IN_PROGRESS" | "DONE" | "NOT_DONE" | "CANCELLED";
type TourAction = "START" | "SUSPEND" | "RESUME" | "REORDER" | "ADD_VISIT" | "CLOSE";
type VisitAction = "ARRIVE" | "START" | "EDIT" | "END" | "NOT_DONE";
interface TourView {
id: Uuid;
number: string; // TRN-2026-000045
date: string; // YYYY-MM-DD (Africa/Algiers)
status: TourStatus;
orderingMode: "IMPOSED" | "RECOMMENDED";
plannedStartAt: Iso;
plannedEndAt: Iso | null;
startedAt: Iso | null;
suspendedAt: Iso | null; // set while SUSPENDED
suspendReasonCode: string | null; // TOUR_SUSPEND_REASON item
closedAt: Iso | null;
startPoint: { label: string; location: GeoPoint } | null; // where the planner expects the day to start
instructions: string | null; // planner's note for the day
counters: TourCounters;
visits: VisitStop[]; // ordered by sequence — list and map use this order (FLD-06)
allowedActions: TourAction[];
closingReport: TourClosingReport | null; // set once COMPLETED
version: number; // increments on any change of status, stops or order
updatedAt: Iso;
}
interface TourCounters {
planned: number; // stops planned by the back-office, not CANCELLED
unplanned: number; // visits added by the delegate
done: number;
notDone: number;
remaining: number; // PLANNED + CHECKED_IN + IN_PROGRESS
late: number; // derived (see VisitStop.isLate)
}
interface VisitStop {
id: Uuid;
number: string; // VIS-2026-001234
sequence: number; // 1-based position in the tour
plannedAt: Iso | null; // start of the time slot; null for unplanned visits
slotEndAt: Iso | null;
priority: "NORMAL" | "HIGH";
status: VisitStatus;
isLate: boolean; // derived: PLANNED, tour TO_START or IN_PROGRESS, slotEndAt passed
isUnplanned: boolean;
visitTypeId: Uuid;
reseller: ResellerCard;
distanceFromPreviousMeters: number | null; // straight line from the previous stop (stop 1: from startPoint)
checkedInAt: Iso | null;
startedAt: Iso | null;
endedAt: Iso | null;
gpsValidated: boolean | null; // null until checked in, and when the store had no coordinates
locked: boolean;
unlockedUntil: Iso | null; // set when the back-office unlocked a DONE visit
version: number;
}
interface VisitTypeView {
id: Uuid;
code: string; // "PROSPECTION"
name: string; // "Prospection"
requiresReport: boolean; // new types default to field.reportRequiredToEndVisit
requiresPhoto: boolean;
minPhotos: number; // ≥ 1 when requiresPhoto
checklistTemplateId: Uuid | null;
reportFields: Array<{ field: ReportField; visible: boolean; required: boolean }>;
}
type ReportField = "personMetName" | "personMetRoleCode" | "interestLevel" | "productsPresented"
| "promotionsPresented" | "issueTagCodes" | "needs" | "nextAction" | "followUpAt" | "comment" | "attachmentFileIds";
interface ChecklistTemplate {
id: Uuid;
version: number;
name: string;
items: Array<{ key: string; label: string; required: boolean; sortOrder: number }>;
}
interface TourClosingSummary {
visitsPlanned: number;
visitsDone: number; // planned + unplanned
notDone: number;
unplannedDone: number;
newResellers: number; // SIGNUPS rule
appsInstalled: number;
firstOrders: number;
revenueTtc: Money;
kmTraveled: number | null;
objectiveAttainment: ObjectiveProgress[]; // the caller's DAY objectives
conversion: ConversionStat; // window = the tour date
conversionRate: BasisPoints; // = conversion.rate
activeMinutes: number; // startedAt → closedAt minus suspensions
}
interface TourClosingReport {
tourId: Uuid;
version: number; // 2+ after a reopening
closedAt: Iso;
receivedAt: Iso;
comment: string | null;
endLocation: GeoPoint | null;
summary: TourClosingSummary;
notDoneVisits: Array<{ visitId: Uuid; sequence: number; storeName: string; reasonCode: string; comment: string | null }>;
}Reference-list codes used in the examples are the seed values (editable in the back-office, admin.referenceLists.crud): TOUR_SUSPEND_REASON — LUNCH_BREAK, PRAYER, VEHICLE_ISSUE, MEETING, OTHER; VISIT_NOT_DONE_REASON — STORE_CLOSED, MANAGER_ABSENT, REFUSED_VISIT, NO_TIME_LEFT, STORE_NOT_FOUND, OTHER; PERSON_ROLE — OWNER, MANAGER, EMPLOYEE; VISIT_ISSUE_TAG — CHARGER_STOCKOUT, EARPHONES_DEMAND, CAR_HOLDER_NEED, PRICE_OBJECTION, COMPETITOR_PRESENT. OTHER always makes the comment mandatory.
GET /delegate/tours
delegate.tours.list · Access session, delegate scope (own tours) · Apps DA · Idempotency-Key — · Offline-capable no — the last result is cached, and the tours of the sync window are also in the local database through sync.delta · Release V1 · Requirements FLD-03, FLD-19
Purpose. Mes tournées: the calendar of planned tours and the history of past ones.
Request. Query parameters: from, to (YYYY-MM-DD, default today − 30 days → today + 14 days, range at most 92 days), status (repeatable TourStatus), cursor, limit (default 20, max 100). Sorted by date descending.
Response 200 OK
interface TourListItem {
id: Uuid;
number: string;
date: string;
status: TourStatus;
orderingMode: "IMPOSED" | "RECOMMENDED";
plannedStartAt: Iso;
startedAt: Iso | null;
closedAt: Iso | null;
counters: TourCounters;
firstStop: { storeName: string; communeName: string; plannedAt: Iso | null } | null;
isNotClosed: boolean; // derived: IN_PROGRESS or SUSPENDED after its date
}{
"items": [
{
"id": "0193a5d1-2e3f-7a40-9b51-6c7d8e9f0a46",
"number": "TRN-2026-000046",
"date": "2026-11-04",
"status": "PLANNED",
"orderingMode": "IMPOSED",
"plannedStartAt": "2026-11-04T07:30:00Z",
"startedAt": null,
"closedAt": null,
"counters": { "planned": 5, "unplanned": 0, "done": 0, "notDone": 0, "remaining": 5, "late": 0 },
"firstStop": { "storeName": "Super Marché Zitoune", "communeName": "Aïn El Beïda", "plannedAt": "2026-11-04T08:00:00Z" },
"isNotClosed": false
},
{
"id": "0193a2f0-5b6c-7e8d-9f01-23456789ab01",
"number": "TRN-2026-000045",
"date": "2026-11-03",
"status": "IN_PROGRESS",
"orderingMode": "RECOMMENDED",
"plannedStartAt": "2026-11-03T07:30:00Z",
"startedAt": "2026-11-03T07:41:00Z",
"closedAt": null,
"counters": { "planned": 4, "unplanned": 0, "done": 2, "notDone": 0, "remaining": 2, "late": 0 },
"firstStop": { "storeName": "Magasin El Wafa", "communeName": "Bir El Djir", "plannedAt": "2026-11-03T08:00:00Z" },
"isNotClosed": false
},
{
"id": "0193a08c-7d6e-7f50-8a62-1b2c3d4e5f44",
"number": "TRN-2026-000044",
"date": "2026-11-02",
"status": "COMPLETED",
"orderingMode": "RECOMMENDED",
"plannedStartAt": "2026-11-02T07:30:00Z",
"startedAt": "2026-11-02T07:52:00Z",
"closedAt": "2026-11-02T16:48:00Z",
"counters": { "planned": 5, "unplanned": 0, "done": 4, "notDone": 1, "remaining": 0, "late": 0 },
"firstStop": { "storeName": "Librairie Moderne", "communeName": "Oran", "plannedAt": "2026-11-02T08:00:00Z" },
"isNotClosed": false
}
],
"nextCursor": null
}Errors — common errors only (400 for a range over 92 days).
Rules & side effects. Read-only. Cancelled tours are listed (with their status) so the delegate understands a day that disappeared. Tours of other delegates are never listed.
Used by DA-07.
GET /delegate/tours/today
delegate.tours.today · Access session, delegate scope · Apps DA · Idempotency-Key — · Offline-capable it is the offline payload: downloaded online, stored in the local database and used for the whole day · Release V1 · Requirements FLD-06, SYN-04
Purpose. Everything the app needs to work a full day without network (cahier §7.3 « conserver localement une tournée, des visites, notes et photos »): the current tour with its ordered visits, the point-of-sale details of every stop, the visit types and checklist templates they use, the reference data for the report, the field settings and the sync cursor that continues from this snapshot.
Request. No parameters. If-None-Match: <etag> → 304 Not Modified when nothing changed.
Response 200 OK (headers ETag, Cache-Control: private, no-cache)
interface TodaySnapshot {
serverTime: Iso; // the app derives its clock offset from it
businessDate: string;
tour: TourView | null; // current tour: an unclosed tour of a previous day first, else today's, else null
pointsOfSale: PosOfflineDetails[]; // one per distinct reseller of tour.visits
visitTypes: VisitTypeView[]; // those used by the visits, plus the default type for unplanned visits
defaultVisitTypeId: Uuid; // type proposed for unplanned visits
checklistTemplates: ChecklistTemplate[];
reportCatalog: {
brands: Array<{ id: Uuid; name: string }>;
categories: Array<{ id: Uuid; name: string; parentId: Uuid | null }>;
promotions: Array<{ id: Uuid; label: string; endsAt: Iso | null }>; // active promotions that can be "presented"
};
referenceLists: { version: string }; // compare with the cached reference.lists; refetch when different
fieldSettings: {
gpsToleranceMeters: number;
gpsMaxAccuracyMeters: number;
allowOutOfToleranceCheckIn: boolean;
reportRequiredToEndVisit: boolean; // default of VisitType.requiresReport; the app reads the visit type
closingRequiresSync: boolean;
noteMaxLength: number; // 300
maxPhotosPerVisit: number;
location: { intervalSeconds: number; minDistanceMeters: number; maxBatchSize: number };
};
deltaCursor: string; // pass to sync.delta to receive changes made after this snapshot
}
interface PosOfflineDetails {
resellerId: Uuid;
facadePhoto: FileRef | null; // signed URL valid 5 min — the app downloads the image at once
lastVisits: Array<{ visitId: Uuid; endedAt: Iso; interestLevel: "LOW" | "MEDIUM" | "HIGH" | null; nextAction: string | null; by: UserSummary }>; // last 3
openTasks: TaskView[];
invitation: { id: Uuid; displayCode: string; currentStep: string | null } | null;
ordersSummary: { count: number; lastOrderAt: Iso | null; totalTtc90d: Money };
}{
"serverTime": "2026-11-03T06:55:04Z",
"businessDate": "2026-11-03",
"tour": {
"id": "0193a2f0-5b6c-7e8d-9f01-23456789ab01",
"number": "TRN-2026-000045",
"date": "2026-11-03",
"status": "TO_START",
"orderingMode": "RECOMMENDED",
"plannedStartAt": "2026-11-03T07:30:00Z",
"plannedEndAt": "2026-11-03T16:30:00Z",
"startedAt": null,
"suspendedAt": null,
"suspendReasonCode": null,
"closedAt": null,
"startPoint": { "label": "Agence HOCO Oran", "location": { "lat": 35.6935, "lng": -0.6255 } },
"instructions": "Priorité à Electro Plus : présenter les écouteurs M1 Pro et le pack chargeur + câble.",
"counters": { "planned": 4, "unplanned": 0, "done": 0, "notDone": 0, "remaining": 4, "late": 0 },
"visits": [
{
"id": "0193a2f0-5b6c-7e8d-9f01-23456789ab11",
"number": "VIS-2026-001231",
"sequence": 1,
"plannedAt": "2026-11-03T08:00:00Z",
"slotEndAt": "2026-11-03T09:00:00Z",
"priority": "NORMAL",
"status": "PLANNED",
"isLate": false,
"isUnplanned": false,
"visitTypeId": "0192c3d0-1111-7a00-8c00-00000000f001",
"reseller": {
"id": "0192d1aa-3b4c-7d5e-8f60-718293a4b123",
"number": "REV-000123",
"storeName": "Magasin El Wafa",
"ownerName": "Karim Belkacem",
"phone": "+213555123456",
"wilayaCode": "31",
"communeName": "Bir El Djir",
"status": "ACTIVE",
"location": { "lat": 35.7112, "lng": -0.5724 },
"addressLine": "Hai Sabah, cité 300 logements, local n° 4",
"landmark": "En face de la mosquée El Feth",
"whatsapp": "+213555123456",
"businessTypeCode": "PHONE_SHOP",
"potential": "HIGH",
"customerType": "DEMI_GROS",
"hasAccount": true,
"assignedToMe": true,
"facadeThumbnailUrl": "https://files.hoco.dz/store-photos/0192d1aa-b123-thumb.webp?X-Amz-Expires=300&X-Amz-Signature=8c1f",
"lastVisitAt": "2026-10-20T09:40:00Z"
},
"distanceFromPreviousMeters": 5183,
"checkedInAt": null,
"startedAt": null,
"endedAt": null,
"gpsValidated": null,
"locked": false,
"unlockedUntil": null,
"version": 1
},
{
"id": "0193a2f0-5b6c-7e8d-9f01-23456789ab12",
"number": "VIS-2026-001232",
"sequence": 2,
"plannedAt": "2026-11-03T09:30:00Z",
"slotEndAt": "2026-11-03T10:30:00Z",
"priority": "HIGH",
"status": "PLANNED",
"isLate": false,
"isUnplanned": false,
"visitTypeId": "0192c3d0-1111-7a00-8c00-00000000f002",
"reseller": {
"id": "0192d1aa-3b4c-7d5e-8f60-718293a4b287",
"number": "REV-000287",
"storeName": "Electro Plus",
"ownerName": "Sofiane Medjahed",
"phone": "+213661457812",
"wilayaCode": "31",
"communeName": "Bir El Djir",
"status": "INTERESTED",
"location": { "lat": 35.7236, "lng": -0.5513 },
"addressLine": "Cité 200 logements, bloc C",
"landmark": null,
"whatsapp": "+213661457812",
"businessTypeCode": "ELECTRONICS_STORE",
"potential": "MEDIUM",
"customerType": "DETAIL",
"hasAccount": false,
"assignedToMe": true,
"facadeThumbnailUrl": "https://files.hoco.dz/store-photos/0192d1aa-b287-thumb.webp?X-Amz-Expires=300&X-Amz-Signature=41d0",
"lastVisitAt": "2026-10-27T10:15:00Z"
},
"distanceFromPreviousMeters": 2352,
"checkedInAt": null,
"startedAt": null,
"endedAt": null,
"gpsValidated": null,
"locked": false,
"unlockedUntil": null,
"version": 1
},
{
"id": "0193a2f0-5b6c-7e8d-9f01-23456789ab13",
"number": "VIS-2026-001233",
"sequence": 3,
"plannedAt": "2026-11-03T11:00:00Z",
"slotEndAt": "2026-11-03T12:00:00Z",
"priority": "NORMAL",
"status": "PLANNED",
"isLate": false,
"isUnplanned": false,
"visitTypeId": "0192c3d0-1111-7a00-8c00-00000000f002",
"reseller": {
"id": "0192d1aa-3b4c-7d5e-8f60-718293a4b301",
"number": "REV-000301",
"storeName": "Bazar du Centre",
"ownerName": "Nadir Benamar",
"phone": "+213770318452",
"wilayaCode": "31",
"communeName": "Oran",
"status": "TO_CONTACT",
"location": { "lat": 35.6987, "lng": -0.6359 },
"addressLine": "Centre-ville, 8 boulevard Emir Abdelkader",
"landmark": null,
"whatsapp": null,
"businessTypeCode": "BAZAAR",
"potential": "MEDIUM",
"customerType": null,
"hasAccount": false,
"assignedToMe": false,
"facadeThumbnailUrl": null,
"lastVisitAt": null
},
"distanceFromPreviousMeters": 8125,
"checkedInAt": null,
"startedAt": null,
"endedAt": null,
"gpsValidated": null,
"locked": false,
"unlockedUntil": null,
"version": 1
},
{
"id": "0193a2f0-5b6c-7e8d-9f01-23456789ab14",
"number": "VIS-2026-001234",
"sequence": 4,
"plannedAt": "2026-11-03T13:00:00Z",
"slotEndAt": "2026-11-03T14:00:00Z",
"priority": "NORMAL",
"status": "PLANNED",
"isLate": false,
"isUnplanned": false,
"visitTypeId": "0192c3d0-1111-7a00-8c00-00000000f003",
"reseller": {
"id": "0192d1aa-3b4c-7d5e-8f60-718293a4b315",
"number": "REV-000315",
"storeName": "Alpha Market",
"ownerName": "Amina Kaddour",
"phone": "+213550982214",
"wilayaCode": "31",
"communeName": "Es Senia",
"status": "ACCOUNT_CREATED",
"location": { "lat": 35.6491, "lng": -0.6202 },
"addressLine": "Route de l'aéroport, cité El Amel",
"landmark": "À côté de la station Naftal",
"whatsapp": "+213550982214",
"businessTypeCode": "SUPERMARKET",
"potential": "HIGH",
"customerType": "DETAIL",
"hasAccount": true,
"assignedToMe": true,
"facadeThumbnailUrl": "https://files.hoco.dz/store-photos/0192d1aa-b315-thumb.webp?X-Amz-Expires=300&X-Amz-Signature=a77e",
"lastVisitAt": "2026-10-29T13:20:00Z"
},
"distanceFromPreviousMeters": 5695,
"checkedInAt": null,
"startedAt": null,
"endedAt": null,
"gpsValidated": null,
"locked": false,
"unlockedUntil": null,
"version": 1
}
],
"allowedActions": ["START", "REORDER", "ADD_VISIT"],
"closingReport": null,
"version": 3,
"updatedAt": "2026-11-02T15:12:00Z"
},
"pointsOfSale": [
{
"resellerId": "0192d1aa-3b4c-7d5e-8f60-718293a4b123",
"facadePhoto": { "id": "0192d1b0-7a1b-7c2d-8e3f-4a5b6c7d8e01", "url": "https://files.hoco.dz/store-photos/0192d1aa-b123.webp?X-Amz-Expires=300&X-Amz-Signature=0b9e", "thumbnailUrl": "https://files.hoco.dz/store-photos/0192d1aa-b123-thumb.webp?X-Amz-Expires=300&X-Amz-Signature=8c1f", "mimeType": "image/webp", "size": 184322, "purpose": "STORE_PHOTO" },
"lastVisits": [
{ "visitId": "0193720a-1b2c-7d3e-8f40-5a6b7c8d9e21", "endedAt": "2026-10-20T09:40:00Z", "interestLevel": "HIGH", "nextAction": "NONE", "by": { "id": "0192f0a1-7c3e-7b10-9a4e-5d2c1b0a9f01", "displayName": "Yacine Belkacem" } }
],
"openTasks": [],
"invitation": null,
"ordersSummary": { "count": 7, "lastOrderAt": "2026-10-22T10:05:00Z", "totalTtc90d": 48230000 }
},
{
"resellerId": "0192d1aa-3b4c-7d5e-8f60-718293a4b287",
"facadePhoto": { "id": "0192d1b0-7a1b-7c2d-8e3f-4a5b6c7d8e02", "url": "https://files.hoco.dz/store-photos/0192d1aa-b287.webp?X-Amz-Expires=300&X-Amz-Signature=5d21", "thumbnailUrl": "https://files.hoco.dz/store-photos/0192d1aa-b287-thumb.webp?X-Amz-Expires=300&X-Amz-Signature=41d0", "mimeType": "image/webp", "size": 201554, "purpose": "STORE_PHOTO" },
"lastVisits": [
{ "visitId": "0193897b-2c3d-7e4f-9a50-6b7c8d9e0f31", "endedAt": "2026-10-27T10:15:00Z", "interestLevel": "MEDIUM", "nextAction": "NEW_VISIT", "by": { "id": "0192f0a1-7c3e-7b10-9a4e-5d2c1b0a9f01", "displayName": "Yacine Belkacem" } }
],
"openTasks": [
{ "id": "01938a10-4d5e-7f60-8a71-2b3c4d5e6f41", "type": "VISIT", "status": "OPEN", "title": "Nouvelle visite — Electro Plus", "dueAt": "2026-11-03T08:00:00Z", "isOverdue": false, "reseller": { "id": "0192d1aa-3b4c-7d5e-8f60-718293a4b287", "number": "REV-000287", "storeName": "Electro Plus", "ownerName": "Sofiane Medjahed", "phone": "+213661457812", "wilayaCode": "31", "communeName": "Bir El Djir", "status": "INTERESTED" }, "sourceVisitId": "0193897b-2c3d-7e4f-9a50-6b7c8d9e0f31", "note": "Présenter les écouteurs M1 Pro", "createdBy": { "id": "0192f0a1-7c3e-7b10-9a4e-5d2c1b0a9f01", "displayName": "Yacine Belkacem" }, "createdAt": "2026-10-27T10:16:00Z", "completedAt": null, "outcome": null, "version": 1 }
],
"invitation": null,
"ordersSummary": { "count": 0, "lastOrderAt": null, "totalTtc90d": 0 }
},
{
"resellerId": "0192d1aa-3b4c-7d5e-8f60-718293a4b301",
"facadePhoto": null,
"lastVisits": [],
"openTasks": [],
"invitation": null,
"ordersSummary": { "count": 0, "lastOrderAt": null, "totalTtc90d": 0 }
},
{
"resellerId": "0192d1aa-3b4c-7d5e-8f60-718293a4b315",
"facadePhoto": { "id": "0192d1b0-7a1b-7c2d-8e3f-4a5b6c7d8e04", "url": "https://files.hoco.dz/store-photos/0192d1aa-b315.webp?X-Amz-Expires=300&X-Amz-Signature=e310", "thumbnailUrl": "https://files.hoco.dz/store-photos/0192d1aa-b315-thumb.webp?X-Amz-Expires=300&X-Amz-Signature=a77e", "mimeType": "image/webp", "size": 176002, "purpose": "STORE_PHOTO" },
"lastVisits": [
{ "visitId": "01939c4e-3d4e-7f50-8b61-7c8d9e0f1a41", "endedAt": "2026-10-29T13:20:00Z", "interestLevel": "HIGH", "nextAction": "NEW_VISIT", "by": { "id": "0192f0a1-7c3e-7b10-9a4e-5d2c1b0a9f01", "displayName": "Yacine Belkacem" } }
],
"openTasks": [],
"invitation": { "id": "01939c52-0a1b-7c2d-9e3f-4a5b6c7d8e51", "displayCode": "AB12-CD34", "currentStep": "ACCOUNT_CREATED" },
"ordersSummary": { "count": 0, "lastOrderAt": null, "totalTtc90d": 0 }
}
],
"visitTypes": [
{
"id": "0192c3d0-1111-7a00-8c00-00000000f001",
"code": "FOLLOW_UP",
"name": "Visite de suivi",
"requiresReport": false,
"requiresPhoto": false,
"minPhotos": 0,
"checklistTemplateId": "0192c3d0-2222-7a00-8c00-00000000c001",
"reportFields": [
{ "field": "personMetName", "visible": true, "required": false },
{ "field": "personMetRoleCode", "visible": true, "required": false },
{ "field": "interestLevel", "visible": true, "required": false },
{ "field": "productsPresented", "visible": true, "required": false },
{ "field": "promotionsPresented", "visible": true, "required": false },
{ "field": "issueTagCodes", "visible": true, "required": false },
{ "field": "needs", "visible": true, "required": false },
{ "field": "nextAction", "visible": true, "required": false },
{ "field": "followUpAt", "visible": true, "required": false },
{ "field": "comment", "visible": true, "required": false },
{ "field": "attachmentFileIds", "visible": true, "required": false }
]
},
{
"id": "0192c3d0-1111-7a00-8c00-00000000f002",
"code": "PROSPECTION",
"name": "Prospection",
"requiresReport": true,
"requiresPhoto": true,
"minPhotos": 1,
"checklistTemplateId": "0192c3d0-2222-7a00-8c00-00000000c001",
"reportFields": [
{ "field": "personMetName", "visible": true, "required": true },
{ "field": "personMetRoleCode", "visible": true, "required": true },
{ "field": "interestLevel", "visible": true, "required": true },
{ "field": "productsPresented", "visible": true, "required": false },
{ "field": "promotionsPresented", "visible": true, "required": false },
{ "field": "issueTagCodes", "visible": true, "required": false },
{ "field": "needs", "visible": true, "required": false },
{ "field": "nextAction", "visible": true, "required": true },
{ "field": "followUpAt", "visible": true, "required": false },
{ "field": "comment", "visible": true, "required": false },
{ "field": "attachmentFileIds", "visible": true, "required": false }
]
},
{
"id": "0192c3d0-1111-7a00-8c00-00000000f003",
"code": "FIRST_ORDER",
"name": "Aide à la 1ère commande",
"requiresReport": true,
"requiresPhoto": false,
"minPhotos": 0,
"checklistTemplateId": "0192c3d0-2222-7a00-8c00-00000000c001",
"reportFields": [
{ "field": "personMetName", "visible": true, "required": true },
{ "field": "personMetRoleCode", "visible": true, "required": false },
{ "field": "interestLevel", "visible": true, "required": true },
{ "field": "productsPresented", "visible": true, "required": false },
{ "field": "promotionsPresented", "visible": true, "required": false },
{ "field": "issueTagCodes", "visible": false, "required": false },
{ "field": "needs", "visible": true, "required": false },
{ "field": "nextAction", "visible": true, "required": true },
{ "field": "followUpAt", "visible": true, "required": false },
{ "field": "comment", "visible": true, "required": false },
{ "field": "attachmentFileIds", "visible": false, "required": false }
]
}
],
"defaultVisitTypeId": "0192c3d0-1111-7a00-8c00-00000000f002",
"checklistTemplates": [
{
"id": "0192c3d0-2222-7a00-8c00-00000000c001",
"version": 3,
"name": "Checklist commerciale HOCO",
"items": [
{ "key": "PRESENT_ACCESSORIES", "label": "Présenter les accessoires HOCO", "required": false, "sortOrder": 1 },
{ "key": "SHOW_NEW_ARRIVALS", "label": "Montrer les nouveautés HOCO", "required": false, "sortOrder": 2 },
{ "key": "HELP_CHOOSE", "label": "Aider à choisir les accessoires", "required": false, "sortOrder": 3 },
{ "key": "PRESENT_PROMOTIONS", "label": "Parler des promotions en cours", "required": false, "sortOrder": 4 },
{ "key": "HELP_FIRST_ORDER", "label": "Aider à la 1ère commande HOCO", "required": false, "sortOrder": 5 }
]
}
],
"reportCatalog": {
"brands": [
{ "id": "0192a001-0000-7000-8000-00000000b001", "name": "HOCO" },
{ "id": "0192a001-0000-7000-8000-00000000b002", "name": "ACEFAST" },
{ "id": "0192a001-0000-7000-8000-00000000b003", "name": "BOROFONE" }
],
"categories": [
{ "id": "0192a002-0000-7000-8000-00000000c010", "name": "Chargeurs", "parentId": null },
{ "id": "0192a002-0000-7000-8000-00000000c011", "name": "Chargeurs rapides", "parentId": "0192a002-0000-7000-8000-00000000c010" },
{ "id": "0192a002-0000-7000-8000-00000000c020", "name": "Câbles", "parentId": null },
{ "id": "0192a002-0000-7000-8000-00000000c021", "name": "Câbles Type-C", "parentId": "0192a002-0000-7000-8000-00000000c020" },
{ "id": "0192a002-0000-7000-8000-00000000c030", "name": "Écouteurs", "parentId": null },
{ "id": "0192a002-0000-7000-8000-00000000c031", "name": "Écouteurs Bluetooth", "parentId": "0192a002-0000-7000-8000-00000000c030" },
{ "id": "0192a002-0000-7000-8000-00000000c040", "name": "Supports voiture", "parentId": null }
],
"promotions": [
{ "id": "01939f11-5e6f-7a80-9b91-0c1d2e3f4a61", "label": "Pack chargeur + câble", "endsAt": "2026-11-30T22:59:59Z" },
{ "id": "01939f11-5e6f-7a80-9b91-0c1d2e3f4a62", "label": "Remise volume HOCO", "endsAt": null }
]
},
"referenceLists": { "version": "2026-10-30T14:02:11Z" },
"fieldSettings": {
"gpsToleranceMeters": 150,
"gpsMaxAccuracyMeters": 100,
"allowOutOfToleranceCheckIn": false,
"reportRequiredToEndVisit": true,
"closingRequiresSync": true,
"noteMaxLength": 300,
"maxPhotosPerVisit": 10,
"location": { "intervalSeconds": 300, "minDistanceMeters": 200, "maxBatchSize": 200 }
},
"deltaCursor": "c3luYzoxODQ0Njc0NDA3MzcwOTU1MTYxNQ"
}Field notes
distanceFromPreviousMetersis the Haversine (straight-line) distance between consecutive stops — the order of magnitude the programme shows (« 1,2 km »). Road distance and ETA on the map (DA-08) come from the maps provider on the device, not from the API.- Signed URLs expire after 5 minutes: the app downloads the façade images into its cache while still online. Product images in the report catalogue are public CDN URLs and can be cached freely.
- The snapshot is about 30 KB for a 12-stop day. The app re-downloads it when the
ETagchanges, when thetour.updatednotification arrives (a push data message that also triggers a delta pull — notifications), and every morning after the 00:05 job has set the tourTO_START(lifecycles). - The reference list « Programme de tournée » tabs map to statuses: « À visiter » =
PLANNED,CHECKED_IN,IN_PROGRESS; « Visitées » =DONE,NOT_DONE; « Prospects » = stops whose reseller has no first order yet. The « En retard » counter iscounters.late.
Errors — common errors only. No tour today is not an error: tour: null, empty lists.
Rules & side effects
- Read-only. The current tour is: an
IN_PROGRESSorSUSPENDEDtour of a previous day if one exists (it blocks the start of today's tour until closed), else the tour dated today in any status, elsenull. deltaCursoris taken in the same database snapshot as the payload: pullingsync.deltafrom it returns exactly the changes made after the snapshot, so nothing falls between the two.isLateandcounters.lateare computed with the server clock at response time; offline, the app recomputes them with its corrected clock.
Used by DA-06, DA-08, DA-10, DA-05 (local counters offline), DA-27.
GET /delegate/tours/{tourId}
delegate.tours.get · Access session, delegate scope (own tours) · Apps DA · Idempotency-Key — · Offline-capable no — tours of the sync window are already in the local database · Release V1 · Requirements FLD-06
Purpose. One tour with its visits — a past tour opened from the history, a future tour from the calendar, or the current tour refreshed after a change.
Request. Path tourId.
Response 200 OK — TourView (same shape as tour in today's snapshot). Example: the 3 November tour after closing (visit objects shortened to the fields that changed, see the full VisitStop above).
{
"id": "0193a2f0-5b6c-7e8d-9f01-23456789ab01",
"number": "TRN-2026-000045",
"date": "2026-11-03",
"status": "COMPLETED",
"orderingMode": "RECOMMENDED",
"plannedStartAt": "2026-11-03T07:30:00Z",
"plannedEndAt": "2026-11-03T16:30:00Z",
"startedAt": "2026-11-03T07:41:00Z",
"suspendedAt": null,
"suspendReasonCode": null,
"closedAt": "2026-11-03T16:35:00Z",
"startPoint": { "label": "Agence HOCO Oran", "location": { "lat": 35.6935, "lng": -0.6255 } },
"instructions": "Priorité à Electro Plus : présenter les écouteurs M1 Pro et le pack chargeur + câble.",
"counters": { "planned": 4, "unplanned": 0, "done": 4, "notDone": 0, "remaining": 0, "late": 0 },
"visits": [
{ "id": "0193a2f0-5b6c-7e8d-9f01-23456789ab11", "number": "VIS-2026-001231", "sequence": 1, "plannedAt": "2026-11-03T08:00:00Z", "slotEndAt": "2026-11-03T09:00:00Z", "priority": "NORMAL", "status": "DONE", "isLate": false, "isUnplanned": false, "visitTypeId": "0192c3d0-1111-7a00-8c00-00000000f001", "reseller": { "id": "0192d1aa-3b4c-7d5e-8f60-718293a4b123", "number": "REV-000123", "storeName": "Magasin El Wafa", "ownerName": "Karim Belkacem", "phone": "+213555123456", "wilayaCode": "31", "communeName": "Bir El Djir", "status": "ACTIVE", "location": { "lat": 35.7112, "lng": -0.5724 }, "addressLine": "Hai Sabah, cité 300 logements, local n° 4", "landmark": "En face de la mosquée El Feth", "whatsapp": "+213555123456", "businessTypeCode": "PHONE_SHOP", "potential": "HIGH", "customerType": "DEMI_GROS", "hasAccount": true, "assignedToMe": true, "facadeThumbnailUrl": null, "lastVisitAt": "2026-11-03T08:41:00Z" }, "distanceFromPreviousMeters": 5183, "checkedInAt": "2026-11-03T08:12:00Z", "startedAt": "2026-11-03T08:13:00Z", "endedAt": "2026-11-03T08:41:00Z", "gpsValidated": true, "locked": true, "unlockedUntil": null, "version": 6 },
{ "id": "0193a2f0-5b6c-7e8d-9f01-23456789ab12", "number": "VIS-2026-001232", "sequence": 2, "plannedAt": "2026-11-03T09:30:00Z", "slotEndAt": "2026-11-03T10:30:00Z", "priority": "HIGH", "status": "DONE", "isLate": false, "isUnplanned": false, "visitTypeId": "0192c3d0-1111-7a00-8c00-00000000f002", "reseller": { "id": "0192d1aa-3b4c-7d5e-8f60-718293a4b287", "number": "REV-000287", "storeName": "Electro Plus", "ownerName": "Sofiane Medjahed", "phone": "+213661457812", "wilayaCode": "31", "communeName": "Bir El Djir", "status": "APP_INSTALLED", "location": { "lat": 35.7236, "lng": -0.5513 }, "addressLine": "Cité 200 logements, bloc C", "landmark": null, "whatsapp": "+213661457812", "businessTypeCode": "ELECTRONICS_STORE", "potential": "MEDIUM", "customerType": "DETAIL", "hasAccount": false, "assignedToMe": true, "facadeThumbnailUrl": null, "lastVisitAt": "2026-11-03T09:58:00Z" }, "distanceFromPreviousMeters": 2352, "checkedInAt": "2026-11-03T09:28:00Z", "startedAt": "2026-11-03T09:29:00Z", "endedAt": "2026-11-03T09:58:00Z", "gpsValidated": true, "locked": true, "unlockedUntil": null, "version": 8 },
{ "id": "0193a2f0-5b6c-7e8d-9f01-23456789ab13", "number": "VIS-2026-001233", "sequence": 3, "plannedAt": "2026-11-03T11:00:00Z", "slotEndAt": "2026-11-03T12:00:00Z", "priority": "NORMAL", "status": "DONE", "isLate": false, "isUnplanned": false, "visitTypeId": "0192c3d0-1111-7a00-8c00-00000000f002", "reseller": { "id": "0192d1aa-3b4c-7d5e-8f60-718293a4b301", "number": "REV-000301", "storeName": "Bazar du Centre", "ownerName": "Nadir Benamar", "phone": "+213770318452", "wilayaCode": "31", "communeName": "Oran", "status": "INTERESTED", "location": { "lat": 35.6987, "lng": -0.6359 }, "addressLine": "Centre-ville, 8 boulevard Emir Abdelkader", "landmark": null, "whatsapp": null, "businessTypeCode": "BAZAAR", "potential": "MEDIUM", "customerType": null, "hasAccount": false, "assignedToMe": false, "facadeThumbnailUrl": null, "lastVisitAt": "2026-11-03T11:40:00Z" }, "distanceFromPreviousMeters": 8125, "checkedInAt": "2026-11-03T11:06:00Z", "startedAt": "2026-11-03T11:07:00Z", "endedAt": "2026-11-03T11:40:00Z", "gpsValidated": true, "locked": true, "unlockedUntil": null, "version": 7 },
{ "id": "0193a2f0-5b6c-7e8d-9f01-23456789ab14", "number": "VIS-2026-001234", "sequence": 4, "plannedAt": "2026-11-03T13:00:00Z", "slotEndAt": "2026-11-03T14:00:00Z", "priority": "NORMAL", "status": "DONE", "isLate": false, "isUnplanned": false, "visitTypeId": "0192c3d0-1111-7a00-8c00-00000000f003", "reseller": { "id": "0192d1aa-3b4c-7d5e-8f60-718293a4b315", "number": "REV-000315", "storeName": "Alpha Market", "ownerName": "Amina Kaddour", "phone": "+213550982214", "wilayaCode": "31", "communeName": "Es Senia", "status": "FIRST_ORDER", "location": { "lat": 35.6491, "lng": -0.6202 }, "addressLine": "Route de l'aéroport, cité El Amel", "landmark": "À côté de la station Naftal", "whatsapp": "+213550982214", "businessTypeCode": "SUPERMARKET", "potential": "HIGH", "customerType": "DETAIL", "hasAccount": true, "assignedToMe": true, "facadeThumbnailUrl": null, "lastVisitAt": "2026-11-03T13:50:00Z" }, "distanceFromPreviousMeters": 5695, "checkedInAt": "2026-11-03T13:04:00Z", "startedAt": "2026-11-03T13:05:00Z", "endedAt": "2026-11-03T13:50:00Z", "gpsValidated": true, "locked": true, "unlockedUntil": null, "version": 7 }
],
"allowedActions": [],
"closingReport": {
"tourId": "0193a2f0-5b6c-7e8d-9f01-23456789ab01",
"version": 1,
"closedAt": "2026-11-03T16:35:00Z",
"receivedAt": "2026-11-03T16:35:02Z",
"comment": "Bonne dynamique sur le secteur Hai Sabah. Intérêt marqué pour l'application et les écouteurs.",
"endLocation": { "lat": 35.6941, "lng": -0.6248, "accuracy": 14, "capturedAt": "2026-11-03T16:34:40Z" },
"summary": {
"visitsPlanned": 4,
"visitsDone": 4,
"notDone": 0,
"unplannedDone": 0,
"newResellers": 2,
"appsInstalled": 1,
"firstOrders": 1,
"revenueTtc": 12511300,
"kmTraveled": 38.6,
"objectiveAttainment": [
{ "objectiveId": "0193a0b2-44c1-7d20-8e31-5a6b7c8d9e01", "metric": "VISITS", "period": "DAY", "from": "2026-11-03", "to": "2026-11-03", "target": 4, "achieved": 4, "percent": 10000, "source": "OBJECTIVE" },
{ "objectiveId": "0193a0b2-44c1-7d20-8e31-5a6b7c8d9e02", "metric": "SIGNUPS", "period": "DAY", "from": "2026-11-03", "to": "2026-11-03", "target": 3, "achieved": 2, "percent": 6667, "source": "OBJECTIVE" }
],
"conversion": { "from": "2026-11-03", "to": "2026-11-03", "prospects": 5, "converted": 1, "rate": 2000 },
"conversionRate": 2000,
"activeMinutes": 489
},
"notDoneVisits": []
},
"version": 19,
"updatedAt": "2026-11-03T16:35:02Z"
}Field notes. facadeThumbnailUrl is null in historical reads to keep them cheap; the sheet (delegate.resellers.get) signs it on demand. The reseller statuses are the current ones (Electro Plus installed the app in the afternoon, Alpha Market placed its first order).
Errors — 404 NOT_FOUND (not the caller's tour) · 403 NOT_ASSIGNED_DELEGATE (the tour was the caller's and has been reassigned — details: { assignedTo: UserSummary }).
Rules & side effects. Read-only.
Used by DA-07, DA-06, DA-21 (tour report).
POST /delegate/tours/{tourId}/start
delegate.tours.start · Access session, delegate scope — only the assigned delegate (cahier §4.5 « Seul le délégué affecté peut démarrer la tournée ») · Apps DA · Idempotency-Key required · Offline-capable yes — applied locally, queued with occurredAt, sent at once when a connection exists · Release V1 · Requirements FLD-04
Purpose. « Commencer la tournée »: records the start time and, when the location permission is granted, the start position (cahier §6.1 « Le démarrage enregistre date, heure et position selon les permissions GPS »). Location sampling starts.
Request. Path tourId. Body:
interface StartTourRequest {
occurredAt: Iso;
location: GeoPoint | null; // null when permission is denied or no fix within 20 s
isMockLocation?: boolean; // Android "mock location" flag of the fix (default false)
}{
"occurredAt": "2026-11-03T07:41:00Z",
"location": { "lat": 35.6936, "lng": -0.6254, "accuracy": 11, "capturedAt": "2026-11-03T07:40:52Z" },
"isMockLocation": false
}Response 200 OK
interface TourStateResult {
tourId: Uuid;
status: TourStatus;
startedAt: Iso | null;
suspendedAt: Iso | null;
closedAt: Iso | null;
allowedActions: TourAction[];
locationPolicy: { sampling: boolean; intervalSeconds: number; minDistanceMeters: number; maxBatchSize: number };
version: number;
}{
"tourId": "0193a2f0-5b6c-7e8d-9f01-23456789ab01",
"status": "IN_PROGRESS",
"startedAt": "2026-11-03T07:41:00Z",
"suspendedAt": null,
"closedAt": null,
"allowedActions": ["SUSPEND", "REORDER", "ADD_VISIT", "CLOSE"],
"locationPolicy": { "sampling": true, "intervalSeconds": 300, "minDistanceMeters": 200, "maxBatchSize": 200 },
"version": 4
}Errors
| Status | Code | When |
|---|---|---|
| 403 | NOT_ASSIGNED_DELEGATE | The tour was reassigned to another delegate after the phone's snapshot (details.assignedTo) |
| 409 | TOUR_TRANSITION_NOT_ALLOWED | Status is not TO_START (details: { from, to: "IN_PROGRESS", allowed }) — e.g. a PLANNED tour of another day, COMPLETED, CANCELLED |
| 409 | TOUR_NOT_TODAY | The Africa/Algiers date of occurredAt is not the tour date |
| 409 | TOUR_ALREADY_IN_PROGRESS | Another tour of the caller is IN_PROGRESS or SUSPENDED (details: { tourId, number, date }) — close it first |
| 422 | GPS_MOCK_LOCATION | isMockLocation = true |
Rules & side effects
TO_START→IN_PROGRESS. APLANNEDtour dated today (the 00:05 job has not run) is treated asTO_START.startedAt=occurredAt; position and accuracy stored on theTourStatusChangerow;startPositionMissing = truewhenlocationisnull.- Outbox
tour.started→ SSEtour.startedon/admin/events(live map BO-23); audit entry withClientAppDELEGATE_APP. - The app starts sampling positions per
locationPolicyand sends them withsync.locationPings; sampling never runs outsideIN_PROGRESS(FLD-15).
POST /delegate/tours/{tourId}/suspend
delegate.tours.suspend · Access session, delegate scope (assigned delegate) · Apps DA · Idempotency-Key required · Offline-capable yes — queued with occurredAt · Release V1 · Requirements FLD-04
Purpose. Pause the tour for a break or an incident; location sampling stops (Suspendue).
Request. Body:
interface SuspendTourRequest {
reasonCode: string; // TOUR_SUSPEND_REASON item
comment?: string | null; // ≤ 300; required when reasonCode = OTHER
occurredAt: Iso;
location?: GeoPoint | null;
}{
"reasonCode": "LUNCH_BREAK",
"comment": null,
"occurredAt": "2026-11-03T12:00:00Z",
"location": { "lat": 35.6979, "lng": -0.6371, "accuracy": 16, "capturedAt": "2026-11-03T11:59:48Z" }
}Response 200 OK — TourStateResult.
{
"tourId": "0193a2f0-5b6c-7e8d-9f01-23456789ab01",
"status": "SUSPENDED",
"startedAt": "2026-11-03T07:41:00Z",
"suspendedAt": "2026-11-03T12:00:00Z",
"closedAt": null,
"allowedActions": ["RESUME", "REORDER", "ADD_VISIT", "CLOSE"],
"locationPolicy": { "sampling": false, "intervalSeconds": 300, "minDistanceMeters": 200, "maxBatchSize": 200 },
"version": 12
}Errors
| Status | Code | When |
|---|---|---|
| 403 | NOT_ASSIGNED_DELEGATE | Tour reassigned |
| 409 | TOUR_NOT_IN_PROGRESS | Status is not IN_PROGRESS (details.status) |
| 409 | VISIT_IN_PROGRESS | A visit is CHECKED_IN or IN_PROGRESS (details: { visitId, number, status }) — end it or mark it not done first |
| 422 | REASON_REQUIRED | reasonCode missing, or OTHER without comment (details: { listKey: "TOUR_SUSPEND_REASON", commentRequired }) |
Rules & side effects. IN_PROGRESS → SUSPENDED; TourStatusChange with reason and position; the suspension interval is excluded from activeMinutes; outbox tour.suspended (live view refresh on BO-23); the app stops sampling and flushes the pings it holds.
Used by DA-06.
POST /delegate/tours/{tourId}/resume
delegate.tours.resume · Access session, delegate scope (assigned delegate) · Apps DA · Idempotency-Key required · Offline-capable yes — queued with occurredAt · Release V1 · Requirements FLD-04
Purpose. « Reprendre la tournée » after a suspension.
Request. Body { "occurredAt": Iso, "location"?: GeoPoint | null }.
{
"occurredAt": "2026-11-03T12:45:00Z",
"location": { "lat": 35.6981, "lng": -0.6368, "accuracy": 12, "capturedAt": "2026-11-03T12:44:51Z" }
}Response 200 OK — TourStateResult.
{
"tourId": "0193a2f0-5b6c-7e8d-9f01-23456789ab01",
"status": "IN_PROGRESS",
"startedAt": "2026-11-03T07:41:00Z",
"suspendedAt": null,
"closedAt": null,
"allowedActions": ["SUSPEND", "REORDER", "ADD_VISIT", "CLOSE"],
"locationPolicy": { "sampling": true, "intervalSeconds": 300, "minDistanceMeters": 200, "maxBatchSize": 200 },
"version": 13
}Errors — 403 NOT_ASSIGNED_DELEGATE · 409 TOUR_TRANSITION_NOT_ALLOWED (status is not SUSPENDED; details: { from, to: "IN_PROGRESS", allowed }).
Rules & side effects. SUSPENDED → IN_PROGRESS; TourStatusChange; outbox tour.resumed; sampling restarts. Resuming on another day than the tour date is allowed (a tour left suspended overnight is resumed to be closed).
PUT /delegate/tours/{tourId}/visit-order
delegate.tours.reorder · Access session, delegate scope (assigned delegate) · Apps DA · Idempotency-Key required · Offline-capable yes — queued with occurredAt and the tour baseVersion; a change made meanwhile by the planner comes back as a conflict · Release V1 · Requirements FLD-03, FLD-06
Purpose. Reorder the remaining stops when the tour's order is only recommended (cahier §6.2 « L'ordre peut être imposé ou recommandé », « Une visite ajoutée ou déplacée doit être synchronisée »).
Request. Body:
interface ReorderRequest {
visitIds: Uuid[]; // exactly the PLANNED visits of the tour, in the new order
baseVersion: number; // TourView.version the app reordered from
occurredAt: Iso;
}Example: on Thursday 5 November, before starting tour TRN-2026-000052 (planned order Super Marché Zitoune → Librairie Moderne → Bazar du Centre), the delegate moves Zitoune to the end to start in the city centre.
{
"visitIds": [
"0193b0e2-4a5b-7c6d-8e7f-9a0b1c2d3e96",
"0193b0e2-4a5b-7c6d-8e7f-9a0b1c2d3e98",
"0193b0e2-4a5b-7c6d-8e7f-9a0b1c2d3e97"
],
"baseVersion": 2,
"occurredAt": "2026-11-05T07:50:00Z"
}Response 200 OK
{
"tourId": "0193b0e2-4a5b-7c6d-8e7f-9a0b1c2d3e01",
"version": 3,
"visits": [
{ "id": "0193b0e2-4a5b-7c6d-8e7f-9a0b1c2d3e96", "sequence": 1, "distanceFromPreviousMeters": 1795 },
{ "id": "0193b0e2-4a5b-7c6d-8e7f-9a0b1c2d3e98", "sequence": 2, "distanceFromPreviousMeters": 694 },
{ "id": "0193b0e2-4a5b-7c6d-8e7f-9a0b1c2d3e97", "sequence": 3, "distanceFromPreviousMeters": 5730 }
]
}Errors
| Status | Code | When |
|---|---|---|
| 400 | VALIDATION_FAILED | visitIds misses or adds a visit, or lists a visit that is not PLANNED (details.fields[].code: MISSING_VISIT, UNKNOWN_VISIT, VISIT_NOT_PLANNED) |
| 403 | NOT_ASSIGNED_DELEGATE | Tour reassigned |
| 409 | TOUR_ORDER_IMPOSED | orderingMode = IMPOSED |
| 409 | TOUR_TRANSITION_NOT_ALLOWED | Tour COMPLETED or CANCELLED |
| 409 | VERSION_CONFLICT | baseVersion differs from the current version because the planner changed the stops (details: { currentVersion, visits: [{ id, sequence, status }] }) |
Rules & side effects
- Allowed while the tour is
TO_START,IN_PROGRESSorSUSPENDED. Visits already checked in, done, not done or cancelled keep their sequence; the listed visits are renumbered after them. versionincrements; distances are recomputed; outboxtour.reordered→ the live view andsync.deltashow the new order, so list and map stay identical everywhere (FLD-06).- On
VERSION_CONFLICTthe app pulls the delta, shows the planner's new order and lets the delegate reorder again (new idempotency key).
POST /delegate/tours/{tourId}/visits
delegate.tours.addVisit · Access session, delegate scope (assigned delegate; reseller in scope) · Apps DA · Idempotency-Key required · Offline-capable yes — client-generated id; may depend on a prospect created offline in the same queue (dependsOn in the batch) · Release V1 · Requirements FLD-06
Purpose. Add an unplanned visit — a shop met on the way, or a prospect just created (DA-14).
Request. Body:
interface AddVisitRequest {
id: Uuid; // client-generated UUIDv7 of the visit
resellerId: Uuid; // in scope (may be a prospect created offline just before)
visitTypeId?: Uuid; // default: TodaySnapshot.defaultVisitTypeId
priority?: "NORMAL" | "HIGH"; // default NORMAL
insertAfterVisitId?: Uuid | null; // RECOMMENDED only; null/absent = after the last stop
note?: string | null; // ≤ 300, purpose of the visit
occurredAt: Iso;
}Example: on 5 November the delegate appends Phone Center Nour — a prospect created two days earlier whose manager was absent — at the end of tour TRN-2026-000052.
{
"id": "0193b0f7-2c3d-7e4f-8a51-6b7c8d9e0f05",
"resellerId": "0193a31c-8e2f-7a40-b1c2-d3e4f5a6b342",
"visitTypeId": "0192c3d0-1111-7a00-8c00-00000000f002",
"priority": "NORMAL",
"insertAfterVisitId": null,
"note": "Présenter le catalogue au gérant (absent le 3/11).",
"occurredAt": "2026-11-05T09:35:00Z"
}Response 201 Created (or 200 OK when the same id already exists with the same content) — the new VisitStop.
{
"id": "0193b0f7-2c3d-7e4f-8a51-6b7c8d9e0f05",
"number": "VIS-2026-001305",
"sequence": 4,
"plannedAt": null,
"slotEndAt": null,
"priority": "NORMAL",
"status": "PLANNED",
"isLate": false,
"isUnplanned": true,
"visitTypeId": "0192c3d0-1111-7a00-8c00-00000000f002",
"reseller": {
"id": "0193a31c-8e2f-7a40-b1c2-d3e4f5a6b342",
"number": "REV-000342",
"storeName": "Phone Center Nour",
"ownerName": "Mohamed Benali",
"phone": "+213558123456",
"wilayaCode": "31",
"communeName": "Bir El Djir",
"status": "TO_CONTACT",
"location": { "lat": 35.7071, "lng": -0.5802 },
"addressLine": "Hai Sabah, rue des Frères Benaouda",
"landmark": null,
"whatsapp": "+213558123456",
"businessTypeCode": "PHONE_SHOP",
"potential": "MEDIUM",
"customerType": "DETAIL",
"hasAccount": false,
"assignedToMe": true,
"facadeThumbnailUrl": null,
"lastVisitAt": null
},
"distanceFromPreviousMeters": 4858,
"checkedInAt": null,
"startedAt": null,
"endedAt": null,
"gpsValidated": null,
"locked": false,
"unlockedUntil": null,
"version": 1
}Errors
| Status | Code | When |
|---|---|---|
| 403 | NOT_ASSIGNED_DELEGATE | Tour reassigned |
| 403 | OUT_OF_SCOPE | The reseller is not in the caller's scope (it was reassigned meanwhile) |
| 409 | TOUR_TRANSITION_NOT_ALLOWED | Tour PLANNED, COMPLETED or CANCELLED |
| 409 | TOUR_ORDER_IMPOSED | insertAfterVisitId given on an IMPOSED tour (unplanned visits are appended at the end there) |
| 409 | VISIT_ALREADY_PLANNED | The reseller already has a visit PLANNED, CHECKED_IN or IN_PROGRESS in this tour (details.visitId) |
| 409 | CLIENT_ID_CONFLICT | A visit with this id exists with a different reseller or tour |
Rules & side effects
- Allowed while the tour is
TO_START,IN_PROGRESSorSUSPENDED. The visit is createdPLANNED,isUnplanned = true, no time slot (never late),VIS-number allocated on reception; following stops are renumbered and the tourversionincrements. - Unplanned visits count in
counters.unplannedand in done visits, never incounters.planned— the visit rate stays done ÷ planned. - A prospect in
NEW_PROSPECTadded as a stop moves toTO_CONTACT(same rule as planning) — the response shows the new status. - Outbox
visit.added→ live view refresh; the planner sees the stop in BO-23.
GET /delegate/tours/{tourId}/closing-preview
delegate.tours.closingPreview · Access session, delegate scope (assigned delegate) · Apps DA · Idempotency-Key — · Offline-capable no — needs the server's figures (revenue, installs); offline the app shows its local summary and disables « Terminer la tournée » · Release V1 · Requirements FLD-12
Purpose. The « Bilan de la journée » before closing, and the list of what still blocks the closing (cahier §6.9 « Les visites non réalisées doivent avoir un motif », « Les données hors ligne doivent être synchronisées »).

Request. Path tourId. Query — the app's outbox counters for this tour, so the server can list them as blocking items: pendingMutations (integer ≥ 0), pendingUploads (photos and attachments not yet uploaded), pendingPings (location samples not yet sent). Missing counters count as 0.
Response 200 OK
interface ClosingPreview {
tourId: Uuid;
status: TourStatus;
canClose: boolean; // true when blockingItems is empty
summary: TourClosingSummary; // computed as if closed now
blockingItems: BlockingItem[];
warnings: Array<{ code: string; message: string }>;
}
interface BlockingItem {
code: "UNVISITED_STOPS_WITHOUT_REASON" | "VISIT_NOT_ENDED" | "UNSYNCED_DATA";
message: string; // localised, ready to display
visits?: Array<{ visitId: Uuid; sequence: number; storeName: string; status: VisitStatus }>;
pending?: { mutations: number; uploads: number; pings: number };
}{
"tourId": "0193a2f0-5b6c-7e8d-9f01-23456789ab01",
"status": "IN_PROGRESS",
"canClose": false,
"summary": {
"visitsPlanned": 4,
"visitsDone": 4,
"notDone": 0,
"unplannedDone": 0,
"newResellers": 2,
"appsInstalled": 1,
"firstOrders": 1,
"revenueTtc": 12511300,
"kmTraveled": 37.9,
"objectiveAttainment": [
{ "objectiveId": "0193a0b2-44c1-7d20-8e31-5a6b7c8d9e01", "metric": "VISITS", "period": "DAY", "from": "2026-11-03", "to": "2026-11-03", "target": 4, "achieved": 4, "percent": 10000, "source": "OBJECTIVE" },
{ "objectiveId": "0193a0b2-44c1-7d20-8e31-5a6b7c8d9e02", "metric": "SIGNUPS", "period": "DAY", "from": "2026-11-03", "to": "2026-11-03", "target": 3, "achieved": 2, "percent": 6667, "source": "OBJECTIVE" }
],
"conversion": { "from": "2026-11-03", "to": "2026-11-03", "prospects": 5, "converted": 1, "rate": 2000 },
"conversionRate": 2000,
"activeMinutes": 474
},
"blockingItems": [
{
"code": "UNSYNCED_DATA",
"message": "1 photo et 3 positions n'ont pas encore été envoyées. Restez connecté quelques instants.",
"pending": { "mutations": 0, "uploads": 1, "pings": 3 }
}
],
"warnings": [
{ "code": "OBJECTIVE_NOT_REACHED", "message": "Objectif d'inscriptions : 2 sur 3." }
]
}Field notes. The summary uses the counting rules; kmTraveled grows until the last pings arrive (37.9 here, 38.6 once the pending pings are in). Warnings never block.
Errors — 403 NOT_ASSIGNED_DELEGATE; 409 TOUR_TRANSITION_NOT_ALLOWED when the tour is not IN_PROGRESS or SUSPENDED (nothing to close).
Rules & side effects. Read-only. Blocking items, in this order: UNSYNCED_DATA (any counter above 0, only when field.closingRequiresSync = true), VISIT_NOT_ENDED (visits CHECKED_IN or IN_PROGRESS — end them or mark them not done), UNVISITED_STOPS_WITHOUT_REASON (visits still PLANNED — mark each not done with a reason from DA-11, or visit them).
Used by DA-20.
POST /delegate/tours/{tourId}/close
delegate.tours.close · Access session, delegate scope (assigned delegate) · Apps DA · Idempotency-Key required · Offline-capable no — closing needs every action of the day on the server (cahier §6.9); the app sends it directly once its outbox for the tour is empty · Release V1 · Requirements FLD-12
Purpose. « Terminer la tournée »: closes the day with the end-of-day comment and returns the final report the back-office receives (cahier §6.9 « Le back-office reçoit le bilan complet et les indicateurs sont recalculés »).
Request. Body:
interface CloseTourRequest {
comment?: string | null; // ≤ 1000 — "Commentaire de fin de tournée"
occurredAt: Iso;
location: GeoPoint | null;
deviceQueue: { pendingMutations: number; pendingUploads: number; pendingPings: number };
}{
"comment": "Bonne dynamique sur le secteur Hai Sabah. Intérêt marqué pour l'application et les écouteurs.",
"occurredAt": "2026-11-03T16:35:00Z",
"location": { "lat": 35.6941, "lng": -0.6248, "accuracy": 14, "capturedAt": "2026-11-03T16:34:40Z" },
"deviceQueue": { "pendingMutations": 0, "pendingUploads": 0, "pendingPings": 0 }
}Response 200 OK
{
"tour": {
"tourId": "0193a2f0-5b6c-7e8d-9f01-23456789ab01",
"status": "COMPLETED",
"startedAt": "2026-11-03T07:41:00Z",
"suspendedAt": null,
"closedAt": "2026-11-03T16:35:00Z",
"allowedActions": [],
"locationPolicy": { "sampling": false, "intervalSeconds": 300, "minDistanceMeters": 200, "maxBatchSize": 200 },
"version": 19
},
"report": {
"tourId": "0193a2f0-5b6c-7e8d-9f01-23456789ab01",
"version": 1,
"closedAt": "2026-11-03T16:35:00Z",
"receivedAt": "2026-11-03T16:35:02Z",
"comment": "Bonne dynamique sur le secteur Hai Sabah. Intérêt marqué pour l'application et les écouteurs.",
"endLocation": { "lat": 35.6941, "lng": -0.6248, "accuracy": 14, "capturedAt": "2026-11-03T16:34:40Z" },
"summary": {
"visitsPlanned": 4,
"visitsDone": 4,
"notDone": 0,
"unplannedDone": 0,
"newResellers": 2,
"appsInstalled": 1,
"firstOrders": 1,
"revenueTtc": 12511300,
"kmTraveled": 38.6,
"objectiveAttainment": [
{ "objectiveId": "0193a0b2-44c1-7d20-8e31-5a6b7c8d9e01", "metric": "VISITS", "period": "DAY", "from": "2026-11-03", "to": "2026-11-03", "target": 4, "achieved": 4, "percent": 10000, "source": "OBJECTIVE" },
{ "objectiveId": "0193a0b2-44c1-7d20-8e31-5a6b7c8d9e02", "metric": "SIGNUPS", "period": "DAY", "from": "2026-11-03", "to": "2026-11-03", "target": 3, "achieved": 2, "percent": 6667, "source": "OBJECTIVE" }
],
"conversion": { "from": "2026-11-03", "to": "2026-11-03", "prospects": 5, "converted": 1, "rate": 2000 },
"conversionRate": 2000,
"activeMinutes": 489
},
"notDoneVisits": []
}
}Field notes — reference screen → field. « 12 Visites réalisées » = summary.visitsDone; « Nouveaux revendeurs » = newResellers; « Applications installées » = appsInstalled; « 1ères commandes générées » = firstOrders; « Chiffre d'affaires généré » = revenueTtc (orders attributed to the caller and confirmed by the back-office that day — here the assisted first order of Alpha Market, 15 113,00 DA, and a 110 000,00 DA order of Magasin El Wafa); « Kilomètres parcourus » = kmTraveled; « Objectif journalier 80 % — 8 / 10 visites » = the VISITS entry of objectiveAttainment; « Taux de conversion » = conversionRate with conversion.converted / conversion.prospects; « Rapport envoyé » = this 200 response.
Errors
| Status | Code | When |
|---|---|---|
| 403 | NOT_ASSIGNED_DELEGATE | Tour reassigned |
| 409 | TOUR_TRANSITION_NOT_ALLOWED | Status is not IN_PROGRESS or SUSPENDED |
| 409 | TOUR_CLOSING_BLOCKED | At least one blocking item — details.blockingItems has the same shape as in the preview, with the codes UNSYNCED_DATA, VISIT_NOT_ENDED, UNVISITED_STOPS_WITHOUT_REASON |
{
"error": {
"code": "TOUR_CLOSING_BLOCKED",
"message": "La tournée ne peut pas être clôturée : une visite n'a ni compte rendu ni motif.",
"details": {
"blockingItems": [
{
"code": "UNVISITED_STOPS_WITHOUT_REASON",
"message": "Indiquez un motif pour la visite non réalisée : Alpha Market.",
"visits": [{ "visitId": "0193a2f0-5b6c-7e8d-9f01-23456789ab14", "sequence": 4, "storeName": "Alpha Market", "status": "PLANNED" }]
}
]
},
"requestId": "req_01JBZ7R2K9"
}
}Rules & side effects
IN_PROGRESSorSUSPENDED→COMPLETED;closedAt=occurredAt; end position stored;TourStatusChange.- The final report (
TourClosingReport, version 1) is computed and stored with the comment; it is what the back-office reads in BO-23 and whatdelegate.reportslists. A reopening by the back-office (admin.tours.reopen) keeps it; the next closing stores version 2. - Kilometres come from the tour's positions (BR-54): the start position, the location pings and the end position. The app flushes its pings before closing (
deviceQueue.pendingPingsmust be 0 withfield.closingRequiresSync); pings sent after the close are refused (409 TOUR_NOT_IN_PROGRESS,sync.locationPings), sokmTraveledis final. - Outbox
tour.closed→ SSEtour.closed; objectives, KPIs and dashboards recomputed; location sampling stops for good. The closing is definitive unless the back-office reopens the tour (cahier §6.9 « La clôture est définitive sauf réouverture autorisée »). deviceQueueis the app's declaration; withfield.closingRequiresSync = trueany non-zero counter blocks (UNSYNCED_DATA). The app does not even offer the button while its outbox holds items of the tour.
Used by DA-20.
GET /delegate/visits/{visitId}
delegate.visits.get · Access session, delegate scope (visits of the caller's tours) · Apps DA · Idempotency-Key — · Offline-capable no — offline, the app builds the same view from the snapshot and its local actions · Release V1 · Requirements FLD-08
Purpose. « Détail de la visite »: the point of sale, the check-in state, the checklist with its template, the note, the photos, the report and its configuration for this visit type, the not-done reason, and what may be done next.

Request. Path visitId.
Response 200 OK
interface VisitView {
id: Uuid;
number: string;
tour: { id: Uuid; number: string; date: string; status: TourStatus };
sequence: number;
status: VisitStatus;
priority: "NORMAL" | "HIGH";
plannedAt: Iso | null;
slotEndAt: Iso | null;
isLate: boolean;
isUnplanned: boolean;
visitType: VisitTypeView;
reseller: ResellerCard;
checkIn: CheckInResult | null;
startedAt: Iso | null;
endedAt: Iso | null;
durationSeconds: number | null; // endedAt (or now) − startedAt
checklist: ChecklistState | null;
note: { text: string; updatedAt: Iso } | null;
photos: VisitPhotoView[];
report: VisitReportView | null;
notDone: { reasonCode: string; comment: string | null; at: Iso } | null;
locked: boolean;
lockedAt: Iso | null;
unlockedUntil: Iso | null;
allowedActions: VisitAction[];
missingToEnd: Array<{ code: "REPORT_REQUIRED" | "PHOTO_REQUIRED" | "CHECKLIST_INCOMPLETE"; details?: Record<string, unknown> }>;
version: number;
updatedAt: Iso;
}
interface ChecklistState {
templateId: Uuid;
templateVersion: number;
items: Array<{ key: string; label: string; required: boolean; checked: boolean; checkedAt: Iso | null }>;
checkedCount: number;
total: number;
}{
"id": "0193a2f0-5b6c-7e8d-9f01-23456789ab11",
"number": "VIS-2026-001231",
"tour": { "id": "0193a2f0-5b6c-7e8d-9f01-23456789ab01", "number": "TRN-2026-000045", "date": "2026-11-03", "status": "IN_PROGRESS" },
"sequence": 1,
"status": "IN_PROGRESS",
"priority": "NORMAL",
"plannedAt": "2026-11-03T08:00:00Z",
"slotEndAt": "2026-11-03T09:00:00Z",
"isLate": false,
"isUnplanned": false,
"visitType": {
"id": "0192c3d0-1111-7a00-8c00-00000000f001",
"code": "FOLLOW_UP",
"name": "Visite de suivi",
"requiresReport": false,
"requiresPhoto": false,
"minPhotos": 0,
"checklistTemplateId": "0192c3d0-2222-7a00-8c00-00000000c001",
"reportFields": [
{ "field": "personMetName", "visible": true, "required": false },
{ "field": "personMetRoleCode", "visible": true, "required": false },
{ "field": "interestLevel", "visible": true, "required": false },
{ "field": "productsPresented", "visible": true, "required": false },
{ "field": "promotionsPresented", "visible": true, "required": false },
{ "field": "issueTagCodes", "visible": true, "required": false },
{ "field": "needs", "visible": true, "required": false },
{ "field": "nextAction", "visible": true, "required": false },
{ "field": "followUpAt", "visible": true, "required": false },
{ "field": "comment", "visible": true, "required": false },
{ "field": "attachmentFileIds", "visible": true, "required": false }
]
},
"reseller": {
"id": "0192d1aa-3b4c-7d5e-8f60-718293a4b123",
"number": "REV-000123",
"storeName": "Magasin El Wafa",
"ownerName": "Karim Belkacem",
"phone": "+213555123456",
"wilayaCode": "31",
"communeName": "Bir El Djir",
"status": "ACTIVE",
"location": { "lat": 35.7112, "lng": -0.5724 },
"addressLine": "Hai Sabah, cité 300 logements, local n° 4",
"landmark": "En face de la mosquée El Feth",
"whatsapp": "+213555123456",
"businessTypeCode": "PHONE_SHOP",
"potential": "HIGH",
"customerType": "DEMI_GROS",
"hasAccount": true,
"assignedToMe": true,
"facadeThumbnailUrl": "https://files.hoco.dz/store-photos/0192d1aa-b123-thumb.webp?X-Amz-Expires=300&X-Amz-Signature=91aa",
"lastVisitAt": "2026-10-20T09:40:00Z"
},
"checkIn": {
"visitId": "0193a2f0-5b6c-7e8d-9f01-23456789ab11",
"status": "CHECKED_IN",
"checkedInAt": "2026-11-03T08:12:00Z",
"receivedAt": "2026-11-03T08:12:03Z",
"location": { "lat": 35.71143, "lng": -0.57262, "accuracy": 12, "capturedAt": "2026-11-03T08:11:58Z" },
"distanceMeters": 32,
"toleranceMeters": 150,
"accuracy": 12,
"maxAccuracyMeters": 100,
"gpsValidated": true,
"gpsCheck": "WITHIN_TOLERANCE",
"justification": null,
"posLocationCandidate": false
},
"startedAt": "2026-11-03T08:13:00Z",
"endedAt": null,
"durationSeconds": 60,
"checklist": {
"templateId": "0192c3d0-2222-7a00-8c00-00000000c001",
"templateVersion": 3,
"items": [
{ "key": "PRESENT_ACCESSORIES", "label": "Présenter les accessoires HOCO", "required": false, "checked": false, "checkedAt": null },
{ "key": "SHOW_NEW_ARRIVALS", "label": "Montrer les nouveautés HOCO", "required": false, "checked": false, "checkedAt": null },
{ "key": "HELP_CHOOSE", "label": "Aider à choisir les accessoires", "required": false, "checked": false, "checkedAt": null },
{ "key": "PRESENT_PROMOTIONS", "label": "Parler des promotions en cours", "required": false, "checked": false, "checkedAt": null },
{ "key": "HELP_FIRST_ORDER", "label": "Aider à la 1ère commande HOCO", "required": false, "checked": false, "checkedAt": null }
],
"checkedCount": 0,
"total": 5
},
"note": null,
"photos": [],
"report": null,
"notDone": null,
"locked": false,
"lockedAt": null,
"unlockedUntil": null,
"allowedActions": ["EDIT", "END", "NOT_DONE"],
"missingToEnd": [],
"version": 3,
"updatedAt": "2026-11-03T08:13:01Z"
}Field notes. « Validation GPS — Position confirmée » = checkIn.gpsValidated; « Arrivée sur site 09:12 » = checkIn.checkedInAt in local time; « Durée de la visite — En cours » runs on the device from startedAt; « Checklist de la visite 0/5 » = checklist.checkedCount / total; « Note rapide 0/300 »; the call button dials reseller.phone. missingToEnd lets the app disable « Terminer la visite » with the reason before calling the server.
Errors — 404 NOT_FOUND (not a visit of the caller's tours) · 403 NOT_ASSIGNED_DELEGATE (its tour was reassigned).
Rules & side effects. Read-only. The checklist template version is frozen on the visit at check-in, so a template edited during the day does not change a visit in progress.
POST /delegate/visits/{visitId}/arrive
delegate.visits.arrive · Access session, delegate scope (assigned delegate of the tour) · Apps DA · Idempotency-Key required · Offline-capable yes — the app evaluates the tolerance locally with the same algorithm and settings (snapshot), records the check-in, queues it and sends it immediately when a connection exists; the server's verdict is final · Release V1 · Requirements FLD-07, FLD-08, GEO-03
Purpose. The « Arrivé » button: records the arrival time and position, measures the distance to the point of sale and opens the visit when the GPS tolerance is met (cahier §6.3 « Le bouton Arrivé enregistre la position et ouvre la visite lorsque la tolérance GPS est respectée », §12 « Le check-in respecte la tolérance et conserve les coordonnées autorisées »).
Request. Path visitId. Body:
interface ArriveRequest {
occurredAt: Iso;
location: GeoPoint; // lat, lng, accuracy (required here), capturedAt
isMockLocation?: boolean; // default false
justification?: string | null; // 10–200 chars; used only when out of tolerance and allowed by settings
}{
"occurredAt": "2026-11-03T08:12:00Z",
"location": { "lat": 35.71143, "lng": -0.57262, "accuracy": 12, "capturedAt": "2026-11-03T08:11:58Z" },
"isMockLocation": false
}Response 200 OK
interface CheckInResult {
visitId: Uuid;
status: "CHECKED_IN";
checkedInAt: Iso; // = occurredAt
receivedAt: Iso;
location: GeoPoint;
distanceMeters: number | null; // null when the point of sale had no coordinates
toleranceMeters: number;
accuracy: number;
maxAccuracyMeters: number;
gpsValidated: boolean | null; // null when there was no reference point
gpsCheck: "WITHIN_TOLERANCE" | "OUT_OF_TOLERANCE_JUSTIFIED" | "NO_REFERENCE_LOCATION";
justification: string | null;
posLocationCandidate: boolean; // true when this position was proposed as the store's location
}{
"visitId": "0193a2f0-5b6c-7e8d-9f01-23456789ab11",
"status": "CHECKED_IN",
"checkedInAt": "2026-11-03T08:12:00Z",
"receivedAt": "2026-11-03T08:12:03Z",
"location": { "lat": 35.71143, "lng": -0.57262, "accuracy": 12, "capturedAt": "2026-11-03T08:11:58Z" },
"distanceMeters": 32,
"toleranceMeters": 150,
"accuracy": 12,
"maxAccuracyMeters": 100,
"gpsValidated": true,
"gpsCheck": "WITHIN_TOLERANCE",
"justification": null,
"posLocationCandidate": false
}Errors
| Status | Code | When |
|---|---|---|
| 403 | NOT_ASSIGNED_DELEGATE | Tour reassigned |
| 409 | TOUR_NOT_IN_PROGRESS | Tour is not IN_PROGRESS (details.status: TO_START → start it, SUSPENDED → resume it) |
| 409 | VISIT_IN_PROGRESS | Another visit of the tour is CHECKED_IN or IN_PROGRESS (details: { visitId, number, status }) |
| 409 | VISIT_TRANSITION_NOT_ALLOWED | Visit is not PLANNED or NOT_DONE (details: { from, to: "CHECKED_IN", allowed }) |
| 409 | VISIT_LOCKED | Visit CANCELLED (details as in VISIT_LOCKED) |
| 422 | GPS_ACCURACY_TOO_LOW | location.accuracy above field.gpsMaxAccuracyMeters and no allowed justification (details: { accuracy, maxAccuracyMeters }) |
| 422 | GPS_OUT_OF_TOLERANCE | Distance above field.gpsToleranceMeters and out-of-tolerance check-ins not allowed, or allowed but no justification given (details: { distanceMeters, toleranceMeters, accuracy, justificationAllowed }) |
| 422 | GPS_MOCK_LOCATION | isMockLocation = true |
{
"error": {
"code": "GPS_OUT_OF_TOLERANCE",
"message": "Vous êtes à 361 m du point de vente (tolérance : 150 m). Rapprochez-vous pour confirmer l'arrivée.",
"details": { "distanceMeters": 361, "toleranceMeters": 150, "accuracy": 18, "justificationAllowed": false },
"requestId": "req_01JBZ3Q8T4"
}
}Rules & side effects — check-in algorithm (server and app run the same steps; GEO-03, BR-50, BR-51, BR-55)
- Guards first: tour
IN_PROGRESS, no other open visit, visitPLANNEDorNOT_DONE(« back later in the same tour »), not a mock location. - Accuracy gate:
accuracy ≤ field.gpsMaxAccuracyMeters(100 m). The app waits up to 30 s for a better fix and shows the GPS state of DA-28 before sending. - Reference point: the point of sale's stored
location. When it has none, the check-in is accepted on the accuracy gate alone:distanceMeters = null,gpsCheck = NO_REFERENCE_LOCATION,gpsValidated = null, and the position is stored as a location candidate (posLocationCandidate = true) that the back-office validates on BO-25 before it becomes the store's coordinates — the delegate never sets the reference point that later check-ins are measured against. - Distance: Haversine between the submitted position and the reference point, Earth radius 6 371 000 m, rounded to the metre:
d = 2R · asin(√(sin²(Δφ/2) + cos φ₁ · cos φ₂ · sin²(Δλ/2))). - Tolerance:
d ≤ field.gpsToleranceMeters(150 m) → accepted,gpsValidated = true,gpsCheck = WITHIN_TOLERANCE. Otherwise: iffield.allowOutOfToleranceCheckIn = false→ refusedGPS_OUT_OF_TOLERANCE(nothing stored; the delegate moves closer or marks the visit not done); iftrue→ accepted only with ajustification(10–200 characters, e.g. « Magasin déplacé dans la galerie, 200 m plus loin »),gpsValidated = false,gpsCheck = OUT_OF_TOLERANCE_JUSTIFIED, flagged in the back-office visit list. The same justification path applies to the accuracy gate when the setting istrue. - Effects:
PLANNED/NOT_DONE→CHECKED_IN;checkedInAt=occurredAt; position, accuracy, distance and the verdict are stored on the visit; the checklist template version is frozen; outboxvisit.checked_in→ SSEvisit.checked_into the back-office live view (cahier §6.4 « La visite apparaît en temps réel dans le back-office »). - A refused check-in changes nothing and records nothing except the audit trail of the attempt (distance, accuracy) for supervisors.
POST /delegate/visits/{visitId}/start
delegate.visits.start · Access session, delegate scope (assigned delegate) · Apps DA · Idempotency-Key required · Offline-capable yes — queued with occurredAt · Release V1 · Requirements FLD-08
Purpose. « Commencer la visite »: starts the visit timer.
Request. Body { "occurredAt": Iso }.
{ "occurredAt": "2026-11-03T08:13:00Z" }Response 200 OK
{
"visitId": "0193a2f0-5b6c-7e8d-9f01-23456789ab11",
"status": "IN_PROGRESS",
"checkedInAt": "2026-11-03T08:12:00Z",
"startedAt": "2026-11-03T08:13:00Z",
"endedAt": null,
"locked": false,
"version": 3
}Errors — 403 NOT_ASSIGNED_DELEGATE · 409 VISIT_NOT_CHECKED_IN (visit PLANNED or NOT_DONE — check in first) · 409 VISIT_TRANSITION_NOT_ALLOWED (already IN_PROGRESS with another key, or DONE) · 409 VISIT_LOCKED.
Rules & side effects. CHECKED_IN → IN_PROGRESS; startedAt = occurredAt (≥ checkedInAt, else OCCURRED_AT_OUT_OF_RANGE with details.reason = "BEFORE_PREVIOUS_STEP"). Saving the checklist, the note, a photo or the report on a CHECKED_IN visit starts it implicitly with the same occurredAt, so the reference flow « Arrivée → checklist → Terminer » never fails for a forgotten tap. Outbox visit.started.
Used by DA-10.
PUT /delegate/visits/{visitId}/checklist
delegate.visits.checklist · Access session, delegate scope (assigned delegate) · Apps DA · Idempotency-Key required · Offline-capable yes — each save is queued; consecutive saves of the same visit are coalesced by the outbox (only the latest unsent state is kept) · Release V1 · Requirements FLD-08
Purpose. Save the state of the « Checklist commerciale » (cahier §6.4 « Réaliser une checklist commerciale »).
Request. Body:
interface ChecklistRequest {
items: Array<{ key: string; checked: boolean; checkedAt: Iso | null }>; // keys of the frozen template
occurredAt: Iso;
}{
"items": [
{ "key": "PRESENT_ACCESSORIES", "checked": true, "checkedAt": "2026-11-03T08:16:00Z" },
{ "key": "SHOW_NEW_ARRIVALS", "checked": true, "checkedAt": "2026-11-03T08:21:00Z" },
{ "key": "HELP_CHOOSE", "checked": false, "checkedAt": null },
{ "key": "PRESENT_PROMOTIONS", "checked": true, "checkedAt": "2026-11-03T08:24:00Z" },
{ "key": "HELP_FIRST_ORDER", "checked": false, "checkedAt": null }
],
"occurredAt": "2026-11-03T08:24:05Z"
}Response 200 OK — ChecklistState.
{
"templateId": "0192c3d0-2222-7a00-8c00-00000000c001",
"templateVersion": 3,
"items": [
{ "key": "PRESENT_ACCESSORIES", "label": "Présenter les accessoires HOCO", "required": false, "checked": true, "checkedAt": "2026-11-03T08:16:00Z" },
{ "key": "SHOW_NEW_ARRIVALS", "label": "Montrer les nouveautés HOCO", "required": false, "checked": true, "checkedAt": "2026-11-03T08:21:00Z" },
{ "key": "HELP_CHOOSE", "label": "Aider à choisir les accessoires", "required": false, "checked": false, "checkedAt": null },
{ "key": "PRESENT_PROMOTIONS", "label": "Parler des promotions en cours", "required": false, "checked": true, "checkedAt": "2026-11-03T08:24:00Z" },
{ "key": "HELP_FIRST_ORDER", "label": "Aider à la 1ère commande HOCO", "required": false, "checked": false, "checkedAt": null }
],
"checkedCount": 3,
"total": 5
}Errors — 400 VALIDATION_FAILED (unknown key: details.fields[].code = "UNKNOWN_CHECKLIST_ITEM"; checked = true without checkedAt) · 403 NOT_ASSIGNED_DELEGATE · 409 VISIT_NOT_CHECKED_IN · 409 VISIT_LOCKED.
Rules & side effects. Keys not sent keep their state. Last write wins by occurredAt: a save older than the stored state is acknowledged with the current state and changes nothing (two devices, out-of-order replays). Stored as VisitChecklistItem rows; implicit start on a CHECKED_IN visit. No event (the end of the visit carries the summary).
Used by DA-10.
PUT /delegate/visits/{visitId}/note
delegate.visits.note · Access session, delegate scope (assigned delegate) · Apps DA · Idempotency-Key required · Offline-capable yes — queued and coalesced like the checklist · Release V1 · Requirements FLD-08
Purpose. The « Note rapide » of the visit (≤ 300 characters).
Request. Body { "text": string, "occurredAt": Iso } — text up to 300 characters after trimming; an empty string clears the note.
{ "text": "Stock de câbles X89 presque épuisé, commande prévue la semaine prochaine.", "occurredAt": "2026-11-03T08:30:00Z" }Response 200 OK
{
"visitId": "0193a2f0-5b6c-7e8d-9f01-23456789ab11",
"note": { "text": "Stock de câbles X89 presque épuisé, commande prévue la semaine prochaine.", "updatedAt": "2026-11-03T08:30:00Z" },
"version": 5
}Errors — 400 VALIDATION_FAILED (text over 300 characters: details.fields[0] = { path: "text", code: "TOO_LONG", max: 300 }) · 403 NOT_ASSIGNED_DELEGATE · 409 VISIT_NOT_CHECKED_IN · 409 VISIT_LOCKED.
Rules & side effects. Replaces the note; last write wins by occurredAt; implicit start; the note is copied to the reseller timeline (Interaction VISIT) when the visit ends.
Used by DA-10.
POST /delegate/visits/{visitId}/photos
delegate.visits.addPhoto · Access session, delegate scope (assigned delegate) · Apps DA · Idempotency-Key required · Offline-capable yes — the photo is kept on the device; the upload queue sends the file (files.createUpload → PUT → files.completeUpload) when the network allows, and this attach call leaves the outbox only once its fileId is known · Release V1 · Requirements FLD-08, CRM-10
Purpose. Attach a photo as proof of visit (cahier §6.4 « Les photos peuvent être obligatoires selon le type de visite »).
Request. Body:
interface AddVisitPhotoRequest {
id: Uuid; // client-generated UUIDv7 of the VisitPhoto
fileId: Uuid; // completed upload, purpose VISIT_PHOTO, uploaded by the caller
kind: "FACADE" | "INTERIOR" | "SHELF" | "OTHER";
takenAt: Iso;
location: GeoPoint | null; // position at capture (uploads are EXIF-free)
caption?: string | null; // ≤ 140
occurredAt: Iso;
}{
"id": "0193a305-6a7b-7c8d-9e0f-1a2b3c4d5e61",
"fileId": "0193a304-1f2e-7d3c-8b4a-596877665544",
"kind": "SHELF",
"takenAt": "2026-11-03T08:27:10Z",
"location": { "lat": 35.71135, "lng": -0.57251, "accuracy": 9, "capturedAt": "2026-11-03T08:27:10Z" },
"caption": "Présentoir HOCO après réassort",
"occurredAt": "2026-11-03T08:27:15Z"
}Response 201 Created (200 OK when the same id already exists)
interface VisitPhotoView {
id: Uuid;
fileId: Uuid;
kind: "FACADE" | "INTERIOR" | "SHELF" | "OTHER";
url: string; // signed, 5 minutes
thumbnailUrl: string;
takenAt: Iso;
location: GeoPoint | null;
distanceFromPosMeters: number | null; // audit hint, never blocking
caption: string | null;
createdAt: Iso;
}{
"id": "0193a305-6a7b-7c8d-9e0f-1a2b3c4d5e61",
"fileId": "0193a304-1f2e-7d3c-8b4a-596877665544",
"kind": "SHELF",
"url": "https://files.hoco.dz/visit-photos/0193a304-1f2e.webp?X-Amz-Expires=300&X-Amz-Signature=c2d4",
"thumbnailUrl": "https://files.hoco.dz/visit-photos/0193a304-1f2e-thumb.webp?X-Amz-Expires=300&X-Amz-Signature=77b0",
"takenAt": "2026-11-03T08:27:10Z",
"location": { "lat": 35.71135, "lng": -0.57251, "accuracy": 9, "capturedAt": "2026-11-03T08:27:10Z" },
"distanceFromPosMeters": 19,
"caption": "Présentoir HOCO après réassort",
"createdAt": "2026-11-03T08:27:17Z"
}Errors
| Status | Code | When |
|---|---|---|
| 400 | VALIDATION_FAILED | File purpose is not VISIT_PHOTO, or the file belongs to another user |
| 403 | NOT_ASSIGNED_DELEGATE | Tour reassigned |
| 409 | VISIT_NOT_CHECKED_IN · VISIT_LOCKED | Visit not checked in · visit locked |
| 409 | CLIENT_ID_CONFLICT | id already used for another visit or file |
| 422 | FILE_NOT_UPLOADED | The upload of fileId was not completed |
| 422 | PHOTO_LIMIT_REACHED | The visit already has field.maxPhotosPerVisit photos (details.max) |
Rules & side effects. Stores a VisitPhoto (private file, signed reads); implicit start; the photo counts toward VisitType.minPhotos. A FACADE photo of a visit also becomes the point of sale's façade photo candidate shown on its sheet. No event.
Used by DA-10.
PUT /delegate/visits/{visitId}/report
delegate.visits.report · Access session, delegate scope (assigned delegate) · Apps DA · Idempotency-Key required · Offline-capable yes — client-generated report id; saves are queued and coalesced · Release V1 · Requirements FLD-10, CRM-08
Purpose. The structured « Rapport de visite » (cahier §6.8): person met, interest, products and promotions presented, issues and needs, next action with follow-up date, comment and attachments. Saving creates or updates the follow-up task.

Request. Body (the whole report — a PUT replaces it; fields may be partial until the visit ends):
interface VisitReportRequest {
id: Uuid; // client-generated on first save, identical on later saves
personMetName?: string | null; // ≤ 120
personMetRoleCode?: string | null; // PERSON_ROLE item
interestLevel?: "LOW" | "MEDIUM" | "HIGH" | null;
productsPresented?: Array<
| { kind: "CATEGORY"; categoryId: Uuid; brandId?: Uuid | null } // a category, optionally of one brand
| { kind: "PRODUCT"; productId: Uuid }
>; // ≤ 20; `kind` is the discriminator
promotionsPresented?: Uuid[]; // ≤ 10 promotion ids, active at occurredAt
issueTagCodes?: string[]; // VISIT_ISSUE_TAG items, ≤ 10
needs?: string | null; // ≤ 1000
nextAction?: "PHONE_FOLLOW_UP" | "NEW_VISIT" | "SEND_CATALOG" | "WHATSAPP_MESSAGE" | "NONE" | null;
followUpAt?: Iso | null; // required when nextAction is set and not NONE
comment?: string | null; // ≤ 2000
attachmentFileIds?: Uuid[]; // ≤ 5 completed uploads (VISIT_PHOTO or STORE_DOCUMENT)
occurredAt: Iso;
}{
"id": "0193a31b-0c2d-7e3f-9a40-5b6c7d8e9f11",
"personMetName": "Karim Belkacem",
"personMetRoleCode": "MANAGER",
"interestLevel": "MEDIUM",
"productsPresented": [
{ "kind": "CATEGORY", "categoryId": "0192a002-0000-7000-8000-00000000c011", "brandId": "0192a001-0000-7000-8000-00000000b001" },
{ "kind": "CATEGORY", "categoryId": "0192a002-0000-7000-8000-00000000c021", "brandId": "0192a001-0000-7000-8000-00000000b001" },
{ "kind": "CATEGORY", "categoryId": "0192a002-0000-7000-8000-00000000c031", "brandId": "0192a001-0000-7000-8000-00000000b001" }
],
"promotionsPresented": ["01939f11-5e6f-7a80-9b91-0c1d2e3f4a61", "01939f11-5e6f-7a80-9b91-0c1d2e3f4a62"],
"issueTagCodes": ["CHARGER_STOCKOUT", "EARPHONES_DEMAND", "CAR_HOLDER_NEED"],
"needs": "Réassort chargeurs rapides 20 W avant la fin du mois.",
"nextAction": "PHONE_FOLLOW_UP",
"followUpAt": "2026-11-10T08:00:00Z",
"comment": "Intéressé par les écouteurs true wireless. Souhaite recevoir le catalogue HOCO.",
"attachmentFileIds": [],
"occurredAt": "2026-11-03T08:35:00Z"
}Response 200 OK
interface VisitReportResult {
report: VisitReportView; // the request fields + resolved labels
followUpTask: TaskView | null;
taskAction: "CREATED" | "UPDATED" | "CANCELLED" | "NONE";
missingForEnd: ReportField[]; // fields this visit type requires that are still empty
}
interface VisitReportView {
id: Uuid;
visitId: Uuid;
personMetName: string | null;
personMetRole: { code: string; label: string } | null;
interestLevel: "LOW" | "MEDIUM" | "HIGH" | null;
productsPresented: Array<{ kind: "CATEGORY" | "PRODUCT"; categoryId?: Uuid; brandId?: Uuid | null; productId?: Uuid; label: string }>;
promotionsPresented: Array<{ id: Uuid; label: string }>;
issueTags: Array<{ code: string; label: string }>;
needs: string | null;
nextAction: string | null;
followUpAt: Iso | null;
comment: string | null;
attachments: FileRef[];
occurredAt: Iso;
updatedAt: Iso;
}{
"report": {
"id": "0193a31b-0c2d-7e3f-9a40-5b6c7d8e9f11",
"visitId": "0193a2f0-5b6c-7e8d-9f01-23456789ab11",
"personMetName": "Karim Belkacem",
"personMetRole": { "code": "MANAGER", "label": "Gérant" },
"interestLevel": "MEDIUM",
"productsPresented": [
{ "kind": "CATEGORY", "categoryId": "0192a002-0000-7000-8000-00000000c011", "brandId": "0192a001-0000-7000-8000-00000000b001", "label": "Chargeurs rapides HOCO" },
{ "kind": "CATEGORY", "categoryId": "0192a002-0000-7000-8000-00000000c021", "brandId": "0192a001-0000-7000-8000-00000000b001", "label": "Câbles Type-C HOCO" },
{ "kind": "CATEGORY", "categoryId": "0192a002-0000-7000-8000-00000000c031", "brandId": "0192a001-0000-7000-8000-00000000b001", "label": "Écouteurs Bluetooth HOCO" }
],
"promotionsPresented": [
{ "id": "01939f11-5e6f-7a80-9b91-0c1d2e3f4a61", "label": "Pack chargeur + câble" },
{ "id": "01939f11-5e6f-7a80-9b91-0c1d2e3f4a62", "label": "Remise volume HOCO" }
],
"issueTags": [
{ "code": "CHARGER_STOCKOUT", "label": "Rupture de chargeurs" },
{ "code": "EARPHONES_DEMAND", "label": "Demande écouteurs" },
{ "code": "CAR_HOLDER_NEED", "label": "Besoin supports voiture" }
],
"needs": "Réassort chargeurs rapides 20 W avant la fin du mois.",
"nextAction": "PHONE_FOLLOW_UP",
"followUpAt": "2026-11-10T08:00:00Z",
"comment": "Intéressé par les écouteurs true wireless. Souhaite recevoir le catalogue HOCO.",
"attachments": [],
"occurredAt": "2026-11-03T08:35:00Z",
"updatedAt": "2026-11-03T08:35:02Z"
},
"followUpTask": {
"id": "0193a31b-77aa-7b10-8c21-3d4e5f6a7b81",
"type": "CALL",
"status": "OPEN",
"title": "Relance téléphonique — Magasin El Wafa",
"dueAt": "2026-11-10T08:00:00Z",
"isOverdue": false,
"reseller": {
"id": "0192d1aa-3b4c-7d5e-8f60-718293a4b123",
"number": "REV-000123",
"storeName": "Magasin El Wafa",
"ownerName": "Karim Belkacem",
"phone": "+213555123456",
"wilayaCode": "31",
"communeName": "Bir El Djir",
"status": "ACTIVE"
},
"sourceVisitId": "0193a2f0-5b6c-7e8d-9f01-23456789ab11",
"note": "Intéressé par les écouteurs true wireless. Souhaite recevoir le catalogue HOCO.",
"createdBy": { "id": "0192f0a1-7c3e-7b10-9a4e-5d2c1b0a9f01", "displayName": "Yacine Belkacem" },
"createdAt": "2026-11-03T08:35:02Z",
"completedAt": null,
"outcome": null,
"version": 1
},
"taskAction": "CREATED",
"missingForEnd": []
}Errors
| Status | Code | When |
|---|---|---|
| 400 | VALIDATION_FAILED | Lengths or counts exceeded; unknown reference codes (UNKNOWN_REFERENCE_ITEM); followUpAt missing with a next action, set with NONE, in the past or more than 90 days ahead; promotion not active; attachment not completed |
| 403 | NOT_ASSIGNED_DELEGATE | Tour reassigned |
| 409 | VISIT_NOT_CHECKED_IN · VISIT_LOCKED | Visit not checked in · visit locked |
| 409 | CLIENT_ID_CONFLICT | The visit already has a report with another id |
Rules & side effects
- One report per visit; partial saves are allowed (« Enregistrer »); the visit type's
reportFields[].requiredis enforced only when the visit ends (delegate.visits.end). Last write wins byoccurredAt; implicit start on aCHECKED_INvisit. - Follow-up task (CRM-08, cahier §6.8 « déclenche la relance planifiée »):
nextActionmaps toTaskType—PHONE_FOLLOW_UP→CALL,NEW_VISIT→VISIT,SEND_CATALOG→SEND_CATALOG,WHATSAPP_MESSAGE→WHATSAPP. First save with a next action → taskOPENassigned to the caller,dueAt = followUpAt, title «{label}—{storeName}», linked to the visit (taskAction = CREATED). Later saves update that same task (type, due date, note —UPDATED); switching toNONEcancels it while stillOPEN(CANCELLED). A reminder push is sent on the due date at 08:00Africa/Algiers. - The ResellerStatus is not changed by saving — it is evaluated once, when the visit ends (rule table in
delegate.visits.end), so successive saves never flap the status. - Outbox
visit.report_saved(task and timeline refresh in the back-office, FLD-14). The report becomes readable on the reseller sheet and indelegate.reports(cahier §6.8 « Le rapport est consultable dans la fiche revendeur »).
Used by DA-12.
POST /delegate/visits/{visitId}/end
delegate.visits.end · Access session, delegate scope (assigned delegate) · Apps DA · Idempotency-Key required · Offline-capable yes — the app checks missingToEnd locally, locks the visit locally, queues the call and sends it at once when a connection exists · Release V1 · Requirements FLD-08, FLD-10
Purpose. « Terminer la visite »: checks the requirements of the visit type, ends and locks the visit (cahier §6.4 « Une visite terminée ne peut être modifiée sans permission »), then applies the report's CRM effects.
Request. Body { "occurredAt": Iso, "location"?: GeoPoint | null }.
{
"occurredAt": "2026-11-03T08:41:00Z",
"location": { "lat": 35.71139, "lng": -0.57259, "accuracy": 10, "capturedAt": "2026-11-03T08:40:55Z" }
}Response 200 OK
{
"visitId": "0193a2f0-5b6c-7e8d-9f01-23456789ab11",
"status": "DONE",
"checkedInAt": "2026-11-03T08:12:00Z",
"startedAt": "2026-11-03T08:13:00Z",
"endedAt": "2026-11-03T08:41:00Z",
"durationSeconds": 1680,
"locked": true,
"resellerStatusChange": null,
"followUpTaskId": "0193a31b-77aa-7b10-8c21-3d4e5f6a7b81",
"tourCounters": { "planned": 4, "unplanned": 0, "done": 1, "notDone": 0, "remaining": 3, "late": 0 },
"version": 6
}resellerStatusChange is { "from": "TO_CONTACT", "to": "INTERESTED" } when the rule below changes the status (here El Wafa is ACTIVE, so it stays).
Errors
| Status | Code | When |
|---|---|---|
| 403 | NOT_ASSIGNED_DELEGATE | Tour reassigned |
| 409 | VISIT_NOT_CHECKED_IN | Visit PLANNED or NOT_DONE |
| 409 | VISIT_TRANSITION_NOT_ALLOWED | Visit CHECKED_IN with nothing saved and not started (details.allowed = ["START", "NOT_DONE"]) |
| 409 | VISIT_LOCKED | Already DONE (another key) or CANCELLED |
| 422 | VISIT_REQUIREMENTS_NOT_MET | details.missing lists every unmet requirement: REPORT_REQUIRED (missingFields), PHOTO_REQUIRED (required, attached), CHECKLIST_INCOMPLETE (missingKeys) |
{
"error": {
"code": "VISIT_REQUIREMENTS_NOT_MET",
"message": "Pour terminer cette visite de prospection, ajoutez une photo et complétez le rapport.",
"details": {
"missing": [
{ "code": "PHOTO_REQUIRED", "required": 1, "attached": 0 },
{ "code": "REPORT_REQUIRED", "missingFields": ["interestLevel", "nextAction"] }
]
},
"requestId": "req_01JBZ5D1W7"
}
}Rules & side effects
- Requirements (all checked, all reported at once): a report is required when
VisitType.requiresReportis true (new visit types takefield.reportRequiredToEndVisitas default), and then everyreportFields[].requiredfield must be filled; at leastVisitType.minPhotosvisit photos whenrequiresPhoto; every checklist item markedrequiredchecked. IN_PROGRESS→DONE(aCHECKED_INvisit that was implicitly started by a save qualifies);endedAt=occurredAt;durationSeconds=endedAt − startedAt;locked = true,lockedAt=receivedAt. Further edits answer409 VISIT_LOCKEDuntil the back-office unlocks the visit (admin.visits.unlock,unlockedUntil= now +field.visitUnlockHours); during that window checklist, note, photos and report may be corrected (audited) and a newendcall locks it again.- CRM status rule (evaluated once, here; applies only when the reseller's current status is a qualification status —
NEW_PROSPECT,TO_CONTACT,INTERESTED,NOT_INTERESTED,TO_FOLLOW_UP; funnel statuses andSUSPENDEDnever change, lifecycles):
Report interestLevel | Report nextAction | New status |
|---|---|---|
HIGH or MEDIUM | any | INTERESTED |
LOW | not NONE | TO_FOLLOW_UP |
LOW | NONE | NOT_INTERESTED (reason « Rapport de visite — intérêt faible »; the record's open tasks are cancelled) |
| not given | not NONE | TO_FOLLOW_UP, except from INTERESTED (unchanged) |
| not given, or no report | NONE or not given | unchanged |
A status equal to the current one writes nothing. A change writes ResellerStatusChange (trigger AUTOMATIC, event key visit.completed, actor the delegate, app DELEGATE_APP) and outbox reseller.status_changed.
4. Interaction of type VISIT on the reseller timeline (with the note and the report summary); Reseller.lastVisitAt updated; objective VISITS and home counters move (cahier §6.1).
5. Outbox visit.completed → SSE visit.completed (back-office live view and visit list).
POST /delegate/visits/{visitId}/not-done
delegate.visits.notDone · Access session, delegate scope (assigned delegate) · Apps DA · Idempotency-Key required · Offline-capable yes — queued with occurredAt, sent at once when a connection exists · Release V1 · Requirements FLD-09
Purpose. « Visite non réalisée »: records why a stop was not visited (cahier §4.5 « Une visite non réalisée doit comporter un motif »).
Request. Body (example: Super Marché Zitoune, stop 4 of the 2 November tour):
interface NotDoneRequest {
reasonCode: string; // VISIT_NOT_DONE_REASON item
comment?: string | null; // ≤ 300; required for OTHER
occurredAt: Iso;
location?: GeoPoint | null; // proof of presence when available (e.g. shop closed)
}{
"reasonCode": "STORE_CLOSED",
"comment": "Rideau baissé, fermeture pour inventaire selon le voisin.",
"occurredAt": "2026-11-02T14:10:00Z",
"location": { "lat": 35.66412, "lng": -0.58893, "accuracy": 15, "capturedAt": "2026-11-02T14:09:51Z" }
}Response 200 OK
{
"visitId": "0193a08c-7d6e-7f50-8a62-1b2c3d4e5f75",
"status": "NOT_DONE",
"notDone": { "reasonCode": "STORE_CLOSED", "comment": "Rideau baissé, fermeture pour inventaire selon le voisin.", "at": "2026-11-02T14:10:00Z" },
"locked": false,
"tourCounters": { "planned": 5, "unplanned": 0, "done": 3, "notDone": 1, "remaining": 1, "late": 0 },
"version": 3
}Errors
| Status | Code | When |
|---|---|---|
| 403 | NOT_ASSIGNED_DELEGATE | Tour reassigned |
| 409 | TOUR_TRANSITION_NOT_ALLOWED | Tour PLANNED, COMPLETED or CANCELLED (allowed: TO_START, IN_PROGRESS, SUSPENDED) |
| 409 | VISIT_TRANSITION_NOT_ALLOWED | Visit already NOT_DONE (another key) |
| 409 | VISIT_LOCKED | Visit DONE or CANCELLED |
| 422 | REASON_REQUIRED | reasonCode missing or blank, or OTHER without comment (details: { listKey: "VISIT_NOT_DONE_REASON", commentRequired }) |
Rules & side effects
PLANNED,CHECKED_INorIN_PROGRESS→NOT_DONE;notDoneAt=occurredAt; reason, comment and position stored. Data already saved on the visit (checklist, photos) is kept.- Not locked: the delegate may come back later in the same tour (
NOT_DONE→CHECKED_INthrougharrive). The tour closing locks it. - Listed in the closing report (
notDoneVisits); excluded from done visits, included in planned ones (the visit rate drops). Outboxvisit.not_done(live view). A planned visit marked not done before the tour starts is allowed (the shop called to cancel).
VISIT_LOCKED details
Every visit mutation answers 409 VISIT_LOCKED on a locked visit, with the cause so the app (and the sync centre) can explain it:
{
"error": {
"code": "VISIT_LOCKED",
"message": "Cette visite a été annulée par le back-office (tournée annulée) : vos modifications n'ont pas été enregistrées.",
"details": {
"visitId": "0193b0e2-4a5b-7c6d-8e7f-9a0b1c2d3e98",
"status": "CANCELLED",
"cause": "TOUR_CANCELLED",
"lockedAt": "2026-11-05T10:20:00Z",
"lockedBy": { "id": "0192f0a1-7c3e-7b10-9a4e-5d2c1b0a9e10", "displayName": "Nadia Ferhat" },
"reason": "Réunion commerciale urgente à l'agence"
},
"requestId": "req_01JC3M8V2Q"
}
}cause is one of VISIT_ENDED (the delegate's own end; lockedBy = the delegate), VISIT_CANCELLED (stop removed by the planner), TOUR_CANCELLED (tour cancelled by the back-office — its visits that were not started, PLANNED or CHECKED_IN, become CANCELLED), TOUR_CLOSED (tour COMPLETED), UNLOCK_EXPIRED (the back-office correction window closed). In sync.batch this error is reported as a CONFLICT.
Delegate home, profile, objectives & reports
The delegate app's read model of the day — home aggregate, profile and zones, objectives, performance and the history of visit and tour reports.
Prospects, points of sale, follow-ups & invitations
The delegate's CRM in the field — scoped list and map of points of sale, duplicate check, offline prospect creation, point-of-sale sheet and edits, notes and photos, invitations with activation tracking, and follow-up tasks.