ADR 0010: PoS-PA Process with SA Governance¶
Status¶
Proposed
Date¶
2026-07-18
Context¶
dirac-odoo already defines:
- 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. - 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).
- SA access security (ADR 0006): Five security gaps identified — no
ir.ruleonpos.order, PA connects as Admin (uid=2), noir.ruleon association tables, full CRUD on association models, no dedicated PA service user. Two-track hardening plan (authentication + authorization). - 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.companynatively. - Serviced Account model (
ov.serviced_account): Company-bound governance entity with hierarchy, membership, andcompany_id→res.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.orderis created, the PA applet inserts two records:ov.sa_pos_order(SA → order) andov.actor_sa_pos_order(actor → SA-assignment). No other SA inserts. No modifications topos.order,pos.order.line,pos.payment,stock.move, oraccount.move. - Pricelists are SA-governed. SA admin sets
ov.sa_pricelist(SA → pricelist). The PA applet enforces thatpos.config.pricelist_idmatches the SA's associated pricelist. The pricelist engine (product.pricelist.itemrules) 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 viaov.sa_locationand the POS config viaov.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.quantrecords 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_idandov.actor_sa_pos_order.actor_idnever 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¶
-
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.
-
Bring items to the counter. The customer brings their selected items to the sales desk. A sales clerk is ready at the register.
-
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.
-
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.
-
Customer pays. The customer pays using cash, mobile money, or card. The register records the payment method, amount, and transaction ID.
-
Electronic receipt. Once payment is confirmed, the system creates an electronic sales record. This record links:
- Which products were sold (by serial number — every item is tracked individually)
- Which customer bought them
- Which sales clerk handled the sale
-
Which store location processed it
-
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¶
- Every customer must be identified. No anonymous sales. If the customer is new, the system creates a record for them at checkout.
- 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.
- 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.
- 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_pricelist → pos.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_orderindicating 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-000123is 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.locationwithout 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.