HOCO B2B
SRSAPI referenceBack-office endpoints

Orders & delivery

Back-office order processing — queue of orders to confirm, controls, confirmation with overrides, refusal, controlled modification, transitions, cancellation, documents, returns, the delivery board, proof of delivery and carriers.

These operations run the order cycle of cahier §4.4 in the back-office, from the moment an order lands to its delivery, return or cancellation: the orders list and detail (BO-38, BO-39), orders entered on behalf of a reseller (BO-40), the orders to confirm queue (BO-41), the delivery board and carriers (BO-42, BO-43) and returns (BO-69). The state machine and its guards are defined once on Lifecycles › Order; this page is the contract that implements it, updated with the decisions of 17 Oct 2026:

  • No order is confirmed automatically. Every order — reseller app, web, assisted, phone — arrives SUBMITTED (or PENDING when on hold) and waits for admin.orders.validate by a person holding orders.validate (ORD-14). There is no auto-validation setting.
  • Every status change reaches the reseller live (SSE on GET /events) and as a notification (push + in-app) (ORD-15).
  • Cancellations and returns restock automatically: cancelling releases reservations; a refused-at-the-door return and the reception of a return request post RETURN movements and, for return requests, issue a credit note (ORD-13, STK-03, FIN-08).

Who uses it. The Administrateur commercial confirms, refuses and modifies orders and enters phone orders; the Responsable livraison works the delivery board (preparation, shipping, proof); the Responsable stock receives returns; the Responsable finance overrides credit decisions; the Super administrateur can do everything.

PermissionUnlocks on this page
orders.viewOrders list, detail, history; returns list and detail
orders.createOrders on behalf of a reseller
orders.validateQueue of orders to confirm, confirmation, refusal, administration confirmation of assisted orders, return decisions
orders.updateLine modification, transitions, cancellation, documents
stock.validateAdditionally required to confirm with a stock override
finance.validateAdditionally required to confirm with a credit override
stock.updateReturn reception (restock + credit note)
delivery.view · delivery.updateDelivery board, picking list · delivery data, proof of delivery, fulfilment transitions (PREPARING, READY, SHIPPED, RETURNED)
delivery.administerCarriers
pricing.viewPurchase price and margin fields of order lines

Settings used (settings keys): stock.reserveOn (default VALIDATED), stock.deductOn (default SHIPPED), billing.invoiceOn (default SHIPPED), orders.cancelAllowedStatuses, orders.assistedConfirmationHours (72), orders.confirmationReminderHours (4 working hours), returns.windowDays (7), orders.minAmount, credit.overLimitPolicy (BLOCK), delivery.proofPhotoRequired.

Audit. Every mutation writes an AuditLog row (schema) with app: "DASHBOARD": CREATE (order on behalf, carrier, return created from a claim), VALIDATE (confirmation, assisted confirmation, return approval), REJECT (refusal, return rejection), UPDATE (lines, delivery data), STATUS_CHANGE (transitions, cancellation), plus one OVERRIDE row per stock or credit override with its reason. Domain history is kept in OrderStatusChange, OrderChange and StockMovement in the same transaction (history).

Conventions: lists return Page<T> = { items, total, page, pageSize } (responses); amounts are integer centimes (Totals and Price from the shared schemas); filters on dates are YYYY-MM-DD in Africa/Algiers; editable records carry version (stale → 409 VERSION_CONFLICT); a missing reason answers 422 REASON_REQUIRED; the dashboard sends an Idempotency-Key on every button (recommended on admin.orders.create and admin.returns.receive). 401, 429 and 500 apply everywhere and are not repeated.

MethodPathOperationAccessAppsRelease
GET/admin/ordersadmin.orders.list
Orders with status tabs, filters (channel, delegate, reseller, wilaya, dates) and search
orders.viewBOV1
GET/admin/orders/{orderId}admin.orders.get
Order with control results (stock, prices, credit, address), history and allowed transitions
orders.viewBOV1
POST/admin/ordersadmin.orders.create
Create an order on behalf of a reseller
orders.createBOV1
GET/admin/orders/to-confirmadmin.orders.toConfirm
Queue of orders waiting for back-office confirmation (SUBMITTED and PENDING), oldest first, with control results
orders.validateBOV1
POST/admin/orders/{orderId}/validateadmin.orders.validate
Confirm an order — every order needs this explicit confirmation (optionally with a recorded stock/credit override)
orders.validateBOV1
POST/admin/orders/{orderId}/refuseadmin.orders.refuse
Refuse with a reason
orders.validateBOV1
PATCH/admin/orders/{orderId}/linesadmin.orders.modify
Controlled modification of lines with a reason (logged, reseller notified)
orders.updateBOV1
POST/admin/orders/{orderId}/transitionsadmin.orders.transition
Move to preparing, ready, shipped, delivered or returned per the transition table
orders.updateBOV1
POST/admin/orders/{orderId}/canceladmin.orders.cancel
Cancel with a reason (stock released)
orders.updateBOV1
POST/admin/orders/{orderId}/confirm-assistedadmin.orders.confirmAssisted
Administration confirmation of an assisted order (e.g. reseller confirmed by phone)
orders.validateBOV1
GET/admin/orders/{orderId}/historyadmin.orders.history
Status history and change log
orders.viewBOV1
POST/admin/orders/{orderId}/documentsadmin.orders.documents
Generate or regenerate invoice / delivery note PDFs
orders.updateBOV1
GET/admin/returnsadmin.returns.list
Return requests queue with filters
orders.viewBOV1
GET/admin/returns/{returnId}admin.returns.get
Return detail
orders.viewBOV1
POST/admin/returns/{returnId}/decisionadmin.returns.decide
Approve or reject a return (reason required on rejection)
orders.validateBOV1
POST/admin/returns/{returnId}/receptionadmin.returns.receive
Record reception: accepted quantities re-enter stock automatically (RETURN movements); credit note issued
stock.updateBOV1
GET/admin/deliveriesadmin.deliveries.list
Delivery board columns (to prepare, preparing, ready, shipped, delivered, failed)
delivery.viewBOV1
PATCH/admin/deliveries/{deliveryId}admin.deliveries.update
Assign carrier, tracking number, driver
delivery.updateBOV1
GET/admin/deliveries/{deliveryId}/picking-listadmin.deliveries.pickingList
Printable picking list
delivery.viewBOV1
POST/admin/deliveries/{deliveryId}/proofadmin.deliveries.proof
Record proof of delivery (photo, recipient) and mark delivered
delivery.updateBOV1
GET · POST · PATCH/admin/carriers[/{carrierId}]admin.carriers.crud
Carriers
delivery.administerBOV1

Schemas

AdminOrderListItem

interface AdminOrderListItem {
  id: Uuid;
  number: string;                       // CMD-2026-000158
  status: OrderStatus;
  channel: OrderChannel;                // RESELLER_APP | RESELLER_WEB | DELEGATE_ASSISTED | BACK_OFFICE
  reseller: ResellerSummary & { customerType: CustomerType };
  delegate?: UserSummary;               // source delegate (assisted) or the reseller's assigned delegate at submission
  isFirstOrder: boolean;
  pendingReasons: PendingReason[];      // non-empty only when status = PENDING
  assisted?: { confirmation: AssistedConfirmation; expiresAt: Iso };
  linesCount: number;
  unitsCount: number;
  totalTtc: Money;
  currency: "DZD";
  paymentMethod: PaymentMethod;
  controls: { stock: "OK" | "INSUFFICIENT"; price: "OK" | "CHANGED"; credit: "OK" | "EXCEEDED" | "BLOCKED" | "NOT_APPLICABLE"; address: "OK" | "MISSING" };
  hasOverride: boolean;
  delivery?: { status: DeliveryStatus; mode: DeliveryMode; carrierName?: string; trackingNumber?: string };
  invoice?: { id: Uuid; number: string; status: InvoiceStatus };
  openReturns: number;                  // return requests not REFUNDED / REJECTED
  submittedAt: Iso | null;              // null for DRAFT
  validatedAt?: Iso;
  statusChangedAt: Iso;
  waitingMinutes?: number;              // SUBMITTED / PENDING only: minutes since submission
  late: boolean;                        // waiting > orders.confirmationReminderHours (working hours), or VALIDATED…READY longer than delivery.shippingSlaHours
}

type PendingReason = "CREDIT_OVER_LIMIT" | "RESELLER_NOT_VALIDATED" | "AWAITING_RESELLER" | "ON_HOLD";

OrderControls

The four automatic controls of cahier §4.4 (« Contrôle du stock, des prix, remises, crédit et adresse »). They are re-run on every read of an order that is not yet VALIDATED (stock and credit move while an order waits) and frozen at confirmation.

interface OrderControls {
  checkedAt: Iso;
  stock: {
    status: "OK" | "INSUFFICIENT";
    lines: { lineId: Uuid; variantId: Uuid; sku: string; requested: number; available: number; shortBy: number; warehouseCode: string }[]; // INSUFFICIENT lines only
  };
  price: {
    status: "OK" | "CHANGED";            // information only: the order keeps its snapshot prices
    lines: { lineId: Uuid; sku: string; orderedUnitPrice: Price; currentUnitPrice: Price }[];
    totalDifferenceTtc: Money;           // current − ordered, signed
  };
  credit: {
    status: "OK" | "EXCEEDED" | "BLOCKED" | "NOT_APPLICABLE";   // NOT_APPLICABLE unless paymentMethod = CREDIT
    limit?: Money; used?: Money; available?: Money; orderTotal?: Money; exceededBy?: Money;
    policy?: "BLOCK" | "REQUIRE_APPROVAL";
  };
  address: { status: "OK" | "MISSING"; missing?: ("addressLine" | "commune" | "phone" | "recipientName")[] };
}

credit.used follows the exposure formula and excludes the order itself; exceededBy = orderTotal − available when positive. Credit amounts are returned to holders of orders.validate or finance.view; others only get status.

AdminOrderDetail

interface AdminOrderDetail extends Omit<AdminOrderListItem, "controls"> {
  reseller: ResellerSummary & { customerType: CustomerType; validationStatus: ValidationStatus; accountStatus?: AccountStatus };
  address: Address | null;               // snapshot taken at submission
  lines: AdminOrderLine[];
  totals: Totals;
  note?: string;                         // reseller's note (≤ 500 chars)
  internalNote?: string;                 // back-office only, never shown to the reseller
  controls: OrderControls;
  allowedTransitions: OrderStatus[];     // computed for this order and this caller
  allowedActions: ("VALIDATE" | "REFUSE" | "MODIFY_LINES" | "CANCEL" | "CONFIRM_ASSISTED" | "HOLD" | "GENERATE_DOCUMENTS" | "RECORD_PROOF")[];
  history: StatusChange[];               // OrderStatusChange, newest first
  changeLog: OrderChangeEntry[];         // OrderChange, newest first
  overrides: { kind: "STOCK" | "CREDIT"; reason: string; by: UserSummary; at: Iso }[];
  delivery?: Delivery;
  documents: { type: "INVOICE" | "DELIVERY_NOTE"; number: string; generatedAt: Iso; file: FileRef }[];
  returns: { id: Uuid; number: string; status: ReturnStatus }[];
  placedBy?: UserSummary;                // BACK_OFFICE channel
  version: number;
}

interface AdminOrderLine {
  id: Uuid;
  variant: { id: Uuid; sku: string; name: string; productId: Uuid; productName: string; brandName: string; imageUrl?: string };
  quantity: number;
  unitPrice: Price;                      // snapshot: the reseller's customer-type price at submission
  discounts: { promotionId: Uuid; label: string; amountHt: Money }[];
  lineTotalHt: Money;                    // quantity × unitPrice.ht − Σ discounts
  lineTotalTtc: Money;
  isFree: boolean;                       // after-sales replacement lines (price 0)
  purchasePrice?: Money;                 // HT — only with pricing.view
  marginHt?: Money;                      // lineTotalHt − quantity × purchasePrice — only with pricing.view
}

interface OrderChangeEntry {
  id: Uuid;
  at: Iso;
  actor: UserSummary;
  reason: string;
  changes: { lineId: Uuid; sku: string; kind: "QUANTITY" | "ADDED" | "REMOVED" | "ADDRESS"; before: number | string | null; after: number | string | null }[];
  totalsBefore: { totalTtc: Money };
  totalsAfter: { totalTtc: Money };
}

Delivery

interface Delivery {
  id: Uuid;
  orderId: Uuid;
  orderNumber: string;
  status: DeliveryStatus;               // mirrors the order; FAILED is its only own status
  mode: DeliveryMode;                   // OWN_FLEET | CARRIER | PICKUP
  carrier?: { id: Uuid; name: string };
  trackingNumber?: string;
  trackingUrl?: string;                 // from the carrier's template
  driverName?: string;
  driverPhone?: string;                 // E.164
  scheduledFor?: string;                // YYYY-MM-DD
  packages?: number;
  deliveryNoteNumber?: string;          // BL-2026-000123, allocated at READY
  attempts: { at: Iso; outcome: "FAILED"; reason: string; nextAttemptOn?: string }[];
  codAmount?: Money;                    // CASH_ON_DELIVERY orders: amount to collect
  proof?: { recipientName: string; deliveredAt: Iso; file?: FileRef; note?: string; recordedBy: UserSummary };
  shippedAt?: Iso;
  deliveredAt?: Iso;
  cancelledAt?: Iso;
  version: number;
}

ReturnRequest

interface ReturnRequest {
  id: Uuid;
  number: string;                       // RET-2026-000012
  status: ReturnStatus;                 // REQUESTED | APPROVED | IN_TRANSIT | RECEIVED | REFUNDED | REJECTED
  order: { id: Uuid; number: string; deliveredAt: Iso };
  reseller: ResellerSummary;
  origin: "RESELLER" | "AFTER_SALES" | "DOOR_REFUSAL";   // returns.create · admin.tickets.resolve (RETURN) · admin.orders.transition SHIPPED → RETURNED
  ticket?: { id: Uuid; number: string };
  reason: string;                       // reseller's reason
  items: {
    id: Uuid;
    orderLineId: Uuid;
    sku: string;
    productName: string;
    quantity: number;                   // requested, ≤ delivered − already returned
    unitPrice: Price;                   // the order line's net unit price
    photos: FileRef[];
    receivedQty?: number;
    acceptedQty?: number;               // credited
    restockQty?: number;                // re-entered into stock (sellable)
    condition?: "SELLABLE" | "DEFECTIVE" | "DAMAGED" | "MISSING";
  }[];
  decision?: { decision: "APPROVE" | "REJECT"; reason?: string; by: UserSummary; at: Iso };
  transport?: { mode: "RESELLER_DROP_OFF" | "CARRIER" | "DELEGATE_PICKUP"; carrierId?: Uuid; trackingNumber?: string };
  reception?: { warehouseCode: string; receivedAt: Iso; by: UserSummary; note?: string };
  creditNote?: { id: Uuid; number: string; amountTtc: Money; issuedAt: Iso; file?: FileRef };
  requestedAt: Iso;
  version: number;
}

Orders

GET /admin/orders

admin.orders.list · Permission orders.view · Apps BO · Release V1 · Requirements ORD-04 · ORD-05

Purpose. The orders table with status tabs, filters and search; also feeds the order tabs of the reseller 360° page and of the delegate detail.

Request

QueryTypeValues / defaultNotes
statusOrderStatus[]repeatableThe tabs send groups, e.g. status=SUBMITTED&status=PENDING
channelOrderChannel[]repeatable
resellerIdUuidReseller 360° tab
delegateIdUuid[]repeatableSource or assigned delegate
wilayaCodestring[]repeatableDelivery address
communeIdUuid[]repeatable
paymentMethodPaymentMethod[]repeatable
customerTypeCustomerType[]repeatable
isFirstOrderboolean
assistedConfirmationAssistedConfirmation[]repeatable
pendingReasonPendingReason[]repeatable
hasIssuesbooleantrue: at least one control not OK
latebooleanSee late in the schema
hasOverrideboolean
dateFieldstringsubmittedAt (default) | validatedAt | deliveredAt | statusChangedAtField from/to apply to
from / tostring (date)Inclusive
minTotal / maxTotalMoneyOn totalTtc
qstringmin 2 charsOrder number (exact or suffix 158), reseller number, store name, owner name, phone
includeCountsbooleanfalseAdds statusCounts for the tab badges (same filters, ignoring status)
sortstringsubmittedAt (default), number, totalTtc, statusChangedAt, storeName, waitingMinutes
orderstringasc | desc (default desc)
page / pageSizenumber1 / 25 (max 200)

Response 200 OK

{
  "items": [
    {
      "id": "0193016e-4b2a-7c11-9d8e-7f6a5b4c3d2e",
      "number": "CMD-2026-000158",
      "status": "SUBMITTED",
      "channel": "RESELLER_APP",
      "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", "customerType": "DEMI_GROS"
      },
      "delegate": { "id": "0192a7d3-1b2c-7d4e-8f5a-6b7c8d9e0f12", "displayName": "Yacine Merabet" },
      "isFirstOrder": false,
      "pendingReasons": [],
      "linesCount": 3,
      "unitsCount": 45,
      "totalTtc": 3974600,
      "currency": "DZD",
      "paymentMethod": "CREDIT",
      "controls": { "stock": "INSUFFICIENT", "price": "OK", "credit": "EXCEEDED", "address": "OK" },
      "hasOverride": false,
      "openReturns": 0,
      "submittedAt": "2026-11-03T08:12:00Z",
      "statusChangedAt": "2026-11-03T08:12:00Z",
      "waitingMinutes": 95,
      "late": false
    },
    {
      "id": "01930170-1c2d-7e3f-8a4b-5c6d7e8f9a0b",
      "number": "CMD-2026-000157",
      "status": "SHIPPED",
      "channel": "DELEGATE_ASSISTED",
      "reseller": {
        "id": "0192f4d2-3e4f-7a5b-9c6d-7e8f9a0b1c2d", "number": "REV-000198", "storeName": "Smart Accessoires Es Senia",
        "ownerName": "Hichem Bouzid", "phone": "+213550987654", "wilayaCode": "31", "communeName": "Es Senia",
        "status": "FIRST_ORDER", "customerType": "DETAIL"
      },
      "delegate": { "id": "0192a7d3-1b2c-7d4e-8f5a-6b7c8d9e0f12", "displayName": "Yacine Merabet" },
      "isFirstOrder": true,
      "pendingReasons": [],
      "assisted": { "confirmation": "CONFIRMED_BY_RESELLER", "expiresAt": "2026-11-05T15:20:00Z" },
      "linesCount": 2,
      "unitsCount": 16,
      "totalTtc": 1023400,
      "currency": "DZD",
      "paymentMethod": "CASH_ON_DELIVERY",
      "controls": { "stock": "OK", "price": "OK", "credit": "NOT_APPLICABLE", "address": "OK" },
      "hasOverride": false,
      "delivery": { "status": "SHIPPED", "mode": "CARRIER", "carrierName": "Yalidine", "trackingNumber": "YAL-58213377" },
      "invoice": { "id": "01930c2e-1a2b-7c3d-9e4f-5a6b7c8d9e01", "number": "FAC-2026-000122", "status": "UNPAID" },
      "openReturns": 0,
      "submittedAt": "2026-11-02T15:24:00Z",
      "validatedAt": "2026-11-02T16:02:00Z",
      "statusChangedAt": "2026-11-03T07:45:00Z",
      "late": false
    }
  ],
  "total": 312,
  "page": 1,
  "pageSize": 25,
  "statusCounts": { "DRAFT": 1, "SUBMITTED": 9, "PENDING": 5, "VALIDATED": 11, "PREPARING": 6, "READY": 4, "SHIPPED": 17, "DELIVERED": 241, "REFUSED": 4, "CANCELLED": 12, "RETURNED": 2 }
}

Field notes. controls in the list is the status summary of OrderControls, recomputed for orders not yet VALIDATED (frozen afterwards). DRAFT orders appear only for back-office drafts and assisted drafts are never listed (they are the delegate's). statusCounts is present only with includeCounts=true.

Errors

StatusCodeWhen
400VALIDATION_FAILEDUnknown enum value, bad date, pageSize over 200, q shorter than 2 characters
403PERMISSION_DENIEDCaller lacks orders.view

Rules & side effects

  1. Read-only. Controls of waiting orders are computed in one batch query per page (stock availability and credit exposure of the page's resellers), not per row.
  2. The search matches business numbers exactly or by numeric suffix and texts accent- and case-insensitively.

Used by BO-38 · BO-11 · BO-17

GET /admin/orders/{orderId}

admin.orders.get · Permission orders.view · Apps BO · Release V1 · Requirements ORD-05 · ORD-06

Purpose. The order detail: lines, totals, live control results, history, change log, delivery, documents, returns and what the caller may do next.

Request

PathTypeNotes
orderIdUuid

Response 200 OK

{
  "id": "0193016e-4b2a-7c11-9d8e-7f6a5b4c3d2e",
  "number": "CMD-2026-000158",
  "status": "SUBMITTED",
  "channel": "RESELLER_APP",
  "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", "customerType": "DEMI_GROS", "validationStatus": "APPROVED", "accountStatus": "ACTIVE"
  },
  "delegate": { "id": "0192a7d3-1b2c-7d4e-8f5a-6b7c8d9e0f12", "displayName": "Yacine Merabet" },
  "isFirstOrder": false,
  "pendingReasons": [],
  "linesCount": 3,
  "unitsCount": 45,
  "totalTtc": 3974600,
  "currency": "DZD",
  "paymentMethod": "CREDIT",
  "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
  },
  "lines": [
    {
      "id": "0193016e-5a01-7b02-8c03-0d0e0f101112",
      "variant": { "id": "0192b210-5e6f-7a8b-9c0d-1e2f3a4b5c6d", "sku": "HC-X14-CC-1M-BK", "name": "Noir · 1 m", "productId": "0192b210-4d5e-7f60-8a1b-2c3d4e5f6a70", "productName": "Câble USB-C vers USB-C 60 W 1 m", "brandName": "HOCO", "imageUrl": "https://cdn.hoco.dz/p/hc-x14/main-400.webp" },
      "quantity": 20,
      "unitPrice": { "ht": 35000, "ttc": 41650, "vatRate": 1900 },
      "discounts": [],
      "lineTotalHt": 700000,
      "lineTotalTtc": 833000,
      "isFree": false,
      "purchasePrice": 21000,
      "marginHt": 280000
    },
    {
      "id": "0193016e-5a02-7b03-8c04-0d0e0f101113",
      "variant": { "id": "0192b215-7a8b-7c9d-8e0f-1a2b3c4d5e6f", "sku": "AF-A29-50W-WH", "name": "Blanc", "productId": "0192b215-6f70-7a81-9b2c-3d4e5f6a7b80", "productName": "Chargeur GaN 50 W 2 ports", "brandName": "ACEFAST", "imageUrl": "https://cdn.hoco.dz/p/af-a29/main-400.webp" },
      "quantity": 10,
      "unitPrice": { "ht": 210000, "ttc": 249900, "vatRate": 1900 },
      "discounts": [{ "promotionId": "01930050-2b3c-7d4e-9f5a-6b7c8d9e0f1a", "label": "ACEFAST −10 % novembre", "amountHt": 210000 }],
      "lineTotalHt": 1890000,
      "lineTotalTtc": 2249100,
      "isFree": false,
      "purchasePrice": 150000,
      "marginHt": 390000
    },
    {
      "id": "0193016e-5a03-7b04-8c05-0d0e0f101114",
      "variant": { "id": "0192b21a-9c0d-7e1f-a2b3-4c5d6e7f8a9b", "sku": "BF-BH20-BK", "name": "Noir", "productId": "0192b21a-8b9c-7d0e-9f1a-2b3c4d5e6f70", "productName": "Support voiture magnétique", "brandName": "BOROFONE", "imageUrl": "https://cdn.hoco.dz/p/bf-bh20/main-400.webp" },
      "quantity": 15,
      "unitPrice": { "ht": 50000, "ttc": 59500, "vatRate": 1900 },
      "discounts": [],
      "lineTotalHt": 750000,
      "lineTotalTtc": 892500,
      "isFree": false,
      "purchasePrice": 30000,
      "marginHt": 300000
    }
  ],
  "totals": { "subtotalHt": 3550000, "discountTotal": 210000, "deliveryFee": 0, "vatTotal": 634600, "totalTtc": 3974600, "currency": "DZD" },
  "note": "Merci de livrer avant jeudi.",
  "controls": {
    "checkedAt": "2026-11-03T09:47:12Z",
    "stock": { "status": "INSUFFICIENT", "lines": [{ "lineId": "0193016e-5a02-7b03-8c04-0d0e0f101113", "variantId": "0192b215-7a8b-7c9d-8e0f-1a2b3c4d5e6f", "sku": "AF-A29-50W-WH", "requested": 10, "available": 6, "shortBy": 4, "warehouseCode": "WH-ORN" }] },
    "price": { "status": "OK", "lines": [], "totalDifferenceTtc": 0 },
    "credit": { "status": "EXCEEDED", "limit": 5000000, "used": 2100000, "available": 2900000, "orderTotal": 3974600, "exceededBy": 1074600, "policy": "BLOCK" },
    "address": { "status": "OK" }
  },
  "allowedTransitions": ["VALIDATED", "REFUSED", "CANCELLED", "PENDING"],
  "allowedActions": ["VALIDATE", "REFUSE", "MODIFY_LINES", "CANCEL", "HOLD"],
  "history": [
    { "from": null, "to": "SUBMITTED", "at": "2026-11-03T08:12:00Z", "actor": { "id": "0192f4b8-7001-7a02-8b03-0c0d0e0f1011", "displayName": "Karim Benali" } }
  ],
  "changeLog": [],
  "overrides": [],
  "hasOverride": false,
  "documents": [],
  "returns": [],
  "openReturns": 0,
  "submittedAt": "2026-11-03T08:12:00Z",
  "statusChangedAt": "2026-11-03T08:12:00Z",
  "waitingMinutes": 95,
  "late": false,
  "version": 1
}

Field notes.

  • Line arithmetic: lineTotalHt = quantity × unitPrice.ht − Σ discounts (10 × 210 000 − 210 000 = 1 890 000); lineTotalTtc applies the line's VAT. The totals above: 3 550 000 − 210 000 + 0 = 3 340 000 HT, VAT 19 % = 634 600, total 3 974 600.
  • purchasePrice and marginHt — only with pricing.view; omitted otherwise.
  • Credit amounts in controls.credit — only with orders.validate or finance.view.
  • allowedTransitions / allowedActions take the status, the caller's permissions and the guards into account (here the caller has orders.validate and orders.update but neither stock.validate nor finance.validate, so VALIDATE is offered and will require an override the caller cannot give — the UI shows why from controls).
  • internalNote is never returned by reseller operations.

Errors

StatusCodeWhen
403PERMISSION_DENIEDCaller lacks orders.view
404NOT_FOUNDUnknown order, or an assisted draft (DRAFT, DELEGATE_ASSISTED)

Rules & side effects

  1. Read-only. Controls are re-run for DRAFT, SUBMITTED and PENDING orders at each read; from VALIDATED on, the response returns the controls frozen at confirmation.
  2. Document file.url values are signed URLs valid 5 minutes.

Used by BO-39 · BO-41

POST /admin/orders

admin.orders.create · Permission orders.create · Apps BO · Release V1 · Requirements ORD-11

Purpose. Enters an order on behalf of a reseller — typically a phone or WhatsApp order — saved as a draft or submitted at once. A submitted back-office order still needs confirmation like any other.

Request

interface CreateAdminOrderBody {
  resellerId: Uuid;
  lines: { variantId: Uuid; quantity: number }[];   // 1–200 lines, one line per variant
  addressId: Uuid;                                  // one of the reseller's addresses
  paymentMethod: PaymentMethod;                     // must be in payments.enabledMethods; CREDIT needs a credit account
  note?: string;                                    // visible to the reseller, ≤ 500
  internalNote?: string;                            // back-office only, ≤ 1 000
  source: "PHONE" | "WHATSAPP" | "VISIT" | "OTHER"; // how the order was taken
  submit: boolean;                                  // false = keep as DRAFT
}
{
  "resellerId": "0192f4b8-61c2-7a3e-9b10-1f2e3d4c5b6a",
  "lines": [
    { "variantId": "0192b210-5e6f-7a8b-9c0d-1e2f3a4b5c6d", "quantity": 30 },
    { "variantId": "0192b21a-9c0d-7e1f-a2b3-4c5d6e7f8a9b", "quantity": 10 }
  ],
  "addressId": "0192f4b9-0a1b-7c2d-8e3f-4a5b6c7d8e9f",
  "paymentMethod": "CASH_ON_DELIVERY",
  "note": "Commande prise par téléphone avec M. Benali.",
  "source": "PHONE",
  "submit": true
}

Response 201 Created — the AdminOrderDetail of the new order. For the body above: number CMD-2026-000163, status SUBMITTED, channel BACK_OFFICE, placedBy the caller, lines 30 × 35 000 = 1 050 000 HT and 10 × 50 000 = 500 000 HT, totals { "subtotalHt": 1550000, "discountTotal": 0, "deliveryFee": 80000, "vatTotal": 309700, "totalTtc": 1939700, "currency": "DZD" } (example settings delivery.feeMode = FLAT, delivery.flatFee = 800,00 DA HT, delivery.freeThreshold = 20 000,00 DA TTC: the goods total 18 445,00 DA TTC is below the threshold, so the fee applies; VAT 19 % on 1 630 000 — see BR-28), controls all OK, version 1.

The response also carries issues (added in contract 1.1.0): the Issue list the controls found while creating it — line issues (price or promotion changed, stock short on a draft, quantity rules) and order issues (minimum not met on a draft), then one WARNING per on-hold reason (RESELLER_NOT_VALIDATED, CREDIT_OVER_LIMIT). BO-40 shows them after saving instead of re-reading the order.

{ "issues": [ { "code": "CREDIT_OVER_LIMIT", "severity": "WARNING", "message": "Le plafond de crédit est dépassé : la commande attend une validation." } ] }

The payment methods to offer come from admin.resellers.paymentMethods.

Errors

StatusCodeWhen
400VALIDATION_FAILEDEmpty lines, duplicate variant, quantity ≤ 0, unknown source
403PERMISSION_DENIEDCaller lacks orders.create
403RESELLER_SUSPENDEDResellerStatus SUSPENDED
404NOT_FOUNDUnknown reseller, variant or address (or address of another reseller)
422PRODUCT_UNAVAILABLEA variant is not orderable for this reseller (hidden, archived, not visible to its segment) — details.lines
422QUANTITY_NOT_ALLOWEDBelow minQty, not a multiple of qtyStep, above maxQtyPerOrder — details.lines
422ORDER_MIN_AMOUNT_NOT_METSubmitted total below orders.minAmount
422STOCK_INSUFFICIENTsubmit: true and a line exceeds available stock (no override at creation)
422CREDIT_LIMIT_EXCEEDEDCREDIT order over the available credit with credit.overLimitPolicy = BLOCK
422ADDRESS_MISSINGThe address lacks commune, line or phone

Rules & side effects

  1. Prices are resolved exactly as at checkout: the variant price of the reseller's customerType (priceSuperGros, priceDemiGros or priceDetail), then active promotions (PRC-02). The back-office cannot type a price.
  2. submit: false stores a DRAFT without number or stock effect; it is submitted later with admin.orders.transition to: SUBMITTED.
  3. submit: true → SUBMITTED with a CMD- number; PENDING at once with pendingReasons CREDIT_OVER_LIMIT (policy REQUIRE_APPROVAL) or RESELLER_NOT_VALIDATED (point of sale still pending validation — phone orders for prospects are allowed). Reservation only if stock.reserveOn = SUBMITTED.
  4. First order: isFirstOrder and ResellerStatus FIRST_ORDER when it is the reseller's first (lifecycles).
  5. Audit CREATE (entity Order); OrderStatusChange — → SUBMITTED with the caller as actor.
  6. Notifications: order.submitted → the reseller (push + in-app, « Une commande a été créée pour vous »), the order validators (in-app, « Commandes à confirmer ») and the assigned delegate. SSE: order.created and order.to_confirm; the reseller's own stream gets order.status_changed.

Used by BO-40

GET /admin/orders/to-confirm

admin.orders.toConfirm · Permission orders.validate · Apps BO · Release V1 · Requirements ORD-14 · ORD-05

Purpose. The queue of orders waiting for a person: every SUBMITTED order and every PENDING order (on hold, credit decision, account not validated, assisted order awaiting the reseller), oldest first, with their live controls so they can be confirmed or refused from the queue.

Request

QueryTypeValues / defaultNotes
kindstring[]NEW (SUBMITTED) | ON_HOLD (PENDING except assisted) | ASSISTED (PENDING + AWAITING_RESELLER); default allRepeatable — the three tabs of BO-41
pendingReasonPendingReason[]repeatable
channelOrderChannel[]repeatable
controlsstringALL_OK | HAS_ISSUES
wilayaCode / delegateIdstring[] / Uuid[]repeatable
overduebooleanWaiting longer than orders.confirmationReminderHours (4 working hours)
qstringmin 2 charsOrder number, reseller name, number or phone
sortstringsubmittedAt (default, oldest first), totalTtc, expiresAt
orderstringasc (default) | desc
page / pageSizenumber1 / 25 (max 200)

Response 200 OK — Page<AdminOrderListItem & { controlsDetail: OrderControls; slaDueAt: Iso }> plus counts

{
  "items": [
    {
      "id": "01930160-7d8e-7f9a-8b0c-1d2e3f4a5b6c",
      "number": "CMD-2026-000149",
      "status": "PENDING",
      "channel": "DELEGATE_ASSISTED",
      "reseller": {
        "id": "0192f4e3-4f5a-7b6c-8d7e-8f9a0b1c2d3e", "number": "REV-000231", "storeName": "Hassi Phone",
        "ownerName": "Ali Kerroum", "phone": "+213552334455", "wilayaCode": "31", "communeName": "Hassi Bounif",
        "status": "ACCOUNT_CREATED", "customerType": "DETAIL"
      },
      "delegate": { "id": "0192a7d3-1b2c-7d4e-8f5a-6b7c8d9e0f12", "displayName": "Yacine Merabet" },
      "isFirstOrder": true,
      "pendingReasons": ["AWAITING_RESELLER"],
      "assisted": { "confirmation": "AWAITING_RESELLER", "expiresAt": "2026-11-05T09:40:00Z" },
      "linesCount": 4,
      "unitsCount": 38,
      "totalTtc": 1677900,
      "currency": "DZD",
      "paymentMethod": "CASH_ON_DELIVERY",
      "controls": { "stock": "OK", "price": "OK", "credit": "NOT_APPLICABLE", "address": "OK" },
      "controlsDetail": {
        "checkedAt": "2026-11-03T09:47:12Z",
        "stock": { "status": "OK", "lines": [] },
        "price": { "status": "OK", "lines": [], "totalDifferenceTtc": 0 },
        "credit": { "status": "NOT_APPLICABLE" },
        "address": { "status": "OK" }
      },
      "hasOverride": false,
      "openReturns": 0,
      "submittedAt": "2026-11-02T09:40:00Z",
      "statusChangedAt": "2026-11-02T09:40:00Z",
      "waitingMinutes": 1447,
      "slaDueAt": "2026-11-03T09:40:00Z",
      "late": true
    }
  ],
  "total": 14,
  "page": 1,
  "pageSize": 25,
  "counts": { "NEW": 9, "ON_HOLD": 3, "ASSISTED": 2, "overdue": 2 }
}

Field notes. For assisted orders slaDueAt is assisted.expiresAt, and submittedAt is the moment the delegate sent it for confirmation. Credit amounts follow the same masking as admin.orders.get (always visible here, since the operation requires orders.validate).

Errors

StatusCodeWhen
400VALIDATION_FAILEDUnknown kind, pendingReason or sort field
403PERMISSION_DENIEDCaller lacks orders.validate

Rules & side effects

  1. Read-only. The queue is exactly status ∈ {SUBMITTED, PENDING} — nothing leaves it except confirmation (VALIDATED), refusal, cancellation, rejection or expiry of an assisted order.
  2. Live updates: order.to_confirm when an order enters the queue and order.status_changed when it leaves it; the client inserts or removes rows without re-fetching.
  3. An overdue order (waiting more than orders.confirmationReminderHours) raises a single reminder order.confirmation_overdue (in-app + email) to the validators (notifications matrix).

Used by BO-41 · BO-04 (card Commandes à confirmer)

POST /admin/orders/{orderId}/validate

admin.orders.validate · Permission orders.validate (+ stock.validate for a stock override, + finance.validate for a credit override) · Apps BO · Release V1 · Requirements ORD-05 · ORD-14 · STK-08 · FIN-05

Purpose. The explicit back-office confirmation every order needs (« Valider la commande »). It re-runs the controls and moves the order to VALIDATED; a failed stock or credit control can be bypassed only with a recorded, reasoned override (cahier §4.3 « Une commande ne peut être validée au-delà du stock disponible sauf autorisation explicite », §4.7).

Request

PathTypeNotes
orderIdUuid
interface ValidateOrderBody {
  override?: {
    stock?: boolean;        // accept the INSUFFICIENT lines as they are (requires stock.validate)
    credit?: boolean;       // accept EXCEEDED or BLOCKED credit (requires finance.validate)
    reason: string;         // 3–500 chars, kept on the order and in the audit log
  };
  note?: string;            // internal note added to the history, ≤ 500
  deliveryMode?: DeliveryMode;   // overrides the proposed mode of the Delivery record
  version: number;
}
{
  "override": { "stock": true, "credit": true, "reason": "Réassort ACEFAST reçu demain matin ; plafond relevé par la finance (accord R. Ouali)." },
  "note": "Livrer en deux colis.",
  "version": 1
}

Response 200 OK — the updated AdminOrderDetail:

{
  "id": "0193016e-4b2a-7c11-9d8e-7f6a5b4c3d2e",
  "number": "CMD-2026-000158",
  "status": "VALIDATED",
  "validatedAt": "2026-11-03T09:52:30Z",
  "controls": {
    "checkedAt": "2026-11-03T09:52:30Z",
    "stock": { "status": "INSUFFICIENT", "lines": [{ "lineId": "0193016e-5a02-7b03-8c04-0d0e0f101113", "variantId": "0192b215-7a8b-7c9d-8e0f-1a2b3c4d5e6f", "sku": "AF-A29-50W-WH", "requested": 10, "available": 6, "shortBy": 4, "warehouseCode": "WH-ORN" }] },
    "price": { "status": "OK", "lines": [], "totalDifferenceTtc": 0 },
    "credit": { "status": "EXCEEDED", "limit": 5000000, "used": 2100000, "available": 2900000, "orderTotal": 3974600, "exceededBy": 1074600, "policy": "BLOCK" },
    "address": { "status": "OK" }
  },
  "overrides": [
    { "kind": "STOCK", "reason": "Réassort ACEFAST reçu demain matin ; plafond relevé par la finance (accord R. Ouali).", "by": { "id": "01929e66-3d4e-7f5a-8b6c-7d8e9f0a1b2c", "displayName": "Sofiane Hamdi" }, "at": "2026-11-03T09:52:30Z" },
    { "kind": "CREDIT", "reason": "Réassort ACEFAST reçu demain matin ; plafond relevé par la finance (accord R. Ouali).", "by": { "id": "01929e66-3d4e-7f5a-8b6c-7d8e9f0a1b2c", "displayName": "Sofiane Hamdi" }, "at": "2026-11-03T09:52:30Z" }
  ],
  "hasOverride": true,
  "delivery": { "id": "01930170-9a0b-7c1d-8e2f-3a4b5c6d7e8f", "orderId": "0193016e-4b2a-7c11-9d8e-7f6a5b4c3d2e", "orderNumber": "CMD-2026-000158", "status": "TO_PREPARE", "mode": "CARRIER", "attempts": [], "version": 1 },
  "history": [
    { "from": "SUBMITTED", "to": "VALIDATED", "at": "2026-11-03T09:52:30Z", "actor": { "id": "01929e66-3d4e-7f5a-8b6c-7d8e9f0a1b2c", "displayName": "Sofiane Hamdi" } },
    { "from": null, "to": "SUBMITTED", "at": "2026-11-03T08:12:00Z", "actor": { "id": "0192f4b8-7001-7a02-8b03-0c0d0e0f1011", "displayName": "Karim Benali" } }
  ],
  "allowedTransitions": ["PREPARING", "CANCELLED"],
  "allowedActions": ["MODIFY_LINES", "CANCEL"],
  "version": 2
}

(Other fields as in admin.orders.get.)

Errors

StatusCodeWhen
400VALIDATION_FAILEDoverride present with neither stock nor credit true
403PERMISSION_DENIEDCaller lacks orders.validate, or sends override.stock without stock.validate / override.credit without finance.validate — details: { required }
403RESELLER_SUSPENDEDResellerStatus is SUSPENDED
404NOT_FOUNDUnknown order
409ORDER_TRANSITION_NOT_ALLOWEDStatus not SUBMITTED/PENDING, or an assisted order still AWAITING_RESELLER (confirm it first) — details: { from, to, allowed }
409VERSION_CONFLICTStale version
422REASON_REQUIREDoverride without reason
422RESELLER_NOT_VALIDATEDThe reseller's ValidationStatus is not APPROVED — cannot be overridden; approve the account first
422STOCK_INSUFFICIENTA line exceeds available stock and override.stock is not true — details.lines as in controls.stock.lines
422CREDIT_LIMIT_EXCEEDEDCREDIT order above available credit and override.credit is not true — details: { limit, used, available, orderTotal }
422CREDIT_ACCOUNT_BLOCKEDThe credit account is blocked and override.credit is not true
422ADDRESS_MISSINGAddress control MISSING — fix it with admin.orders.modify (addressId)

Rules & side effects

  1. Allowed from SUBMITTED and from PENDING once its pending reasons are resolved or overridden (CREDIT_OVER_LIMIT → credit override; ON_HOLD → confirmation lifts it; RESELLER_NOT_VALIDATED and AWAITING_RESELLER cannot be bypassed here). The four controls are re-run inside the transaction; a price change is information only — the order keeps its snapshot prices.
  2. Status VALIDATED, validatedAt, validatedBy; controls frozen on the order. OrderStatusChange row.
  3. Stock (stock.reserveOn = VALIDATED, default): one RESERVATION movement per line (reserved += qty). With a stock override the reservation is made anyway, so available can become negative; a stock.alert follows and the shortage is listed on the stock dashboard.
  4. A Delivery is created in TO_PREPARE with the proposed mode (from the address and the delivery settings, or deliveryMode). Invoice created now only if billing.invoiceOn = VALIDATED.
  5. CRM: ResellerStatus TO_FOLLOW_UP → ACTIVE when the reseller already had a delivered order; the open inactivity task is closed. Objectives ORDERS and REVENUE of the attributed delegate count the order.
  6. Audit: VALIDATE on Order, plus one OVERRIDE row per overridden control (changes: the control result at that moment, reason).
  7. Notifications: order.validated → reseller (push + in-app, « Commande validée »); assisted orders also → the source delegate. SSE: order.status_changed (back-office) and order.status_changed on the reseller's own stream.

Used by BO-41 · BO-39

POST /admin/orders/{orderId}/refuse

admin.orders.refuse · Permission orders.validate · Apps BO · Release V1 · Requirements ORD-05

Purpose. Refuses an order that was never accepted, with a reason the reseller sees (« Refuser la commande »).

Request

interface RefuseOrderBody {
  reasonCode: string;     // ReferenceItem.code of ORDER_REFUSAL_REASON, e.g. STOCK_UNAVAILABLE, CREDIT_EXCEEDED, DUPLICATE_ORDER, RESELLER_UNREACHABLE, OTHER
  reason?: string;        // 3–500 chars; required when the item has requiresComment (OTHER); shown to the reseller
  version: number;
}
{ "reasonCode": "DUPLICATE_ORDER", "reason": "Doublon de la commande CMD-2026-000157 passée la veille.", "version": 1 }

Response 200 OK — the AdminOrderDetail with "status": "REFUSED", allowedTransitions: [], the new history entry { "from": "SUBMITTED", "to": "REFUSED", "reason": "Doublon de la commande CMD-2026-000157 passée la veille." } and version + 1.

Errors

StatusCodeWhen
400VALIDATION_FAILEDUnknown or inactive reasonCode
403PERMISSION_DENIEDCaller lacks orders.validate
404NOT_FOUNDUnknown order
409ORDER_TRANSITION_NOT_ALLOWEDStatus not SUBMITTED or PENDING (an accepted order is cancelled, not refused)
409VERSION_CONFLICTStale version
422REASON_REQUIREDreason missing for an item that requires a comment

Rules & side effects

  1. SUBMITTED or PENDING → REFUSED (terminal). An assisted order awaiting the reseller may be refused too (its confirmation is closed as is).
  2. Reservation released (RESERVATION_RELEASE) only if one was made (stock.reserveOn = SUBMITTED); promotion caps released; credit exposure released; first-order revert (lifecycles).
  3. Audit REJECT with reasonCode and text.
  4. Notifications: order.refused → reseller (push + in-app) with the reason label and comment; → the source or assigned delegate. SSE: order.status_changed (back-office and reseller streams).

Used by BO-41 · BO-39

PATCH /admin/orders/{orderId}/lines

admin.orders.modify · Permission orders.update · Apps BO · Release V1 · Requirements ORD-05 · ORD-06

Purpose. Controlled modification of an order (cahier §4.4 « modification contrôlée »): change quantities, remove or add lines, or fix the delivery address — always with a reason, logged and notified to the reseller.

Request

interface ModifyOrderLinesBody {
  reason: string;                                        // 3–500 chars, shown to the reseller
  changes?: { lineId: Uuid; quantity: number }[];        // 0 removes the line
  add?: { variantId: Uuid; quantity: number }[];
  addressId?: Uuid;                                      // another address of the same reseller
  version: number;
}
{
  "reason": "Rupture ACEFAST A29 : 6 pièces disponibles, quantité ajustée avec l'accord du client.",
  "changes": [{ "lineId": "0193016e-5a02-7b03-8c04-0d0e0f101113", "quantity": 6 }],
  "version": 1
}

Response 200 OK — the updated AdminOrderDetail plus warnings. For the body above the A29 line becomes 6 × 210 000 = 1 260 000 HT − 10 % promotion 126 000 = 1 134 000 HT, and the totals become { "subtotalHt": 2710000, "discountTotal": 126000, "deliveryFee": 0, "vatTotal": 490960, "totalTtc": 3074960, "currency": "DZD" }; controls.stock is now OK; the new changeLog entry is:

{
  "id": "01930171-2b3c-7d4e-9f5a-6b7c8d9e0f1a",
  "at": "2026-11-03T09:50:02Z",
  "actor": { "id": "01929e55-2c3d-7e4f-9a1b-2c3d4e5f6a7b", "displayName": "Nadia Belkacem" },
  "reason": "Rupture ACEFAST A29 : 6 pièces disponibles, quantité ajustée avec l'accord du client.",
  "changes": [{ "lineId": "0193016e-5a02-7b03-8c04-0d0e0f101113", "sku": "AF-A29-50W-WH", "kind": "QUANTITY", "before": 10, "after": 6 }],
  "totalsBefore": { "totalTtc": 3974600 },
  "totalsAfter": { "totalTtc": 3074960 }
}

and "warnings": [] (a total below orders.minAmount would give [{ "code": "ORDER_MIN_AMOUNT_NOT_MET", "details": { "minAmount": 1000000 } }] — a warning, not a refusal, because the back-office may knowingly reduce an order).

Errors

StatusCodeWhen
400VALIDATION_FAILEDNothing to change, unknown lineId, quantity < 0, a change that leaves no line (cancel the order instead)
403PERMISSION_DENIEDCaller lacks orders.update
404NOT_FOUNDUnknown order, variant or address
409ORDER_NOT_MODIFIABLEStatus other than SUBMITTED, PENDING, VALIDATED, PREPARING; or an increase / addition while PREPARING (only decreases are allowed during picking)
409ORDER_INVOICEDAn invoice is already issued (cancel it first)
409VERSION_CONFLICTStale version
422REASON_REQUIREDEmpty reason
422PRODUCT_UNAVAILABLE · QUANTITY_NOT_ALLOWEDAdded or increased line not orderable or not respecting minQty / qtyStep / maxQtyPerOrder
422STOCK_INSUFFICIENTVALIDATED order: an increase exceeds available stock
422CREDIT_LIMIT_EXCEEDEDCREDIT order: the new total exceeds available credit under BLOCK

Rules & side effects

  1. Allowed in SUBMITTED, PENDING, VALIDATED (any change) and PREPARING (decrease or remove only — picking shortage). Assisted orders awaiting the reseller cannot be modified (the reseller confirms what the delegate prepared).
  2. Unchanged lines keep their snapshot prices; changed quantities keep the line's unit price; new lines take today's price for the reseller's customer type. Promotions are re-evaluated for changed and added lines; caps are adjusted by the difference.
  3. Controls re-run. On a reserved order the difference is reserved or released (RESERVATION / RESERVATION_RELEASE movements).
  4. One OrderChange row with the diff and totals before/after; audit UPDATE on Order with the reason.
  5. Notifications: order.modified → reseller (push + in-app) with the new total and the reason. SSE: order.modified (back-office) and order.modified on the reseller stream.

Used by BO-39 · BO-41

POST /admin/orders/{orderId}/transitions

admin.orders.transition · Permission orders.update (fulfilment moves also delivery.update) · Apps BO · Release V1 · Requirements ORD-04 · DLV-01

Purpose. Moves an order along the fulfilment part of the lifecycle (preparation, packing, shipping, door refusal) and handles drafts and holds. Every other move has its own operation.

Request

interface OrderTransitionBody {
  to: OrderStatus;
  note?: string;                 // ≤ 500, added to the history
  reason?: string;               // required for PENDING (hold) and RETURNED
  packages?: number;             // READY: number of parcels (≥ 1)
  warehouseId?: Uuid;            // RETURNED: where the goods are re-entered (default: the order's warehouse)
  version: number;
}
{ "to": "READY", "packages": 2, "note": "Colis 1/2 : câbles et supports ; 2/2 : chargeurs.", "version": 4 }

Allowed transitions for back-office users

From → ToPermissionGuardsSide effects
DRAFT → SUBMITTEDorders.updateBack-office draft only; same controls as creationNumber allocated; as creation (first order, order.created, order.to_confirm)
SUBMITTED → PENDINGorders.updatereason required — puts the order on hold (missing information, customer to call back)pendingReasons += ON_HOLD; reseller sees « En attente »
PENDING → SUBMITTEDorders.updateThe only pending reason is ON_HOLDHold lifted; back to the Nouvelles tab of the queue
VALIDATED → PREPARINGorders.update or delivery.update—Delivery PREPARING; picker = caller; picking list available
PREPARING → READYorders.update or delivery.updatepackages ≥ 1; shortages handled first by a line modificationBL-2026-… allocated and the delivery-note PDF generated; Delivery READY
READY → SHIPPEDorders.update or delivery.updateDelivery data complete: OWN_FLEET → driver name and phone; CARRIER → active carrier and tracking number when the carrier requires it; PICKUP cannot shipSHIPMENT movements when stock.deductOn = SHIPPED (physical and reserved decrease); invoice FAC-… issued when billing.invoiceOn is VALIDATED or SHIPPED and none exists yet; Delivery SHIPPED, shippedAt
SHIPPED → RETURNEDorders.update or delivery.updatereason (refused at the door, unreachable after the last failed attempt)Door refusal (BR-31): a full return request is created automatically (origin: DOOR_REFUSAL, status IN_TRANSIT, every delivered line); its reception at the depot re-enters the accepted units (RETURN movements) and issues the credit note against the invoice. When stock had not been deducted yet (stock.deductOn = DELIVERED) the reservations are released at once instead. Delivery RETURNED

Moves refused here with 409 ORDER_TRANSITION_NOT_ALLOWED and details.useOperation: to VALIDATED (admin.orders.validate), to REFUSED (admin.orders.refuse), to CANCELLED (admin.orders.cancel), PENDING → SUBMITTED for an assisted order (admin.orders.confirmAssisted), DELIVERED → RETURNED (only through a return request). A move to DELIVERED answers 422 DELIVERY_PROOF_REQUIRED — use admin.deliveries.proof.

Response 200 OK — the updated AdminOrderDetail; for the example "status": "READY", delivery.status READY, delivery.deliveryNoteNumber BL-2026-000123, documents [{ "type": "DELIVERY_NOTE", "number": "BL-2026-000123", "generatedAt": "2026-11-04T10:05:11Z", "file": { "id": "01930d50-0a1b-7c2d-8e3f-4a5b6c7d8e9f", "url": "https://files.hoco.dz/s/bl-2026-000123.pdf?sig=…", "mimeType": "application/pdf", "size": 48211, "purpose": "GENERATED_DOCUMENT" } }], version 5.

Errors

StatusCodeWhen
400VALIDATION_FAILEDMissing packages for READY, unknown to
403PERMISSION_DENIEDCaller lacks the permission of the move
404NOT_FOUNDUnknown order or warehouse
409ORDER_TRANSITION_NOT_ALLOWEDMove not in the table — details: { from, to, allowed, useOperation? }
409VERSION_CONFLICTStale version
422REASON_REQUIREDHold or return without reason
422DELIVERY_INFO_REQUIREDShipping without the delivery data — details: { missing: ["trackingNumber"] }
422CARRIER_INACTIVEThe delivery's carrier was deactivated
422DELIVERY_PROOF_REQUIREDto: DELIVERED

Rules & side effects

  1. Stock and documents follow the crossing rule of lifecycles: reaching or passing the configured status triggers its movement or document exactly once.
  2. One OrderStatusChange per move; audit STATUS_CHANGE with note/reason; the Delivery mirrors the order in the same transaction.
  3. Notifications: order.status_changed → reseller (push + in-app) for PENDING (hold), PREPARING, READY, SHIPPED (with carrier and tracking) and RETURNED, as listed in the notifications matrix; the assigned delegate is told of RETURNED. Every move is also pushed live on the reseller stream (order.status_changed), so the tracker updates in real time (ORD-15).
  4. SSE: order.status_changed.

Used by BO-42 · BO-39 · BO-40

POST /admin/orders/{orderId}/cancel

admin.orders.cancel · Permission orders.update · Apps BO · Release V1 · Requirements ORD-10

Purpose. Withdraws an order before stock is deducted, with a reason; reserved quantities return to stock automatically.

Request

interface CancelOrderBody {
  reasonCode: string;     // ORDER_CANCEL_REASON: RESELLER_REQUEST, STOCK_UNAVAILABLE, PAYMENT_ISSUE, DUPLICATE_ORDER, DELIVERY_IMPOSSIBLE, OTHER…
  reason?: string;        // required when the item requires a comment; shown to the reseller
  version: number;
}
{ "reasonCode": "RESELLER_REQUEST", "reason": "Le client a demandé l'annulation par téléphone.", "version": 3 }

Response 200 OK — the AdminOrderDetail with "status": "CANCELLED". For a back-office DRAFT the draft is deleted instead and the answer is 200 { "id": "…", "deleted": true }.

Errors

StatusCodeWhen
400VALIDATION_FAILEDUnknown or inactive reasonCode
403PERMISSION_DENIEDCaller lacks orders.update
404NOT_FOUNDUnknown order
409ORDER_TRANSITION_NOT_ALLOWEDSHIPPED or later (stock deducted — the path is RETURNED), or already terminal
409INVOICE_HAS_PAYMENTSAn issued invoice has confirmed allocations — deallocate first
409VERSION_CONFLICTStale version
422REASON_REQUIREDComment missing for an item that requires it

Rules & side effects

  1. Allowed from SUBMITTED, PENDING, VALIDATED, PREPARING, READY (cancellation windows).
  2. Stock: RESERVATION_RELEASE for every reserved line, automatically — nothing to re-enter by hand. (If a configuration had deducted stock before shipping, the deducted quantities would be re-entered with RETURN movements in the same transaction.)
  3. Delivery closed (cancelledAt), leaves the board; an issued invoice without payments is cancelled; promotion caps and credit exposure released; first-order revert.
  4. Audit STATUS_CHANGE with reason; OrderStatusChange.
  5. Notifications: order.cancelled → reseller (push + in-app, with the reason) and the source/assigned delegate. SSE: order.status_changed (back-office and reseller streams).

Used by BO-39 · BO-41 · BO-42

POST /admin/orders/{orderId}/confirm-assisted

admin.orders.confirmAssisted · Permission orders.validate · Apps BO · Release V1 · Requirements ORD-08

Purpose. Records that the reseller agreed to an assisted order outside the app — typically by phone, because they do not have the app yet (cahier §4.4 « confirmée par le revendeur ou validée par l'administration »). It replaces the reseller's confirmation; the order then waits for the normal confirmation.

Request

interface ConfirmAssistedBody {
  channel: "PHONE" | "IN_PERSON" | "WHATSAPP" | "OTHER";   // ConfirmChannel
  note: string;                                             // 10–500 chars: who confirmed, when, how
  version: number;
}
{ "channel": "PHONE", "note": "M. Kerroum a confirmé par téléphone à 10 h 05 (appel sortant N. Belkacem).", "version": 2 }

Response 200 OK — the AdminOrderDetail with "status": "SUBMITTED" (or PENDING with the remaining pendingReasons, for example RESELLER_NOT_VALIDATED), "assisted": { "confirmation": "CONFIRMED_BY_ADMIN", "expiresAt": "2026-11-05T09:40:00Z" }, re-priced lines and fresh controls.

Errors

StatusCodeWhen
400VALIDATION_FAILEDnote shorter than 10 characters, unknown channel
403PERMISSION_DENIEDCaller lacks orders.validate
404NOT_FOUNDUnknown order
409ASSISTED_CONFIRMATION_NOT_PENDINGNot an assisted order, or its confirmation is not AWAITING_RESELLER
409ASSISTED_ORDER_EXPIREDassistedExpiresAt has passed (even if the expiry job has not run yet)
409VERSION_CONFLICTStale version
422STOCK_INSUFFICIENT · CREDIT_LIMIT_EXCEEDEDRe-pricing and re-control fail as at checkout

Rules & side effects

  1. AssistedConfirmation AWAITING_RESELLER → CONFIRMED_BY_ADMIN; order PENDING → SUBMITTED after re-pricing at today's prices and re-running the controls (or stays PENDING with another reason). From here the side effects of a checkout apply: promotion caps, first order (FIRST_ORDER, invitation step), events.
  2. The note is stored in the order history (OrderStatusChange.comment); audit VALIDATE on Order with channel and note.
  3. Notifications: assisted_order.confirmed → the source delegate; the reseller receives the standard status update. SSE: order.to_confirm (it moves to the Nouvelles tab) and order.status_changed.

Used by BO-41 · BO-39

GET /admin/orders/{orderId}/history

admin.orders.history · Permission orders.view · Apps BO · Release V1 · Requirements ORD-06 · AUD-02

Purpose. The complete, merged timeline of an order: status changes, line modifications, overrides, documents, notifications sent and returns.

Request

QueryTypeValues / defaultNotes
kindstring[]STATUS | CHANGE | OVERRIDE | DOCUMENT | NOTIFICATION | RETURN; default allRepeatable

Response 200 OK — { items }, newest first (an order has at most a few hundred entries; not paginated, capped at 500)

{
  "items": [
    { "kind": "DOCUMENT", "at": "2026-11-04T10:05:11Z", "actor": { "id": "01929e77-4e5f-7a6b-8c7d-8e9f0a1b2c3d", "displayName": "Lamia Kaci" }, "document": { "type": "DELIVERY_NOTE", "number": "BL-2026-000123", "version": 1 } },
    { "kind": "STATUS", "at": "2026-11-04T10:05:11Z", "actor": { "id": "01929e77-4e5f-7a6b-8c7d-8e9f0a1b2c3d", "displayName": "Lamia Kaci" }, "from": "PREPARING", "to": "READY", "note": "Colis 1/2 : câbles et supports ; 2/2 : chargeurs.", "app": "DASHBOARD" },
    { "kind": "NOTIFICATION", "at": "2026-11-03T09:52:33Z", "actor": null, "eventKey": "order.validated", "channel": "PUSH", "recipient": { "id": "0192f4b8-7001-7a02-8b03-0c0d0e0f1011", "displayName": "Karim Benali" }, "deliveryStatus": "DELIVERED" },
    { "kind": "OVERRIDE", "at": "2026-11-03T09:52:30Z", "actor": { "id": "01929e66-3d4e-7f5a-8b6c-7d8e9f0a1b2c", "displayName": "Sofiane Hamdi" }, "override": "STOCK", "reason": "Réassort ACEFAST reçu demain matin." },
    { "kind": "CHANGE", "at": "2026-11-03T09:50:02Z", "actor": { "id": "01929e55-2c3d-7e4f-9a1b-2c3d4e5f6a7b", "displayName": "Nadia Belkacem" }, "reason": "Rupture ACEFAST A29 : 6 pièces disponibles.", "changes": [{ "sku": "AF-A29-50W-WH", "kind": "QUANTITY", "before": 10, "after": 6 }], "totalBefore": 3974600, "totalAfter": 3074960 },
    { "kind": "STATUS", "at": "2026-11-03T08:12:00Z", "actor": { "id": "0192f4b8-7001-7a02-8b03-0c0d0e0f1011", "displayName": "Karim Benali" }, "from": null, "to": "SUBMITTED", "app": "RESELLER_APP" }
  ]
}

Errors

StatusCodeWhen
400VALIDATION_FAILEDUnknown kind
403PERMISSION_DENIEDCaller lacks orders.view
404NOT_FOUNDUnknown order

Rules & side effects

  1. Read-only; merges OrderStatusChange, OrderChange, override audit rows, generated documents, NotificationDelivery rows of the order's events and return requests.
  2. The reseller-facing history (orders.get) shows only status, date and public reason; internal notes and overrides stay here.

Used by BO-39

POST /admin/orders/{orderId}/documents

admin.orders.documents · Permission orders.update · Apps BO · Release V1 · Requirements ORD-12

Purpose. Generates a missing document or regenerates the PDF of an issued invoice or delivery note (after a template fix, a lost file, a second print).

Request

interface OrderDocumentsBody {
  types: ("INVOICE" | "DELIVERY_NOTE")[];   // OrderDocumentType, 1–2 items
  regenerate?: boolean;                      // default false: return the current PDFs when they exist
}
{ "types": ["DELIVERY_NOTE", "INVOICE"], "regenerate": true }

Response 200 OK

{
  "documents": [
    { "type": "DELIVERY_NOTE", "number": "BL-2026-000123", "version": 2, "generatedAt": "2026-11-05T08:01:44Z",
      "file": { "id": "01930d60-1b2c-7d3e-9f4a-5b6c7d8e9f0a", "url": "https://files.hoco.dz/s/bl-2026-000123-v2.pdf?sig=…", "mimeType": "application/pdf", "size": 48530, "purpose": "GENERATED_DOCUMENT" } },
    { "type": "INVOICE", "number": "FAC-2026-000123", "version": 2, "generatedAt": "2026-11-05T08:01:45Z",
      "file": { "id": "01930d60-2c3d-7e4f-8a5b-6c7d8e9f0a1b", "url": "https://files.hoco.dz/s/fac-2026-000123-v2.pdf?sig=…", "mimeType": "application/pdf", "size": 61207, "purpose": "GENERATED_DOCUMENT" } }
  ]
}

Errors

StatusCodeWhen
400VALIDATION_FAILEDEmpty or duplicate types
403PERMISSION_DENIEDCaller lacks orders.update
404NOT_FOUNDUnknown order
409DOCUMENT_NOT_AVAILABLEThe order has not reached the document's status yet: delivery note from READY, invoice from billing.invoiceOn — details: { type, availableFrom }

Rules & side effects

  1. Never changes numbers or amounts: an issued invoice is regenerated from its stored snapshot (legal mentions, VAT per rate). Correcting an amount means cancelling the invoice (admin.invoices.cancel) or issuing a credit note.
  2. Generation is synchronous (target under 5 s); each regeneration increments version and keeps previous files for audit.
  3. Audit UPDATE on Invoice / Delivery (changes: [{ field: "pdfVersion" }]). No notification; the reseller downloads the current version through orders.document.

Used by BO-39 · BO-42

Returns

Returns are V1 since 17 Oct 2026 (ORD-13). A return request comes from the reseller (returns.create, on a DELIVERED order within returns.windowDays) from an after-sales claim resolved with RETURN (admin.tickets.resolve), or automatically from a refusal at the door (admin.orders.transition SHIPPED → RETURNED). Rules: BR-31. Lifecycle: REQUESTED → APPROVED (→ IN_TRANSIT when transport is arranged) → RECEIVED → REFUNDED, or REJECTED at the decision or at inspection. Reception re-enters stock and issues the credit note automatically.

GET /admin/returns

admin.returns.list · Permission orders.view · Apps BO · Release V1 · Requirements ORD-13

Purpose. The return requests queue of BO-69, with filters.

Request

QueryTypeValues / defaultNotes
statusReturnStatus[]repeatable; default REQUESTED, APPROVED, IN_TRANSIT
originstringRESELLER | AFTER_SALES
resellerId / orderIdUuid
wilayaCodestring[]repeatable
from / tostring (date)On requestedAt
qstringmin 2 charsRET- or CMD- number, store name, SKU
sortstringrequestedAt (default), number, status
orderstringasc | desc (default asc — oldest first)
page / pageSizenumber1 / 25 (max 200)

Response 200 OK

{
  "items": [
    {
      "id": "01931a20-6b7c-7d8e-9f01-2a3b4c5d6e7f",
      "number": "RET-2026-000012",
      "status": "REQUESTED",
      "origin": "RESELLER",
      "order": { "id": "01930170-1c2d-7e3f-8a4b-5c6d7e8f9a0b", "number": "CMD-2026-000157", "deliveredAt": "2026-11-04T11:20:00Z" },
      "reseller": { "id": "0192f4d2-3e4f-7a5b-9c6d-7e8f9a0b1c2d", "number": "REV-000198", "storeName": "Smart Accessoires Es Senia", "ownerName": "Hichem Bouzid", "phone": "+213550987654", "wilayaCode": "31", "communeName": "Es Senia", "status": "ACTIVE" },
      "itemsCount": 1,
      "unitsCount": 3,
      "amountTtc": 124950,
      "requestedAt": "2026-11-06T09:14:00Z",
      "ageHours": 26
    }
  ],
  "total": 2,
  "page": 1,
  "pageSize": 25
}

amountTtc is the value of the requested items at the order's net unit prices (3 × 41 650).

Errors

StatusCodeWhen
400VALIDATION_FAILEDUnknown status or sort field
403PERMISSION_DENIEDCaller lacks orders.view

Rules & side effects

  1. Read-only. New requests arrive live with return.requested.

Used by BO-69 · BO-39 · BO-11

GET /admin/returns/{returnId}

admin.returns.get · Permission orders.view · Apps BO · Release V1 · Requirements ORD-13

Purpose. A return request with its items, photos, decision, transport, reception and credit note.

Request

PathTypeNotes
returnIdUuid

Response 200 OK — ReturnRequest

{
  "id": "01931a20-6b7c-7d8e-9f01-2a3b4c5d6e7f",
  "number": "RET-2026-000012",
  "status": "REQUESTED",
  "order": { "id": "01930170-1c2d-7e3f-8a4b-5c6d7e8f9a0b", "number": "CMD-2026-000157", "deliveredAt": "2026-11-04T11:20:00Z" },
  "reseller": { "id": "0192f4d2-3e4f-7a5b-9c6d-7e8f9a0b1c2d", "number": "REV-000198", "storeName": "Smart Accessoires Es Senia", "ownerName": "Hichem Bouzid", "phone": "+213550987654", "wilayaCode": "31", "communeName": "Es Senia", "status": "ACTIVE" },
  "origin": "RESELLER",
  "reason": "Trois câbles livrés en blanc au lieu de noir.",
  "items": [
    {
      "id": "01931a20-7c8d-7e9f-8a01-2b3c4d5e6f70",
      "orderLineId": "01930170-2d3e-7f4a-9b5c-6d7e8f9a0b1c",
      "sku": "HC-X14-CC-1M-BK",
      "productName": "Câble USB-C vers USB-C 60 W 1 m",
      "quantity": 3,
      "unitPrice": { "ht": 35000, "ttc": 41650, "vatRate": 1900 },
      "photos": [{ "id": "01931a20-8d9e-7fa0-9b12-3c4d5e6f7081", "url": "https://files.hoco.dz/s/ret12-1.webp?sig=…", "thumbnailUrl": "https://files.hoco.dz/s/ret12-1-thumb.webp?sig=…", "mimeType": "image/webp", "size": 182340, "purpose": "TICKET_ATTACHMENT" }]
    }
  ],
  "requestedAt": "2026-11-06T09:14:00Z",
  "version": 1
}

Errors

StatusCodeWhen
403PERMISSION_DENIEDCaller lacks orders.view
404NOT_FOUNDUnknown return

Rules & side effects

  1. Read-only; photo URLs are signed for 5 minutes.

Used by BO-69

POST /admin/returns/{returnId}/decision

admin.returns.decide · Permission orders.validate · Apps BO · Release V1 · Requirements ORD-13

Purpose. Approves or rejects a return request; an approval may arrange the transport at once.

Request

interface ReturnDecisionBody {
  decision: "APPROVE" | "REJECT";
  reason?: string;            // required on REJECT (10–500 chars, shown to the reseller)
  items?: { returnItemId: Uuid; quantity: number }[];   // APPROVE: approve fewer units than requested (default: all)
  transport?: {               // APPROVE only; when present the return goes straight to IN_TRANSIT
    mode: "RESELLER_DROP_OFF" | "CARRIER" | "DELEGATE_PICKUP";
    carrierId?: Uuid;         // CARRIER
    trackingNumber?: string;
  };
  version: number;
}
{
  "decision": "APPROVE",
  "transport": { "mode": "DELEGATE_PICKUP" },
  "version": 1
}

Response 200 OK — the ReturnRequest with "status": "IN_TRANSIT", "decision": { "decision": "APPROVE", "by": { "id": "01929e55-2c3d-7e4f-9a1b-2c3d4e5f6a7b", "displayName": "Nadia Belkacem" }, "at": "2026-11-07T08:30:00Z" }, "transport": { "mode": "DELEGATE_PICKUP" }, version 2.

Errors

StatusCodeWhen
400VALIDATION_FAILEDUnknown item, quantity above the requested one, transport on a rejection
403PERMISSION_DENIEDCaller lacks orders.validate
404NOT_FOUNDUnknown return or carrier
409RETURN_TRANSITION_NOT_ALLOWEDStatus not REQUESTED (or not APPROVED when only transport is added) — details: { from, to, allowed }
409VERSION_CONFLICTStale version
422REASON_REQUIREDRejection without reason

Rules & side effects

  1. REQUESTED → APPROVED (or IN_TRANSIT with transport), or → REJECTED. Calling it again on an APPROVED return with decision: "APPROVE" and a transport moves it to IN_TRANSIT.
  2. No stock or money moves at this stage.
  3. A return created from an after-sales claim is born APPROVED (the claim resolution is the decision); a linked SAV ticket follows the decision (its status is updated and the decision is posted as a public message).
  4. Audit VALIDATE or REJECT on ReturnRequest.
  5. Notifications: return.approved or return.rejected → reseller (push + in-app); DELEGATE_PICKUP creates a CrmTask (type VISIT, « Récupérer le retour RET-… ») for the reseller's assigned delegate (task.assigned); stock holders get return.approved (reception expected). SSE: the reseller stream gets return.status_changed.

Used by BO-69

POST /admin/returns/{returnId}/reception

admin.returns.receive · Permission stock.update · Apps BO · Release V1 · Requirements ORD-13 · STK-03 · FIN-08

Purpose. Records the inspected goods at the depot. Sellable units re-enter stock automatically (RETURN movements) and a credit note is issued for the accepted units in the same transaction.

Request

interface ReturnReceptionBody {
  warehouseId: Uuid;
  receivedAt?: Iso;                       // default now; not in the future
  items: {
    returnItemId: Uuid;
    receivedQty: number;                  // physically received, ≤ approved quantity
    acceptedQty: number;                  // credited to the reseller, ≤ receivedQty
    restockQty: number;                   // sellable units re-entered into stock, ≤ acceptedQty
    condition: "SELLABLE" | "DEFECTIVE" | "DAMAGED" | "MISSING";
    note?: string;
  }[];
  note?: string;
  version: number;
}
{
  "warehouseId": "01928c01-1111-7aaa-8bbb-0c0d0e0f1011",
  "items": [
    { "returnItemId": "01931a20-7c8d-7e9f-8a01-2b3c4d5e6f70", "receivedQty": 3, "acceptedQty": 3, "restockQty": 3, "condition": "SELLABLE", "note": "Emballages intacts." }
  ],
  "version": 2
}

Response 200 OK — the ReturnRequest with "status": "REFUNDED", the items' receivedQty/acceptedQty/restockQty/condition, "reception": { "warehouseCode": "WH-ORN", "receivedAt": "2026-11-08T10:12:00Z", "by": { "id": "01929e88-5f6a-7b7c-8d8e-9f0a1b2c3d4e", "displayName": "Amine Saadi" } }, and:

{
  "creditNote": {
    "id": "01931c40-8d9e-7fa0-9b12-4c5d6e7f8091",
    "number": "AV-2026-000004",
    "amountTtc": 124950,
    "issuedAt": "2026-11-08T10:12:00Z",
    "file": { "id": "01931c40-9eaf-7b01-8c23-5d6e7f809102", "url": "https://files.hoco.dz/s/av-2026-000004.pdf?sig=…", "mimeType": "application/pdf", "size": 40112, "purpose": "GENERATED_DOCUMENT" }
  },
  "stockMovements": [
    { "id": "01931c41-0a1b-7c2d-9e3f-4a5b6c7d8e9f", "type": "RETURN", "sku": "HC-X14-CC-1M-BK", "warehouseCode": "WH-ORN", "quantity": 3, "physicalAfter": 1743, "reservedAfter": 160 }
  ]
}

Errors

StatusCodeWhen
400VALIDATION_FAILEDQuantities not satisfying restockQty ≤ acceptedQty ≤ receivedQty ≤ approved, unknown item, receivedAt in the future
403PERMISSION_DENIEDCaller lacks stock.update
404NOT_FOUNDUnknown return or warehouse
409RETURN_TRANSITION_NOT_ALLOWEDStatus not APPROVED or IN_TRANSIT
409VERSION_CONFLICTStale version
422WAREHOUSE_INACTIVEThe warehouse is inactive

Rules & side effects

  1. APPROVED / IN_TRANSIT → RECEIVED → REFUNDED in one transaction when at least one unit is accepted; → REJECTED when nothing is accepted (inspection failed — the reseller is told why from the item notes).
  2. Stock: one RETURN movement per item with restockQty > 0 (physical += restockQty) in warehouseId; defective and damaged units are not re-entered (they are recorded on the return for the supplier claim). A product put OUT_OF_STOCK by the system goes back to ACTIVE.
  3. Credit note (CreditNote, V1): amount = Σ acceptedQty × the order line's net unit price TTC (3 × 41 650 = 124 950); allocated first to the invoice of the order when it has a balance (its status is recomputed), otherwise kept as a credit balance of the reseller that lowers credit exposure (finance). PDF generated. V2 loyalty: points REVERSAL.
  4. Order: when every delivered unit has been returned and accepted, the order becomes RETURNED (OrderStatusChange); otherwise it stays DELIVERED with openReturns updated.
  5. Audit STATUS_CHANGE on ReturnRequest, CREATE on CreditNote; StockMovement rows reference the return.
  6. Notifications: return.received → reseller (push + in-app, credit note downloadable) and finance (in-app, credit note issued). SSE: reseller stream return.status_changed; back-office stock.alert when a level changes alertLevel (for example OUT → OK).

Used by BO-69 · BO-35

Deliveries

GET /admin/deliveries

admin.deliveries.list · Permission delivery.view · Apps BO · Release V1 · Requirements DLV-01 · DLV-02

Purpose. The delivery board — one column per delivery status (À préparer, En préparation, Prête, Expédiée, Livrée, Échec de livraison) — or the same records as a paginated list.

Request

QueryTypeValues / defaultNotes
viewstringboard (default) | list
statusDeliveryStatus[]repeatable; board default TO_PREPARE, PREPARING, READY, SHIPPED, DELIVERED, FAILED
modeDeliveryMode[]repeatable
carrierIdUuid[]repeatable
wilayaCodestring[]repeatableDelivery address
warehouseIdUuid[]repeatableThe order's shipping warehouse. Added in contract 1.1.0
codbooleantrue: cash-on-delivery orders (an amount to collect); false: the others. Added in contract 1.1.0
validatedFrom / validatedTostring (date)Order confirmation day, inclusive (Africa/Algiers). Added in contract 1.1.0
scheduledForstring (date)
deliveredSincestring (date)default: 7 days agoLimits the Livrée column
qstringmin 2 charsOrder or BL- number, tracking number, store name
perColumnnumber50 (max 200)Board view
sortstringvalidatedAt (default), scheduledFor, shippedAtList view
order / page / pageSizeasc | desc · 1 · 25 (max 200)List view

Response 200 OK — board view

{
  "columns": [
    {
      "status": "TO_PREPARE",
      "total": 11,
      "items": [
        {
          "id": "01930170-9a0b-7c1d-8e2f-3a4b5c6d7e8f",
          "status": "TO_PREPARE",
          "mode": "CARRIER",
          "order": { "id": "0193016e-4b2a-7c11-9d8e-7f6a5b4c3d2e", "number": "CMD-2026-000158", "totalTtc": 3074960, "paymentMethod": "CREDIT", "linesCount": 3, "unitsCount": 41, "validatedAt": "2026-11-03T09:52:30Z", "hasOverride": true },
          "reseller": { "id": "0192f4b8-61c2-7a3e-9b10-1f2e3d4c5b6a", "number": "REV-000123", "storeName": "Phone Store El Bahia", "wilayaCode": "31", "communeName": "Bir El Djir" },
          "ageHours": 2,
          "late": false,
          "attemptsCount": 0,
          "warehouse": { "id": "01928c00-0a1b-7c2d-8e3f-4a5b6c7d8e9f", "code": "ORN", "name": "Dépôt Oran" },
          "version": 1
        }
      ]
    },
    { "status": "PREPARING", "total": 6, "items": [] },
    { "status": "READY", "total": 4, "items": [] },
    {
      "status": "SHIPPED",
      "total": 17,
      "items": [
        {
          "id": "01930171-0b1c-7d2e-8f3a-4b5c6d7e8f90",
          "status": "SHIPPED",
          "mode": "CARRIER",
          "carrier": { "id": "01928e10-1a2b-7c3d-8e4f-5a6b7c8d9e0f", "name": "Yalidine" },
          "trackingNumber": "YAL-58213377",
          "trackingUrl": "https://yalidine.com/suivi/YAL-58213377",
          "order": { "id": "01930170-1c2d-7e3f-8a4b-5c6d7e8f9a0b", "number": "CMD-2026-000157", "totalTtc": 1023400, "paymentMethod": "CASH_ON_DELIVERY", "linesCount": 2, "unitsCount": 16, "validatedAt": "2026-11-02T16:02:00Z", "hasOverride": false },
          "reseller": { "id": "0192f4d2-3e4f-7a5b-9c6d-7e8f9a0b1c2d", "number": "REV-000198", "storeName": "Smart Accessoires Es Senia", "wilayaCode": "31", "communeName": "Es Senia" },
          "codAmount": 1023400,
          "shippedAt": "2026-11-03T07:45:00Z",
          "ageHours": 26,
          "late": false,
          "attemptsCount": 0
        }
      ]
    },
    { "status": "DELIVERED", "total": 38, "items": [] },
    { "status": "FAILED", "total": 1, "items": [] }
  ],
  "generatedAt": "2026-11-04T09:00:00Z"
}

The list view returns Page<DeliveryCard> with the same card shape.

Field notes. version is the delivery's optimistic-lock version, to send to admin.deliveries.update straight from a card; warehouse is the order's shipping warehouse (absent when none is set). Both added in contract 1.1.0.

Errors

StatusCodeWhen
400VALIDATION_FAILEDUnknown status, mode or view
403PERMISSION_DENIEDCaller lacks delivery.view

Rules & side effects

  1. Read-only. Cancelled orders leave the board. Moving a card calls admin.orders.transition (the board never changes a delivery status on its own, except FAILED through admin.deliveries.update).
  2. late: validated longer than delivery.shippingSlaHours (48 h) and not shipped.
  3. Live: cards move on order.status_changed.

Used by BO-42

PATCH /admin/deliveries/{deliveryId}

admin.deliveries.update · Permission delivery.update · Apps BO · Release V1 · Requirements DLV-02

Purpose. Sets the delivery data (mode, carrier, tracking number, driver, planned date, parcels) and records failed delivery attempts and new attempts.

Request

interface UpdateDeliveryBody {
  mode?: DeliveryMode;                 // until SHIPPED
  carrierId?: Uuid | null;
  trackingNumber?: string | null;      // 3–60 chars
  driverName?: string | null;
  driverPhone?: string | null;         // E.164 after normalisation
  scheduledFor?: string | null;        // YYYY-MM-DD
  packages?: number;
  note?: string;
  attempt?: { outcome: "FAILED"; reason: string; nextAttemptOn?: string };   // SHIPPED only → FAILED
  reattempt?: boolean;                 // FAILED → SHIPPED (new attempt)
  version: number;
}
{ "mode": "CARRIER", "carrierId": "01928e10-1a2b-7c3d-8e4f-5a6b7c8d9e0f", "trackingNumber": "YAL-58219004", "scheduledFor": "2026-11-05", "version": 3 }

Response 200 OK — the Delivery:

{
  "id": "01930170-9a0b-7c1d-8e2f-3a4b5c6d7e8f",
  "orderId": "0193016e-4b2a-7c11-9d8e-7f6a5b4c3d2e",
  "orderNumber": "CMD-2026-000158",
  "status": "READY",
  "mode": "CARRIER",
  "carrier": { "id": "01928e10-1a2b-7c3d-8e4f-5a6b7c8d9e0f", "name": "Yalidine" },
  "trackingNumber": "YAL-58219004",
  "trackingUrl": "https://yalidine.com/suivi/YAL-58219004",
  "scheduledFor": "2026-11-05",
  "packages": 2,
  "deliveryNoteNumber": "BL-2026-000123",
  "attempts": [],
  "version": 4
}

Errors

StatusCodeWhen
400VALIDATION_FAILEDBad phone, past scheduledFor, attempt and reattempt together
403PERMISSION_DENIEDCaller lacks delivery.update
404NOT_FOUNDUnknown delivery or carrier
409DELIVERY_NOT_EDITABLEDelivery DELIVERED, RETURNED or cancelled; mode change after SHIPPED; attempt when not SHIPPED; reattempt when not FAILED
409VERSION_CONFLICTStale version
422CARRIER_INACTIVEThe carrier is inactive or does not cover the delivery wilaya
422REASON_REQUIREDattempt without reason

Rules & side effects

  1. Data only — the order status does not change, except the delivery's own FAILED state (order stays SHIPPED) and its return to SHIPPED on reattempt. After the last failed attempt the goods come back through admin.orders.transition to: RETURNED.
  2. trackingUrl is built from the carrier's trackingUrlTemplate.
  3. Audit UPDATE on Delivery.
  4. Notifications: a tracking number set or changed after SHIPPED → order.status_changed re-sent with the new reference; a failed attempt → delivery.failed to the reseller (push + in-app, « Livraison non aboutie — nouvelle tentative le … »). SSE: order.status_changed carries deliveryStatus changes.

Used by BO-42 · BO-39

GET /admin/deliveries/{deliveryId}/picking-list

admin.deliveries.pickingList · Permission delivery.view · Apps BO · Release V1 · Requirements DLV-01

Purpose. The printable picking list of an order (bon de préparation), as data or as a PDF.

Request

QueryTypeValues / defaultNotes
formatstringjson (default) | pdf

Response 200 OK — format=json

{
  "delivery": { "id": "01930170-9a0b-7c1d-8e2f-3a4b5c6d7e8f", "status": "PREPARING", "mode": "CARRIER" },
  "order": { "id": "0193016e-4b2a-7c11-9d8e-7f6a5b4c3d2e", "number": "CMD-2026-000158", "validatedAt": "2026-11-03T09:52:30Z", "note": "Merci de livrer avant jeudi.", "internalNote": "Livrer en deux colis." },
  "reseller": { "number": "REV-000123", "storeName": "Phone Store El Bahia", "phone": "+213555123456" },
  "address": { "wilayaName": "Oran", "communeName": "Bir El Djir", "addressLine": "Cité 1 000 logements, local n° 12", "landmark": "En face de la pharmacie El Amel" },
  "warehouse": { "code": "WH-ORN", "name": "Dépôt Oran" },
  "lines": [
    { "sku": "AF-A29-50W-WH", "barcode": "6974316280521", "brandName": "ACEFAST", "productName": "Chargeur GaN 50 W 2 ports", "variantName": "Blanc", "quantity": 6 },
    { "sku": "BF-BH20-BK", "barcode": "6941991101234", "brandName": "BOROFONE", "productName": "Support voiture magnétique", "variantName": "Noir", "quantity": 15 },
    { "sku": "HC-X14-CC-1M-BK", "barcode": "6931474761234", "brandName": "HOCO", "productName": "Câble USB-C vers USB-C 60 W 1 m", "variantName": "Noir · 1 m", "quantity": 20 }
  ],
  "unitsTotal": 41,
  "generatedAt": "2026-11-04T08:31:00Z"
}

format=pdf returns { "file": FileRef } (purpose GENERATED_DOCUMENT, signed URL valid 5 minutes, A4, one page per 40 lines, with a barcode of the order number).

Errors

StatusCodeWhen
400VALIDATION_FAILEDUnknown format
403PERMISSION_DENIEDCaller lacks delivery.view
404NOT_FOUNDUnknown delivery
409DOCUMENT_NOT_AVAILABLEDelivery cancelled

Rules & side effects

  1. Read-only; lines sorted by brand then SKU (no bin locations in V1). No prices are printed.

Used by BO-42

POST /admin/deliveries/{deliveryId}/proof

admin.deliveries.proof · Permission delivery.update · Apps BO · Release V1 · Requirements DLV-03

Purpose. Records the proof of delivery (photo or signature, recipient, time) and marks the order DELIVERED — also used for PICKUP orders handed over at the depot.

Request

interface DeliveryProofBody {
  recipientName: string;             // 2–120 chars
  deliveredAt: Iso;                  // ≥ shippedAt (or READY time for PICKUP), ≤ now + 5 min
  fileId?: Uuid;                     // DELIVERY_PROOF photo or signature; required when delivery.proofPhotoRequired = true
  note?: string;
  codCollected?: { amount: Money; method: "CASH_ON_DELIVERY" };   // CASH_ON_DELIVERY orders
  version: number;
}
{
  "recipientName": "Hichem Bouzid",
  "deliveredAt": "2026-11-04T11:20:00Z",
  "fileId": "01930e10-3c4d-7e5f-8a6b-7c8d9e0f1a2b",
  "codCollected": { "amount": 1023400, "method": "CASH_ON_DELIVERY" },
  "version": 2
}

Response 200 OK — the Delivery with "status": "DELIVERED", "deliveredAt": "2026-11-04T11:20:00Z", proof (recipient, file, recordedBy) and "payment": { "id": "01930e11-4d5e-7f6a-9b7c-8d9e0f1a2b3c", "number": "PAY-2026-000081", "status": "PENDING_VERIFICATION", "amount": 1023400 } when cash was collected.

Errors

StatusCodeWhen
400VALIDATION_FAILEDdeliveredAt before shipping or in the future, codCollected on a non-COD order
403PERMISSION_DENIEDCaller lacks delivery.update
404NOT_FOUNDUnknown delivery or file
409ORDER_TRANSITION_NOT_ALLOWEDOrder not SHIPPED (or READY with mode PICKUP)
409VERSION_CONFLICTStale version
422DELIVERY_PROOF_REQUIREDNo fileId while delivery.proofPhotoRequired = true
422FILE_NOT_READY · FILE_PURPOSE_MISMATCHUpload not completed, or purpose not DELIVERY_PROOF

Rules & side effects

  1. Order SHIPPED (or READY for pickup) → DELIVERED; Delivery DELIVERED; deliveredAt.
  2. Stock: when stock.deductOn = DELIVERED (or for a pickup that skipped SHIPPED), SHIPMENT movements happen now (crossing rule). Invoice issued now if billing.invoiceOn = DELIVERED or if it was skipped.
  3. Cash collected creates a Payment (PaymentSource DELIVERY_COLLECTION, PENDING_VERIFICATION, PAY- number) linked to the order and its invoice — finance confirms it (admin.payments.verify); delivery staff never confirm money.
  4. CRM: ResellerStatus FIRST_ORDER → ACTIVE; conversion counted for the attributed delegate. V2: loyalty points when loyalty.earnOn = DELIVERED. The return window (returns.windowDays) starts.
  5. Audit STATUS_CHANGE on Order, CREATE on Payment when applicable.
  6. Notifications: order.status_changed (DELIVERED) → reseller (push + in-app) and the assigned delegate. SSE: order.status_changed (back-office and reseller streams); payment.declared to finance when cash was collected.

Used by BO-42 · BO-39

Carriers

Carrier records list the delivery companies and the own fleet (cahier §11 « Connexion aux agences disposant d'une API ; sinon gestion manuelle structurée »). API integration is V2 (DLV-05); V1 carriers are managed manually.

interface Carrier {
  id: Uuid;
  name: string;                         // unique, 2–80 chars
  mode: "OWN_FLEET" | "CARRIER";        // DeliveryMode subset
  phone?: string;
  trackingUrlTemplate?: string;         // must contain {tracking}
  requiresTrackingNumber: boolean;
  coverage: string[] | null;            // wilaya codes; null = all 58
  apiCapable: boolean;                  // informative in V1; V2 integration flag
  active: boolean;
  deliveriesCount: number;              // all time
  createdAt: Iso;
  updatedAt: Iso;
  version: number;
}

GET /admin/carriers

admin.carriers.crud (list) · Permission delivery.view · Apps BO · Release V1 · Requirements DLV-04

Purpose. Lists carriers for the carriers screen and the carrier picker of the delivery board.

Request

QueryTypeValues / defaultNotes
activeboolean
wilayaCodestringCarriers covering this wilaya
qstringmin 2 charsName
sort / order / page / pageSizename (default) · asc · 1 · 25 (max 200)

Response 200 OK

{
  "items": [
    { "id": "01928e10-1a2b-7c3d-8e4f-5a6b7c8d9e0f", "name": "Yalidine", "mode": "CARRIER", "phone": "+213982401500", "trackingUrlTemplate": "https://yalidine.com/suivi/{tracking}", "requiresTrackingNumber": true, "coverage": null, "apiCapable": true, "active": true, "deliveriesCount": 812, "createdAt": "2026-10-20T09:00:00Z", "updatedAt": "2026-10-20T09:00:00Z", "version": 1 },
    { "id": "01928e10-2b3c-7d4e-9f5a-6b7c8d9e0f1a", "name": "Livreur HOCO Oran", "mode": "OWN_FLEET", "requiresTrackingNumber": false, "coverage": ["31", "46", "29"], "apiCapable": false, "active": true, "deliveriesCount": 214, "createdAt": "2026-10-20T09:05:00Z", "updatedAt": "2026-10-28T14:12:00Z", "version": 2 }
  ],
  "total": 4,
  "page": 1,
  "pageSize": 25
}

Errors

StatusCodeWhen
403PERMISSION_DENIEDCaller lacks delivery.view

Rules & side effects

  1. Read-only. Reading is relaxed to delivery.view (the bundle's delivery.administer applies to writes) so the board can offer the carrier picker — Assumption.

Used by BO-43 · BO-42

GET /admin/carriers/{carrierId}

admin.carriers.crud (get) · Permission delivery.view · Apps BO · Release V1 · Requirements DLV-04

Purpose. One carrier.

Response 200 OK — a Carrier (as in the list).

Errors

StatusCodeWhen
403PERMISSION_DENIEDCaller lacks delivery.view
404NOT_FOUNDUnknown carrier

Rules & side effects

  1. Read-only.

Used by BO-43

POST /admin/carriers

admin.carriers.crud (create) · Permission delivery.administer · Apps BO · Release V1 · Requirements DLV-04

Purpose. Adds a carrier or an own-fleet entry.

Request

interface CreateCarrierBody {
  name: string;
  mode: "OWN_FLEET" | "CARRIER";
  phone?: string;
  trackingUrlTemplate?: string;
  requiresTrackingNumber?: boolean;   // default true for CARRIER, false for OWN_FLEET
  coverage?: string[] | null;
  apiCapable?: boolean;
}
{ "name": "ZR Express", "mode": "CARRIER", "phone": "+213982405060", "trackingUrlTemplate": "https://zrexpress.dz/tracking/{tracking}", "coverage": null }

Response 201 Created — the Carrier with active: true, deliveriesCount: 0, version: 1.

Errors

StatusCodeWhen
400VALIDATION_FAILEDTemplate without {tracking} or not https, unknown wilaya code
403PERMISSION_DENIEDCaller lacks delivery.administer
409DUPLICATE_VALUEName already used — details: { field: "name", value, existingId }

Rules & side effects

  1. Audit CREATE on Carrier. No notification.

Used by BO-43

PATCH /admin/carriers/{carrierId}

admin.carriers.crud (update) · Permission delivery.administer · Apps BO · Release V1 · Requirements DLV-04

Purpose. Edits a carrier or deactivates it (carriers are never deleted — past deliveries reference them).

Request

interface UpdateCarrierBody extends Partial<CreateCarrierBody> { active?: boolean; version: number }
{ "active": false, "version": 2 }

Response 200 OK — the updated Carrier.

Errors

StatusCodeWhen
400VALIDATION_FAILEDAs for creation
403PERMISSION_DENIEDCaller lacks delivery.administer
404NOT_FOUNDUnknown carrier
409DUPLICATE_VALUENew name already used
409VERSION_CONFLICTStale version

Rules & side effects

  1. Deactivating keeps current deliveries untouched; new assignments and shipping with this carrier answer 422 CARRIER_INACTIVE.
  2. Audit UPDATE on Carrier.

Used by BO-43

On this page