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.
| Permission | Unlocks on this page |
|---|---|
crm.view | List, map, 360° record, timeline, documents, phone check, tasks list, segments list and detail |
crm.create | Creating a reseller or prospect |
crm.update | Editing (including customer type), status, assignment, bulk actions, notes, documents, tasks |
crm.validate | Validation queue and approve / reject |
crm.administer | Creating, editing and deleting segments |
crm.export | Exporting the list |
finance.view | Finance 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.
| Method | Path | Operation | Access | Apps | Release |
|---|---|---|---|---|---|
| GET | /admin/resellers | admin.resellers.listResellers and prospects with filters, sort and pagination | crm.view | BO | V1 |
| GET | /admin/resellers/map | admin.resellers.mapMap pins within a bounding box (clustered server-side above a threshold) | crm.view | BO | V1 |
| GET | /admin/resellers/{resellerId}/payment-methods | admin.resellers.paymentMethodsPayment methods the reseller may use for an order (back-office order creation) | orders.create | BO | V1 |
| POST | /admin/resellers | admin.resellers.createCreate a reseller or prospect | crm.create | BO | V1 |
| GET | /admin/resellers/{resellerId} | admin.resellers.getReseller 360° header and tab counters | crm.view | BO | V1 |
| PATCH | /admin/resellers/{resellerId} | admin.resellers.updateEdit a reseller | crm.update | BO | V1 |
| POST | /admin/resellers/{resellerId}/status | admin.resellers.setStatusChange lifecycle status with a reason | crm.update | BO | V1 |
| POST | /admin/resellers/{resellerId}/assignment | admin.resellers.assignAssign delegate and zone (bulk variant via /admin/resellers/bulk) | crm.update | BO | V1 |
| POST | /admin/resellers/bulk | admin.resellers.bulkBulk assign, change status or tag | crm.update | BO | V1 |
| GET | /admin/resellers/validation-queue | admin.resellers.validation.listAccounts awaiting validation with duplicate hints | crm.validate | BO | V1 |
| POST | /admin/resellers/{resellerId}/validation | admin.resellers.validateApprove or reject an account and confirm or change its customer type (reason required on rejection) | crm.validate | BO | V1 |
| GET | /admin/resellers/{resellerId}/timeline | admin.resellers.timelineUnified interaction timeline | crm.view | BO | V1 |
| POST | /admin/resellers/{resellerId}/notes | admin.resellers.notes.addAdd a note or call log | crm.update | BO | V1 |
| GET | /admin/resellers/{resellerId}/documents | admin.resellers.documentsDocuments and photos | crm.view | BO | V1 |
| POST | /admin/resellers/{resellerId}/documents | admin.resellers.documents.addAttach a document or photo | crm.update | BO | V1 |
| GET | /admin/resellers/check-phone | admin.resellers.checkPhoneDuplicate check by normalised phone | crm.view | BO | V1 |
| GET | /admin/tasks | admin.tasks.listFollow-ups and tasks across the team | crm.view | BO | V1 |
| POST | /admin/tasks | admin.tasks.createCreate a task for a user about a reseller | crm.update | BO | V1 |
| PATCH | /admin/tasks/{taskId} | admin.tasks.updateReassign, reschedule, complete or cancel | crm.update | BO | V1 |
| GET · POST · PATCH · DELETE | /admin/segments[/{segmentId}] | admin.segments.crudSegments CRUD | crm.administer | BO | V1 |
| POST | /admin/resellers/export | admin.resellers.exportExport the filtered list (async job) | crm.export | BO | V1 |
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
| Query | Type | Values / default | Notes |
|---|---|---|---|
ids | Uuid[] | repeatable | Only 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 |
status | ResellerStatus[] | repeatable | |
validationStatus | ValidationStatus | PENDING | APPROVED | REJECTED | |
customerType | CustomerType[] | repeatable; NONE for records without a type | |
zoneId | Uuid | ||
wilayaCode | string[] | repeatable | Store address |
communeId | Uuid[] | repeatable | |
delegateId | Uuid[] | repeatable; none = unassigned | Assigned delegate |
segmentId | Uuid[] | repeatable | Member of any of them |
potential | Potential[] | repeatable | |
businessTypeCode | string[] | repeatable | BUSINESS_TYPE codes |
activity | ResellerActivity | ACTIVE | INACTIVE | NEVER_ORDERED | |
source | ResellerSource[] | repeatable | |
hasLocation | boolean | ||
hasOpenTask / overdueTask | boolean | ||
createdFrom / createdTo | string (date) | On createdAt | |
lastOrderFrom / lastOrderTo | string (date) | ||
q | string | min 2 chars | Number (REV-000123 or 123), store name, owner name, phone, WhatsApp, email |
sort | string | createdAt (default), storeName, number, lastOrderAt, revenue12mHt, lastVisitAt, status | |
order | string | asc | desc (default desc) | |
page / pageSize | number | 1 / 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
| Status | Code | When |
|---|---|---|
| 400 | VALIDATION_FAILED | Unknown enum value or sort field, q shorter than 2 characters, pageSize over 200 |
| 403 | PERMISSION_DENIED | Caller lacks crm.view |
Rules & side effects
- Read-only.
activity,ordersCount,revenue12mHtandlastOrderAtcome from a per-reseller aggregate refreshed by order events, so sorting on them is index-backed. - 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
| Query | Type | Values / default | Notes |
|---|---|---|---|
bbox | string | minLng,minLat,maxLng,maxLat (required) | WGS84; the box must be inside Algeria's bounding box (−8.7 … 12.0, 18.9 … 37.2) |
zoom | number | 4–20 (required) | Map zoom level; drives the cluster grid |
| all filters of the list | Same 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
| Status | Code | When |
|---|---|---|
| 400 | VALIDATION_FAILED | Missing or malformed bbox, zoom out of range, box outside Algeria |
| 403 | PERMISSION_DENIED | Caller lacks crm.view |
Rules & side effects
- Read-only. Clustering: grid of
2^(zoom) × 2cells over the box (PostGISST_SnapToGridon Web-Mercator), threshold 500 points; the client never receives more than 500 markers. - The map and the list show the same population for the same filters (cahier §6.2 rule applied to the back-office).
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 / query | Type | Notes |
|---|---|---|
resellerId | Uuid | |
totalTtc | Money (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
| Status | Code | When |
|---|---|---|
| 403 | PERMISSION_DENIED | Caller lacks orders.create |
| 404 | NOT_FOUND | Unknown 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
| Status | Code | When |
|---|---|---|
| 400 | VALIDATION_FAILED | Missing §15.5 field, invalid phone/email, commune not in the wilaya, unknown business type |
| 403 | PERMISSION_DENIED | Caller lacks crm.create |
| 404 | NOT_FOUND | Unknown delegate or segment |
| 409 | PHONE_ALREADY_EXISTS | The main phone is already a reseller's main phone or a user's login — details: { existing: ResellerSummary } |
| 409 | DUPLICATE_SUSPECTED | A 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[] |
| 422 | DELEGATE_ZONE_MISMATCH | delegateId does not cover the commune (use admin.resellers.assign with allowOutOfZone) |
Rules & side effects
sourceBACK_OFFICE; ValidationStatusAPPROVED(created by an authorised user);REV-number allocated; zone derived from the commune;ResellerAssignmentrow when a delegate is set.- 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. - Audit
CREATE; timelineInteractionNOTEfornote;ResellerStatusChange— → NEW_PROSPECT. - 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
| Path | Type | Notes |
|---|---|---|
resellerId | Uuid |
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
| Status | Code | When |
|---|---|---|
| 403 | PERMISSION_DENIED | Caller lacks crm.view |
| 404 | NOT_FOUND | Unknown reseller |
Rules & side effects
- 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
| Status | Code | When |
|---|---|---|
| 400 | VALIDATION_FAILED | Invalid phone/email, commune outside the wilaya, empty body |
| 403 | PERMISSION_DENIED | Caller lacks crm.update |
| 404 | NOT_FOUND | Unknown reseller or business type |
| 409 | PHONE_ALREADY_EXISTS | New main phone used by another reseller or user |
| 409 | VERSION_CONFLICT | Stale version |
| 422 | REASON_REQUIRED | customerType changes without customerTypeReason |
Rules & side effects
- Changing
customerTypeswitches the reseller's price immediately: the server cart is re-priced at its next read (lines show the issuePRICE_CHANGED), submitted orders keep their snapshot prices. The change is recorded incustomerTypeHistory, in the audit log (UPDATE,changes: [{ field: "customerType", before, after }],reason) and on the timeline (STATUS_CHANGE-likeNOTEentry « Type client : Demi-gros → Super-gros »). - 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.
- A changed commune re-derives the zone; the assigned delegate is kept (reassign explicitly). A changed
locationon a field-created record also clears the « sans GPS » flag. - Audit
UPDATEwith before/after for every changed field. - Notifications:
account.customer_type_changed→ reseller (push + in-app, « Vos conditions tarifaires ont été mises à jour ») on a customer-type change; none otherwise.
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
| Status | Code | When |
|---|---|---|
| 400 | VALIDATION_FAILED | Both or neither of to / restore; nextAction missing for TO_FOLLOW_UP |
| 403 | PERMISSION_DENIED | Caller lacks crm.update |
| 404 | NOT_FOUND | Unknown reseller |
| 409 | RESELLER_TRANSITION_NOT_ALLOWED | Target 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 |
| 409 | VERSION_CONFLICT | Stale version |
| 422 | REASON_REQUIRED | Empty reason |
Rules & side effects
- 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 itACTIVE). SUSPENDEDblocks checkout and assisted orders (403 RESELLER_SUSPENDED); the login keeps working (orders, invoices, payments stay readable); open orders are flagged, not cancelled.TO_FOLLOW_UPcreates the follow-upCrmTask(source: MANUAL);NOT_INTERESTEDcancels the record's open tasks.ResellerStatusChange(trigger: MANUAL) and timelineInteractionSTATUS_CHANGE; auditSTATUS_CHANGEwith the reason.- Notifications:
SUSPENDED→account.suspendedto the reseller (push + in-app, without the internal reason) and to the assigned delegate;restorenotifies 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
| Status | Code | When |
|---|---|---|
| 403 | PERMISSION_DENIED | Caller lacks crm.update |
| 404 | NOT_FOUND | Unknown reseller, delegate or zone |
| 409 | VERSION_CONFLICT | Stale version |
| 422 | DELEGATE_INACTIVE | The delegate's account is not ACTIVE |
| 422 | DELEGATE_ZONE_MISMATCH | The delegate does not cover the reseller's commune and allowOutOfZone is not true |
| 422 | REASON_REQUIRED | allowOutOfZone without reason |
Rules & side effects
- One
ResellerAssignmentrow (from/to delegate and zone, actor, reason). The new delegate gains the reseller in their scope at the nextsync.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 futurePLANNEDvisits in the former delegate's tours are cancelled (BR-48). - Audit
UPDATE; withallowOutOfZonean additionalOVERRIDErow. - Notifications:
reseller.assigned→ the new and the former delegate (push,FIELD; proposed event, BR-48 requires both to be told). SSE: none.
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
| Status | Code | When |
|---|---|---|
| 400 | VALIDATION_FAILED | Neither or both of resellerIds / filter, more than 500 ids, payload missing for the action, a DYNAMIC segment in segmentIds |
| 403 | PERMISSION_DENIED | Caller lacks crm.update |
| 422 | BULK_TOO_LARGE | filter matches more than 5 000 records — details: { matched, max: 5000 } |
| 422 | REASON_REQUIRED | SET_STATUS without reason |
Rules & side effects
- 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.
- Audit: one
AuditLogrow per updated record plus one summary row (UPDATEonReseller,entityId: null,summarywith counts and the filter). - Notifications are grouped: one
reseller.assignedper delegate with the count (« 22 points de vente vous ont été attribués »).
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
| Query | Type | Values / default | Notes |
|---|---|---|---|
kind | string[] | SELF_SIGNUP | FIELD_PROSPECT | CLAIM | Repeatable |
hasHints | boolean | Only records with at least one duplicate hint | |
declaredCustomerType | CustomerType[] | repeatable | |
wilayaCode / delegateId | string[] / Uuid[] | repeatable | Creating or assigned delegate |
from / to | string (date) | On submittedAt | |
q | string | min 2 chars | Store, owner, phone, number |
sort | string | submittedAt (default) | storeName | |
order | string | asc (default) | desc | |
page / pageSize | number | 1 / 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
| Status | Code | When |
|---|---|---|
| 400 | VALIDATION_FAILED | Unknown kind or sort field |
| 403 | PERMISSION_DENIED | Caller lacks crm.validate |
Rules & side effects
- Read-only. New entries arrive live with
account.pending. Records waiting longer thancrm.validationReminderHours(24 h) trigger one in-app reminderaccount.pendingto the validators.
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
| Status | Code | When |
|---|---|---|
| 400 | VALIDATION_FAILED | reason shorter than 10 characters on a rejection, customerType on a rejection |
| 403 | PERMISSION_DENIED | Caller lacks crm.validate |
| 404 | NOT_FOUND | Unknown reseller or duplicateOfId |
| 409 | VALIDATION_NOT_PENDING | ValidationStatus is not PENDING — details: { validationStatus } (an approved record is never re-validated; a rejected one comes back as PENDING when corrected) |
| 409 | VERSION_CONFLICT | Stale version |
| 422 | REASON_REQUIRED | Rejection without reason, or type change without customerTypeReason |
| 422 | CUSTOMER_TYPE_REQUIRED | Approval of a record without declared type and without customerType |
| 422 | LOCATION_REQUIRED | Approval of a field-created point of sale without GPS position |
| 422 | RESELLER_INCOMPLETE | Minimum data of cahier §15.5 missing — details: { missing: [] } |
Rules & side effects
PENDING→APPROVED: AccountStatusPENDING_VALIDATION→ACTIVEwhen a login exists (checkout unlocked); the customer type becomes final (customerTypeHistory, auditchangesentry); ordersPENDINGforRESELLER_NOT_VALIDATEDare re-checked and move toSUBMITTEDwhen nothing else blocks — they still wait for confirmation.PENDING→REJECTED: the reason is shown on the reseller's waiting screen (RA-06); the commercial status is unchanged;disableAccountsets AccountStatusDISABLEDand revokes sessions.- Audit
VALIDATEorREJECTwith the decision, the customer type before/after and the reason. - Notifications: approve →
account.approvedto the reseller (SMS + push, « Votre compte est validé », with the confirmed customer type — BR-77) and to the assigned or creating delegate (templateaccount.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).
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
| Query | Type | Values / default | Notes |
|---|---|---|---|
type | InteractionType[] | repeatable | |
actorId | Uuid | ||
from / to | string (date) | ||
page / pageSize | number | 1 / 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
| Status | Code | When |
|---|---|---|
| 400 | VALIDATION_FAILED | Unknown type |
| 403 | PERMISSION_DENIED | Caller lacks crm.view |
| 404 | NOT_FOUND | Unknown reseller |
Rules & side effects
- 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.PAYMENTentries are listed only for callers withfinance.view(amounts masked otherwise);CLAIMentries needsupport.viewto 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
| Status | Code | When |
|---|---|---|
| 400 | VALIDATION_FAILED | Empty or too long body, call on a non-CALL entry, future occurredAt |
| 403 | PERMISSION_DENIED | Caller lacks crm.update |
| 404 | NOT_FOUND | Unknown reseller |
Rules & side effects
- Notes are immutable (no edit or delete in V1); a correction is a new note. Audit
CREATEonInteraction. No notification.
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
| Status | Code | When |
|---|---|---|
| 403 | PERMISSION_DENIED | Caller lacks crm.view |
| 404 | NOT_FOUND | Unknown reseller |
Rules & side effects
- 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).
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
| Status | Code | When |
|---|---|---|
| 400 | VALIDATION_FAILED | documentType missing for a document, photoKind missing for a photo |
| 403 | PERMISSION_DENIED | Caller lacks crm.update |
| 404 | NOT_FOUND | Unknown reseller, file or replaceId |
| 422 | FILE_NOT_READY | Upload not completed |
| 422 | FILE_PURPOSE_MISMATCH | Purpose is not STORE_DOCUMENT / STORE_PHOTO — details: { expected, actual } |
Rules & side effects
- Files stay private; the file is bound to the reseller (access checks follow the reseller). Audit
CREATEonResellerDocument/ResellerPhoto; timeline entryNOTE« Document ajouté : NIF ». No notification.
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
| Query | Type | Values / default | Notes |
|---|---|---|---|
phone | string | required | Any common format (0555 12 34 56, 00213…, +213…) |
excludeId | Uuid | The 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
| Status | Code | When |
|---|---|---|
| 400 | VALIDATION_FAILED | Missing phone |
| 403 | PERMISSION_DENIED | Caller lacks crm.view |
Rules & side effects
- Read-only; an invalid number answers
200withvalid: falseso the form can show the error inline. Rate-limited to 60 calls/minute per user.
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
| Query | Type | Values / default | Notes |
|---|---|---|---|
status | TaskStatus[] | repeatable; default OPEN | |
type | TaskType[] | repeatable | |
assigneeId | Uuid[] | repeatable; me allowed | |
resellerId | Uuid | ||
source | string[] | MANUAL | VISIT_REPORT | INACTIVITY | SYSTEM | |
overdue | boolean | ||
dueFrom / dueTo | string (date) | ||
q | string | min 2 chars | Title, store name, reseller number |
sort | string | dueAt (default), createdAt, storeName | |
order | string | asc (default) | desc | |
page / pageSize | number | 1 / 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
| Status | Code | When |
|---|---|---|
| 400 | VALIDATION_FAILED | Unknown enum value |
| 403 | PERMISSION_DENIED | Caller lacks crm.view |
Rules & side effects
- Read-only;
overdueis derived (OPENanddueAtpassed).
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
| Status | Code | When |
|---|---|---|
| 400 | VALIDATION_FAILED | dueAt in the past, unknown type |
| 403 | PERMISSION_DENIED | Caller lacks crm.update |
| 404 | NOT_FOUND | Unknown reseller or assignee |
| 422 | DELEGATE_INACTIVE | Assignee account not ACTIVE |
Rules & side effects
- A
CALLorVISITtask on aNEW_PROSPECTmoves it toTO_CONTACT(system rule, lifecycles). A delegate assignee must have the reseller in scope; otherwise the reseller is added to their scope for this task. - Audit
CREATEonCrmTask. - Notifications:
task.assigned→ assignee (push for delegates, in-app for staff);task.duereminder at 08:00Africa/Algierson the due date.
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
| Status | Code | When |
|---|---|---|
| 400 | VALIDATION_FAILED | Empty body, outcome missing with DONE |
| 403 | PERMISSION_DENIED | Caller lacks crm.update |
| 404 | NOT_FOUND | Unknown task or assignee |
| 409 | TASK_TRANSITION_NOT_ALLOWED | CANCELLED task changed, OPEN requested on an open task — details: { from, to, allowed } |
| 409 | VERSION_CONFLICT | Stale version |
| 422 | REASON_REQUIRED | Reopen without reason |
Rules & side effects
- Transitions of Lifecycles › Task:
OPEN→DONE(outcome),OPEN→CANCELLED,DONE→OPEN(reason). Rescheduling and reassigning keep the status. DONEadds a timelineInteraction(type from the task:CALL,VISIT,WHATSAPP…,summarywith the outcome).- Audit
UPDATEorSTATUS_CHANGE. - Notifications: reassignment →
task.assignedto the new assignee.
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
| Query | Type | Values / default | Notes |
|---|---|---|---|
type | SegmentType | STATIC | DYNAMIC | |
q | string | min 2 chars | Name |
sort / order / page / pageSize | name (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
| Status | Code | When |
|---|---|---|
| 403 | PERMISSION_DENIED | Caller lacks crm.view |
Rules & side effects
- Read-only. Reading is relaxed to
crm.view(the bundle'scrm.administerapplies 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
| Status | Code | When |
|---|---|---|
| 403 | PERMISSION_DENIED | Caller lacks crm.view |
| 404 | NOT_FOUND | Unknown segment |
Rules & side effects
- 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
| Status | Code | When |
|---|---|---|
| 400 | VALIDATION_FAILED | criteria on a STATIC segment, empty criteria on a DYNAMIC one, unknown codes, bad colour |
| 403 | PERMISSION_DENIED | Caller lacks crm.administer |
| 409 | DUPLICATE_VALUE | Name already used |
Rules & side effects
- Dynamic membership (
ResellerSegmentrows) is materialised at once and refreshed nightly (01:30) and on reseller, order and status events. - Audit
CREATEonSegment.
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
| Status | Code | When |
|---|---|---|
| 400 | VALIDATION_FAILED | As for creation; type cannot change |
| 403 | PERMISSION_DENIED | Caller lacks crm.administer |
| 404 | NOT_FOUND | Unknown segment |
| 409 | DUPLICATE_VALUE · VERSION_CONFLICT | Name taken · stale version |
Rules & side effects
- Changing criteria changes who sees targeted promotions, banners and restricted products from the next request; the response's
warningslists the active promotions and visibility rules that use the segment. - Audit
UPDATEwith 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
| Status | Code | When |
|---|---|---|
| 403 | PERMISSION_DENIED | Caller lacks crm.administer |
| 404 | NOT_FOUND | Unknown segment |
| 409 | RESOURCE_IN_USE | Referenced by a non-archived promotion, an active banner, a scheduled announcement or a visibility rule — details: { references: [{ entityType: "Promotion", count: 1 }] } |
Rules & side effects
- 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
| Status | Code | When |
|---|---|---|
| 400 | VALIDATION_FAILED | Unknown column or filter |
| 403 | PERMISSION_DENIED | Caller lacks crm.export; a finance column (balanceDue) without finance.view |
| 422 | EXPORT_TOO_LARGE | More than 100 000 rows (2 000 for PDF) — details: { rowCount, maxRows, format } |
| 429 | RATE_LIMITED | Already 3 running exports |
Rules & side effects
- The export contains exactly what the requester may see; phone numbers are included (personal data — the export is journaled).
- Audit
EXPORTwith format, filters and, on completion,rowCount. - Notifications:
export.ready→ requester (in-app + email). SSE:export.ready.
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.
Catalog & pricing
Back-office catalog operations — brands, category tree, filterable attributes, products and variants with their three customer-type prices, visibility, price history, product import and export.