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(orPENDINGwhen on hold) and waits foradmin.orders.validateby a person holdingorders.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
RETURNmovements 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.
| Permission | Unlocks on this page |
|---|---|
orders.view | Orders list, detail, history; returns list and detail |
orders.create | Orders on behalf of a reseller |
orders.validate | Queue of orders to confirm, confirmation, refusal, administration confirmation of assisted orders, return decisions |
orders.update | Line modification, transitions, cancellation, documents |
stock.validate | Additionally required to confirm with a stock override |
finance.validate | Additionally required to confirm with a credit override |
stock.update | Return reception (restock + credit note) |
delivery.view · delivery.update | Delivery board, picking list · delivery data, proof of delivery, fulfilment transitions (PREPARING, READY, SHIPPED, RETURNED) |
delivery.administer | Carriers |
pricing.view | Purchase 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.
| Method | Path | Operation | Access | Apps | Release |
|---|---|---|---|---|---|
| GET | /admin/orders | admin.orders.listOrders with status tabs, filters (channel, delegate, reseller, wilaya, dates) and search | orders.view | BO | V1 |
| GET | /admin/orders/{orderId} | admin.orders.getOrder with control results (stock, prices, credit, address), history and allowed transitions | orders.view | BO | V1 |
| POST | /admin/orders | admin.orders.createCreate an order on behalf of a reseller | orders.create | BO | V1 |
| GET | /admin/orders/to-confirm | admin.orders.toConfirmQueue of orders waiting for back-office confirmation (SUBMITTED and PENDING), oldest first, with control results | orders.validate | BO | V1 |
| POST | /admin/orders/{orderId}/validate | admin.orders.validateConfirm an order — every order needs this explicit confirmation (optionally with a recorded stock/credit override) | orders.validate | BO | V1 |
| POST | /admin/orders/{orderId}/refuse | admin.orders.refuseRefuse with a reason | orders.validate | BO | V1 |
| PATCH | /admin/orders/{orderId}/lines | admin.orders.modifyControlled modification of lines with a reason (logged, reseller notified) | orders.update | BO | V1 |
| POST | /admin/orders/{orderId}/transitions | admin.orders.transitionMove to preparing, ready, shipped, delivered or returned per the transition table | orders.update | BO | V1 |
| POST | /admin/orders/{orderId}/cancel | admin.orders.cancelCancel with a reason (stock released) | orders.update | BO | V1 |
| POST | /admin/orders/{orderId}/confirm-assisted | admin.orders.confirmAssistedAdministration confirmation of an assisted order (e.g. reseller confirmed by phone) | orders.validate | BO | V1 |
| GET | /admin/orders/{orderId}/history | admin.orders.historyStatus history and change log | orders.view | BO | V1 |
| POST | /admin/orders/{orderId}/documents | admin.orders.documentsGenerate or regenerate invoice / delivery note PDFs | orders.update | BO | V1 |
| GET | /admin/returns | admin.returns.listReturn requests queue with filters | orders.view | BO | V1 |
| GET | /admin/returns/{returnId} | admin.returns.getReturn detail | orders.view | BO | V1 |
| POST | /admin/returns/{returnId}/decision | admin.returns.decideApprove or reject a return (reason required on rejection) | orders.validate | BO | V1 |
| POST | /admin/returns/{returnId}/reception | admin.returns.receiveRecord reception: accepted quantities re-enter stock automatically (RETURN movements); credit note issued | stock.update | BO | V1 |
| GET | /admin/deliveries | admin.deliveries.listDelivery board columns (to prepare, preparing, ready, shipped, delivered, failed) | delivery.view | BO | V1 |
| PATCH | /admin/deliveries/{deliveryId} | admin.deliveries.updateAssign carrier, tracking number, driver | delivery.update | BO | V1 |
| GET | /admin/deliveries/{deliveryId}/picking-list | admin.deliveries.pickingListPrintable picking list | delivery.view | BO | V1 |
| POST | /admin/deliveries/{deliveryId}/proof | admin.deliveries.proofRecord proof of delivery (photo, recipient) and mark delivered | delivery.update | BO | V1 |
| GET · POST · PATCH | /admin/carriers[/{carrierId}] | admin.carriers.crudCarriers | delivery.administer | BO | V1 |
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
| Query | Type | Values / default | Notes |
|---|---|---|---|
status | OrderStatus[] | repeatable | The tabs send groups, e.g. status=SUBMITTED&status=PENDING |
channel | OrderChannel[] | repeatable | |
resellerId | Uuid | Reseller 360° tab | |
delegateId | Uuid[] | repeatable | Source or assigned delegate |
wilayaCode | string[] | repeatable | Delivery address |
communeId | Uuid[] | repeatable | |
paymentMethod | PaymentMethod[] | repeatable | |
customerType | CustomerType[] | repeatable | |
isFirstOrder | boolean | ||
assistedConfirmation | AssistedConfirmation[] | repeatable | |
pendingReason | PendingReason[] | repeatable | |
hasIssues | boolean | true: at least one control not OK | |
late | boolean | See late in the schema | |
hasOverride | boolean | ||
dateField | string | submittedAt (default) | validatedAt | deliveredAt | statusChangedAt | Field from/to apply to |
from / to | string (date) | Inclusive | |
minTotal / maxTotal | Money | On totalTtc | |
q | string | min 2 chars | Order number (exact or suffix 158), reseller number, store name, owner name, phone |
includeCounts | boolean | false | Adds statusCounts for the tab badges (same filters, ignoring status) |
sort | string | submittedAt (default), number, totalTtc, statusChangedAt, storeName, waitingMinutes | |
order | string | asc | desc (default desc) | |
page / pageSize | number | 1 / 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
| Status | Code | When |
|---|---|---|
| 400 | VALIDATION_FAILED | Unknown enum value, bad date, pageSize over 200, q shorter than 2 characters |
| 403 | PERMISSION_DENIED | Caller lacks orders.view |
Rules & side effects
- 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.
- The search matches business numbers exactly or by numeric suffix and texts accent- and case-insensitively.
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
| Path | Type | Notes |
|---|---|---|
orderId | Uuid |
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);lineTotalTtcapplies 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. purchasePriceandmarginHt— only withpricing.view; omitted otherwise.- Credit amounts in
controls.credit— only withorders.validateorfinance.view. allowedTransitions/allowedActionstake the status, the caller's permissions and the guards into account (here the caller hasorders.validateandorders.updatebut neitherstock.validatenorfinance.validate, soVALIDATEis offered and will require an override the caller cannot give — the UI shows why fromcontrols).internalNoteis never returned by reseller operations.
Errors
| Status | Code | When |
|---|---|---|
| 403 | PERMISSION_DENIED | Caller lacks orders.view |
| 404 | NOT_FOUND | Unknown order, or an assisted draft (DRAFT, DELEGATE_ASSISTED) |
Rules & side effects
- Read-only. Controls are re-run for
DRAFT,SUBMITTEDandPENDINGorders at each read; fromVALIDATEDon, the response returns the controls frozen at confirmation. - Document
file.urlvalues are signed URLs valid 5 minutes.
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
| Status | Code | When |
|---|---|---|
| 400 | VALIDATION_FAILED | Empty lines, duplicate variant, quantity ≤ 0, unknown source |
| 403 | PERMISSION_DENIED | Caller lacks orders.create |
| 403 | RESELLER_SUSPENDED | ResellerStatus SUSPENDED |
| 404 | NOT_FOUND | Unknown reseller, variant or address (or address of another reseller) |
| 422 | PRODUCT_UNAVAILABLE | A variant is not orderable for this reseller (hidden, archived, not visible to its segment) — details.lines |
| 422 | QUANTITY_NOT_ALLOWED | Below minQty, not a multiple of qtyStep, above maxQtyPerOrder — details.lines |
| 422 | ORDER_MIN_AMOUNT_NOT_MET | Submitted total below orders.minAmount |
| 422 | STOCK_INSUFFICIENT | submit: true and a line exceeds available stock (no override at creation) |
| 422 | CREDIT_LIMIT_EXCEEDED | CREDIT order over the available credit with credit.overLimitPolicy = BLOCK |
| 422 | ADDRESS_MISSING | The address lacks commune, line or phone |
Rules & side effects
- Prices are resolved exactly as at checkout: the variant price of the reseller's
customerType(priceSuperGros,priceDemiGrosorpriceDetail), then active promotions (PRC-02). The back-office cannot type a price. submit: falsestores aDRAFTwithout number or stock effect; it is submitted later withadmin.orders.transitionto: SUBMITTED.submit: true→SUBMITTEDwith aCMD-number;PENDINGat once withpendingReasonsCREDIT_OVER_LIMIT(policyREQUIRE_APPROVAL) orRESELLER_NOT_VALIDATED(point of sale still pending validation — phone orders for prospects are allowed). Reservation only ifstock.reserveOn = SUBMITTED.- First order:
isFirstOrderand ResellerStatusFIRST_ORDERwhen it is the reseller's first (lifecycles). - Audit
CREATE(entityOrder);OrderStatusChange— → SUBMITTEDwith the caller as actor. - 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.createdandorder.to_confirm; the reseller's own stream getsorder.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
| Query | Type | Values / default | Notes |
|---|---|---|---|
kind | string[] | NEW (SUBMITTED) | ON_HOLD (PENDING except assisted) | ASSISTED (PENDING + AWAITING_RESELLER); default all | Repeatable — the three tabs of BO-41 |
pendingReason | PendingReason[] | repeatable | |
channel | OrderChannel[] | repeatable | |
controls | string | ALL_OK | HAS_ISSUES | |
wilayaCode / delegateId | string[] / Uuid[] | repeatable | |
overdue | boolean | Waiting longer than orders.confirmationReminderHours (4 working hours) | |
q | string | min 2 chars | Order number, reseller name, number or phone |
sort | string | submittedAt (default, oldest first), totalTtc, expiresAt | |
order | string | asc (default) | desc | |
page / pageSize | number | 1 / 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
| Status | Code | When |
|---|---|---|
| 400 | VALIDATION_FAILED | Unknown kind, pendingReason or sort field |
| 403 | PERMISSION_DENIED | Caller lacks orders.validate |
Rules & side effects
- Read-only. The queue is exactly
status ∈ {SUBMITTED, PENDING}— nothing leaves it except confirmation (VALIDATED), refusal, cancellation, rejection or expiry of an assisted order. - Live updates:
order.to_confirmwhen an order enters the queue andorder.status_changedwhen it leaves it; the client inserts or removes rows without re-fetching. - An overdue order (waiting more than
orders.confirmationReminderHours) raises a single reminderorder.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
| Path | Type | Notes |
|---|---|---|
orderId | Uuid |
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
| Status | Code | When |
|---|---|---|
| 400 | VALIDATION_FAILED | override present with neither stock nor credit true |
| 403 | PERMISSION_DENIED | Caller lacks orders.validate, or sends override.stock without stock.validate / override.credit without finance.validate — details: { required } |
| 403 | RESELLER_SUSPENDED | ResellerStatus is SUSPENDED |
| 404 | NOT_FOUND | Unknown order |
| 409 | ORDER_TRANSITION_NOT_ALLOWED | Status not SUBMITTED/PENDING, or an assisted order still AWAITING_RESELLER (confirm it first) — details: { from, to, allowed } |
| 409 | VERSION_CONFLICT | Stale version |
| 422 | REASON_REQUIRED | override without reason |
| 422 | RESELLER_NOT_VALIDATED | The reseller's ValidationStatus is not APPROVED — cannot be overridden; approve the account first |
| 422 | STOCK_INSUFFICIENT | A line exceeds available stock and override.stock is not true — details.lines as in controls.stock.lines |
| 422 | CREDIT_LIMIT_EXCEEDED | CREDIT order above available credit and override.credit is not true — details: { limit, used, available, orderTotal } |
| 422 | CREDIT_ACCOUNT_BLOCKED | The credit account is blocked and override.credit is not true |
| 422 | ADDRESS_MISSING | Address control MISSING — fix it with admin.orders.modify (addressId) |
Rules & side effects
- Allowed from
SUBMITTEDand fromPENDINGonce its pending reasons are resolved or overridden (CREDIT_OVER_LIMIT→ credit override;ON_HOLD→ confirmation lifts it;RESELLER_NOT_VALIDATEDandAWAITING_RESELLERcannot be bypassed here). The four controls are re-run inside the transaction; a price change is information only — the order keeps its snapshot prices. - Status
VALIDATED,validatedAt,validatedBy; controls frozen on the order.OrderStatusChangerow. - Stock (
stock.reserveOn = VALIDATED, default): oneRESERVATIONmovement per line (reserved += qty). With a stock override the reservation is made anyway, so available can become negative; astock.alertfollows and the shortage is listed on the stock dashboard. - A
Deliveryis created inTO_PREPAREwith the proposed mode (from the address and the delivery settings, ordeliveryMode). Invoice created now only ifbilling.invoiceOn = VALIDATED. - CRM: ResellerStatus
TO_FOLLOW_UP→ACTIVEwhen the reseller already had a delivered order; the open inactivity task is closed. ObjectivesORDERSandREVENUEof the attributed delegate count the order. - Audit:
VALIDATEonOrder, plus oneOVERRIDErow per overridden control (changes: the control result at that moment,reason). - Notifications:
order.validated→ reseller (push + in-app, « Commande validée »); assisted orders also → the source delegate. SSE:order.status_changed(back-office) andorder.status_changedon the reseller's own stream.
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
| Status | Code | When |
|---|---|---|
| 400 | VALIDATION_FAILED | Unknown or inactive reasonCode |
| 403 | PERMISSION_DENIED | Caller lacks orders.validate |
| 404 | NOT_FOUND | Unknown order |
| 409 | ORDER_TRANSITION_NOT_ALLOWED | Status not SUBMITTED or PENDING (an accepted order is cancelled, not refused) |
| 409 | VERSION_CONFLICT | Stale version |
| 422 | REASON_REQUIRED | reason missing for an item that requires a comment |
Rules & side effects
SUBMITTEDorPENDING→REFUSED(terminal). An assisted order awaiting the reseller may be refused too (its confirmation is closed as is).- Reservation released (
RESERVATION_RELEASE) only if one was made (stock.reserveOn = SUBMITTED); promotion caps released; credit exposure released; first-order revert (lifecycles). - Audit
REJECTwithreasonCodeand text. - 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).
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
| Status | Code | When |
|---|---|---|
| 400 | VALIDATION_FAILED | Nothing to change, unknown lineId, quantity < 0, a change that leaves no line (cancel the order instead) |
| 403 | PERMISSION_DENIED | Caller lacks orders.update |
| 404 | NOT_FOUND | Unknown order, variant or address |
| 409 | ORDER_NOT_MODIFIABLE | Status other than SUBMITTED, PENDING, VALIDATED, PREPARING; or an increase / addition while PREPARING (only decreases are allowed during picking) |
| 409 | ORDER_INVOICED | An invoice is already issued (cancel it first) |
| 409 | VERSION_CONFLICT | Stale version |
| 422 | REASON_REQUIRED | Empty reason |
| 422 | PRODUCT_UNAVAILABLE · QUANTITY_NOT_ALLOWED | Added or increased line not orderable or not respecting minQty / qtyStep / maxQtyPerOrder |
| 422 | STOCK_INSUFFICIENT | VALIDATED order: an increase exceeds available stock |
| 422 | CREDIT_LIMIT_EXCEEDED | CREDIT order: the new total exceeds available credit under BLOCK |
Rules & side effects
- Allowed in
SUBMITTED,PENDING,VALIDATED(any change) andPREPARING(decrease or remove only — picking shortage). Assisted orders awaiting the reseller cannot be modified (the reseller confirms what the delegate prepared). - 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.
- Controls re-run. On a reserved order the difference is reserved or released (
RESERVATION/RESERVATION_RELEASEmovements). - One
OrderChangerow with the diff and totals before/after; auditUPDATEonOrderwith the reason. - Notifications:
order.modified→ reseller (push + in-app) with the new total and the reason. SSE:order.modified(back-office) andorder.modifiedon the reseller stream.
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 → To | Permission | Guards | Side effects |
|---|---|---|---|
DRAFT → SUBMITTED | orders.update | Back-office draft only; same controls as creation | Number allocated; as creation (first order, order.created, order.to_confirm) |
SUBMITTED → PENDING | orders.update | reason required — puts the order on hold (missing information, customer to call back) | pendingReasons += ON_HOLD; reseller sees « En attente » |
PENDING → SUBMITTED | orders.update | The only pending reason is ON_HOLD | Hold lifted; back to the Nouvelles tab of the queue |
VALIDATED → PREPARING | orders.update or delivery.update | — | Delivery PREPARING; picker = caller; picking list available |
PREPARING → READY | orders.update or delivery.update | packages ≥ 1; shortages handled first by a line modification | BL-2026-… allocated and the delivery-note PDF generated; Delivery READY |
READY → SHIPPED | orders.update or delivery.update | Delivery data complete: OWN_FLEET → driver name and phone; CARRIER → active carrier and tracking number when the carrier requires it; PICKUP cannot ship | SHIPMENT 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 → RETURNED | orders.update or delivery.update | reason (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
| Status | Code | When |
|---|---|---|
| 400 | VALIDATION_FAILED | Missing packages for READY, unknown to |
| 403 | PERMISSION_DENIED | Caller lacks the permission of the move |
| 404 | NOT_FOUND | Unknown order or warehouse |
| 409 | ORDER_TRANSITION_NOT_ALLOWED | Move not in the table — details: { from, to, allowed, useOperation? } |
| 409 | VERSION_CONFLICT | Stale version |
| 422 | REASON_REQUIRED | Hold or return without reason |
| 422 | DELIVERY_INFO_REQUIRED | Shipping without the delivery data — details: { missing: ["trackingNumber"] } |
| 422 | CARRIER_INACTIVE | The delivery's carrier was deactivated |
| 422 | DELIVERY_PROOF_REQUIRED | to: DELIVERED |
Rules & side effects
- Stock and documents follow the crossing rule of lifecycles: reaching or passing the configured status triggers its movement or document exactly once.
- One
OrderStatusChangeper move; auditSTATUS_CHANGEwith note/reason; theDeliverymirrors the order in the same transaction. - Notifications:
order.status_changed→ reseller (push + in-app) forPENDING(hold),PREPARING,READY,SHIPPED(with carrier and tracking) andRETURNED, as listed in the notifications matrix; the assigned delegate is told ofRETURNED. Every move is also pushed live on the reseller stream (order.status_changed), so the tracker updates in real time (ORD-15). - SSE:
order.status_changed.
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
| Status | Code | When |
|---|---|---|
| 400 | VALIDATION_FAILED | Unknown or inactive reasonCode |
| 403 | PERMISSION_DENIED | Caller lacks orders.update |
| 404 | NOT_FOUND | Unknown order |
| 409 | ORDER_TRANSITION_NOT_ALLOWED | SHIPPED or later (stock deducted — the path is RETURNED), or already terminal |
| 409 | INVOICE_HAS_PAYMENTS | An issued invoice has confirmed allocations — deallocate first |
| 409 | VERSION_CONFLICT | Stale version |
| 422 | REASON_REQUIRED | Comment missing for an item that requires it |
Rules & side effects
- Allowed from
SUBMITTED,PENDING,VALIDATED,PREPARING,READY(cancellation windows). - Stock:
RESERVATION_RELEASEfor 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 withRETURNmovements in the same transaction.) - Delivery closed (
cancelledAt), leaves the board; an issued invoice without payments is cancelled; promotion caps and credit exposure released; first-order revert. - Audit
STATUS_CHANGEwith reason;OrderStatusChange. - Notifications:
order.cancelled→ reseller (push + in-app, with the reason) and the source/assigned delegate. SSE:order.status_changed(back-office and reseller streams).
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
| Status | Code | When |
|---|---|---|
| 400 | VALIDATION_FAILED | note shorter than 10 characters, unknown channel |
| 403 | PERMISSION_DENIED | Caller lacks orders.validate |
| 404 | NOT_FOUND | Unknown order |
| 409 | ASSISTED_CONFIRMATION_NOT_PENDING | Not an assisted order, or its confirmation is not AWAITING_RESELLER |
| 409 | ASSISTED_ORDER_EXPIRED | assistedExpiresAt has passed (even if the expiry job has not run yet) |
| 409 | VERSION_CONFLICT | Stale version |
| 422 | STOCK_INSUFFICIENT · CREDIT_LIMIT_EXCEEDED | Re-pricing and re-control fail as at checkout |
Rules & side effects
- AssistedConfirmation
AWAITING_RESELLER→CONFIRMED_BY_ADMIN; orderPENDING→SUBMITTEDafter re-pricing at today's prices and re-running the controls (or staysPENDINGwith another reason). From here the side effects of a checkout apply: promotion caps, first order (FIRST_ORDER, invitation step), events. - The note is stored in the order history (
OrderStatusChange.comment); auditVALIDATEonOrderwithchanneland note. - Notifications:
assisted_order.confirmed→ the source delegate; the reseller receives the standard status update. SSE:order.to_confirm(it moves to the Nouvelles tab) andorder.status_changed.
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
| Query | Type | Values / default | Notes |
|---|---|---|---|
kind | string[] | STATUS | CHANGE | OVERRIDE | DOCUMENT | NOTIFICATION | RETURN; default all | Repeatable |
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
| Status | Code | When |
|---|---|---|
| 400 | VALIDATION_FAILED | Unknown kind |
| 403 | PERMISSION_DENIED | Caller lacks orders.view |
| 404 | NOT_FOUND | Unknown order |
Rules & side effects
- Read-only; merges
OrderStatusChange,OrderChange, override audit rows, generated documents,NotificationDeliveryrows of the order's events and return requests. - 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
| Status | Code | When |
|---|---|---|
| 400 | VALIDATION_FAILED | Empty or duplicate types |
| 403 | PERMISSION_DENIED | Caller lacks orders.update |
| 404 | NOT_FOUND | Unknown order |
| 409 | DOCUMENT_NOT_AVAILABLE | The order has not reached the document's status yet: delivery note from READY, invoice from billing.invoiceOn — details: { type, availableFrom } |
Rules & side effects
- 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. - Generation is synchronous (target under 5 s); each regeneration increments
versionand keeps previous files for audit. - Audit
UPDATEonInvoice/Delivery(changes: [{ field: "pdfVersion" }]). No notification; the reseller downloads the current version throughorders.document.
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
| Query | Type | Values / default | Notes |
|---|---|---|---|
status | ReturnStatus[] | repeatable; default REQUESTED, APPROVED, IN_TRANSIT | |
origin | string | RESELLER | AFTER_SALES | |
resellerId / orderId | Uuid | ||
wilayaCode | string[] | repeatable | |
from / to | string (date) | On requestedAt | |
q | string | min 2 chars | RET- or CMD- number, store name, SKU |
sort | string | requestedAt (default), number, status | |
order | string | asc | desc (default asc — oldest first) | |
page / pageSize | number | 1 / 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
| Status | Code | When |
|---|---|---|
| 400 | VALIDATION_FAILED | Unknown status or sort field |
| 403 | PERMISSION_DENIED | Caller lacks orders.view |
Rules & side effects
- Read-only. New requests arrive live with
return.requested.
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
| Path | Type | Notes |
|---|---|---|
returnId | Uuid |
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
| Status | Code | When |
|---|---|---|
| 403 | PERMISSION_DENIED | Caller lacks orders.view |
| 404 | NOT_FOUND | Unknown return |
Rules & side effects
- 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
| Status | Code | When |
|---|---|---|
| 400 | VALIDATION_FAILED | Unknown item, quantity above the requested one, transport on a rejection |
| 403 | PERMISSION_DENIED | Caller lacks orders.validate |
| 404 | NOT_FOUND | Unknown return or carrier |
| 409 | RETURN_TRANSITION_NOT_ALLOWED | Status not REQUESTED (or not APPROVED when only transport is added) — details: { from, to, allowed } |
| 409 | VERSION_CONFLICT | Stale version |
| 422 | REASON_REQUIRED | Rejection without reason |
Rules & side effects
REQUESTED→APPROVED(orIN_TRANSITwithtransport), or →REJECTED. Calling it again on anAPPROVEDreturn withdecision: "APPROVE"and atransportmoves it toIN_TRANSIT.- No stock or money moves at this stage.
- 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). - Audit
VALIDATEorREJECTonReturnRequest. - Notifications:
return.approvedorreturn.rejected→ reseller (push + in-app);DELEGATE_PICKUPcreates aCrmTask(typeVISIT, « Récupérer le retour RET-… ») for the reseller's assigned delegate (task.assigned); stock holders getreturn.approved(reception expected). SSE: the reseller stream getsreturn.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
| Status | Code | When |
|---|---|---|
| 400 | VALIDATION_FAILED | Quantities not satisfying restockQty ≤ acceptedQty ≤ receivedQty ≤ approved, unknown item, receivedAt in the future |
| 403 | PERMISSION_DENIED | Caller lacks stock.update |
| 404 | NOT_FOUND | Unknown return or warehouse |
| 409 | RETURN_TRANSITION_NOT_ALLOWED | Status not APPROVED or IN_TRANSIT |
| 409 | VERSION_CONFLICT | Stale version |
| 422 | WAREHOUSE_INACTIVE | The warehouse is inactive |
Rules & side effects
APPROVED/IN_TRANSIT→RECEIVED→REFUNDEDin one transaction when at least one unit is accepted; →REJECTEDwhen nothing is accepted (inspection failed — the reseller is told why from the item notes).- Stock: one
RETURNmovement per item withrestockQty> 0 (physical += restockQty) inwarehouseId; defective and damaged units are not re-entered (they are recorded on the return for the supplier claim). A product putOUT_OF_STOCKby the system goes back toACTIVE. - 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: pointsREVERSAL. - Order: when every delivered unit has been returned and accepted, the order becomes
RETURNED(OrderStatusChange); otherwise it staysDELIVEREDwithopenReturnsupdated. - Audit
STATUS_CHANGEonReturnRequest,CREATEonCreditNote;StockMovementrows reference the return. - Notifications:
return.received→ reseller (push + in-app, credit note downloadable) and finance (in-app, credit note issued). SSE: reseller streamreturn.status_changed; back-officestock.alertwhen a level changesalertLevel(for exampleOUT→OK).
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
| Query | Type | Values / default | Notes |
|---|---|---|---|
view | string | board (default) | list | |
status | DeliveryStatus[] | repeatable; board default TO_PREPARE, PREPARING, READY, SHIPPED, DELIVERED, FAILED | |
mode | DeliveryMode[] | repeatable | |
carrierId | Uuid[] | repeatable | |
wilayaCode | string[] | repeatable | Delivery address |
warehouseId | Uuid[] | repeatable | The order's shipping warehouse. Added in contract 1.1.0 |
cod | boolean | true: cash-on-delivery orders (an amount to collect); false: the others. Added in contract 1.1.0 | |
validatedFrom / validatedTo | string (date) | Order confirmation day, inclusive (Africa/Algiers). Added in contract 1.1.0 | |
scheduledFor | string (date) | ||
deliveredSince | string (date) | default: 7 days ago | Limits the Livrée column |
q | string | min 2 chars | Order or BL- number, tracking number, store name |
perColumn | number | 50 (max 200) | Board view |
sort | string | validatedAt (default), scheduledFor, shippedAt | List view |
order / page / pageSize | asc | 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
| Status | Code | When |
|---|---|---|
| 400 | VALIDATION_FAILED | Unknown status, mode or view |
| 403 | PERMISSION_DENIED | Caller lacks delivery.view |
Rules & side effects
- Read-only. Cancelled orders leave the board. Moving a card calls
admin.orders.transition(the board never changes a delivery status on its own, exceptFAILEDthroughadmin.deliveries.update). late: validated longer thandelivery.shippingSlaHours(48 h) and not shipped.- 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
| Status | Code | When |
|---|---|---|
| 400 | VALIDATION_FAILED | Bad phone, past scheduledFor, attempt and reattempt together |
| 403 | PERMISSION_DENIED | Caller lacks delivery.update |
| 404 | NOT_FOUND | Unknown delivery or carrier |
| 409 | DELIVERY_NOT_EDITABLE | Delivery DELIVERED, RETURNED or cancelled; mode change after SHIPPED; attempt when not SHIPPED; reattempt when not FAILED |
| 409 | VERSION_CONFLICT | Stale version |
| 422 | CARRIER_INACTIVE | The carrier is inactive or does not cover the delivery wilaya |
| 422 | REASON_REQUIRED | attempt without reason |
Rules & side effects
- Data only — the order status does not change, except the delivery's own
FAILEDstate (order staysSHIPPED) and its return toSHIPPEDonreattempt. After the last failed attempt the goods come back throughadmin.orders.transitionto: RETURNED. trackingUrlis built from the carrier'strackingUrlTemplate.- Audit
UPDATEonDelivery. - Notifications: a tracking number set or changed after
SHIPPED→order.status_changedre-sent with the new reference; a failed attempt →delivery.failedto the reseller (push + in-app, « Livraison non aboutie — nouvelle tentative le … »). SSE:order.status_changedcarriesdeliveryStatuschanges.
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
| Query | Type | Values / default | Notes |
|---|---|---|---|
format | string | json (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
| Status | Code | When |
|---|---|---|
| 400 | VALIDATION_FAILED | Unknown format |
| 403 | PERMISSION_DENIED | Caller lacks delivery.view |
| 404 | NOT_FOUND | Unknown delivery |
| 409 | DOCUMENT_NOT_AVAILABLE | Delivery cancelled |
Rules & side effects
- 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
| Status | Code | When |
|---|---|---|
| 400 | VALIDATION_FAILED | deliveredAt before shipping or in the future, codCollected on a non-COD order |
| 403 | PERMISSION_DENIED | Caller lacks delivery.update |
| 404 | NOT_FOUND | Unknown delivery or file |
| 409 | ORDER_TRANSITION_NOT_ALLOWED | Order not SHIPPED (or READY with mode PICKUP) |
| 409 | VERSION_CONFLICT | Stale version |
| 422 | DELIVERY_PROOF_REQUIRED | No fileId while delivery.proofPhotoRequired = true |
| 422 | FILE_NOT_READY · FILE_PURPOSE_MISMATCH | Upload not completed, or purpose not DELIVERY_PROOF |
Rules & side effects
- Order
SHIPPED(orREADYfor pickup) →DELIVERED; DeliveryDELIVERED;deliveredAt. - Stock: when
stock.deductOn = DELIVERED(or for a pickup that skippedSHIPPED),SHIPMENTmovements happen now (crossing rule). Invoice issued now ifbilling.invoiceOn = DELIVEREDor if it was skipped. - Cash collected creates a
Payment(PaymentSourceDELIVERY_COLLECTION,PENDING_VERIFICATION,PAY-number) linked to the order and its invoice — finance confirms it (admin.payments.verify); delivery staff never confirm money. - CRM: ResellerStatus
FIRST_ORDER→ACTIVE; conversion counted for the attributed delegate. V2: loyalty points whenloyalty.earnOn = DELIVERED. The return window (returns.windowDays) starts. - Audit
STATUS_CHANGEonOrder,CREATEonPaymentwhen applicable. - Notifications:
order.status_changed(DELIVERED) → reseller (push + in-app) and the assigned delegate. SSE:order.status_changed(back-office and reseller streams);payment.declaredto finance when cash was collected.
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
| Query | Type | Values / default | Notes |
|---|---|---|---|
active | boolean | ||
wilayaCode | string | Carriers covering this wilaya | |
q | string | min 2 chars | Name |
sort / order / page / pageSize | name (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
| Status | Code | When |
|---|---|---|
| 403 | PERMISSION_DENIED | Caller lacks delivery.view |
Rules & side effects
- Read-only. Reading is relaxed to
delivery.view(the bundle'sdelivery.administerapplies to writes) so the board can offer the carrier picker — Assumption.
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
| Status | Code | When |
|---|---|---|
| 403 | PERMISSION_DENIED | Caller lacks delivery.view |
| 404 | NOT_FOUND | Unknown carrier |
Rules & side effects
- 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
| Status | Code | When |
|---|---|---|
| 400 | VALIDATION_FAILED | Template without {tracking} or not https, unknown wilaya code |
| 403 | PERMISSION_DENIED | Caller lacks delivery.administer |
| 409 | DUPLICATE_VALUE | Name already used — details: { field: "name", value, existingId } |
Rules & side effects
- Audit
CREATEonCarrier. 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
| Status | Code | When |
|---|---|---|
| 400 | VALIDATION_FAILED | As for creation |
| 403 | PERMISSION_DENIED | Caller lacks delivery.administer |
| 404 | NOT_FOUND | Unknown carrier |
| 409 | DUPLICATE_VALUE | New name already used |
| 409 | VERSION_CONFLICT | Stale version |
Rules & side effects
- Deactivating keeps current deliveries untouched; new assignments and shipping with this carrier answer
422 CARRIER_INACTIVE. - Audit
UPDATEonCarrier.
Used by BO-43
Stock
Back-office stock operations — levels per variant and warehouse, the movement journal, receipts, adjustments, alert thresholds, inventory sessions and warehouses.
Finance
Back-office finance operations — invoices and their cancellation, payments recorded or declared and their verification, allocations, credit accounts with exposure and limit history, and the V2 overdue and accounting exports.