Offline sync & location
How the offline-first delegate app talks to the server: ordered replay of queued mutations with per-item results, the delta feed of its offline scopes with tombstones, and batched location pings during a tour.
The delegate app is offline-first (cahier §7.3, SYN-03 to SYN-06): it works on its local database, records every action in an outbox, and talks to the server through three operations. sync.batch replays the outbox in order; sync.delta brings back what changed on the server; sync.locationPings uploads the position samples of the tour. The architecture behind them (local schema, outbox states, retry policy, conflict UX) is in the offline sync design; this page is the contract.
The three guarantees.
- Nothing is applied twice. Every queued mutation carries the
Idempotency-Keyit would carry as a direct call; the server keeps oneIdempotencyRecordper key (scope = the user, 30 days) whether the mutation arrived directly or inside a batch. A replay returns the stored result. - Nothing is applied out of order. Items of a batch run one after the other in the order sent, each in its own transaction, exactly as the direct operation would (same validation, scope, guards, events).
dependsOnlets an item be skipped when an item it needs failed. - Nothing fails silently. Every item gets its own result (
SyncItemResult); a conflict on one item never fails the batch. The app shows pending, failed and conflicted items in the sync centre (DA-27) with the states synchronisé, en attente, en erreur (cahier §7.3).
Scope. Every item is executed with the caller's delegate scope as it is at execution time, not as it was when the action happened offline; a record that left the scope meanwhile comes back as a conflict.
| Method | Path | Operation | Access | Apps | Release |
|---|---|---|---|---|---|
| POST | /sync/batch | sync.batchReplay up to 50 queued offline mutations in order; per-item results with idempotent replays | delegate | DA | V1 |
| GET | /sync/delta | sync.deltaChanges since a cursor for the delegate's offline scopes (tours, visits, resellers, tasks, reference lists) | delegate | DA | V1 |
| POST | /delegate/location-pings | sync.locationPingsBatch of position samples during an active tour (policy-driven frequency) | delegateIdempotency-Key | DA | V1 |
Conventions on this page
- Caller: delegate session; mobile headers on every call. Clock: the app stamps
occurredAtwith its device clock corrected by the offset it derives fromserverTime(returned by every response of this page and by the today snapshot), so a phone with a wrong clock does not produceOCCURRED_AT_OUT_OF_RANGErefusals. - Errors common to every operation, not repeated:
400 VALIDATION_FAILED,400 IDEMPOTENCY_KEY_REQUIRED(batch or ping chunk without the header),401 UNAUTHENTICATED,403 ACCOUNT_SUSPENDED,403 PERMISSION_DENIED,426 UPGRADE_REQUIRED,429 RATE_LIMITED,500 INTERNAL_ERROR,503 MAINTENANCE. - Session expiry while offline: a delegate session lasts 30 days and slides; when a batch answers
401, the app keeps its outbox untouched, asks the delegate to sign in again and resumes the replay with the same keys.
Shared shapes of this page
type SyncItemResult = "APPLIED" | "REPLAYED" | "CONFLICT" | "REJECTED" | "RETRY";
interface SyncItem {
idempotencyKey: Uuid; // the key of this action — identical to the direct call's key
method: "POST" | "PUT" | "PATCH" | "DELETE";
path: string; // relative to /v1, ids filled in: "/delegate/visits/0193…/arrive"
body: Record<string, unknown> | null; // exactly the body of the direct call
occurredAt: Iso; // device time of the action
dependsOn?: Uuid[]; // keys of items that must have succeeded first
}
interface SyncItemOutcome {
idempotencyKey: Uuid;
result: SyncItemResult;
status: number; // HTTP status the direct call returned (or would have)
body?: unknown; // success body (APPLIED, REPLAYED)
error?: { code: string; message: string; details?: Record<string, unknown> }; // CONFLICT, REJECTED, RETRY
replayed?: boolean; // true when the outcome comes from an earlier execution
receivedAt: Iso;
}| Value | UI label (fr) | Meaning |
|---|---|---|
APPLIED | Synchronisé | |
REPLAYED | Déjà synchronisé | Same Idempotency-Key seen before; original result returned. |
CONFLICT | Conflit | State changed on the server; the client must show it in the sync centre. |
REJECTED | Rejeté | Validation or permission error; never retried automatically. |
RETRY | À réessayer | Transient server error; retried with backoff. |
POST /sync/batch
sync.batch · Access session, delegate scope (each item is authorised like its direct call) · Apps DA · Idempotency-Key required (one key per batch content) · Offline-capable this is the replay channel of the outbox · Release V1 · Requirements SYN-03, SYN-05
Purpose. Replay up to 50 queued offline mutations in order and get one result per item.
Request. Header Idempotency-Key: <uuid> for the batch itself — retrying the exact same batch (same items) reuses it and gets the whole stored response back; any change in the items needs a new batch key. Body:
interface SyncBatchRequest {
items: SyncItem[]; // 1–50, oldest first; total body ≤ 1 MB
}Batchable operations — the offline-capable delegate mutations, and only them:
| Group | Operations |
|---|---|
| Tours | delegate.tours.start, suspend, resume, reorder, addVisit |
| Visits | delegate.visits.arrive, start, checklist, note, addPhoto, report, end, notDone |
| Points of sale | delegate.resellers.create, update, addNote, addPhoto, delegate.invitations.markShared |
| Follow-ups | delegate.tasks.update, delegate.tasks.complete |
Everything else is refused per item with REJECTED / NOT_BATCHABLE: reads, delegate.tours.close (needs everything synced first), assisted orders (prices and stock must be confirmed live), delegate.invitations.create (server-generated code), file uploads (binary, through files.createUpload) and location pings (their own route below).
Response 200 OK — always 200 when the batch itself is well-formed, whatever the item results:
interface SyncBatchResponse {
results: SyncItemOutcome[]; // same order and length as items
serverTime: Iso;
}Execution rules
- Order. Items run sequentially in the order sent. Each runs the direct operation in its own database transaction — an item never sees a half-applied previous item, and a failed item rolls back alone.
- Idempotency per item. The item's
idempotencyKeyis looked up inIdempotencyRecord(scope = the caller) with the fingerprint ofmethod+path+body:- unknown key → executed; its outcome is stored under the key (2xx and 4xx; 5xx outcomes are not stored, so the key can be retried);
- known key, same fingerprint, stored success → not executed, result
REPLAYEDwith the storedstatusandbody; - known key, same fingerprint, stored refusal → not executed, the stored outcome is returned with its result class (
CONFLICTorREJECTED) andreplayed: true; - known key, different fingerprint →
REJECTED,409 IDEMPOTENCY_KEY_REUSED; - same key still running (a direct call in flight) →
RETRY,409 IDEMPOTENCY_IN_PROGRESS.
occurredAt. The server injects the item'soccurredAtinto the operation input when the body has none; a body value different from the item value →REJECTED/VALIDATION_FAILED. Plausibility (more than 5 minutes ahead, older than 72 hours, before the previous step of the same record) →REJECTED/422 OCCURRED_AT_OUT_OF_RANGE. BothoccurredAtandreceivedAtare stored.- Dependencies.
dependsOnlists keys of items earlier in the same batch or already sent in a previous request. Before running an item, the server checks each dependency's outcome:APPLIEDorREPLAYED→ run;CONFLICTorREJECTED→ the item is not run and getsREJECTED, status424, codeDEPENDENCY_FAILED(details: { dependsOn, dependencyResult });RETRY→ not run,RETRYwith codeDEPENDENCY_PENDING; a key the server has never seen →REJECTED/DEPENDENCY_FAILEDwithdependencyResult: "UNKNOWN". A dependency on a later item of the batch makes the whole request400 VALIDATION_FAILED. - Result classes — from the outcome of the direct operation:
| Outcome of the operation | Result | Client action |
|---|---|---|
2xx first execution | APPLIED | Mark the item synced, merge body into the local record (server numbers, times, verdicts) |
| Stored success replayed | REPLAYED | Same as APPLIED — the previous attempt had worked, only its response was lost |
409 state conflicts (VISIT_LOCKED, VERSION_CONFLICT, PHONE_ALREADY_EXISTS, TOUR_NOT_IN_PROGRESS, VISIT_IN_PROGRESS, *_TRANSITION_NOT_ALLOWED, RESELLER_TRANSITION_NOT_ALLOWED, TOUR_ORDER_IMPOSED, CLIENT_ID_CONFLICT, VISIT_ALREADY_PLANNED…) and 403 OUT_OF_SCOPE, 403 NOT_ASSIGNED_DELEGATE | CONFLICT | Pull sync.delta, show the item in the sync centre with the server state; the delegate decides (re-do, adapt, discard). Never retried automatically |
400, 404, 422 (validation, business rule, plausibility), 424 dependency | REJECTED | Show in the sync centre as « en erreur » with the message; never retried automatically; a fix is a new action with a new key |
5xx, 429, IDEMPOTENCY_IN_PROGRESS, dependency pending, time budget exceeded | RETRY | Keep in the outbox, retry with the same key with exponential backoff (2 s, 4 s, 8 s … capped at 5 min) |
- Time budget. The server stops starting new items after 20 seconds; the remaining items come back
RETRYwith codeBATCH_TIME_BUDGETand are simply sent again. - Events of each applied item are emitted exactly as for a direct call (SSE to the back-office, notifications, CRM automation); a replay emits nothing.
Errors of the whole request
| Status | Code | When |
|---|---|---|
| 400 | VALIDATION_FAILED | Malformed item, duplicate idempotencyKey inside the batch, dependsOn pointing to a later item |
| 409 | IDEMPOTENCY_KEY_REUSED · IDEMPOTENCY_IN_PROGRESS | Batch key reused with other items · the same batch is still being processed |
| 413 | BATCH_TOO_LARGE | More than 50 items or a body over 1 MB (details: { maxItems: 50, maxBytes: 1048576 }) — split it |
Worked example — one visit replayed after a network gap
Thursday 5 November, tour TRN-2026-000052. At 11:02 (10:02Z) Yacine taps « Arrivé » at Bazar du Centre — Centre-ville. The app sends the check-in at once over a weak link: the server applies it at 10:02:41Z, but the response never reaches the phone, so the item stays in the outbox. The shop is in a basement arcade with no signal: the checklist (10:15Z), the report (10:31Z) and « Terminer la visite » (10:36Z) are recorded locally; the app chains them with dependsOn (each visit action depends on the previous queued action of the same visit). Meanwhile, at 10:20Z, the commercial administrator cancels the rest of Yacine's tour from the back-office (« Réunion commerciale urgente à l'agence »); the Bazar du Centre visit was checked in but not started on the server, so it becomes CANCELLED — locked (lifecycles). At 11:15Z the network is back and the outbox sends:
{
"items": [
{
"idempotencyKey": "6f0c2a4e-1b3d-4c5e-9f70-8a1b2c3d4e01",
"method": "POST",
"path": "/delegate/visits/0193b0e2-4a5b-7c6d-8e7f-9a0b1c2d3e98/arrive",
"body": {
"occurredAt": "2026-11-05T10:02:00Z",
"location": { "lat": 35.69881, "lng": -0.63571, "accuracy": 14, "capturedAt": "2026-11-05T10:01:55Z" },
"isMockLocation": false
},
"occurredAt": "2026-11-05T10:02:00Z"
},
{
"idempotencyKey": "6f0c2a4e-1b3d-4c5e-9f70-8a1b2c3d4e02",
"method": "PUT",
"path": "/delegate/visits/0193b0e2-4a5b-7c6d-8e7f-9a0b1c2d3e98/checklist",
"body": {
"items": [
{ "key": "PRESENT_ACCESSORIES", "checked": true, "checkedAt": "2026-11-05T10:09:00Z" },
{ "key": "SHOW_NEW_ARRIVALS", "checked": true, "checkedAt": "2026-11-05T10:12:00Z" },
{ "key": "PRESENT_PROMOTIONS", "checked": true, "checkedAt": "2026-11-05T10:15:00Z" }
],
"occurredAt": "2026-11-05T10:15:00Z"
},
"occurredAt": "2026-11-05T10:15:00Z",
"dependsOn": ["6f0c2a4e-1b3d-4c5e-9f70-8a1b2c3d4e01"]
},
{
"idempotencyKey": "6f0c2a4e-1b3d-4c5e-9f70-8a1b2c3d4e03",
"method": "PUT",
"path": "/delegate/visits/0193b0e2-4a5b-7c6d-8e7f-9a0b1c2d3e98/report",
"body": {
"id": "0193b10a-5c6d-7e8f-9a01-2b3c4d5e6f03",
"personMetName": "Nadir Benamar",
"personMetRoleCode": "OWNER",
"interestLevel": "HIGH",
"productsPresented": [{ "categoryId": "0192a002-0000-7000-8000-00000000c031", "brandId": "0192a001-0000-7000-8000-00000000b001" }],
"promotionsPresented": ["01939f11-5e6f-7a80-9b91-0c1d2e3f4a61"],
"issueTagCodes": ["EARPHONES_DEMAND"],
"needs": "Écouteurs Bluetooth d'entrée de gamme pour la clientèle étudiante.",
"nextAction": "PHONE_FOLLOW_UP",
"followUpAt": "2026-11-09T09:00:00Z",
"comment": "Prêt à passer une première commande après l'installation de l'application.",
"attachmentFileIds": [],
"occurredAt": "2026-11-05T10:31:00Z"
},
"occurredAt": "2026-11-05T10:31:00Z",
"dependsOn": ["6f0c2a4e-1b3d-4c5e-9f70-8a1b2c3d4e02"]
},
{
"idempotencyKey": "6f0c2a4e-1b3d-4c5e-9f70-8a1b2c3d4e04",
"method": "POST",
"path": "/delegate/visits/0193b0e2-4a5b-7c6d-8e7f-9a0b1c2d3e98/end",
"body": {
"occurredAt": "2026-11-05T10:36:00Z",
"location": { "lat": 35.69879, "lng": -0.63566, "accuracy": 22, "capturedAt": "2026-11-05T10:35:52Z" }
},
"occurredAt": "2026-11-05T10:36:00Z",
"dependsOn": ["6f0c2a4e-1b3d-4c5e-9f70-8a1b2c3d4e03"]
}
]
}Response 200 OK:
{
"results": [
{
"idempotencyKey": "6f0c2a4e-1b3d-4c5e-9f70-8a1b2c3d4e01",
"result": "REPLAYED",
"status": 200,
"body": {
"visitId": "0193b0e2-4a5b-7c6d-8e7f-9a0b1c2d3e98",
"status": "CHECKED_IN",
"checkedInAt": "2026-11-05T10:02:00Z",
"receivedAt": "2026-11-05T10:02:41Z",
"location": { "lat": 35.69881, "lng": -0.63571, "accuracy": 14, "capturedAt": "2026-11-05T10:01:55Z" },
"distanceMeters": 21,
"toleranceMeters": 150,
"accuracy": 14,
"maxAccuracyMeters": 100,
"gpsValidated": true,
"gpsCheck": "WITHIN_TOLERANCE",
"justification": null,
"posLocationCandidate": false
},
"replayed": true,
"receivedAt": "2026-11-05T11:15:03Z"
},
{
"idempotencyKey": "6f0c2a4e-1b3d-4c5e-9f70-8a1b2c3d4e02",
"result": "CONFLICT",
"status": 409,
"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"
}
},
"receivedAt": "2026-11-05T11:15:03Z"
},
{
"idempotencyKey": "6f0c2a4e-1b3d-4c5e-9f70-8a1b2c3d4e03",
"result": "REJECTED",
"status": 424,
"error": {
"code": "DEPENDENCY_FAILED",
"message": "Non appliqué : une action précédente de cette visite n'a pas pu être synchronisée.",
"details": { "dependsOn": "6f0c2a4e-1b3d-4c5e-9f70-8a1b2c3d4e02", "dependencyResult": "CONFLICT" }
},
"receivedAt": "2026-11-05T11:15:03Z"
},
{
"idempotencyKey": "6f0c2a4e-1b3d-4c5e-9f70-8a1b2c3d4e04",
"result": "REJECTED",
"status": 424,
"error": {
"code": "DEPENDENCY_FAILED",
"message": "Non appliqué : une action précédente de cette visite n'a pas pu être synchronisée.",
"details": { "dependsOn": "6f0c2a4e-1b3d-4c5e-9f70-8a1b2c3d4e03", "dependencyResult": "REJECTED" }
},
"receivedAt": "2026-11-05T11:15:03Z"
}
],
"serverTime": "2026-11-05T11:15:03Z"
}Client handling of each result
| Item | Result | What the app does |
|---|---|---|
| 1 — arrive | REPLAYED | Treats it as applied: removes it from the outbox and stores the server's check-in (distance 21 m, gpsValidated). The visit was checked in once; nothing is duplicated. |
| 2 — checklist | CONFLICT (VISIT_LOCKED, cause TOUR_CANCELLED) | Pulls sync.delta: the tour and the visit come back CANCELLED. Marks the visit « Conflit » in the sync centre with the server's message, who and when (Nadia Ferhat, 11:20) and keeps the local checklist, report and note readable. Never retried. |
| 3 — report | REJECTED (DEPENDENCY_FAILED) | Grouped under the conflict of item 2 — one decision for the whole visit. Not retried. No follow-up task was created on the server. |
| 4 — end | REJECTED (DEPENDENCY_FAILED) | Same group; the visit is not counted as done. |
| Group resolution | — | The sync centre offers « Conserver comme note » — the app sends one new delegate.resellers.addNote on Bazar du Centre with a summary of the checklist and the report (new key, new client id; the store is still in scope), and the delegate can create the follow-up by hand — or « Abandonner ». Either way the four items leave the outbox and the local visit takes the server state (CANCELLED). |
GET /sync/delta
sync.delta · Access session, delegate scope · Apps DA · Idempotency-Key — · Offline-capable the pull side of sync: called on app start, after each batch, on a sync hint (silent push or notification.created on the live stream), on pull-to-refresh and every 15 minutes in the foreground · Release V1 · Requirements SYN-01, SYN-04
Purpose. Return everything that changed in the delegate's offline scopes since a cursor — back-office edits of tours and stops, reassignments, tasks, reference lists, announcements — plus tombstones for what left the scope (cahier §7.1).
Request. Query parameters:
| Name | Type | Rule |
|---|---|---|
scopes | comma list | Any of tours, visits, resellers, tasks, referenceLists, announcements; default all |
cursor | string | Opaque, from the previous response or the today snapshot (deltaCursor); absent = initial load (current state of every scope) |
limit | integer | Records per page across scopes, default 500, max 1 000 |
What each scope covers
| Scope | Records | Shape |
|---|---|---|
tours | The caller's tours dated from today − 7 days to today + 30 days | TourView without visits |
visits | The visits of those tours | VisitStop plus tourId |
resellers | Every point of sale in the delegate scope | ResellerCard |
tasks | Tasks assigned to the caller: OPEN, plus those closed during the last 7 days | TaskView |
referenceLists | Reference items (BUSINESS_TYPE, PERSON_ROLE, VISIT_NOT_DONE_REASON, VISIT_ISSUE_TAG, TOUR_SUSPEND_REASON), visit types and checklist templates | referenceItems, visitTypes, checklistTemplates |
announcements | Announcements targeted to the caller and not expired | as announcements.list |
Response 200 OK
interface SyncDeltaResponse {
changes: {
tours: Array<Omit<TourView, "visits">>;
visits: Array<VisitStop & { tourId: Uuid }>;
resellers: ResellerCard[];
tasks: TaskView[];
referenceItems: Array<{ listKey: string; code: string; label: string; active: boolean; requiresComment: boolean; sortOrder: number }>;
visitTypes: VisitTypeView[];
checklistTemplates: ChecklistTemplate[];
announcements: Array<{ id: Uuid; title: string; body: string; publishedAt: Iso; expiresAt: Iso | null; read: boolean }>;
};
tombstones: Array<{
entity: "TOUR" | "VISIT" | "RESELLER" | "TASK" | "REFERENCE_ITEM" | "VISIT_TYPE" | "CHECKLIST_TEMPLATE" | "ANNOUNCEMENT";
id: Uuid;
reason: "DELETED" | "OUT_OF_SCOPE";
at: Iso;
}>;
nextCursor: string;
hasMore: boolean; // true → call again at once with nextCursor
fullResyncRequired: boolean; // true → drop the cached scopes (never the outbox) and reload without cursor
serverTime: Iso;
}Example — the pull right after the batch above (GET /v1/sync/delta?cursor=c3luYzoxODQ0Njc0NDA3MzcwOTU2MDIx):
{
"changes": {
"tours": [
{
"id": "0193b0e2-4a5b-7c6d-8e7f-9a0b1c2d3e01",
"number": "TRN-2026-000052",
"date": "2026-11-05",
"status": "CANCELLED",
"orderingMode": "RECOMMENDED",
"plannedStartAt": "2026-11-05T07:30:00Z",
"plannedEndAt": "2026-11-05T16:30:00Z",
"startedAt": "2026-11-05T07:58:00Z",
"suspendedAt": null,
"suspendReasonCode": null,
"closedAt": null,
"startPoint": { "label": "Agence HOCO Oran", "location": { "lat": 35.6935, "lng": -0.6255 } },
"instructions": null,
"counters": { "planned": 3, "unplanned": 1, "done": 1, "notDone": 0, "remaining": 0, "late": 0 },
"allowedActions": [],
"closingReport": null,
"version": 9,
"updatedAt": "2026-11-05T10:20:00Z"
}
],
"visits": [
{
"id": "0193b0e2-4a5b-7c6d-8e7f-9a0b1c2d3e98",
"tourId": "0193b0e2-4a5b-7c6d-8e7f-9a0b1c2d3e01",
"number": "VIS-2026-001298",
"sequence": 2,
"plannedAt": "2026-11-05T09:30:00Z",
"slotEndAt": "2026-11-05T10:30:00Z",
"priority": "HIGH",
"status": "CANCELLED",
"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": 694,
"checkedInAt": "2026-11-05T10:02:00Z",
"startedAt": null,
"endedAt": null,
"gpsValidated": true,
"locked": true,
"unlockedUntil": null,
"version": 4
},
{
"id": "0193b0e2-4a5b-7c6d-8e7f-9a0b1c2d3e97",
"tourId": "0193b0e2-4a5b-7c6d-8e7f-9a0b1c2d3e01",
"number": "VIS-2026-001297",
"sequence": 3,
"plannedAt": "2026-11-05T11:00:00Z",
"slotEndAt": "2026-11-05T12:00:00Z",
"priority": "NORMAL",
"status": "CANCELLED",
"isLate": false,
"isUnplanned": false,
"visitTypeId": "0192c3d0-1111-7a00-8c00-00000000f001",
"reseller": {
"id": "0192d1aa-3b4c-7d5e-8f60-718293a4b402",
"number": "REV-000402",
"storeName": "Super Marché Zitoune",
"ownerName": "Hocine Zitouni",
"phone": "+213698220417",
"wilayaCode": "31",
"communeName": "Aïn El Beïda",
"status": "ACTIVE",
"location": { "lat": 35.664, "lng": -0.589 },
"addressLine": "Cité 600 logements, local n° 2",
"landmark": null,
"whatsapp": "+213698220417",
"businessTypeCode": "SUPERMARKET",
"potential": "MEDIUM",
"customerType": "DEMI_GROS",
"hasAccount": true,
"assignedToMe": true,
"facadeThumbnailUrl": null,
"lastVisitAt": "2026-10-29T09:10:00Z"
},
"distanceFromPreviousMeters": 5730,
"checkedInAt": null,
"startedAt": null,
"endedAt": null,
"gpsValidated": null,
"locked": true,
"unlockedUntil": null,
"version": 3
},
{
"id": "0193b0f7-2c3d-7e4f-8a51-6b7c8d9e0f05",
"tourId": "0193b0e2-4a5b-7c6d-8e7f-9a0b1c2d3e01",
"number": "VIS-2026-001305",
"sequence": 4,
"plannedAt": null,
"slotEndAt": null,
"priority": "NORMAL",
"status": "CANCELLED",
"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": true,
"unlockedUntil": null,
"version": 2
}
],
"resellers": [],
"tasks": [],
"referenceItems": [],
"visitTypes": [],
"checklistTemplates": [],
"announcements": [
{ "id": "0193b0e9-7a8b-7c9d-8e0f-1a2b3c4d5e61", "title": "Réunion commerciale à 12h", "body": "Tous les délégués d'Oran sont attendus à l'agence à 12h00.", "publishedAt": "2026-11-05T10:18:00Z", "expiresAt": "2026-11-05T22:59:59Z", "read": false }
]
},
"tombstones": [
{ "entity": "RESELLER", "id": "0193a31d-1a2b-7c3d-9e4f-5a6b7c8d9e43", "reason": "OUT_OF_SCOPE", "at": "2026-11-05T08:30:00Z" }
],
"nextCursor": "c3luYzoxODQ0Njc0NDA3MzcwOTU2MTg4",
"hasMore": false,
"fullResyncRequired": false,
"serverTime": "2026-11-05T11:15:05Z"
}Field notes. The tombstone is Taxiphone Es Salam (created by Yacine on 3 November), reassigned that morning to the delegate of its commune (BR-48): the app removes it from its cache and from any local list; queued actions on it will come back CONFLICT / OUT_OF_SCOPE. Visits keep their sequence after a cancellation.
Errors — 400 VALIDATION_FAILED (unknown scope, limit out of range, cursor malformed or with an invalid signature).
Rules & side effects
- Cursor. Opaque, signed by the server (
SYNC_CURSOR_SECRET), it encodes the position in the server's monotonic change sequence for each scope, the date anchor of the tour window and a digest of the caller's scope membership (zones, assignment). Changes are returned oldest first, each record once in its latest state (intermediate states are collapsed). The cursor of the today snapshot is taken in the same database snapshot as its payload. - Tombstones.
OUT_OF_SCOPEwhen a record leaves the scope — reseller reassigned, tour reassigned or out of the date window, task reassigned;DELETEDfor the rare hard deletions (a reference item is deactivated, never deleted — it comes back withactive: false). fullResyncRequiredwhen the cursor is older than 7 days, when the delegate's zones changed (delegate.zones_changed: the membership digest no longer matches) or after a contract change of the delta format. The app then reloads every scope without cursor but keeps its outbox and replays it afterwards; items that no longer fit come back as conflicts.- Paging.
hasMore = trueuntil the backlog is drained; the app loops without delay and applies each page in one local transaction. - Precedence on the device. A record pulled by the delta never overwrites a local change still in the outbox: the app keeps its pending local state on top of the server state until the item is acknowledged (
APPLIED/REPLAYED) or resolved (conflict decision). - Read-only; no event. Sync hints (silent pushes) are sent by the server when the back-office changes something in a delegate's scope (
tour.updated,task.assigned,announcement.published…) so the app pulls within seconds when online.
Used by the sync engine of the delegate app (hoco_offline, MOB-09) — visible on DA-27 and behind every screen working from the local database (DA-05, DA-06, DA-13, DA-19, DA-23).
POST /delegate/location-pings
sync.locationPings · Access session, delegate scope (the caller's tour) · Apps DA · Idempotency-Key required (one per chunk) · Offline-capable yes — samples are stored on the device and uploaded in chunks of up to 200, oldest first, as soon as a connection exists; not part of sync.batch · Release V1 · Requirements FLD-13, FLD-15
Purpose. Upload the positions sampled during the tour: they feed the back-office live map (BO-23, cahier §4.5 « Suivi … des positions ») and the kilometres of the closing report (BR-54).
Request. Body:
interface LocationPingsRequest {
tourId: Uuid;
pings: Array<{
lat: number;
lng: number;
accuracy: number; // metres
speed?: number | null; // m/s as reported by the OS
heading?: number | null; // degrees 0–359
batteryPct?: number | null;
isMock?: boolean; // Android mock-location flag
recordedAt: Iso; // device sampling time (stored as LocationPing.occurredAt)
}>; // 1–200, any order
}{
"tourId": "0193a2f0-5b6c-7e8d-9f01-23456789ab01",
"pings": [
{ "lat": 35.6936, "lng": -0.6254, "accuracy": 11, "speed": 0.0, "heading": null, "batteryPct": 92, "isMock": false, "recordedAt": "2026-11-03T07:41:05Z" },
{ "lat": 35.699, "lng": -0.606, "accuracy": 9, "speed": 9.7, "heading": 71, "batteryPct": 92, "isMock": false, "recordedAt": "2026-11-03T07:46:05Z" },
{ "lat": 35.705, "lng": -0.588, "accuracy": 13, "speed": 11.2, "heading": 64, "batteryPct": 91, "isMock": false, "recordedAt": "2026-11-03T07:51:05Z" },
{ "lat": 35.7101, "lng": -0.5741, "accuracy": 8, "speed": 4.1, "heading": 58, "batteryPct": 91, "isMock": false, "recordedAt": "2026-11-03T07:56:05Z" }
]
}Response 200 OK
{
"accepted": 4,
"duplicates": 0,
"dropped": [],
"policy": { "sampling": true, "intervalSeconds": 300, "distanceMeters": 200, "maxBatchSize": 200 },
"serverTime": "2026-11-03T07:56:09Z"
}dropped lists refused samples as { "index": 2, "reason": "OUTSIDE_TOUR_INTERVAL" } — reasons OUTSIDE_TOUR_INTERVAL (sampled before the start, during a suspension or after the close) and OCCURRED_AT_OUT_OF_RANGE (more than 5 minutes ahead or older than 72 hours). policy is the current sampling policy: the app applies it at once (a supervisor may change field.locationIntervalSeconds during the day) and stops sampling when sampling is false.
Errors
| Status | Code | When |
|---|---|---|
| 404 | NOT_FOUND | Not a tour of the caller |
| 403 | LOCATION_CONSENT_REQUIRED | The delegate has no current location consent (BR-53, DA-04) |
| 409 | TOUR_NOT_IN_PROGRESS | The tour is not IN_PROGRESS or SUSPENDED (details.status) — PLANNED, TO_START, COMPLETED or CANCELLED: nothing is stored |
| 413 | BATCH_TOO_LARGE | More than 200 pings (details.maxItems) |
Rules & side effects
- Only during the tour (BR-52): the app samples only while the tour is
IN_PROGRESS, everyfield.locationIntervalSeconds(300) orfield.locationDistanceMeters(200), whichever comes first, with the Android foreground-service notice « Localisation active pendant la tournée ». The server accepts a chunk while the tour isIN_PROGRESSorSUSPENDED(so samples taken before a pause can still arrive) and stores only samples whoserecordedAtfalls inside an in-progress interval of the tour. Withfield.closingRequiresSync = truethe app flushes its samples before closing; samples sent after the close are refused, so the kilometres of the closing report are final. - Idempotent: rows are unique on (delegate,
recordedAt); a replayed chunk inserts nothing and reportsduplicates. Mocked positions are stored with their flag and shown on BO-23, never used to reject a ping (BR-55 applies to check-ins). - Live map: the newest accepted sample is published as SSE
delegate.positionon/admin/events(at most one per delegate every 30 s). Positions are never notified to anyone. - Retention: samples older than
field.locationRetentionDays(90 days) are purged nightly (BR-84); the tour keeps its computed kilometres.
Used by the location service of the delegate app (MDA-12); the policy is shown to the delegate on DA-26 and consented on DA-04.
Assisted orders
The delegate's first-order assistance: a draft priced with the visited reseller's customer-type prices, sent for the reseller's (or the administration's) confirmation, then confirmed by the back-office like every order — plus the read-only orders of the delegate's resellers.
Dashboards & KPIs
Back-office KPI cards, chart series and role-configurable layouts behind the general and business dashboards, with the exact formula of every indicator.