HOCO B2B
SRSAPI referenceBack-office endpoints

CRM

Back-office operations on resellers and prospects — list and map, 360° record, creation with duplicate checks, customer type, lifecycle, assignment, validation queue, timeline, documents, tasks, segments and export.

The CRM centralises the commercial life of every point of sale, from the first prospect record to the active reseller (cahier §4.2, §15.1, §15.5). These operations serve the CRM screens of the back-office: list and map (BO-09, BO-10), the 360° record (BO-11), creation and edit (BO-12), the account validation queue (BO-13), follow-ups and tasks (BO-14) and segments (BO-15). The statuses and who may move them are defined on Lifecycles › Reseller and Account validation.

Since the 17 Oct 2026 decisions every reseller carries a customer type (CustomerType: SUPER_GROS, DEMI_GROS, DETAIL) that selects which of the three variant prices they pay. It is declared at sign-up, confirmed or changed at validation (admin.resellers.validate) and afterwards changed only by the back-office (admin.resellers.update), always with a reason and an audit entry (AUTH-11).

Who uses it. The Administrateur commercial runs the CRM (create, edit, assign, validate, segment, export); the Super administrateur too; the Responsable finance and the Direction read reseller records (finance tabs need finance.view). Delegates use their own scoped operations (delegate resellers), never these.

PermissionUnlocks on this page
crm.viewList, map, 360° record, timeline, documents, phone check, tasks list, segments list and detail
crm.createCreating a reseller or prospect
crm.updateEditing (including customer type), status, assignment, bulk actions, notes, documents, tasks
crm.validateValidation queue and approve / reject
crm.administerCreating, editing and deleting segments
crm.exportExporting the list
finance.viewFinance fields of the list and of the 360° record (balanceDue, credit, invoice and payment counters)

Settings used (settings keys): crm.inactivityDays (60 — activity window), crm.validationReminderHours (24). Duplicate rules follow BR-04 (unique main phone) and BR-05 (hints).

Audit. Every mutation writes an AuditLog row (schema): CREATE (reseller, note, document, task, segment), UPDATE (edits — a customer-type change is its own changes entry with the reason), STATUS_CHANGE (lifecycle, task status), VALIDATE / REJECT (validation), DELETE (segment), EXPORT, and OVERRIDE for an assignment outside the delegate's zones. Domain history: ResellerStatusChange, ResellerAssignment and the Interaction timeline.

Conventions: lists return Page<T> = { items, total, page, pageSize } (responses); ResellerSummary, UserSummary, Address, GeoPoint, FileRef are shared schemas; phones are E.164 and normalised on input; dates in filters are YYYY-MM-DD (Africa/Algiers); editable records carry version (409 VERSION_CONFLICT); a missing reason answers 422 REASON_REQUIRED. 401, 429 and 500 apply everywhere and are not repeated.

MethodPathOperationAccessAppsRelease
GET/admin/resellersadmin.resellers.list
Resellers and prospects with filters, sort and pagination
crm.viewBOV1
GET/admin/resellers/mapadmin.resellers.map
Map pins within a bounding box (clustered server-side above a threshold)
crm.viewBOV1
GET/admin/resellers/{resellerId}/payment-methodsadmin.resellers.paymentMethods
Payment methods the reseller may use for an order (back-office order creation)
orders.createBOV1
POST/admin/resellersadmin.resellers.create
Create a reseller or prospect
crm.createBOV1
GET/admin/resellers/{resellerId}admin.resellers.get
Reseller 360° header and tab counters
crm.viewBOV1
PATCH/admin/resellers/{resellerId}admin.resellers.update
Edit a reseller
crm.updateBOV1
POST/admin/resellers/{resellerId}/statusadmin.resellers.setStatus
Change lifecycle status with a reason
crm.updateBOV1
POST/admin/resellers/{resellerId}/assignmentadmin.resellers.assign
Assign delegate and zone (bulk variant via /admin/resellers/bulk)
crm.updateBOV1
POST/admin/resellers/bulkadmin.resellers.bulk
Bulk assign, change status or tag
crm.updateBOV1
GET/admin/resellers/validation-queueadmin.resellers.validation.list
Accounts awaiting validation with duplicate hints
crm.validateBOV1
POST/admin/resellers/{resellerId}/validationadmin.resellers.validate
Approve or reject an account and confirm or change its customer type (reason required on rejection)
crm.validateBOV1
GET/admin/resellers/{resellerId}/timelineadmin.resellers.timeline
Unified interaction timeline
crm.viewBOV1
POST/admin/resellers/{resellerId}/notesadmin.resellers.notes.add
Add a note or call log
crm.updateBOV1
GET/admin/resellers/{resellerId}/documentsadmin.resellers.documents
Documents and photos
crm.viewBOV1
POST/admin/resellers/{resellerId}/documentsadmin.resellers.documents.add
Attach a document or photo
crm.updateBOV1
GET/admin/resellers/check-phoneadmin.resellers.checkPhone
Duplicate check by normalised phone
crm.viewBOV1
GET/admin/tasksadmin.tasks.list
Follow-ups and tasks across the team
crm.viewBOV1
POST/admin/tasksadmin.tasks.create
Create a task for a user about a reseller
crm.updateBOV1
PATCH/admin/tasks/{taskId}admin.tasks.update
Reassign, reschedule, complete or cancel
crm.updateBOV1
GET · POST · PATCH · DELETE/admin/segments[/{segmentId}]admin.segments.crud
Segments CRUD
crm.administerBOV1
POST/admin/resellers/exportadmin.resellers.export
Export the filtered list (async job)
crm.exportBOV1

Schemas

ResellerListItem

One row of the CRM table (BO-09). Every column of the table is here, so the list never needs a second call.

type ResellerActivity = "ACTIVE" | "INACTIVE" | "NEVER_ORDERED";

interface ResellerListItem {
  id: Uuid;
  number: string;                          // REV-000123
  storeName: string;
  ownerName: string;
  phone: string;                           // main phone, unique
  whatsapp?: string;
  email?: string;
  businessType: { code: string; label: string } | null;   // ReferenceItem of BUSINESS_TYPE
  customerType: CustomerType | null;       // null = prospect that never declared one
  potential: Potential | null;
  status: ResellerStatus;
  validationStatus: ValidationStatus;
  accountStatus: AccountStatus | null;     // null = no login account yet (field prospect)
  source: ResellerSource;
  wilayaCode: string;
  wilayaName: string;
  communeId: Uuid;
  communeName: string;
  zone: { id: Uuid; name: string } | null;
  delegate: UserSummary | null;            // assigned delegate
  segments: { id: Uuid; name: string }[];
  hasLocation: boolean;
  facadePhotoUrl?: string;                 // signed thumbnail, 5 min
  activity: ResellerActivity;              // see below
  lastOrderAt: Iso | null;
  ordersCount: number;                     // non-draft, not refused/cancelled
  revenue12mHt: Money;                     // last 12 months, HT net of discounts, minus credit notes
  lastVisitAt: Iso | null;
  nextTask?: { id: Uuid; type: TaskType; dueAt: Iso; overdue: boolean };
  balanceDue?: Money;                      // only with finance.view
  createdAt: Iso;
}

activity: ACTIVE = at least one order validated (not cancelled) during the last crm.inactivityDays days; INACTIVE = has ordered before, none in the window; NEVER_ORDERED = no validated order ever (all prospects).

Reseller360

The header of the 360° record and the counters of its tabs (cahier §4.2, CRM-12). Tab contents come from the module lists filtered by resellerId.

interface Reseller360 extends ResellerListItem {
  address: Address;                        // store address (also the default delivery address)
  addresses: Address[];                    // all delivery addresses
  location: GeoPoint | null;
  legal: { rc?: string; nif?: string; nis?: string; ai?: string };
  brandsSold: { id: Uuid; name: string }[];   // brands the store declares selling (segmentation, CRM-06)
  customerTypeDeclared: CustomerType | null;  // what the reseller declared at sign-up
  customerTypeHistory: { from: CustomerType | null; to: CustomerType; at: Iso; by: UserSummary | null; reason?: string }[];
  createdBy: UserSummary | null;
  createdByDelegate: UserSummary | null;   // cahier §4.2 « lié au délégué qui l'a créé »
  invitation?: { id: Uuid; code: string; delegate: UserSummary; steps: InvitationStep[] };
  validation: { status: ValidationStatus; decidedAt?: Iso; decidedBy?: UserSummary; rejectionReason?: string };
  credit?: { limit: Money; used: Money; available: Money; blocked: boolean };   // only with finance.view
  kpis: { firstOrderAt: Iso | null; ordersCount12m: number; averageBasketHt: Money; revenue12mHt: Money };
  counters: {                              // a counter is omitted when the caller lacks its tab's permission
    orders?: { total: number; open: number };            // orders.view
    invoices?: { total: number; unpaid: number; overdue: number };   // finance.view
    payments?: { total: number; pendingVerification: number };       // finance.view
    visits?: { total: number; last90Days: number; withReport: number };   // field.view
    tasks: { open: number; overdue: number };
    interactions: number;
    documents: number;
    photos: number;
    tickets?: { open: number; afterSalesOpen: number };  // support.view
    returns?: { open: number };                          // orders.view
  };
  allowedActions: ("EDIT" | "SET_STATUS" | "ASSIGN" | "VALIDATE" | "ADD_NOTE" | "ADD_DOCUMENT" | "CREATE_TASK" | "CREATE_ORDER")[];
  version: number;
}

DuplicateHint

interface DuplicateHint {
  kind: "WHATSAPP" | "NAME_SAME_COMMUNE" | "NEARBY" | "LEGAL_ID";
  reseller: ResellerSummary;
  field?: "phone" | "whatsapp" | "rc" | "nif";   // which field of the other record matched
  distanceMeters?: number;           // NEARBY (≤ 30 m), and NAME_SAME_COMMUNE when both have GPS
  similarityBps?: number;            // NAME_SAME_COMMUNE: similarity of normalised store names (≥ 8000)
}

Hints are computed on normalised values: phones in E.164; store names lower-cased, accents and the words « boutique », « magasin », « store », « shop » removed. Rules of BR-05: NAME_SAME_COMMUNE = same normalised store name (similarity ≥ 0.80) in the same commune; NEARBY = another point of sale within 30 m; WHATSAPP = same WhatsApp number; LEGAL_ID = same RC or NIF. Hints never block validation — the validator decides. The main phone itself is unique (BR-04) and answers 409 PHONE_ALREADY_EXISTS instead of a hint.

Interaction

interface Interaction {
  id: Uuid;
  type: InteractionType;             // CALL, VISIT, NOTE, WHATSAPP, SMS, EMAIL, ORDER, PAYMENT, CLAIM, STATUS_CHANGE
  at: Iso;                           // occurredAt
  actor: UserSummary | null;         // null = system
  app: ClientApp;
  summary: string;                   // French one-liner built server-side
  body?: string;                     // note text
  ref?: { entityType: "Order" | "Payment" | "Visit" | "Ticket" | "ReturnRequest" | "CrmTask"; id: Uuid; number?: string };
  meta?: Record<string, string | number | boolean>;   // e.g. call direction/duration, status from/to, order total
}

CrmTask

interface CrmTask {
  id: Uuid;
  type: TaskType;                    // CALL, VISIT, WHATSAPP, SEND_CATALOG, OTHER
  status: TaskStatus;                // OPEN, DONE, CANCELLED
  title: string;
  description?: string;
  reseller: ResellerSummary;
  assignee: UserSummary;
  createdBy: UserSummary | null;     // null = system (inactivity scan)
  source: "MANUAL" | "VISIT_REPORT" | "INACTIVITY" | "SYSTEM";   // TaskSource
  visitId?: Uuid;
  dueAt: Iso;
  overdue: boolean;                  // derived: OPEN and dueAt passed
  completedAt?: Iso;
  outcome?: string;
  createdAt: Iso;
  version: number;
}

Resellers

GET /admin/resellers

admin.resellers.list · Permission crm.view · Apps BO · Release V1 · Requirements CRM-11

Purpose. The CRM table of resellers and prospects with every filter of cahier §4.1/§4.2 (status, zone, wilaya, commune, delegate, segment, potential, activity), sort and pagination.

Request

QueryTypeValues / defaultNotes
idsUuid[]repeatableOnly these resellers — the « export selection » bulk action; also accepted in the filters of admin.resellers.export and the filter of bulk actions. Added in contract 1.1.0
statusResellerStatus[]repeatable
validationStatusValidationStatusPENDING | APPROVED | REJECTED
customerTypeCustomerType[]repeatable; NONE for records without a type
zoneIdUuid
wilayaCodestring[]repeatableStore address
communeIdUuid[]repeatable
delegateIdUuid[]repeatable; none = unassignedAssigned delegate
segmentIdUuid[]repeatableMember of any of them
potentialPotential[]repeatable
businessTypeCodestring[]repeatableBUSINESS_TYPE codes
activityResellerActivityACTIVE | INACTIVE | NEVER_ORDERED
sourceResellerSource[]repeatable
hasLocationboolean
hasOpenTask / overdueTaskboolean
createdFrom / createdTostring (date)On createdAt
lastOrderFrom / lastOrderTostring (date)
qstringmin 2 charsNumber (REV-000123 or 123), store name, owner name, phone, WhatsApp, email
sortstringcreatedAt (default), storeName, number, lastOrderAt, revenue12mHt, lastVisitAt, status
orderstringasc | desc (default desc)
page / pageSizenumber1 / 25 (max 200)

Response 200 OK

{
  "items": [
    {
      "id": "0192f4b8-61c2-7a3e-9b10-1f2e3d4c5b6a",
      "number": "REV-000123",
      "storeName": "Phone Store El Bahia",
      "ownerName": "Karim Benali",
      "phone": "+213555123456",
      "whatsapp": "+213555123456",
      "email": "phonestore.elbahia@gmail.com",
      "businessType": { "code": "PHONE_SHOP", "label": "Boutique de téléphonie" },
      "customerType": "DEMI_GROS",
      "potential": "HIGH",
      "status": "ACTIVE",
      "validationStatus": "APPROVED",
      "accountStatus": "ACTIVE",
      "source": "DELEGATE_APP",
      "wilayaCode": "31",
      "wilayaName": "Oran",
      "communeId": "0191c0aa-3101-7a00-8c00-000000003107",
      "communeName": "Bir El Djir",
      "zone": { "id": "01928f20-2a3b-7c4d-8e5f-6a7b8c9d0e1f", "name": "Oran Est" },
      "delegate": { "id": "0192a7d3-1b2c-7d4e-8f5a-6b7c8d9e0f12", "displayName": "Yacine Merabet" },
      "segments": [{ "id": "01929a10-1b2c-7d3e-8f4a-5b6c7d8e9f01", "name": "Grands comptes Ouest" }],
      "hasLocation": true,
      "facadePhotoUrl": "https://files.hoco.dz/s/rev123-facade-thumb.webp?sig=…",
      "activity": "ACTIVE",
      "lastOrderAt": "2026-11-03T08:12:00Z",
      "ordersCount": 14,
      "revenue12mHt": 31820000,
      "lastVisitAt": "2026-10-29T10:41:00Z",
      "nextTask": { "id": "01930b11-2c3d-7e4f-9a5b-6c7d8e9f0a1b", "type": "CALL", "dueAt": "2026-11-05T08:00:00Z", "overdue": false },
      "balanceDue": 2100000,
      "createdAt": "2026-06-14T09:20:00Z"
    },
    {
      "id": "0192f4c1-0a7d-7b21-8c34-2d3e4f5a6b7c",
      "number": "REV-000245",
      "storeName": "Mobile Center Hydra",
      "ownerName": "Samir Haddad",
      "phone": "+213661234567",
      "businessType": { "code": "ACCESSORIES_SHOP", "label": "Boutique d'accessoires" },
      "customerType": "DETAIL",
      "potential": null,
      "status": "ACCOUNT_CREATED",
      "validationStatus": "PENDING",
      "accountStatus": "PENDING_VALIDATION",
      "source": "SELF_SIGNUP",
      "wilayaCode": "16",
      "wilayaName": "Alger",
      "communeId": "0191c0aa-1601-7a00-8c00-000000001623",
      "communeName": "Hydra",
      "zone": null,
      "delegate": null,
      "segments": [],
      "hasLocation": false,
      "activity": "NEVER_ORDERED",
      "lastOrderAt": null,
      "ordersCount": 0,
      "revenue12mHt": 0,
      "lastVisitAt": null,
      "balanceDue": 0,
      "createdAt": "2026-11-02T18:03:00Z"
    }
  ],
  "total": 412,
  "page": 1,
  "pageSize": 25
}

Field notes. balanceDue — only with finance.view; omitted otherwise. customerType of a self sign-up is the declared type until validation confirms it. businessType.label is the reference item's French label (data, not an enum).

Errors

StatusCodeWhen
400VALIDATION_FAILEDUnknown enum value or sort field, q shorter than 2 characters, pageSize over 200
403PERMISSION_DENIEDCaller lacks crm.view

Rules & side effects

  1. Read-only. activity, ordersCount, revenue12mHt and lastOrderAt come from a per-reseller aggregate refreshed by order events, so sorting on them is index-backed.
  2. The same query string is accepted by the map, bulk (filter) and export operations, so "select all matching" always means the same set.

Used by BO-09 · BO-15 · BO-17 · BO-22 (stop picker)

GET /admin/resellers/map

admin.resellers.map · Permission crm.view · Apps BO · Release V1 · Requirements CRM-11

Purpose. Points of sale inside the visible map area, as pins or — above 500 points — as server-side clusters.

Request

QueryTypeValues / defaultNotes
bboxstringminLng,minLat,maxLng,maxLat (required)WGS84; the box must be inside Algeria's bounding box (−8.7 … 12.0, 18.9 … 37.2)
zoomnumber4–20 (required)Map zoom level; drives the cluster grid
all filters of the listSame names and semantics

Response 200 OK — pins (≤ 500 points in the box)

{
  "mode": "PINS",
  "count": 37,
  "withoutLocation": 4,
  "pins": [
    { "id": "0192f4b8-61c2-7a3e-9b10-1f2e3d4c5b6a", "number": "REV-000123", "storeName": "Phone Store El Bahia", "status": "ACTIVE", "potential": "HIGH", "customerType": "DEMI_GROS", "activity": "ACTIVE", "delegateId": "0192a7d3-1b2c-7d4e-8f5a-6b7c8d9e0f12", "delegateName": "Yacine Belkacem", "lat": 35.7206, "lng": -0.544 },
    { "id": "0192f4d2-3e4f-7a5b-9c6d-7e8f9a0b1c2d", "number": "REV-000198", "storeName": "Smart Accessoires Es Senia", "status": "FIRST_ORDER", "potential": "MEDIUM", "customerType": "DETAIL", "activity": "ACTIVE", "delegateId": "0192a7d3-1b2c-7d4e-8f5a-6b7c8d9e0f12", "delegateName": "Yacine Belkacem", "lat": 35.6481, "lng": -0.6232 }
  ]
}

Clusters (more than 500 points):

{
  "mode": "CLUSTERS",
  "count": 1284,
  "withoutLocation": 61,
  "clusters": [
    { "lat": 35.6991, "lng": -0.6359, "count": 214, "bbox": [-0.71, 35.64, -0.55, 35.76], "statusCounts": { "ACTIVE": 132, "NEW_PROSPECT": 41, "TO_FOLLOW_UP": 22, "INTERESTED": 19 } },
    { "lat": 36.7538, "lng": 3.0588, "count": 388, "bbox": [2.95, 36.68, 3.21, 36.81], "statusCounts": { "ACTIVE": 251, "ACCOUNT_CREATED": 54, "NEW_PROSPECT": 83 } }
  ]
}

Field notes. A pin carries delegateName, the assigned delegate's name for the pin popup (null when unassigned; added in contract 1.1.0). Cluster lat/lng is the centroid of its points; bbox ([minLng, minLat, maxLng, maxLat]) is what the client zooms to on click. Records without coordinates are counted in withoutLocation and are never placed at a default point.

Errors

StatusCodeWhen
400VALIDATION_FAILEDMissing or malformed bbox, zoom out of range, box outside Algeria
403PERMISSION_DENIEDCaller lacks crm.view

Rules & side effects

  1. Read-only. Clustering: grid of 2^(zoom) × 2 cells over the box (PostGIS ST_SnapToGrid on Web-Mercator), threshold 500 points; the client never receives more than 500 markers.
  2. The map and the list show the same population for the same filters (cahier §6.2 rule applied to the back-office).

Used by BO-10 · BO-06

GET /admin/resellers/{resellerId}/payment-methods

admin.resellers.paymentMethods · Permission orders.create (credit figures: finance.view) · Apps BO · Release V1 · Requirements ORD-11 · FIN-05 · Added in contract 1.1.0 (SRV-41)

Purpose. The payment methods staff may choose when entering an order for this reseller (BO-40), with the same rules as the reseller's own checkout options: methods enabled in payments.enabledMethods, and CREDIT with its state.

Request

Path / queryTypeNotes
resellerIdUuid
totalTtcMoney (optional)The order total being entered: CREDIT is checked against the available credit (default 0)

Response 200 OK

{
  "items": [
    { "method": "CASH_ON_DELIVERY", "enabled": true, "disabledReason": null, "requiresApproval": false, "requiresDeclaration": false, "instructions": null },
    { "method": "CREDIT", "enabled": true, "disabledReason": null, "requiresApproval": true, "requiresDeclaration": false, "instructions": "Montant ajouté à votre encours ; crédit disponible : 12 000,00 DA." }
  ],
  "credit": { "limit": 5000000, "used": 3800000, "available": 1200000, "blocked": false, "overLimitPolicy": "REQUIRE_APPROVAL" }
}

disabledReason: NO_CREDIT_LINE, CREDIT_BLOCKED, CREDIT_LIMIT_EXCEEDED (policy BLOCK); requiresApproval: the order will be PENDING with CREDIT_OVER_LIMIT (policy REQUIRE_APPROVAL). credit is null without a credit line or when the caller lacks finance.view.

Errors

StatusCodeWhen
403PERMISSION_DENIEDCaller lacks orders.create
404NOT_FOUNDUnknown or archived reseller

Used by BO-40

POST /admin/resellers

admin.resellers.create · Permission crm.create · Apps BO · Release V1 · Requirements CRM-02 · CRM-03

Purpose. Creates a reseller or prospect from the back-office, after the duplicate checks of cahier §4.2 (« Le téléphone principal doit être unique »).

Request

interface CreateResellerBody {
  storeName: string;                 // 2–120
  ownerName: string;                 // 2–120
  phone: string;                     // main phone, normalised to E.164, unique
  whatsapp?: string;
  email?: string;
  businessTypeCode: string;          // BUSINESS_TYPE
  potential?: Potential;
  customerType?: CustomerType;       // the back-office sets it directly (no declaration step)
  address: { wilayaCode: string; communeId: Uuid; addressLine: string; landmark?: string; location?: GeoPoint };
  legal?: { rc?: string; nif?: string; nis?: string; ai?: string };
  brandsSold?: Uuid[];
  delegateId?: Uuid;                 // default: the delegate covering the commune's zone, if exactly one
  segmentIds?: Uuid[];               // STATIC segments
  status?: "NEW_PROSPECT" | "TO_CONTACT" | "INTERESTED";   // default NEW_PROSPECT
  note?: string;                     // first timeline note
  ignoreDuplicateHints?: boolean;    // confirm after a DUPLICATE_SUSPECTED answer
}
{
  "storeName": "Galaxy Phone Sidi Chami",
  "ownerName": "Mourad Tahri",
  "phone": "0770 45 12 98",
  "whatsapp": "0770 45 12 98",
  "businessTypeCode": "PHONE_SHOP",
  "potential": "MEDIUM",
  "customerType": "DETAIL",
  "address": { "wilayaCode": "31", "communeId": "0191c0aa-3101-7a00-8c00-000000003119", "addressLine": "Rue principale, à côté de la poste", "location": { "lat": 35.6612, "lng": -0.5611, "accuracy": 8 } },
  "brandsSold": ["01928a01-1a2b-7c3d-8e4f-5a6b7c8d9e01"],
  "note": "Appelé suite à une recommandation de Phone Store El Bahia."
}

Response 201 Created — the Reseller360 of the new record: number REV-000251, phone +213770451298, status NEW_PROSPECT, validationStatus APPROVED, accountStatus null, source BACK_OFFICE, zone Oran Est, delegate Yacine Merabet (zone default), activity NEVER_ORDERED, counters at 0 except interactions: 1, version: 1.

409 DUPLICATE_SUSPECTED example:

{
  "error": {
    "code": "DUPLICATE_SUSPECTED",
    "message": "Un point de vente très proche porte un nom similaire.",
    "details": {
      "hints": [
        { "kind": "NAME_SAME_COMMUNE", "reseller": { "id": "0192f4f4-5a6b-7c7d-8e8f-9a0b1c2d3e4f", "number": "REV-000207", "storeName": "Galaxy Phone", "ownerName": "Mourad Tahri", "phone": "+213661778899", "wilayaCode": "31", "communeName": "Sidi Chami", "status": "NOT_INTERESTED" }, "distanceMeters": 18, "similarityBps": 9100 }
      ]
    },
    "requestId": "req_01J9Z5A1B2"
  }
}

Errors

StatusCodeWhen
400VALIDATION_FAILEDMissing §15.5 field, invalid phone/email, commune not in the wilaya, unknown business type
403PERMISSION_DENIEDCaller lacks crm.create
404NOT_FOUNDUnknown delegate or segment
409PHONE_ALREADY_EXISTSThe main phone is already a reseller's main phone or a user's login — details: { existing: ResellerSummary }
409DUPLICATE_SUSPECTEDA BR-05 hint (same WhatsApp, same name in the commune, another point of sale within 30 m, same RC/NIF) matches another record and ignoreDuplicateHints is not true — details.hints: DuplicateHint[]
422DELEGATE_ZONE_MISMATCHdelegateId does not cover the commune (use admin.resellers.assign with allowOutOfZone)

Rules & side effects

  1. source BACK_OFFICE; ValidationStatus APPROVED (created by an authorised user); REV- number allocated; zone derived from the commune; ResellerAssignment row when a delegate is set.
  2. No login account is created: the owner claims the record by signing up with the same phone (AUTH-05); the claim keeps the back-office customerType.
  3. Audit CREATE; timeline Interaction NOTE for note; ResellerStatusChange — → NEW_PROSPECT.
  4. Notifications: reseller.assigned → the assigned delegate (push, FIELD) — proposed event, not yet in the notifications matrix. SSE: none (back-office creations are not pending accounts).

Used by BO-12 · BO-40 (new customer during a phone order)

GET /admin/resellers/{resellerId}

admin.resellers.get · Permission crm.view · Apps BO · Release V1 · Requirements CRM-12

Purpose. The 360° record: identity, customer type, status, assignment, location, legal identifiers, KPIs and the counters of every tab.

Request

PathTypeNotes
resellerIdUuid

Response 200 OK — Reseller360

{
  "id": "0192f4b8-61c2-7a3e-9b10-1f2e3d4c5b6a",
  "number": "REV-000123",
  "storeName": "Phone Store El Bahia",
  "ownerName": "Karim Benali",
  "phone": "+213555123456",
  "whatsapp": "+213555123456",
  "email": "phonestore.elbahia@gmail.com",
  "businessType": { "code": "PHONE_SHOP", "label": "Boutique de téléphonie" },
  "customerType": "DEMI_GROS",
  "customerTypeDeclared": "DETAIL",
  "customerTypeHistory": [
    { "from": "DETAIL", "to": "DEMI_GROS", "at": "2026-06-16T10:02:00Z", "by": { "id": "01929e55-2c3d-7e4f-9a1b-2c3d4e5f6a7b", "displayName": "Nadia Belkacem" }, "reason": "Volumes mensuels confirmés par le délégué (≈ 300 pièces)." }
  ],
  "potential": "HIGH",
  "status": "ACTIVE",
  "validationStatus": "APPROVED",
  "accountStatus": "ACTIVE",
  "source": "DELEGATE_APP",
  "wilayaCode": "31",
  "wilayaName": "Oran",
  "communeId": "0191c0aa-3101-7a00-8c00-000000003107",
  "communeName": "Bir El Djir",
  "zone": { "id": "01928f20-2a3b-7c4d-8e5f-6a7b8c9d0e1f", "name": "Oran Est" },
  "delegate": { "id": "0192a7d3-1b2c-7d4e-8f5a-6b7c8d9e0f12", "displayName": "Yacine Merabet" },
  "segments": [{ "id": "01929a10-1b2c-7d3e-8f4a-5b6c7d8e9f01", "name": "Grands comptes Ouest" }],
  "hasLocation": true,
  "facadePhotoUrl": "https://files.hoco.dz/s/rev123-facade-thumb.webp?sig=…",
  "activity": "ACTIVE",
  "lastOrderAt": "2026-11-03T08:12:00Z",
  "ordersCount": 14,
  "revenue12mHt": 31820000,
  "lastVisitAt": "2026-10-29T10:41:00Z",
  "balanceDue": 2100000,
  "createdAt": "2026-06-14T09:20:00Z",
  "address": {
    "id": "0192f4b9-0a1b-7c2d-8e3f-4a5b6c7d8e9f", "label": "Magasin", "recipientName": "Karim Benali", "phone": "+213555123456",
    "wilayaCode": "31", "wilayaName": "Oran", "communeId": "0191c0aa-3101-7a00-8c00-000000003107", "communeName": "Bir El Djir",
    "addressLine": "Cité 1 000 logements, local n° 12", "landmark": "En face de la pharmacie El Amel",
    "location": { "lat": 35.7206, "lng": -0.544 }, "isDefault": true
  },
  "addresses": [],
  "location": { "lat": 35.7206, "lng": -0.544, "accuracy": 6, "capturedAt": "2026-06-14T09:18:00Z" },
  "legal": { "rc": "31/00-1234567 A 19", "nif": "001931123456789", "nis": "198731010012345", "ai": "31015123456" },
  "brandsSold": [{ "id": "01928a01-1a2b-7c3d-8e4f-5a6b7c8d9e01", "name": "HOCO" }, { "id": "01928a01-2b3c-7d4e-9f5a-6b7c8d9e0f12", "name": "ACEFAST" }],
  "createdBy": { "id": "0192a7d3-1b2c-7d4e-8f5a-6b7c8d9e0f12", "displayName": "Yacine Merabet" },
  "createdByDelegate": { "id": "0192a7d3-1b2c-7d4e-8f5a-6b7c8d9e0f12", "displayName": "Yacine Merabet" },
  "invitation": { "id": "0192f4b8-9a01-7b02-8c03-0d0e0f101112", "code": "K7QM-3XRP", "delegate": { "id": "0192a7d3-1b2c-7d4e-8f5a-6b7c8d9e0f12", "displayName": "Yacine Merabet" }, "steps": ["SENT", "APP_INSTALLED", "ACCOUNT_CREATED", "PHONE_VERIFIED", "CATALOG_VIEWED", "FIRST_ORDER"] },
  "validation": { "status": "APPROVED", "decidedAt": "2026-06-16T10:02:00Z", "decidedBy": { "id": "01929e55-2c3d-7e4f-9a1b-2c3d4e5f6a7b", "displayName": "Nadia Belkacem" } },
  "credit": { "limit": 5000000, "used": 2100000, "available": 2900000, "blocked": false },
  "kpis": { "firstOrderAt": "2026-06-18T11:40:00Z", "ordersCount12m": 14, "averageBasketHt": 2272857, "revenue12mHt": 31820000 },
  "counters": {
    "orders": { "total": 14, "open": 1 },
    "invoices": { "total": 12, "unpaid": 1, "overdue": 0 },
    "payments": { "total": 11, "pendingVerification": 0 },
    "visits": { "total": 9, "last90Days": 4, "withReport": 9 },
    "tasks": { "open": 1, "overdue": 0 },
    "interactions": 47,
    "documents": 3,
    "photos": 4,
    "tickets": { "open": 0, "afterSalesOpen": 0 },
    "returns": { "open": 0 }
  },
  "allowedActions": ["EDIT", "SET_STATUS", "ASSIGN", "ADD_NOTE", "ADD_DOCUMENT", "CREATE_TASK", "CREATE_ORDER"],
  "version": 12
}

Field notes. balanceDue and credit — only with finance.view; counters.invoices and counters.payments — finance.view; counters.orders and counters.returns — orders.view; counters.visits — field.view; counters.tickets — support.view. Absent keys mean "no permission", not zero.

Errors

StatusCodeWhen
403PERMISSION_DENIEDCaller lacks crm.view
404NOT_FOUNDUnknown reseller

Rules & side effects

  1. Read-only. Tab contents: orders, invoices, payments, visits, tasks, timeline, documents, tickets (support) — each with resellerId.

Used by BO-11 · BO-12 (edit form)

PATCH /admin/resellers/{resellerId}

admin.resellers.update · Permission crm.update · Apps BO · Release V1 · Requirements CRM-01 · AUD-02 · AUTH-11

Purpose. Edits a reseller record — identity, contact, address, GPS, business data, legal identifiers — and is the only way to change the customer type after validation.

Request

interface UpdateResellerBody {
  storeName?: string;
  ownerName?: string;
  phone?: string;                    // main phone: also changes the login phone of the linked account
  whatsapp?: string | null;
  email?: string | null;
  businessTypeCode?: string;
  potential?: Potential | null;
  customerType?: CustomerType;       // requires customerTypeReason when it changes
  customerTypeReason?: string;       // 3–500 chars
  address?: { wilayaCode: string; communeId: Uuid; addressLine: string; landmark?: string | null; location?: GeoPoint | null };
  legal?: { rc?: string | null; nif?: string | null; nis?: string | null; ai?: string | null };
  brandsSold?: Uuid[];
  version: number;
}
{ "customerType": "SUPER_GROS", "customerTypeReason": "Ouverture d'un dépôt de gros, commandes > 1 000 pièces/mois.", "version": 12 }

Response 200 OK — the updated Reseller360 with customerType SUPER_GROS, a new customerTypeHistory entry and version 13.

Errors

StatusCodeWhen
400VALIDATION_FAILEDInvalid phone/email, commune outside the wilaya, empty body
403PERMISSION_DENIEDCaller lacks crm.update
404NOT_FOUNDUnknown reseller or business type
409PHONE_ALREADY_EXISTSNew main phone used by another reseller or user
409VERSION_CONFLICTStale version
422REASON_REQUIREDcustomerType changes without customerTypeReason

Rules & side effects

  1. Changing customerType switches the reseller's price immediately: the server cart is re-priced at its next read (lines show the issue PRICE_CHANGED), submitted orders keep their snapshot prices. The change is recorded in customerTypeHistory, in the audit log (UPDATE, changes: [{ field: "customerType", before, after }], reason) and on the timeline (STATUS_CHANGE-like NOTE entry « Type client : Demi-gros → Super-gros »).
  2. A main-phone change updates the linked user's login phone in the same transaction and revokes its sessions; the reseller signs in again with the new number.
  3. A changed commune re-derives the zone; the assigned delegate is kept (reassign explicitly). A changed location on a field-created record also clears the « sans GPS » flag.
  4. Audit UPDATE with before/after for every changed field.
  5. Notifications: account.customer_type_changed → reseller (push + in-app, « Vos conditions tarifaires ont été mises à jour ») on a customer-type change; none otherwise.

Used by BO-12 · BO-11

POST /admin/resellers/{resellerId}/status

admin.resellers.setStatus · Permission crm.update · Apps BO · Release V1 · Requirements CRM-04

Purpose. Moves a reseller to a manual lifecycle status (qualification statuses or commercial suspension), or restores a suspended reseller, always with a reason.

Request

interface SetResellerStatusBody {
  to?: "TO_CONTACT" | "INTERESTED" | "NOT_INTERESTED" | "TO_FOLLOW_UP" | "SUSPENDED";
  restore?: boolean;                 // SUSPENDED → status before the suspension (exclusive with `to`)
  reason: string;                    // 3–500 chars (NOT_INTERESTED: 3–300)
  nextAction?: { type: TaskType; dueAt: Iso; assigneeId?: Uuid };   // required for TO_FOLLOW_UP
  version: number;
}
{ "to": "SUSPENDED", "reason": "Deux factures impayées de plus de 60 jours — blocage commercial décidé par la finance.", "version": 13 }

Response 200 OK — the updated Reseller360 (status SUSPENDED, allowedActions without CREATE_ORDER).

Errors

StatusCodeWhen
400VALIDATION_FAILEDBoth or neither of to / restore; nextAction missing for TO_FOLLOW_UP
403PERMISSION_DENIEDCaller lacks crm.update
404NOT_FOUNDUnknown reseller
409RESELLER_TRANSITION_NOT_ALLOWEDTarget equals current status; a funnel status (APP_INSTALLED, ACCOUNT_CREATED, FIRST_ORDER, ACTIVE) requested (details.reason: "AUTOMATIC_STATUS"); a qualification target while the reseller is past the funnel's start; restore when not SUSPENDED
409VERSION_CONFLICTStale version
422REASON_REQUIREDEmpty reason

Rules & side effects

  1. Allowed moves are those of manual transitions: qualification statuses among themselves, any status → SUSPENDED, SUSPENDED → previous status (restore), after which automation is re-evaluated (an order delivered meanwhile makes it ACTIVE).
  2. SUSPENDED blocks checkout and assisted orders (403 RESELLER_SUSPENDED); the login keeps working (orders, invoices, payments stay readable); open orders are flagged, not cancelled.
  3. TO_FOLLOW_UP creates the follow-up CrmTask (source: MANUAL); NOT_INTERESTED cancels the record's open tasks.
  4. ResellerStatusChange (trigger: MANUAL) and timeline Interaction STATUS_CHANGE; audit STATUS_CHANGE with the reason.
  5. Notifications: SUSPENDED → account.suspended to the reseller (push + in-app, without the internal reason) and to the assigned delegate; restore notifies nobody (the reseller simply can order again). Qualification moves notify nobody.

Used by BO-11 · BO-09 (row action)

POST /admin/resellers/{resellerId}/assignment

admin.resellers.assign · Permission crm.update · Apps BO · Release V1 · Requirements CRM-05

Purpose. Assigns (or unassigns) the reseller to a delegate and a zone; history is kept.

Request

interface AssignResellerBody {
  delegateId: Uuid | null;           // null = unassigned
  zoneId?: Uuid | null;              // default: the zone containing the reseller's commune
  reason?: string;                   // required when allowOutOfZone is true
  allowOutOfZone?: boolean;          // assign a delegate who does not cover the commune
  version: number;
}
{ "delegateId": "0192a7d3-1b2c-7d4e-8f5a-6b7c8d9e0f12", "zoneId": "01928f20-2a3b-7c4d-8e5f-6a7b8c9d0e1f", "version": 13 }

Response 200 OK

{
  "resellerId": "0192f4b8-61c2-7a3e-9b10-1f2e3d4c5b6a",
  "delegate": { "id": "0192a7d3-1b2c-7d4e-8f5a-6b7c8d9e0f12", "displayName": "Yacine Merabet" },
  "zone": { "id": "01928f20-2a3b-7c4d-8e5f-6a7b8c9d0e1f", "name": "Oran Est" },
  "previous": { "delegate": null, "zone": { "id": "01928f20-2a3b-7c4d-8e5f-6a7b8c9d0e1f", "name": "Oran Est" } },
  "assignedAt": "2026-11-04T08:15:00Z",
  "assignedBy": { "id": "01929e55-2c3d-7e4f-9a1b-2c3d4e5f6a7b", "displayName": "Nadia Belkacem" },
  "version": 14
}

Errors

StatusCodeWhen
403PERMISSION_DENIEDCaller lacks crm.update
404NOT_FOUNDUnknown reseller, delegate or zone
409VERSION_CONFLICTStale version
422DELEGATE_INACTIVEThe delegate's account is not ACTIVE
422DELEGATE_ZONE_MISMATCHThe delegate does not cover the reseller's commune and allowOutOfZone is not true
422REASON_REQUIREDallowOutOfZone without reason

Rules & side effects

  1. One ResellerAssignment row (from/to delegate and zone, actor, reason). The new delegate gains the reseller in their scope at the next sync.delta; the previous one loses it unless the commune is still in their zones. Open tasks assigned to the previous delegate move to the new one, and the reseller's future PLANNED visits in the former delegate's tours are cancelled (BR-48).
  2. Audit UPDATE; with allowOutOfZone an additional OVERRIDE row.
  3. Notifications: reseller.assigned → the new and the former delegate (push, FIELD; proposed event, BR-48 requires both to be told). SSE: none.

Used by BO-11 · BO-12

POST /admin/resellers/bulk

admin.resellers.bulk · Permission crm.update · Apps BO · Release V1 · Requirements CRM-05 · CRM-06

Purpose. Applies one action to many resellers: assign, change status, or add/remove static segments.

Request

interface ResellersBulkBody {
  resellerIds?: Uuid[];              // 1–500 ids, or…
  filter?: Record<string, unknown>;  // …the list query (same keys as GET /admin/resellers); at most 5 000 matches
  action: "ASSIGN" | "SET_STATUS" | "ADD_SEGMENTS" | "REMOVE_SEGMENTS";
  assign?: { delegateId: Uuid | null; zoneId?: Uuid | null; allowOutOfZone?: boolean; reason?: string };
  status?: { to: "TO_CONTACT" | "INTERESTED" | "NOT_INTERESTED" | "TO_FOLLOW_UP" | "SUSPENDED"; reason: string; nextAction?: { type: TaskType; dueAt: Iso } };
  segmentIds?: Uuid[];               // STATIC segments only
}
{
  "filter": { "wilayaCode": ["31"], "communeId": ["0191c0aa-3101-7a00-8c00-000000003119"], "delegateId": ["none"] },
  "action": "ASSIGN",
  "assign": { "delegateId": "0192a7d3-1b2c-7d4e-8f5a-6b7c8d9e0f12" }
}

Response 200 OK

{
  "action": "ASSIGN",
  "matched": 23,
  "updated": 22,
  "skipped": [
    { "resellerId": "0192f4f4-5a6b-7c7d-8e8f-9a0b1c2d3e4f", "number": "REV-000207", "code": "DELEGATE_ZONE_MISMATCH" }
  ]
}

Errors

StatusCodeWhen
400VALIDATION_FAILEDNeither or both of resellerIds / filter, more than 500 ids, payload missing for the action, a DYNAMIC segment in segmentIds
403PERMISSION_DENIEDCaller lacks crm.update
422BULK_TOO_LARGEfilter matches more than 5 000 records — details: { matched, max: 5000 }
422REASON_REQUIREDSET_STATUS without reason

Rules & side effects

  1. Each record is processed with the same guards as the single operation; records failing a guard are skipped and reported (this operation is not all-or-nothing). Records are processed in one transaction per 100.
  2. Audit: one AuditLog row per updated record plus one summary row (UPDATE on Reseller, entityId: null, summary with counts and the filter).
  3. Notifications are grouped: one reseller.assigned per delegate with the count (« 22 points de vente vous ont été attribués »).

Used by BO-09 · BO-15

Validation queue

Accounts and points of sale enter the queue with ValidationStatus PENDING: self sign-ups (with a declared customer type), field prospects created by delegates (cahier §6.5 « Le compte revendeur peut rester en attente de validation ») and claims of records that were not yet approved. The decision is taken by a crm.validate holder (cahier §4.2 « La validation du compte est réalisée par un utilisateur autorisé », CRM-09, FLD-14).

GET /admin/resellers/validation-queue

admin.resellers.validation.list · Permission crm.validate · Apps BO · Release V1 · Requirements CRM-09

Purpose. The records waiting for validation, oldest first, each with its duplicate hints and what is missing.

Request

QueryTypeValues / defaultNotes
kindstring[]SELF_SIGNUP | FIELD_PROSPECT | CLAIMRepeatable
hasHintsbooleanOnly records with at least one duplicate hint
declaredCustomerTypeCustomerType[]repeatable
wilayaCode / delegateIdstring[] / Uuid[]repeatableCreating or assigned delegate
from / tostring (date)On submittedAt
qstringmin 2 charsStore, owner, phone, number
sortstringsubmittedAt (default) | storeName
orderstringasc (default) | desc
page / pageSizenumber1 / 25 (max 200)

Response 200 OK

{
  "items": [
    {
      "reseller": {
        "id": "0192f4c1-0a7d-7b21-8c34-2d3e4f5a6b7c", "number": "REV-000245", "storeName": "Mobile Center Hydra", "ownerName": "Samir Haddad",
        "phone": "+213661234567", "wilayaCode": "16", "communeName": "Hydra", "status": "ACCOUNT_CREATED"
      },
      "kind": "SELF_SIGNUP",
      "declaredCustomerType": "DETAIL",
      "businessType": { "code": "ACCESSORIES_SHOP", "label": "Boutique d'accessoires" },
      "submittedAt": "2026-11-02T18:03:00Z",
      "waitingHours": 38,
      "createdByDelegate": null,
      "location": null,
      "facadePhotoUrl": null,
      "documentsCount": 1,
      "missing": ["location", "facadePhoto"],
      "duplicateHints": [
        {
          "kind": "WHATSAPP",
          "field": "whatsapp",
          "reseller": { "id": "0192f501-6b7c-7d8e-9f0a-1b2c3d4e5f60", "number": "REV-000188", "storeName": "Mobile Center", "ownerName": "Samir Haddad", "phone": "+213770998877", "wilayaCode": "16", "communeName": "Bir Mourad Raïs", "status": "NOT_INTERESTED" }
        }
      ],
      "previousRejection": null
    }
  ],
  "total": 6,
  "page": 1,
  "pageSize": 25,
  "counts": { "SELF_SIGNUP": 4, "FIELD_PROSPECT": 2, "CLAIM": 0, "withHints": 2 }
}

Field notes. missing lists what blocks or weakens an approval: location (blocking for field prospects — BR-76), facadePhoto, customerType (no declared type — must be set at approval), businessType, commune. previousRejection holds { reason, at, by } when the record was rejected before and corrected since.

Errors

StatusCodeWhen
400VALIDATION_FAILEDUnknown kind or sort field
403PERMISSION_DENIEDCaller lacks crm.validate

Rules & side effects

  1. Read-only. New entries arrive live with account.pending. Records waiting longer than crm.validationReminderHours (24 h) trigger one in-app reminder account.pending to the validators.

Used by BO-13 · BO-04

POST /admin/resellers/{resellerId}/validation

admin.resellers.validate · Permission crm.validate · Apps BO · Release V1 · Requirements CRM-09 · AUTH-11

Purpose. Approves or rejects an account or a field point of sale, and confirms or changes its customer type — the type the reseller declared at sign-up is reviewed here.

Request

interface ValidateResellerBody {
  decision: "APPROVE" | "REJECT";
  customerType?: CustomerType;       // APPROVE: required when none was declared; a different value changes the declared one
  customerTypeReason?: string;       // required when customerType differs from the declared type (3–500)
  reason?: string;                   // REJECT: required, 10–500 chars, shown to the reseller
  disableAccount?: boolean;          // REJECT: duplicate or fraud → the login account becomes DISABLED
  duplicateOfId?: Uuid;              // REJECT as duplicate of this record (kept in the history)
  version: number;
}
{
  "decision": "APPROVE",
  "customerType": "DEMI_GROS",
  "customerTypeReason": "Achète par cartons de 50 auprès d'un grossiste d'Alger — volumes de demi-gros.",
  "version": 3
}

Response 200 OK — the Reseller360 with validationStatus APPROVED, accountStatus ACTIVE, customerType DEMI_GROS, customerTypeDeclared DETAIL, a customerTypeHistory entry, validation.decidedAt / decidedBy, and version 4.

Errors

StatusCodeWhen
400VALIDATION_FAILEDreason shorter than 10 characters on a rejection, customerType on a rejection
403PERMISSION_DENIEDCaller lacks crm.validate
404NOT_FOUNDUnknown reseller or duplicateOfId
409VALIDATION_NOT_PENDINGValidationStatus is not PENDING — details: { validationStatus } (an approved record is never re-validated; a rejected one comes back as PENDING when corrected)
409VERSION_CONFLICTStale version
422REASON_REQUIREDRejection without reason, or type change without customerTypeReason
422CUSTOMER_TYPE_REQUIREDApproval of a record without declared type and without customerType
422LOCATION_REQUIREDApproval of a field-created point of sale without GPS position
422RESELLER_INCOMPLETEMinimum data of cahier §15.5 missing — details: { missing: [] }

Rules & side effects

  1. PENDING → APPROVED: AccountStatus PENDING_VALIDATION → ACTIVE when a login exists (checkout unlocked); the customer type becomes final (customerTypeHistory, audit changes entry); orders PENDING for RESELLER_NOT_VALIDATED are re-checked and move to SUBMITTED when nothing else blocks — they still wait for confirmation.
  2. PENDING → REJECTED: the reason is shown on the reseller's waiting screen (RA-06); the commercial status is unchanged; disableAccount sets AccountStatus DISABLED and revokes sessions.
  3. Audit VALIDATE or REJECT with the decision, the customer type before/after and the reason.
  4. Notifications: approve → account.approved to the reseller (SMS + push, « Votre compte est validé », with the confirmed customer type — BR-77) and to the assigned or creating delegate (template account.approved.delegate); reject → account.rejected (push + in-app with the reason) to the reseller and the delegate (account.rejected.delegate). SSE: none (the queue removes the row from the response).

Used by BO-13 · BO-11

Timeline, notes and documents

GET /admin/resellers/{resellerId}/timeline

admin.resellers.timeline · Permission crm.view · Apps BO · Release V1 · Requirements CRM-07

Purpose. The unified interaction history of cahier §4.2 (« Historique des appels, visites, notes, commandes, paiements et réclamations »), newest first.

Request

QueryTypeValues / defaultNotes
typeInteractionType[]repeatable
actorIdUuid
from / tostring (date)
page / pageSizenumber1 / 50 (max 200)Always sorted by at desc

Response 200 OK — Page<Interaction>

{
  "items": [
    {
      "id": "01930171-3c4d-7e5f-8a6b-7c8d9e0f1a2b", "type": "ORDER", "at": "2026-11-03T09:52:30Z",
      "actor": { "id": "01929e66-3d4e-7f5a-8b6c-7d8e9f0a1b2c", "displayName": "Sofiane Hamdi" }, "app": "DASHBOARD",
      "summary": "Commande CMD-2026-000158 validée — 30 749,60 DA",
      "ref": { "entityType": "Order", "id": "0193016e-4b2a-7c11-9d8e-7f6a5b4c3d2e", "number": "CMD-2026-000158" },
      "meta": { "status": "VALIDATED", "totalTtc": 3074960 }
    },
    {
      "id": "01930a10-6f70-7a81-9b92-a3b4c5d6e7f8", "type": "VISIT", "at": "2026-10-29T10:41:00Z",
      "actor": { "id": "0192a7d3-1b2c-7d4e-8f5a-6b7c8d9e0f12", "displayName": "Yacine Merabet" }, "app": "DELEGATE_APP",
      "summary": "Visite réalisée (32 min) — intérêt élevé, relance téléphonique le 5 nov.",
      "ref": { "entityType": "Visit", "id": "01930a10-5e6f-7a8b-9c0d-1e2f3a4b5c6e", "number": "VIS-2026-001234" },
      "meta": { "durationMinutes": 32, "interestLevel": "HIGH", "nextAction": "PHONE_FOLLOW_UP" }
    }
  ],
  "total": 47,
  "page": 1,
  "pageSize": 50
}

Errors

StatusCodeWhen
400VALIDATION_FAILEDUnknown type
403PERMISSION_DENIEDCaller lacks crm.view
404NOT_FOUNDUnknown reseller

Rules & side effects

  1. Read-only. Entries are written by the modules as side effects (orders, payments, visits, tickets and returns, status changes, tasks completed) and by admin.resellers.notes.add. PAYMENT entries are listed only for callers with finance.view (amounts masked otherwise); CLAIM entries need support.view to open the ticket.

Used by BO-11

POST /admin/resellers/{resellerId}/notes

admin.resellers.notes.add · Permission crm.update · Apps BO · Release V1 · Requirements CRM-07

Purpose. Adds a note or logs a call or message to the timeline.

Request

interface AddResellerNoteBody {
  id?: Uuid;                          // client-generated, idempotent upsert
  type: "NOTE" | "CALL" | "WHATSAPP" | "SMS" | "EMAIL";
  body: string;                       // 1–2 000 chars
  occurredAt?: Iso;                   // default now; not in the future
  call?: { direction: "INBOUND" | "OUTBOUND"; durationSeconds?: number; reached: boolean };
}
{ "type": "CALL", "body": "Relance faite : M. Benali commandera après réception du réassort ACEFAST.", "call": { "direction": "OUTBOUND", "durationSeconds": 240, "reached": true } }

Response 201 Created — the Interaction (type CALL, summary « Appel sortant (4 min) — joint »).

Errors

StatusCodeWhen
400VALIDATION_FAILEDEmpty or too long body, call on a non-CALL entry, future occurredAt
403PERMISSION_DENIEDCaller lacks crm.update
404NOT_FOUNDUnknown reseller

Rules & side effects

  1. Notes are immutable (no edit or delete in V1); a correction is a new note. Audit CREATE on Interaction. No notification.

Used by BO-11 · BO-14

GET /admin/resellers/{resellerId}/documents

admin.resellers.documents · Permission crm.view · Apps BO · Release V1 · Requirements CRM-10

Purpose. The store documents (RC, NIF, NIS, AI, ID card, contract) and photos (façade, interior, shelf) of a reseller, with protected access.

Response 200 OK

{
  "documents": [
    {
      "id": "0192f4b8-a1b2-7c3d-8e4f-5a6b7c8d9e0f", "documentType": "COMMERCE_REGISTER", "number": "31/00-1234567 A 19",
      "file": { "id": "0192f4b8-b2c3-7d4e-9f5a-6b7c8d9e0f1a", "url": "https://files.hoco.dz/s/rev123-rc.pdf?sig=…", "mimeType": "application/pdf", "size": 412880, "purpose": "STORE_DOCUMENT" },
      "uploadedBy": { "id": "0192f4b8-7001-7a02-8b03-0c0d0e0f1011", "displayName": "Karim Benali" }, "source": "RESELLER_APP", "uploadedAt": "2026-06-15T08:12:00Z"
    }
  ],
  "photos": [
    {
      "id": "0192f4b8-c3d4-7e5f-8a6b-7c8d9e0f1a2b", "photoKind": "FACADE",
      "file": { "id": "0192f4b8-d4e5-7f6a-9b7c-8d9e0f1a2b3c", "url": "https://files.hoco.dz/s/rev123-facade.webp?sig=…", "thumbnailUrl": "https://files.hoco.dz/s/rev123-facade-thumb.webp?sig=…", "mimeType": "image/webp", "size": 248112, "purpose": "STORE_PHOTO" },
      "capturedAt": "2026-06-14T09:17:00Z", "position": { "lat": 35.7206, "lng": -0.544, "accuracy": 6 },
      "uploadedBy": { "id": "0192a7d3-1b2c-7d4e-8f5a-6b7c8d9e0f12", "displayName": "Yacine Merabet" }, "source": "DELEGATE_APP"
    }
  ]
}

Errors

StatusCodeWhen
403PERMISSION_DENIEDCaller lacks crm.view
404NOT_FOUNDUnknown reseller

Rules & side effects

  1. Read-only; URLs are signed for 5 minutes and generated per request (files are private — SEC-04). Visit photos are not here; they belong to visits (admin.visits.get).

Used by BO-11 · BO-13

POST /admin/resellers/{resellerId}/documents

admin.resellers.documents.add · Permission crm.update · Apps BO · Release V1 · Requirements CRM-10

Purpose. Attaches an uploaded document or photo to a reseller.

Request

interface AddResellerDocumentBody {
  kind: "DOCUMENT" | "PHOTO";
  fileId: Uuid;                      // completed upload, purpose STORE_DOCUMENT or STORE_PHOTO
  documentType?: DocumentType;       // DOCUMENT
  number?: string;                   // RC / NIF / NIS / AI number, copied to legal identifiers when empty
  photoKind?: PhotoKind;             // PHOTO
  replaceId?: Uuid;                  // replaces an existing document of the same type (kept in history)
}
{ "kind": "DOCUMENT", "fileId": "01930b20-1a2b-7c3d-8e4f-5a6b7c8d9e0f", "documentType": "TAX_ID", "number": "001931123456789" }

Response 201 Created — the document or photo entry as in admin.resellers.documents.

Errors

StatusCodeWhen
400VALIDATION_FAILEDdocumentType missing for a document, photoKind missing for a photo
403PERMISSION_DENIEDCaller lacks crm.update
404NOT_FOUNDUnknown reseller, file or replaceId
422FILE_NOT_READYUpload not completed
422FILE_PURPOSE_MISMATCHPurpose is not STORE_DOCUMENT / STORE_PHOTO — details: { expected, actual }

Rules & side effects

  1. Files stay private; the file is bound to the reseller (access checks follow the reseller). Audit CREATE on ResellerDocument / ResellerPhoto; timeline entry NOTE « Document ajouté : NIF ». No notification.

Used by BO-11 · BO-12

GET /admin/resellers/check-phone

admin.resellers.checkPhone · Permission crm.view · Apps BO · Release V1 · Requirements CRM-03

Purpose. Duplicate check as the user types a phone number in the creation form or the phone-order screen.

Request

QueryTypeValues / defaultNotes
phonestringrequiredAny common format (0555 12 34 56, 00213…, +213…)
excludeIdUuidThe record being edited

Response 200 OK

{
  "normalized": "+213555123456",
  "valid": true,
  "available": false,
  "match": {
    "kind": "RESELLER_PHONE",
    "reseller": { "id": "0192f4b8-61c2-7a3e-9b10-1f2e3d4c5b6a", "number": "REV-000123", "storeName": "Phone Store El Bahia", "ownerName": "Karim Benali", "phone": "+213555123456", "wilayaCode": "31", "communeName": "Bir El Djir", "status": "ACTIVE" }
  },
  "hints": []
}

Field notes. match.kind: RESELLER_PHONE (another reseller's main phone — blocking), USER_LOGIN (a staff or delegate login — blocking, no details returned beyond the user type). hints lists non-blocking WHATSAPP matches (DuplicateHint).

Errors

StatusCodeWhen
400VALIDATION_FAILEDMissing phone
403PERMISSION_DENIEDCaller lacks crm.view

Rules & side effects

  1. Read-only; an invalid number answers 200 with valid: false so the form can show the error inline. Rate-limited to 60 calls/minute per user.

Used by BO-12 · BO-40

Tasks

GET /admin/tasks

admin.tasks.list · Permission crm.view · Apps BO · Release V1 · Requirements CRM-08

Purpose. Follow-ups (relances) and tasks across the team — the BO-14 board and the tasks tab of the 360° record.

Request

QueryTypeValues / defaultNotes
statusTaskStatus[]repeatable; default OPEN
typeTaskType[]repeatable
assigneeIdUuid[]repeatable; me allowed
resellerIdUuid
sourcestring[]MANUAL | VISIT_REPORT | INACTIVITY | SYSTEM
overdueboolean
dueFrom / dueTostring (date)
qstringmin 2 charsTitle, store name, reseller number
sortstringdueAt (default), createdAt, storeName
orderstringasc (default) | desc
page / pageSizenumber1 / 25 (max 200)

Response 200 OK — Page<CrmTask>

{
  "items": [
    {
      "id": "01930b11-2c3d-7e4f-9a5b-6c7d8e9f0a1b",
      "type": "CALL",
      "status": "OPEN",
      "title": "Relance téléphonique après visite",
      "description": "Intéressé par la gamme ACEFAST GaN — rappeler pour la commande.",
      "reseller": { "id": "0192f4b8-61c2-7a3e-9b10-1f2e3d4c5b6a", "number": "REV-000123", "storeName": "Phone Store El Bahia", "ownerName": "Karim Benali", "phone": "+213555123456", "wilayaCode": "31", "communeName": "Bir El Djir", "status": "ACTIVE" },
      "assignee": { "id": "0192a7d3-1b2c-7d4e-8f5a-6b7c8d9e0f12", "displayName": "Yacine Merabet" },
      "createdBy": { "id": "0192a7d3-1b2c-7d4e-8f5a-6b7c8d9e0f12", "displayName": "Yacine Merabet" },
      "source": "VISIT_REPORT",
      "visitId": "01930a10-5e6f-7a8b-9c0d-1e2f3a4b5c6e",
      "dueAt": "2026-11-05T08:00:00Z",
      "overdue": false,
      "createdAt": "2026-10-29T11:15:00Z",
      "version": 1
    }
  ],
  "total": 38,
  "page": 1,
  "pageSize": 25
}

Errors

StatusCodeWhen
400VALIDATION_FAILEDUnknown enum value
403PERMISSION_DENIEDCaller lacks crm.view

Rules & side effects

  1. Read-only; overdue is derived (OPEN and dueAt passed).

Used by BO-14 · BO-11

POST /admin/tasks

admin.tasks.create · Permission crm.update · Apps BO · Release V1 · Requirements CRM-08

Purpose. Creates a follow-up or task about a reseller for a user (delegate or staff).

Request

interface CreateTaskBody {
  resellerId: Uuid;
  type: TaskType;
  title?: string;                     // default: the French label of the type
  description?: string;               // ≤ 2 000
  assigneeId?: Uuid;                  // default: the reseller's delegate, else the caller
  dueAt: Iso;                         // not in the past
}
{ "resellerId": "0192f4c1-0a7d-7b21-8c34-2d3e4f5a6b7c", "type": "VISIT", "description": "Passer vérifier l'adresse et photographier la façade avant validation.", "assigneeId": "0192a7d3-1b2c-7d4e-8f5a-6b7c8d9e0f12", "dueAt": "2026-11-06T08:00:00Z" }

Response 201 Created — the CrmTask (status OPEN, source MANUAL, version 1).

Errors

StatusCodeWhen
400VALIDATION_FAILEDdueAt in the past, unknown type
403PERMISSION_DENIEDCaller lacks crm.update
404NOT_FOUNDUnknown reseller or assignee
422DELEGATE_INACTIVEAssignee account not ACTIVE

Rules & side effects

  1. A CALL or VISIT task on a NEW_PROSPECT moves it to TO_CONTACT (system rule, lifecycles). A delegate assignee must have the reseller in scope; otherwise the reseller is added to their scope for this task.
  2. Audit CREATE on CrmTask.
  3. Notifications: task.assigned → assignee (push for delegates, in-app for staff); task.due reminder at 08:00 Africa/Algiers on the due date.

Used by BO-14 · BO-11

PATCH /admin/tasks/{taskId}

admin.tasks.update · Permission crm.update · Apps BO · Release V1 · Requirements CRM-08

Purpose. Reassigns, reschedules, completes, cancels or reopens a task.

Request

interface UpdateTaskBody {
  assigneeId?: Uuid;
  dueAt?: Iso;
  title?: string;
  description?: string | null;
  status?: "DONE" | "CANCELLED" | "OPEN";   // OPEN = reopen a DONE task
  outcome?: string;                          // required with DONE (3–500)
  reason?: string;                           // required to reopen
  version: number;
}
{ "status": "DONE", "outcome": "Client joint : commande de 30 câbles passée par téléphone (CMD-2026-000163).", "version": 1 }

Response 200 OK — the updated CrmTask (status DONE, completedAt, outcome, version 2).

Errors

StatusCodeWhen
400VALIDATION_FAILEDEmpty body, outcome missing with DONE
403PERMISSION_DENIEDCaller lacks crm.update
404NOT_FOUNDUnknown task or assignee
409TASK_TRANSITION_NOT_ALLOWEDCANCELLED task changed, OPEN requested on an open task — details: { from, to, allowed }
409VERSION_CONFLICTStale version
422REASON_REQUIREDReopen without reason

Rules & side effects

  1. Transitions of Lifecycles › Task: OPEN → DONE (outcome), OPEN → CANCELLED, DONE → OPEN (reason). Rescheduling and reassigning keep the status.
  2. DONE adds a timeline Interaction (type from the task: CALL, VISIT, WHATSAPP…, summary with the outcome).
  3. Audit UPDATE or STATUS_CHANGE.
  4. Notifications: reassignment → task.assigned to the new assignee.

Used by BO-14 · BO-11

Segments

Segments group resellers for targeting (promotions, banners, announcements, catalog visibility) and analysis (cahier §4.2 « Segmentation par potentiel, type de commerce, marque vendue, niveau et activité », CRM-06). A STATIC segment has members added by hand (bulk ADD_SEGMENTS); a DYNAMIC segment is defined by criteria and recomputed nightly and on every reseller change.

interface Segment {
  id: Uuid;
  name: string;                       // unique, 2–80
  description?: string;
  type: "STATIC" | "DYNAMIC";         // SegmentType
  criteria?: {                        // DYNAMIC only; criteria are AND-ed, values inside a criterion OR-ed
    potential?: Potential[];
    businessTypeCodes?: string[];
    customerTypes?: CustomerType[];
    brandsSold?: Uuid[];              // declared brands sold
    brandsBought?: { brandIds: Uuid[]; withinDays: number };   // ordered these brands in the last N days
    activity?: ResellerActivity[];
    wilayaCodes?: string[];
    zoneIds?: Uuid[];
    loyaltyLevels?: LoyaltyLevel[];   // V2
  };
  color?: string;                     // #RRGGBB, for chips
  resellerCount: number;
  usedBy: { promotions: number; banners: number; announcements: number; visibilityRules: number };
  recomputedAt?: Iso;
  createdAt: Iso;
  updatedAt: Iso;
  version: number;
}

GET /admin/segments

admin.segments.crud (list) · Permission crm.view · Apps BO · Release V1 · Requirements CRM-06

Purpose. Lists segments for the segments screen and for every segment picker (CRM filters, promotion and banner targeting, visibility rules).

Request

QueryTypeValues / defaultNotes
typeSegmentTypeSTATIC | DYNAMIC
qstringmin 2 charsName
sort / order / page / pageSizename (default) · asc · 1 · 25 (max 200)

Response 200 OK

{
  "items": [
    {
      "id": "01929a10-1b2c-7d3e-8f4a-5b6c7d8e9f01", "name": "Grands comptes Ouest", "description": "Demi-gros et super-gros actifs des wilayas de l'Ouest.",
      "type": "DYNAMIC",
      "criteria": { "customerTypes": ["SUPER_GROS", "DEMI_GROS"], "activity": ["ACTIVE"], "wilayaCodes": ["31", "46", "13", "29"] },
      "color": "#1F6FEB", "resellerCount": 64,
      "usedBy": { "promotions": 1, "banners": 1, "announcements": 0, "visibilityRules": 0 },
      "recomputedAt": "2026-11-04T01:30:00Z", "createdAt": "2026-10-21T10:00:00Z", "updatedAt": "2026-10-21T10:00:00Z", "version": 1
    }
  ],
  "total": 7,
  "page": 1,
  "pageSize": 25
}

Errors

StatusCodeWhen
403PERMISSION_DENIEDCaller lacks crm.view

Rules & side effects

  1. Read-only. Reading is relaxed to crm.view (the bundle's crm.administer applies to writes) because segments appear in filters and pickers — Assumption.

Used by BO-15 · BO-09 · BO-50 · BO-51 · BO-57

GET /admin/segments/{segmentId}

admin.segments.crud (get) · Permission crm.view · Apps BO · Release V1 · Requirements CRM-06

Purpose. One segment; its members are read with admin.resellers.list ?segmentId=.

Response 200 OK — a Segment.

Errors

StatusCodeWhen
403PERMISSION_DENIEDCaller lacks crm.view
404NOT_FOUNDUnknown segment

Rules & side effects

  1. Read-only.

Used by BO-15

POST /admin/segments

admin.segments.crud (create) · Permission crm.administer · Apps BO · Release V1 · Requirements CRM-06

Purpose. Creates a static or dynamic segment.

Request

interface CreateSegmentBody { name: string; description?: string; type: "STATIC" | "DYNAMIC"; criteria?: Segment["criteria"]; color?: string }
{ "name": "Détaillants inactifs Oran", "type": "DYNAMIC", "criteria": { "customerTypes": ["DETAIL"], "activity": ["INACTIVE"], "wilayaCodes": ["31"] }, "color": "#D97706" }

Response 201 Created — the Segment with resellerCount computed synchronously (≤ 2 s) and version: 1.

Errors

StatusCodeWhen
400VALIDATION_FAILEDcriteria on a STATIC segment, empty criteria on a DYNAMIC one, unknown codes, bad colour
403PERMISSION_DENIEDCaller lacks crm.administer
409DUPLICATE_VALUEName already used

Rules & side effects

  1. Dynamic membership (ResellerSegment rows) is materialised at once and refreshed nightly (01:30) and on reseller, order and status events.
  2. Audit CREATE on Segment.

Used by BO-15

PATCH /admin/segments/{segmentId}

admin.segments.crud (update) · Permission crm.administer · Apps BO · Release V1 · Requirements CRM-06

Purpose. Renames a segment or changes its criteria.

Request

interface UpdateSegmentBody { name?: string; description?: string | null; criteria?: Segment["criteria"]; color?: string | null; version: number }
{ "criteria": { "customerTypes": ["DETAIL"], "activity": ["INACTIVE", "NEVER_ORDERED"], "wilayaCodes": ["31"] }, "version": 1 }

Response 200 OK — the updated Segment with the recomputed resellerCount.

Errors

StatusCodeWhen
400VALIDATION_FAILEDAs for creation; type cannot change
403PERMISSION_DENIEDCaller lacks crm.administer
404NOT_FOUNDUnknown segment
409DUPLICATE_VALUE · VERSION_CONFLICTName taken · stale version

Rules & side effects

  1. Changing criteria changes who sees targeted promotions, banners and restricted products from the next request; the response's warnings lists the active promotions and visibility rules that use the segment.
  2. Audit UPDATE with criteria before/after.

Used by BO-15

DELETE /admin/segments/{segmentId}

admin.segments.crud (delete) · Permission crm.administer · Apps BO · Release V1 · Requirements CRM-06

Purpose. Deletes a segment that nothing uses.

Response 204 No Content

Errors

StatusCodeWhen
403PERMISSION_DENIEDCaller lacks crm.administer
404NOT_FOUNDUnknown segment
409RESOURCE_IN_USEReferenced by a non-archived promotion, an active banner, a scheduled announcement or a visibility rule — details: { references: [{ entityType: "Promotion", count: 1 }] }

Rules & side effects

  1. Memberships are removed with the segment; past promotions keep a copy of their audience. Audit DELETE.

Used by BO-15

Export

POST /admin/resellers/export

admin.resellers.export · Permission crm.export · Apps BO · Release V1 · Requirements ANA-05

Purpose. Exports the filtered CRM list to Excel, CSV or PDF as an asynchronous job (cahier §9 « PDF et Excel/CSV pour les listes et rapports autorisés »).

Request

interface ExportResellersBody {
  format: "XLSX" | "CSV" | "PDF";
  filters: Record<string, unknown>;   // the list query (same keys as GET /admin/resellers)
  columns?: string[];                 // default: the visible columns of BO-09
  sort?: string;
  order?: "asc" | "desc";
}
{ "format": "XLSX", "filters": { "wilayaCode": ["31"], "activity": "INACTIVE" }, "columns": ["number", "storeName", "ownerName", "phone", "communeName", "customerType", "delegate", "lastOrderAt", "revenue12mHt"] }

Response 202 Accepted — an ExportJob

{
  "id": "01930b40-5e6f-7a8b-9c0d-1e2f3a4b5c6d",
  "source": { "kind": "LIST", "key": "resellers" },
  "format": "XLSX",
  "status": "QUEUED",
  "filters": { "wilayaCode": ["31"], "activity": "INACTIVE" },
  "columns": ["number", "storeName", "ownerName", "phone", "communeName", "customerType", "delegate", "lastOrderAt", "revenue12mHt"],
  "requestedBy": { "id": "01929e55-2c3d-7e4f-9a1b-2c3d4e5f6a7b", "displayName": "Nadia Belkacem" },
  "createdAt": "2026-11-04T09:12:00Z"
}

Errors

StatusCodeWhen
400VALIDATION_FAILEDUnknown column or filter
403PERMISSION_DENIEDCaller lacks crm.export; a finance column (balanceDue) without finance.view
422EXPORT_TOO_LARGEMore than 100 000 rows (2 000 for PDF) — details: { rowCount, maxRows, format }
429RATE_LIMITEDAlready 3 running exports

Rules & side effects

  1. The export contains exactly what the requester may see; phone numbers are included (personal data — the export is journaled).
  2. Audit EXPORT with format, filters and, on completion, rowCount.
  3. Notifications: export.ready → requester (in-app + email). SSE: export.ready.

Used by BO-09 · BO-60

On this page