Skip to content

ADR 0010: PoS-PA Process with SA Governance

Status

Proposed

Date

2026-07-18


Context

dirac-odoo already defines:

  1. V3 association model (ADR 0003): Explicit edge objects (ov.sa_x, ov.actor_sa_x) as the default for SA-governed visibility scope. No direct stamping on native Odoo models. All SA governance records live in association tables, not on native objects.
  2. SA-POS governance (ADR 0005): Mandatory customer identification, two-level agent identity (system-level + SA context), external admin role, SA scope as ACL, three-tier reporting (actor/manager/parent-manager).
  3. SA access security (ADR 0006): Five security gaps identified — no ir.rule on pos.order, PA connects as Admin (uid=2), no ir.rule on association tables, full CRUD on association models, no dedicated PA service user. Two-track hardening plan (authentication + authorization).
  4. Product Profile framework (ADR 0009): Three-layer model (Body + Profile + SA governance). SA governs access and profile selection; pricing, cost, and inventory are resolved from res.company natively.
  5. Serviced Account model (ov.serviced_account): Company-bound governance entity with hierarchy, membership, and company_idres.company.

Existing deployed models: ov.sa_pos_order (27 records), ov.actor_sa_pos_order (27 records), ov.sa_pos_config (model exists, 0 records), ov.sa_pricelist (1 record).

This ADR defines the end-to-end retail workflow under SA governance — physical goods, service products, rentals, and product returns — confirming that Odoo's native POS handles all transaction logic and that the SA layer adds exactly two governance records per transaction plus pricelist and location constraints.


Decision

Adopt the SA governance overlay for PoS-PA retail transactions.

  • All transaction logic is native Odoo POS. Cart, checkout, barcode scanning, payment, inventory updates (stock.move, stock.quant), ledger entries (account.move) — all 100% native. The SA layer does not override, replace, or reimplement any transaction engine.
  • The SA layer adds governance records only. When a pos.order is created, the PA applet inserts two records: ov.sa_pos_order (SA → order) and ov.actor_sa_pos_order (actor → SA-assignment). No other SA inserts. No modifications to pos.order, pos.order.line, pos.payment, stock.move, or account.move.
  • Pricelists are SA-governed. SA admin sets ov.sa_pricelist (SA → pricelist). The PA applet enforces that pos.config.pricelist_id matches the SA's associated pricelist. The pricelist engine (product.pricelist.item rules) is native and untouched.
  • PoS location ≠ warehouse. A PoS (pos.config) references a warehouse (warehouse_id) and has its own stock location (stock_location_id). Multiple PoS configs can share one warehouse. SA governs the location via ov.sa_location and the POS config via ov.sa_pos_config. These are independent associations.
  • Inventory is serial-tracked at OVES SA-PoS locations. Every PoS location's inventory is a list of stock.quant records at that location. For serial-tracked products, each quant = one serial (lot_id). Stock moves between locations must identify the physical serials being moved.
  • The PA terminal is a compute device with cache, not a logic engine. Zero business logic runs on the terminal. All rules (pricing, tax, stock validation, SA access) execute on the Odoo server. The terminal caches product data, pricelists, and customer data for offline resilience, but never creates or enforces business rules independently.
  • SA context is frozen at order creation. ov.sa_pos_order.account_id and ov.actor_sa_pos_order.actor_id never change after creation (per ADR 0005). This holds for physical goods, service products, and rentals regardless of online/offline state.
  • Service products and rentals follow the same SA governance pattern as physical goods. The SA layer adds the same two governance records. No additional inserts. The only differences are inventory behavior: service products create no stock move; rentals use the standard sale + return flow and recycle returned serials directly to the rental location. Product returns (§4) also follow the same SA governance pattern.

Retail Checkout Workflow — For Sales Teams

This section describes the retail checkout process from the perspective of the customer and sales clerk. No database jargon. This is what happens on the shop floor.

What the Customer Experiences

  1. Browse and select. A customer walks into the shop and picks products from the shelves. Each product has a price tag — the price is set by the shop's price list, which is managed by the store manager.

  2. Bring items to the counter. The customer brings their selected items to the sales desk. A sales clerk is ready at the register.

  3. Items are scanned. The clerk scans each item's barcode. The register screen shows each product's name, unit price, and the running total. Sales tax is calculated automatically and shown in the total.

  4. Customer provides their identity. The clerk asks for the customer's name or phone number. This is mandatory — every sale must be linked to an identified customer. This protects the customer: if there is ever a product recall, the store can contact them.

  5. Customer pays. The customer pays using cash, mobile money, or card. The register records the payment method, amount, and transaction ID.

  6. Electronic receipt. Once payment is confirmed, the system creates an electronic sales record. This record links:

  7. Which products were sold (by serial number — every item is tracked individually)
  8. Which customer bought them
  9. Which sales clerk handled the sale
  10. Which store location processed it

  11. Inventory updates automatically. As soon as the sale is complete, the store's inventory count is updated. The sold serial numbers leave the store's stock. The store manager can see exactly how many of each product remain.

What the Sales Clerk Does

Step Clerk Action System Response
Login Clerk logs into the register with their credentials System identifies the clerk, their store, and their permissions
Scan Clerk scans each item's barcode System adds the item to the checkout list with name, price, quantity
Identify Clerk enters the customer's name or phone number System links the sale to the customer
Total Clerk confirms the checkout total System calculates tax and final amount
Payment Clerk selects payment method and confirms System records the transaction
Done Clerk hands the receipt to the customer System updates inventory, creates the sales record, and posts to accounts

What Happens Behind the Scenes

The sales clerk does not need to know any of the following. The system handles it automatically:

  • Each sold item is tracked by its unique serial number. The system knows exactly which physical unit left the store.
  • The store's inventory count decreases by the sold quantity. If only 3 units of a product remain, the register will prevent overselling.
  • The sale is recorded in the company's accounts (revenue, cost of goods sold, tax liability).
  • The sale is tagged with the store's identity so that sales reports are always accurate per location, per clerk, per product.

Key Rules for Sales Teams

  1. Every customer must be identified. No anonymous sales. If the customer is new, the system creates a record for them at checkout.
  2. The price on the tag is the price. Prices come from the store's price list, set by the store manager. The clerk does not set or change prices.
  3. The register is a terminal, not a computer. All business rules and product data live on the company's central system. The register only displays information and sends transactions. If the internet goes down, the register queues sales and submits them when the connection returns.
  4. Every sale is traceable. Each transaction records who sold what to whom, at which store, on what date. This is for the customer's protection and for accurate business reporting.

1. End-to-End Retail Workflow (Technical)

Step 1 — Cart (Odoo Native)

A customer or sales rep selects products and places them into the PoS cart. This is 100% Odoo native PoS UI. No SA involvement.

Step 2 — Checkout (Odoo Native + SA Pricelist Constraint)

The sales clerk scans each item using a barcode reader. Odoo resolves the product via product.barcode and populates the checkout list:

Data Odoo Field
Product name pos.order.line.product_id
Quantity pos.order.line.qty
Unit price pos.order.line.price_subtotal (tax excl.) / price_subtotal_incl (tax incl.)
Total + tax pos.order.amount_total, pos.order.amount_tax

SA governance: The pricelist applied is constrained by ov.sa_pricelistpos.config.pricelist_id. If the SA has an associated pricelist, the PA applet enforces it. The price calculation engine (product.pricelist.item) is native Odoo — SA does not modify how prices are computed.

Step 3 — Payment (Odoo Native)

Customer pays. Odoo native capture:

Data Odoo Field
Payment method pos.payment.payment_method_id
Amount pos.payment.amount
Transaction ID pos.payment.transaction_id
Payment date pos.payment.payment_date
Customer pos.payment.partner_id (related from pos.order.partner_id)

Step 4 — SA-PA Capture (SA Layer)

Once payment is confirmed, the PA applet creates the SA governance records linking this transaction to the SA and agent:

pos.order.id = 12345 →

  ov.sa_pos_order:
    account_id     → ov.serviced_account  (cashier's active SA)
    pos_order_id   → pos.order (12345)
    association_kind → binding
    state          → active

  ov.actor_sa_pos_order:
    assignment_id  → ov.sa_pos_order (above)
    actor_id       → res.partner  (cashier's partner identity)
    is_primary     → true
    state          → active

The sold serials are linked to the customer through Odoo's native stock move chain:

pos.order (partner_id=customer) →
  pos.session closing →
    stock.picking (partner_id=customer) →
      stock.move.line (lot_id=serial, product_id=product)

SA does not recreate this linkage. The serial→customer relationship is established by the native stock.picking.partner_id + stock.move.line.lot_id chain. The SA governance record is concerned only with "which SA and agent conducted this sale."

Step 5 — Inventory and Ledger (Odoo Native)

When the PoS session closes, Odoo automatically creates:

What Model
Stock moves (PoS location → customer) stock.picking + stock.move + stock.move.line
Inventory update stock.quant (quantity decremented at PoS location)
Accounting entries account.move + account.move.line (revenue + COGS)
Invoice (if configured) pos.order.account_move

Zero SA involvement. Odoo native engine runs exactly as designed.


2. Service Products (Non-Inventory)

A service product follows the same SA governance pattern as physical goods (§1) but differs in inventory behavior: no stock movement is created. This is a major retail type — OVES shops sell battery swaps, maintenance services, installation fees, and other non-physical products alongside physical goods. Odoo's native behavior:

Aspect Behavior
Product type product.template.type = 'service'
At checkout Appears in pos.order.line with price from pricelist
At payment pos.payment created — same as physical goods
At session close account.move created (revenue + tax)
Inventory No stock.move created. Service products are excluded from stock operations natively.

Example: A battery swap at an OVES shop. The customer pays a set fee. The clerk selects "Battery Swap Service" on the register. The transaction produces a pos.order, a payment, and an accounting entry — but no stock movement.

SA governance is identical to physical goods. The same two governance records are created:

ov.sa_pos_order       (account_id=SA, pos_order_id=order)
ov.actor_sa_pos_order (assignment_id=above, actor_id=clerk)

Pricelist governance applies equally. The service product's price comes from the SA's pricelist, same as physical goods.

Operational note — barcode cards for services. Since service products have no physical inventory, they cannot be picked from a shelf. However, the same "scan → checkout → pay" PoS workflow can be used by printing barcode cards (small physical cards or cartons) for each service product. A card shows the service name and its barcode. The clerk scans the card just like a physical product. This keeps the checkout process uniform: every item — goods or service — enters the cart through a barcode scan. The barcode card itself is not inventory; it is a reusable shop tool.

3. Rentals (Serial-Tracked Goods with Deposit and Return)

A rental follows the same SA governance pattern as physical goods (§1) but adds deposit handling and return processing. This is a major retail type — OVES shops rent batteries, vehicles, and equipment to customers who need temporary use rather than purchase.

This is a three-phase transaction, not a single checkout:

Phase A — Rental Out (Checkout)

Step What Happens Odoo Mechanism
Customer identified Mandatory as with all SA-PoS sales pos.order.partner_id
Rental product scanned Serial-tracked goods, scanned by barcode pos.order.line with product_id
Rental fee charged The rental price from the pricelist pos.order.line.price_subtotal
Deposit charged A separate line item or a service product for the deposit amount Additional pos.order.line or separate pos.order
Payment collected Rental fee + deposit paid together pos.payment × 2 or single payment
Serial leaves inventory Goods move from PoS location → customer stock.move.line with lot_id=serial, destination = customer location

Phase B — Rental Active (Tracking)

While the goods are with the customer, the system tracks:

What Is Tracked How
Which customer has the goods stock.quant at customer location (serial is physically there)
Deposit amount held pos.order line for the deposit
Rental start date pos.order.date_order
Expected return date Configurable rental period (TBD)

No SA governance changes during this phase. The SA context was frozen at Phase A.

Phase C — Rental Return (Refund)

Step What Happens Odoo Mechanism
Goods returned to shop Clerk scans the returning serial Validate serial matches the rental record
Condition check Clerk confirms goods are undamaged Optional inspection step (TBD)
Serial returns to inventory Goods move from customer → PoS location Reverse stock.move — serial returns to stock_location_id
Deposit refunded Full or partial refund (less damage deductions if applicable) pos.order with negative amount or account.payment refund
Rental closed Rental status updated Rental record marked complete

SA governance for rentals follows the same pattern, plus a rental state tracking model:

Phase A (rental out):
  ov.sa_pos_order       — SA + order (same as regular sale)
  ov.actor_sa_pos_order — clerk identity (same as regular sale)

Phase C (rental return):
  Refund is a new pos.order with the same SA context
  Rental status updated on the tracking record

Rental-specific design considerations (future ADR):

  • Rental deposit tracking: a separate model or a flag on ov.sa_pos_order indicating rental type
  • Rental period enforcement: late fees if not returned by expected date
  • Damage inspection: partial deposit deductions
  • Rental inventory view: which serials are currently out on rental at each SA-PoS location

Rental Design Approach: Standard Processes vs. Purpose-Built

What Odoo provides natively:

Mechanism Works with POS? Details
sale_rental module ❌ Sales Orders only Purpose-built rental lifecycle: sale.order.is_rental, rental_start_date, rental_return_date, rental_status (draft → pickup → return → returned). Handles deposits, tracks rental periods, manages pickup/return stock moves.
POS sale Standard pos.order with pos.order.line, payment, stock move on session close.
POS return Native POS return: scan original order → select items → refund → stock returns to location → credit note.
POS + rental fields pos.order has no is_rental or rental tracking fields.

Option A: Sale + Return (recommended for Phase 1)

Treat the rental as a standard POS sale, and the return as a standard POS return. This is the "simple way" — it uses native Odoo processes end-to-end with zero POS customization.

Phase POS Action What Happens
Rental out Standard sale pos.order created. Rental fee = sale line. Deposit = separate line item. Customer pays total. Stock move removes serial from PoS location.
Rental active Nothing in POS Serial is at customer location. Deposit held.
Rental return Standard POS return Clerk opens the original order, selects "Return." Serial re-enters PoS location via reverse stock move. Deposit line refunded as credit note. Rental fee is NOT refunded.

Advantages: - Zero POS customization. Uses native pos.order return flow natively. - SA governance is identical to regular sales (same two governance records). - Clerk workflow is familiar: scan → checkout → pay, and later: find order → return items → refund. - Stock move tracking is native: serial leaves on sale, returns on return → full audit trail.

Accounting note: The deposit appears as revenue at sale time and as a credit note at return time — netting to zero over the full rental lifecycle. For Phase 1, this is acceptable. A future Phase 2 could treat the deposit as a liability (customer deposit account) if accounting precision is required.

Option B: Purpose-built POS rental (future Phase 2)

Extend the POS with an ov.rental model for dedicated rental tracking:

ov.rental:
  sa_id            → ov.serviced_account
  product_id       → product.product
  lot_id           → stock.lot (serial being rented)
  customer_id      → res.partner
  pos_order_out_id → pos.order (rental start)
  pos_order_in_id  → pos.order (rental return, if returned via POS)
  deposit_amount   → monetary
  rental_fee       → monetary
  rental_status    → active | returned | overdue
  start_date       → datetime
  expected_return  → datetime
  actual_return    → datetime

This handles deposits as liabilities, tracks rental periods, enables late fee calculation, and supports damage deduction workflows. But it requires a new Odoo module, POS customization, and accounting configuration.

Decision: Start with Option A (sale + return). It validates the rental workflow using standard Odoo POS processes. Migrate to Option B when rental volume, accounting precision requirements, or period-tracking needs justify the investment.


4. Product Returns (Physical Goods Only)

Any physical goods sold through SA-PoS can be returned by the original customer. Service products cannot be returned — they have no physical item to bring back. Rentals use the same return flow but are covered separately in §3.

Return Validation Rules

Before a return is accepted, two validations must pass:

Rule Validation How
Customer match The person returning the goods must be the original purchaser System checks pos.order.partner_id against the original sale. The serial is traced through stock.move.line.lot_id to the original pos.order. Only the customer on that order can return it.
Condition check The goods must not be damaged beyond normal use Clerk inspects the physical item. If acceptable, clerk approves in the POS. If damaged, the return may be rejected or accepted with a partial refund (damage deduction, TBD).

Return Workflow

Step Clerk Action System Response
1. Identify customer Clerk asks for customer name or phone number System finds the customer's purchase history at this store
2. Scan returned serial Clerk scans the barcode of the returned item System validates: "Did this customer buy this serial?" — Yes/No
3. Condition inspection Clerk inspects the physical item Clerk selects: "Good condition" or "Damaged." If damaged, manager approval may be required (TBD)
4. Approval Clerk (or manager) confirms the return System authorizes the return
5. Execute return System processes the POS return pos.order (return type) created. Stock move returns serial to RMA Returned Goods location (not directly to Sales Goods — the item must be inspected before resale). Credit note issued for the returned amount.
6. Inventory updated Automatic stock.quant updated — serial appears at RMA Returned Goods location. After inspection/refurbishment, a manual transfer moves it to Sales Goods.

Technical Flow

Original sale:
  pos.order (id=12345, partner_id=customer_A)
    → stock.move.line (lot_id=serial_X, location_dest_id=customer_location)

Return:
  pos.order (id=12346, partner_id=customer_A, returned_order_id=12345)
    → stock.move.line (lot_id=serial_X, location_id=customer_location, location_dest_id=rma_location)
    → account.move (credit note for refund amount)
    → stock.quant updated: serial_X at RMA location (not available for sale until refurbished)

Validation query (pseudocode):

Given: serial_X scanned, customer_A identified

1. Find the original stock.move.line where lot_id=serial_X and move was OUT of PoS location
2. Trace to stock.move → stock.picking → pos.order
3. Check pos.order.partner_id == customer_A
4. If match → allow return. If no match → reject.

SA Governance for Returns

Returns follow the same SA governance pattern as sales. A return is a new pos.order with a separate SA context:

pos.order (return) →
  ov.sa_pos_order       (account_id=SA, pos_order_id=return_order)
  ov.actor_sa_pos_order (assignment_id=above, actor_id=clerk_processing_return)

The original sale's SA context is not modified. The return creates its own governance record, linking back to the original order through pos.order's native return reference.

Design Considerations (Future)

  • Manager approval for damaged returns: If the clerk marks the item as damaged, a manager must approve before the return can proceed. This could be implemented as a PA applet approval step (similar to Assembly-Cell manager sign-off in ADR 0008).
  • Partial refund for damage: If goods are returned with minor damage, a percentage of the refund may be withheld. This requires a damage assessment step and a deduction amount field.
  • Return window: Returns may be limited to a configurable number of days after purchase (e.g., 30 days). This is enforced at validation time.

5. Inventory: Serialized, Per-Location

Odoo Concepts Reference — Warehouse, Location, Inventory, Stock Level

These four terms are often confused. The table below defines each one precisely, with its Odoo model, intended use, and how OVES SA-PoS adopts it.

Term Odoo Model Concept Odoo Intended Use SA-PoS Adoption Notes
Warehouse stock.warehouse A physical building or facility that holds inventory. Has an address, a code, and a default set of locations. The top-level organizational unit for stock. Each warehouse gets a default Input, Output, and Stock location. A company can have many warehouses. SA governs warehouse access via ov.sa_location. An SA may control one warehouse and operate multiple PoS configs within it. Warehouse ≠ PoS shop. A warehouse is a storage facility. A PoS is a sales terminal. One warehouse can serve multiple PoS configs.
Location stock.location A place within a warehouse that stores products. Organized as a tree (parent/child). Every location has a usage type. Locations define where stock physically sits. Types: internal (storage), customer (at customer site), supplier (at vendor), transit, view, inventory, production. SA adopts Odoo's native location tree. Each PoS config has its own stock_location_id. Under it, OVES creates three child locations: Sales Goods, Rental Goods, RMA Returned Goods (§7). SA governs which locations an agent can access via ov.sa_location. Locations are nested. A PoS stock location is a child of its warehouse's Stock location. Multiple PoS configs within the same warehouse each get their own stock location.
Inventory stock.quant A collection of records. Each record = one product + one lot/serial + one location + a quantity. This IS the inventory — the sum of all quant records at a location. Odoo's source of truth for "what do we have and where is it." Every stock move updates quants. Quants are created, split, merged, and deleted as stock moves execute. SA-PoS inventory = all stock.quant records at pos.config.stock_location_id and its children. For serial-tracked products, each quant = 1 serial with quantity=1.0 (verified in live instance: product "123456" at "TC Main/Stock"). Inventory is NOT a number — it is a table. The number is the stock level (see next row). stock.quant records are created by stock moves and cannot be modified directly.
Stock Level Derived — no standalone model The count: how many units of a product are at a location. Computed as SUM(stock.quant.quantity) where product_id=X and location_id=Y. Odoo displays stock levels in reports, POS screens, and inventory views. The available quantity excludes reservations (stock.quant.available_quantity). SA-PoS reads stock levels from the native stock.quant aggregation. The POS register only sells if stock > 0. For serial-tracked: stock level = count of serials at Sales Goods location. Stock level is a number, not a model. It is derived on-the-fly from stock.quant. It can be negative if moves are not properly tracked (a known issue — see the multi-company inventory audit finding).

Everyday Example

"The OVES Kenya shop has one warehouse in Nairobi. Inside the PoS location, there are three sub-locations: Sales Goods, Rental Goods, and RMA. The inventory tells me serial BAT-000123 is at Sales Goods. The stock level for BAT-LFP-7250 at Sales Goods is 14 units."

In Odoo terms:

stock.warehouse:      "OVK Nairobi" (id=XX)
stock.location:       "OVK Main/PoS Sales Goods" (id=YY, parent=PoS stock location)
stock.quant:          14 rows for product_id=BAT-LFP-7250 at location_id=YY, each lot_id = unique serial
stock level:          SUM(quantity) = 14.0

A PoS location's inventory is exactly:

SELECT lot_id FROM stock.quant WHERE location_id = {pos.config.stock_location_id}
Odoo Fact Field
Inventory at a location stock.quant (product + lot + location + quantity)
Serial-tracked product stock.quant.quantity = 1.0 per serial
Quantity at location stock.quant.available_quantity (excludes reservations)
Company scope stock.quant.company_id (derived from location)

OVES rule: All product inventory at SA-PoS locations is serialized. When a stock move occurs — delivery to PoS, PoS sale, inter-location transfer — the physical serials being moved must be identified. This is Odoo's native behavior for serial-tracked products: stock.move.line.lot_id captures the serial at the move line level.

SA governance: SA governs which locations an agent can access via ov.sa_location. SA does not modify stock move creation, serial assignment, or quantity updates.


6. Pricelist Governance

Entity Model Role
SA → Pricelist association ov.sa_pricelist SA admin binds a pricelist to this SA
POS pricelist pos.config.pricelist_id Constrained by SA's associated pricelist
Price rule engine product.pricelist.item Native Odoo — untouched by SA

Enforcement: When a PA applet opens a PoS session, it reads the SA's ov.sa_pricelist record and verifies that pos.config.pricelist_id matches. If the POS config has no pricelist set, the SA's pricelist is applied. If the POS config has a different pricelist, the SA's pricelist wins (per ADR 0005: "SA rule wins if there is a conflict").


7. Location Model: PoS ≠ Warehouse

Entity Odoo Model What It Is
Physical warehouse stock.warehouse Physical building/storage area
PoS stock location stock.location (child of warehouse) Shelf/display area for this PoS
PoS sales terminal pos.config Sales desk, register, tablet

A warehouse may serve multiple PoS configs. Each PoS config may have its own stock location within the warehouse. Multiple sales desks (managed by different agents) are distinct pos.config records, each with its own stock_location_id, potentially sharing the same warehouse_id.

SA governance:

Association Model Questions It Answers
SA → Warehouse ov.sa_location "Which physical inventory does this SA control?"
SA → POS Config ov.sa_pos_config "Which terminals can this SA's agents operate?"

These are independent. An SA could control one warehouse but operate across multiple PoS configs within it.

Internal PoS Location Hierarchy

Even at a single physical shop address, inventory must be logically separated by purpose. Odoo's stock.location tree supports this natively — child locations can be created under the PoS's stock_location_id.

OVES standard location structure for each SA-PoS:

Warehouse (stock.warehouse)
  └── PoS Stock Location (pos.config.stock_location_id)
        ├── Sales Goods         ← Products available for immediate sale
        ├── Rental Goods        ← Products designated for rental; returned rentals recycle here directly
        └── RMA Returned Goods  ← Products returned by customers; must be refurbished before resale
Location Purpose Restock Path
Sales Goods Active inventory sold to customers at the register. Stock is consumed by standard PoS sales. Replenished from warehouse or supplier.
Rental Goods Inventory reserved for the rental program. When a rental is returned, the serial goes directly back here — ready for the next rental. Self-recycling: rental returns → back to this location.
RMA Returned Goods Products returned by customers (non-rental returns, §4). These items are NOT available for immediate sale. They must be inspected, refurbished if needed, and then transferred to Sales Goods. Manual transfer: RMA → inspection/refurbishment → Sales Goods.

Why separate locations matter:

  • Inventory accuracy: A shop manager can see exactly how many units are available for sale vs. how many are out for rental vs. how many need refurbishment — all at a glance, per location.
  • POS register behavior: The POS register only sells from the "Sales Goods" location. Rental and RMA goods are invisible to the sales register.
  • Serial audit trail: Every serial's movement between these locations is tracked in stock.move.line. You can always answer: "Where is serial X right now, and why?"
  • SA governance: Each sub-location can be included or excluded from SA access via ov.sa_location. For example, an SA for a retail-only agent may only see Sales Goods, while a rental agent sees Rental Goods.

Location transitions:

From To When
Sales Goods Customer Standard POS sale (§1)
Sales Goods → RMA Returned Goods Customer return (§4) — goods need inspection
Rental Goods → Customer Rental out (§3 Phase A)
Customer → Rental Goods Rental return (§3 Phase C) — direct recycle
RMA Returned Goods → Sales Goods After refurbishment inspection passes

All transitions are stock.move operations — native Odoo, zero SA modification.


8. Warehouse Architecture: One Country, One Warehouse, Multiple PoS Locations

The Rule

One warehouse per country. Multiple retail shops served by sub-locations under that single warehouse.

This is not a suggestion. It is the correct architecture for OVES retail operations. Do not create a separate warehouse for each shop.

Why One Warehouse per Country

Doing It Right — One Warehouse Doing It Wrong — One Warehouse per Shop
1 warehouse to configure and maintain N warehouses to configure and maintain
Stock transfers between shops = internal transfer (simple) Stock transfers between shops = inter-warehouse transfer (complex routing, separate picking types, more accounting entries)
Country-level stock view: all inventory visible at OVK/Stock No single view. Must run reports across N warehouses.
One set of routes, picking types, and resupply rules N sets, all potentially diverging
SA manages one ov.sa_location for the country warehouse SA manages N ov.sa_location records, one per shop

Reference Implementation: OVES Kenya

This is the blueprint. Follow it exactly. Adapt the names for other countries.

Step 1 — Create the Warehouse

Name:      OVK Kenya
Code:      OVK01
Company:   OVES Kenya (id=11)
Address:   Nairobi warehouse address

Odoo auto-creates: OVK Kenya, OVK Kenya/Stock, OVK Kenya/Input, OVK Kenya/Output, plus picking types and routes.

Step 2 — Create Shop Locations Under OVK Kenya/Stock

Do NOT use Odoo's warehouse auto-generation for shops. Manually create these as child locations of OVK Kenya/Stock:

OVK Kenya (view)                    ← warehouse.view_location_id
  └── OVK Kenya/Stock (internal)    ← warehouse.lot_stock_id (主仓库)
        │
        ├── OVK Kenya/Shop Nairobi CBD/ (view)     ← PoS 的容器
        │     ├── Sales Goods (internal)            ← pos.config.stock_location_id
        │     ├── Rental Goods (internal)
        │     └── RMA Returned Goods (internal)
        │
        ├── OVK Kenya/Shop Kisumu/ (view)
        │     ├── Sales Goods (internal)
        │     ├── Rental Goods (internal)
        │     └── RMA Returned Goods (internal)
        │
        └── OVK Kenya/Shop Mombasa/ (view)
              ├── Sales Goods (internal)
              ├── Rental Goods (internal)
              └── RMA Returned Goods (internal)
Location Usage Purpose Contains Products?
OVK Kenya/Shop Nairobi CBD/ view Container for the shop — groups its sub-locations
.../Sales Goods internal Products available for sale at this shop
.../Rental Goods internal Products available for rental at this shop
.../RMA Returned Goods internal Products returned, awaiting refurbishment

Step 3 — Create POS Configs

Each sales desk is a pos.config. Multiple desks in the same shop share the same Sales Goods location:

pos.config "Nairobi CBD — Counter 1"
  warehouse_id      → OVK Kenya
  stock_location_id → OVK Kenya/Shop Nairobi CBD/Sales Goods
  pricelist_id      → (set by SA via ov.sa_pricelist)

pos.config "Nairobi CBD — Counter 2"
  warehouse_id      → OVK Kenya
  stock_location_id → OVK Kenya/Shop Nairobi CBD/Sales Goods   ← same location!
  pricelist_id      → (set by SA via ov.sa_pricelist)

pos.config "Kisumu — Counter"
  warehouse_id      → OVK Kenya
  stock_location_id → OVK Kenya/Shop Kisumu/Sales Goods
  pricelist_id      → (set by SA via ov.sa_pricelist)

Key point: Multiple POS configs can share the same stock_location_id. Two counters in the same shop sell from the same shelf. Odoo handles concurrent sales natively.

Step 4 — Stock Movement: Warehouse → Shop → Customer

供应商 → OVK Kenya/Input → OVK Kenya/Stock
                               │
       ┌───────────────────────┼───────────────────────┐
       │ 内部调拨              │                       │
       ▼                       ▼                       ▼
  Nairobi/Sales           Kisumu/Sales          Mombasa/Sales
       │                       │
       │ POS 销售               │ POS 销售
       ▼                       ▼
    Customer               Customer
Movement Type Who Does It Automation
Supplier → OVK/Input → OVK/Stock Purchase receipt Purchasing Odoo auto (reception steps)
OVK/Stock → Shop/Sales Goods Internal transfer Shop manager Manual. Can be automated with reorder rules: when Sales Goods stock < minimum, auto-generate transfer from OVK/Stock.
Shop/Sales Goods → Customer POS sale Sales clerk Odoo auto (POS order → stock.move on session close)
Customer → Shop/RMA POS return Sales clerk Odoo auto (POS return → stock.move)
Shop/RMA → Shop/Sales Goods Internal transfer Shop manager Manual (after inspection/refurbishment)

Step 5 — SA Governance Wiring

ov.sa_location:
  sa_id    → SA "OVES Kenya Retail"
  location_id → OVK Kenya  (the entire warehouse tree)

ov.sa_pos_config:
  sa_id       → SA "OVES Kenya Retail"
  pos_config_id → pos.config "Nairobi CBD — Counter 1"

ov.sa_pos_config:
  sa_id       → SA "OVES Kenya Retail"
  pos_config_id → pos.config "Kisumu — Counter"

ov.sa_pricelist:
  sa_id       → SA "OVES Kenya Retail"
  pricelist_id → "Kenya Retail Price List"

All three shops under one SA. One pricelist for all. Agents access the POS terminals assigned to their shop.

Do NOT Do This

  • ❌ Create a warehouse per shop. A shop is not a warehouse. A shop is a shelf inside a warehouse.
  • ❌ Give each POS its own stock.location without the shop container grouping. Use the three-level tree: Warehouse → Shop → Purpose (Sales/Rental/RMA).
  • ❌ Skip the internal transfer step and sell directly from OVK/Stock. That defeats the purpose of shop-level inventory visibility.
  • ❌ Use the same stock location for Sales and RMA. Returned goods must not mix with saleable stock.

Summary Checklist — Setting Up a New Country

Step Action
1 Create one stock.warehouse for the country
2 Under lot_stock_id, create a view location for each shop: {Warehouse}/{Shop Name}/
3 Under each shop, create three internal locations: Sales Goods, Rental Goods, RMA
4 Create pos.config records — one per sales desk — pointing to the shop's Sales Goods location
5 Create ov.sa_location linking the SA to the warehouse
6 Create ov.sa_pos_config records linking the SA to each POS config
7 Create ov.sa_pricelist linking the SA to the retail pricelist
8 Train shop managers on internal transfers from warehouse stock to shop stock

9. PA Terminal: Cache Device, No Logic

The PA applet (Physical Appliance) is a compute device — desktop, tablet, or phone — that runs the PoS interface. It is NOT a logic engine.

Terminal Responsibility Where It Lives
Cache product catalog IndexedDB (local)
Cache pricelist data IndexedDB (local)
Cache customer data IndexedDB (local)
Scan barcodes Terminal hardware
Display UI PA applet
Calculate prices Odoo server (never terminal)
Validate tax rules Odoo server (never terminal)
Validate stock availability Odoo server (never terminal)
Enforce SA access rules Odoo server (never terminal)
Create pos.order Odoo RPC from terminal
Create SA governance records Odoo RPC from terminal
Offline order storage IndexedDB queue → sync on reconnect

Design rule: The terminal creates zero hidden logic. All business rules — pricing, taxation, stock validation, SA access enforcement, promotional discounts — execute exclusively on the Odoo server. The terminal is a presentation layer with a read cache. Offline orders are queued locally and submitted verbatim on reconnection; the server validates all rules at submission time.


10. SA Session Lifecycle

Shift start:
  Agent logs into PA applet →
    System reads agent's ov.membership →
    Selects active SA →
    Opens pos.session (native Odoo)

During shift:
  Each order →
    Native pos.order created (RPC) →
    SA inserts: ov.sa_pos_order + ov.actor_sa_pos_order

Shift close:
  Agent closes session →
    Odoo posts: stock.picking, account.move, stock.quant updates
    SA context remains immutable on each order

SA context is frozen at the moment pos.order is created. If the agent changes SAs mid-shift, previous orders retain their original SA. If the agent's membership is revoked, existing orders are unaffected — only future orders are blocked.


11. Relationship to Existing ADRs

ADR Relationship
ADR 0003 (V3 Association Model) ov.sa_pos_order + ov.actor_sa_pos_order follow the exact V3 pattern.
ADR 0005 (SA-POS Governance) This ADR implements the 9 design principles from ADR 0005: mandatory customer ID, two-level agent identity, external admin, SA scope = ACL, frozen SA context, three-tier reporting.
ADR 0006 (SA Access Security) All 5 security gaps apply to PoS-PA. Track A Stage 1 (PA service user) is prerequisite before ir.rule enforcement on pos.order.
ADR 0009 (Product Profile) Pricelists are SA-governed (this ADR), not profile-governed (ADR 0009). Profiles carry localization content only.
ADR 0008 (Assembly-Cell) Same PA applet pattern: form-based, SA-governed, agent-as-actor, manager sign-off.

12. Open Security Gaps

Per ADR 0006, the following gaps remain and are prerequisites for production PoS-PA deployment:

Gap Severity Status
G1: No ir.rule on pos.order HIGH Open
G2: PA connects as Admin (uid=2) HIGH Open
G3: No ir.rule on association tables MEDIUM Open
G4: Full CRUD on association models MEDIUM Open
G5: No dedicated PA service user HIGH Open

These are tracked in ADR 0006, not duplicated here.


Cross-References

  • ADR 0005: docs/adr/0005-v3-pos-applet-design.md — V3 SA-POS governance design.
  • ADR 0006: docs/adr/0006-sa-access-security-foundation.md — SA access security gaps and hardening plan.
  • ADR 0003: docs/adr/0003-v3-association-model-migration-pattern.md — V3 association model.
  • ADR 0009: docs/adr/0009-company-product-profile-framework.md — Product Profile framework.
  • ADR 0008: docs/adr/0008-assembly-cell-process-with-sa-governance.md — Assembly-Cell PA applet (same pattern).
  • Concept document: docs/concepts/pos-applet.md — POS Applet concept.
  • Implementation Scorecard: docs/reference/implementation-scorecard.md — live Odoo model inventory.