Retail Operations¶
Who this is for
Sales ops and accounting handling Reyder's retail channel — devices sold through retail partners like AnyMobile rather than through standard wholesale sales orders.
What you'll accomplish: reserve devices for a retail partner, and record the sales as they happen (either one at a time or via a CSV import).
Retail vs. wholesale — which flow to use¶
| Situation | Use |
|---|---|
| B2B customer placing a specific order ("I want these 3 IMEIs") | Sales Order |
| Retail partner holding your stock on consignment and selling whatever moves | Retail Reservation + Retail Sale (this doc) |
The retail flow is built around the reality that retail partners don't know in advance which specific IMEIs they'll sell. You reserve a batch first, they sell over time, and you record each sale back into the system.
A reservation is required — there's no direct retail-sale path
Retail sale lines have a domain requiring is_retail_reserved=True on the device, with the retail partner matching the sale header's partner. You cannot add a device to a retail sale line unless it was first reserved to that retail partner. If you need to record a one-off sale, create a quick reservation first, then the retail sale.
Part 1 — Retail reservations¶
A reservation pins a set of devices to a specific retail partner. While active, those devices can't be allocated to other orders.
Step 1 — Open Reserve/Release Devices¶
Go to Inventory → Device Inventory → Reserve/Release Devices.

Pick the selection mode that matches how you want to pull devices:
- Manual — pick IMEIs individually
- By manifest — everything on a specific receiving manifest
- By PO — everything from a specific purchase order
- By filters — model / storage / grade / color match
Set the retail partner, retail location, and expected return date (when you expect unsold devices back). Click Reserve.
What a reservation does¶
- Creates a
device.retail.reservationrecord inActivestate. - Flags each device with the retail partner and
is_retail_reserved = True. - Optionally moves the devices to the retail location physically (creates a
device.movementrecord for the audit trail).
You can review active reservations at Inventory → Device Inventory → Retail Reservations.

Step 2 (later) — Release devices that didn't sell¶
When the partner returns unsold devices, open the reservation and click Release Devices. The wizard lets you select the specific IMEIs coming back — those are:
- Cleared of the retail partner flag.
- Moved back to your storage location.
Auto-close behavior¶
You don't need to manually release a reservation once everything's sold. When the last reserved device gets recorded as a retail sale, the reservation auto-closes itself and moves to Released.
Part 2 — Retail sales¶
Record the actual sales the retail partner made. Each sale closes out specific IMEIs and posts the accounting automatically.
Option A — Enter sales one at a time¶
Go to Inventory → Device Inventory → Retail Sales → New.

- Set the Retail Partner and Sale Date.
- Add one device line per IMEI sold — pick the device, set its Sale Price.
- Click Confirm when the sale is complete.
By default, the device picker only offers devices reserved to that retail partner, still Available, QC Complete, and with a positive purchase cost. Sales Managers and Inventory Managers can tick Exception Override and enter a reason to sell a reserved device with a QC/cost exception.

That override is intentionally narrow: it does not let you sell an unreserved device, a device reserved to a different partner, a sold/reserved device, a duplicate line, or a line with a zero sale price.
Option B — Import a CSV of sales¶
Easier when the partner sends you a weekly sales report.
Go to Inventory → Device Inventory → Import Retail Sales.

Upload a CSV. The wizard recognizes these columns — headers are case-insensitive and matched by substring (IMEI, imei, IMEI Number, etc. all work), extra columns are ignored, blank rows are skipped:
| Column | Required | Notes |
|---|---|---|
IMEI (or any header containing imei) |
yes | Must match a device already in the system |
Sale Price (or any header containing price) |
yes | The price this IMEI sold for — must parse as a positive number |
Sale Date (or any header containing date) |
no | Per-row sale date; falls back to the wizard's default if absent |
You can leave your vendor's extra columns (supplier SKU, export markers, etc.) in the file — they're ignored.
The wizard creates one device.retail.sale record in Draft state and adds a line per IMEI. It does not auto-confirm — you review the draft and click Confirm on the retail sale form to post accounting.
CSV import validates the same retail-reservation and price rules as manual entry. QC/cost readiness is checked when you confirm the draft sale, so managers can review the imported sale and apply an exception override before confirming if needed.
Importing a mix of partners' sales in one file?
Do one file per retail partner. The retail partner is set on the sale header, not per-line — so mixing partners in one file won't work. Each line's device must also be already reserved to that partner.
What happens when you confirm a retail sale¶
Click Confirm. All in one transaction:
- Each device is marked
Soldwith the recorded sale price and today's date. - The COGS entry posts (DR COGS / CR Valuation) for the sum of the devices'
purchase_cost. - A customer invoice is created and posted to the retail partner — one line per device at the sale price, no tax applied.
- For consignment devices (owned by Axis), the paired Owner + Consignee Settlement Reports are created and auto-confirmed — same as a standard SO sale. Axis gets their vendor bill.
After confirmation, the retail sale shows its smart buttons for the invoice, the COGS move, and the settlement reports.
Common problems¶
A device I want to sell isn't on the list
The retail-sale line domain requires is_retail_reserved=True AND retail_partner_id matching the sale's partner. Reserve the device to the partner first (via the Reserve/Release wizard), then return to the retail sale and the device will appear in the picker. If the device is reserved correctly but not QC complete or has zero cost, a manager can use Exception Override.
Confirm button is disabled
Most likely cause: at least one device line is missing a sale price, or the retail partner isn't set on the header.
Confirm says the device is not ready for sale
The device is not QC Complete or has missing/zero purchase cost. Complete QC and fix cost, or have a Sales Manager / Inventory Manager tick Exception Override and enter a reason. The reason is posted to the device and retail-sale chatter for audit.
The CSV import rejects my file
Headers are normalized (lowercased, whitespace → underscore) and matched by substring, so IMEI, imei, IMEI Number, Device IMEI all work — same for Sale Price, price, Unit Price. An optional Sale Date column is also recognized. Blank rows are skipped; extra columns are ignored. What the wizard actually enforces:
- There must be a column whose name contains imei and one that contains price.
- Each IMEI must already exist as a
stock.lot(the import can't receive new devices). - Each IMEI must be retail-reserved to the partner on the sale header —
is_retail_reserved=TrueAND matchingretail_partner_id. - Sale price must parse as a positive number.
If an individual row errors, the wizard reports the row number so you can fix just that row.
A retail sale was confirmed but no settlement report appeared
Settlement reports only generate for consignment devices (where owner company ≠ selling company). If the devices belonged to Reyder directly, no settlement is needed — the customer invoice alone covers the accounting. Check each device's owner_company_id to confirm.
The invoice was posted to the wrong journal
Retail invoices use the standard sales journal by default. To route them elsewhere, set a specific journal in the retail partner's customer record (Contacts → Partner → Accounting tab). Invoices created going forward will use that journal.
Under the hood¶
Technical details for developers
Models
device.retail.reservation— groups devices flagged for a retail partnerretail.reservation.wizard— the reserve/release wizard withselection_modeinmanual / by_manifest / by_purchase_order / by_filtersdevice.retail.sale— one sale; contains linesdevice.retail.sale.line— one per IMEI soldretail.sale.import.wizard— CSV importer
Reservation state machine
draft → active → released → cancelled
action_activate()setsstate='active', stampsretail_partner_idon each device, optional movement to retail locationaction_release()clears the partner flag, moves devices back_check_auto_close()runs after each retail sale confirmation; ifremaining_count == 0, sets reservation toreleased
Retail sale action_confirm() sequence
_validate_before_confirm()checks accounting config, sale journal, duplicate lines, availability, retail reservation ownership, positive sale price, and QC/cost readiness.SELECT ... FOR UPDATE NOWAITonstock.lotrows to serialize concurrent confirms.stock.lot.action_mark_sold_retail(sale_price, selling_company)per device — setsdevice_status='sold', stampssale_date,retail_sale_id.consignment.settlement.report.create_paired_reports()for consignment devices (via.sudo())._create_retail_invoice()→ customer invoice, one line per device, no tax, auto-posted._create_retail_cogs_entry()→ COGS move, auto-posted.
Selection field gotcha (fixed v2.x)
_create_retail_invoice() previously tried to dict(field.selection) on the storage_capacity field, which failed because Odoo 19 makes related Selection fields callable. The fix checks callable(sel) and invokes it with sel(record) before dict-wrapping. See device_retail_sale.py:244.
Key fields
| Model | Field | Notes |
|---|---|---|
device.retail.sale |
state |
draft / confirmed / cancelled |
device.retail.sale |
invoice_id |
Customer invoice (account.move out_invoice) |
device.retail.sale |
cogs_move_id |
COGS journal entry |
device.retail.sale |
settlement_report_ids |
Many2many to settlement reports generated |
device.retail.reservation |
retail_partner_id, retail_location_id, device_count, sold_count, remaining_count |