How the Real Cashier 2.0 engine runs
The dashboard lives online, but the Chrome window that acts as your cashier opens on your PC. A small helper (the local agent) does that. Set it up once:
- 1Install PythonPython 3 with “Add to PATH” ticked. That's all you need.
- 2Download 2 filesGet pos_agent.py + launch_odoo_pos_hub.bat (step 1).
- 3Double-click the .batIt installs Playwright + Chromium if missing, then opens the controller.
1 Download the Agent & One-Click Launcher
Save both files in the same folder (e.g. Documents). Double-click launch_odoo_pos_hub.bat — it checks Python, installs Playwright, Pytest and Chromium the first time, starts the agent and opens the Real Cashier Controller. Keep that window open while testing.
@echo off
title Odoo POS Cashier Simulation Hub 2.0
color 0b
echo ========================================================
echo Odoo POS Cashier Simulation Hub 2.0 - Local Agent
echo ========================================================
echo.
cd /d "%~dp0"
:: 1. Check for Python on PATH (or the default per-user install)
set "PYTHON_CMD=python"
where python >nul 2>nul
if errorlevel 1 (
if exist "%LOCALAPPDATA%\Programs\Python\Python311\python.exe" (
set "PYTHON_CMD=%LOCALAPPDATA%\Programs\Python\Python311\python.exe"
) else (
echo [ERROR] Python was not found on your system PATH.
echo Please install Python and tick "Add python.exe to PATH".
pause
exit /b 1
)
)
echo [OK] Python detected: %PYTHON_CMD%
:: 2. Playwright and Pytest packages
echo [Check] Verifying Playwright and Pytest installation...
"%PYTHON_CMD%" -c "import playwright, pytest, pytest_playwright" >nul 2>nul
if errorlevel 1 (
echo [Setup] Installing playwright, pytest and pytest-playwright...
"%PYTHON_CMD%" -m pip install --upgrade pip
"%PYTHON_CMD%" -m pip install playwright pytest pytest-playwright
if errorlevel 1 (
echo [ERROR] Failed to install Playwright packages.
pause
exit /b 1
)
echo [OK] Packages installed successfully.
) else (
echo [OK] Playwright and Pytest are installed.
)
:: 3. Chromium browser engine
if not exist "%LOCALAPPDATA%\ms-playwright\chromium*" (
echo [Setup] Chromium not found. Downloading Chromium...
"%PYTHON_CMD%" -m playwright install chromium
if errorlevel 1 (
echo [ERROR] Failed to download Chromium browser.
pause
exit /b 1
)
echo [OK] Chromium installed successfully.
) else (
echo [OK] Chromium browser detected.
)
:: 4. Start the local agent
if not exist "%~dp0pos_agent.py" (
echo [ERROR] pos_agent.py not found next to this .bat file.
echo Download it from the User Guide page and save it in this folder.
pause
exit /b 1
)
echo.
echo [OK] Starting agent on http://127.0.0.1:5050 ...
echo (Keep this window open while using the dashboard)
echo.
"%PYTHON_CMD%" "%~dp0pos_agent.py"
if errorlevel 1 (
echo.
echo [Notice] Agent exited with an error.
)
pause
- The agent listens only on your PC at
http://127.0.0.1:5050and only accepts commands from this website. - Use Chrome or Edge. If the browser asks to allow access to devices on your local network, click Allow.
- Downloaded the helper before? Download pos_agent.py again — version 3.0 adds out-of-stock and unknown-barcode handling, skipped sales and payment matching. The checklist on the controller tells you if yours is out of date.
2 Real Cashier Behavior Principles
Each product row in the controller has its own method and quantity, so one sale can mix scanned and typed items.
Releases the search bar, then types the barcode into the POS and presses Enter, like a real scanner. Qty 2 scans it twice. No barcode? It scans the product name instead. Mix scan and typed rows in one sale freely.
Clicks the POS search bar, types the product name, clicks the matching product once per Qty, then clears the search so the full catalog comes back.
Unlocks the register, opens the cashier list and picks the name you set. If you enter a PIN, it is typed on the POS keypad.
Pays with CASH KJ_1, CASH, BANK, QRIS, Customer Account or any name you type. If that exact button isn't there, it picks the closest match (CASH finds CASH KJ_1) or the first one shown, and says which it used.
Spots the out-of-stock popup, an unknown barcode, or a product that isn't in the catalog. It closes the message, leaves that item out and carries on with the rest.
If no item made it into the cart, that sale is marked Skipped with the reason and the next sale starts. Sales with some items left out finish with a warning.
3 Manual Environment Setup (Optional)
Only needed if you want to run the scripts by hand or the launcher can't install packages on your PC:
# Optional: the launcher installs these automatically on first run.
cd ~/Documents
mkdir odoo-pos-testing -ErrorAction SilentlyContinue
cd odoo-pos-testing
# Install Playwright & Pytest
python -m pip install --upgrade pip
python -m pip install playwright pytest pytest-playwright
# Download Chromium browser engine
python -m playwright install chromium4 Record Steps Manually Using Codegen
Playwright Codegen opens an interactive browser and records your clicks and typing as Python code — handy when your POS screens differ from the defaults.
python -m playwright codegen https://teststaging.ddns.net/web/loginStep-by-step recording procedure
- Browser opens: a Chromium window and the Playwright Inspector appear side-by-side.
- Log in & open POS: select database
test161026, enter credentials, and open your POS terminal session. - Pick the cashier: click 'Unlock Register', click the cashier icon and select
Administrator. - Add products: type a name in the search bar or scan a barcode, then click the product.
- Pay: click 'Payment', choose
CASH KJ_1and click 'Validate'. - Copy & save: copy the code from the Inspector and paste the locators into your test script.
5 Embedded File 1: conftest.py (Silent Printing)
Suppresses Chrome's print preview so receipt printing never blocks validation:
import pytest
@pytest.fixture(scope="session")
def browser_type_launch_args(browser_type_launch_args):
"""
Launch Chromium with flags that disable the print preview dialog,
allowing Odoo POS to print/validate silently without opening the print modal.
"""
args = browser_type_launch_args.get("args", [])
args.extend([
"--disable-print-preview",
"--kiosk-printing",
# Large POS data downloads get cut off over HTTP/2 by some servers
"--disable-http2",
])
return {
**browser_type_launch_args,
"args": args,
}
6 Embedded File 2: test_odoo_pos.py (Real Cashier Engine)
Built from your settings in the Real Cashier Controller. To run it without the agent: put both files in one folder and run python -m pytest test_odoo_pos.py -s --headed.
import re
import pytest
from playwright.sync_api import Page, expect
# ========================================================
# CONFIGURATION - Generated by Odoo POS Cashier Simulation Hub
# ========================================================
BASE_URL = "https://teststaging.ddns.net"
DB_NAME = "test161026"
POS_CONFIG_ID = "214"
LOGIN_EMAIL = ""
LOGIN_PASS = ""
CASHIER_NAME = "Administrator"
CASHIER_PIN = ""
PAYMENT_METHOD = "CASH KJ_1"
REPEAT_SALES_COUNT = 1
ITEMS_TO_BUY = [
{
"name": "AQUA 1500 ML BOTTLE",
"method": "type",
"barcode": "8992753000102",
"qty": 1
},
{
"name": "BEAR BRAND TIN 189 ML",
"method": "barcode",
"barcode": "8992753000188",
"qty": 1
}
]
KEEP_BROWSER_MODE = "0"
# ========================================================
def show_onscreen_status(page: Page, message: str):
page.evaluate(
"""(msg) => {
let el = document.getElementById('playwright-status-pill');
if (!el) {
el = document.createElement('div');
el.id = 'playwright-status-pill';
el.style.position = 'fixed';
el.style.bottom = '20px';
el.style.right = '20px';
el.style.backgroundColor = '#0284c7';
el.style.color = '#ffffff';
el.style.padding = '12px 20px';
el.style.borderRadius = '10px';
el.style.boxShadow = '0 4px 15px rgba(0,0,0,0.25)';
el.style.fontFamily = 'Segoe UI, sans-serif';
el.style.fontSize = '15px';
el.style.fontWeight = 'bold';
el.style.zIndex = '999999';
el.style.border = '2px solid #38bdf8';
document.body.appendChild(el);
}
el.innerText = msg;
}""",
message,
)
def dismiss_popups(page: Page):
try:
page.keyboard.press("Escape")
page.wait_for_timeout(300)
close_btn = page.locator(".modal button:has-text('Close'), .modal .btn-close, button[name='close'], .popup button:has-text('Cancel')").first
if close_btn.is_visible(timeout=500):
close_btn.click()
page.wait_for_timeout(300)
except Exception:
pass
def appears(locator, timeout_ms: int) -> bool:
"""True if the element shows up within timeout_ms (is_visible() does not wait)."""
try:
locator.wait_for(state="visible", timeout=timeout_ms)
return True
except Exception:
return False
def test_pos_transaction(page: Page):
page.set_default_timeout(60000)
# Suppress browser print dialog to prevent blocking validation
page.add_init_script(
"""
window.print = () => { console.log('[Playwright] window.print() intercepted and suppressed.'); };
window.addEventListener('load', () => {
window.print = () => { console.log('[Playwright] window.print() suppressed on load.'); };
});
"""
)
print("\n--- Step 1: Navigating to Odoo ---")
page.goto(f"{BASE_URL}/web/login?db={DB_NAME}")
page.wait_for_load_state("domcontentloaded")
# Some servers show the database list first
db_link = page.locator(f"a:has-text('{DB_NAME}')").first
try:
if "/database/selector" in page.url and db_link.is_visible(timeout=5000):
print(f"Selecting database: {DB_NAME}")
db_link.click()
page.wait_for_load_state("domcontentloaded")
except Exception:
pass
login_form = page.locator(".oe_login_form, form.oe_login_form, form[action*='/web/login']").first
try:
login_form.wait_for(state="visible", timeout=15000)
form_shown = True
except Exception:
form_shown = False
if form_shown:
print("Entering credentials...")
login_form.locator("input#login, input[name='login']").fill(LOGIN_EMAIL)
login_form.locator("input#password, input[name='password']").fill(LOGIN_PASS)
print("Clicking Log in button...")
login_form.locator("button[type='submit']").first.click()
print("Waiting for login to complete...")
try:
page.wait_for_url(lambda u: "/login" not in u and "/database/selector" not in u, timeout=30000, wait_until="commit")
except Exception:
alert = page.locator(".alert-danger").first
reason = alert.inner_text().strip() if alert.is_visible() else "Odoo stayed on the login page"
raise AssertionError(f"[ERROR] Login failed: {reason}. Check the login email, password and database name.")
print("[OK] Logged in successfully!")
else:
print("Login form not shown or already authenticated.")
print(f"Opening POS Session (Config ID: {POS_CONFIG_ID})...")
pos_load_failed = []
page.on("requestfailed", lambda req: pos_load_failed.append(req.url) if "load_data" in req.url else None)
page.goto(f"{BASE_URL}/pos/ui?config_id={POS_CONFIG_ID}")
page.wait_for_load_state("domcontentloaded")
print("Waiting for POS screen / Cashier Selection...")
show_onscreen_status(page, "Checking POS interface...")
# Wait for the POS to load. Large POS data can be cut off by the server, so reload up to 3 times.
pos_ready = page.locator(".open-register-btn, button:has-text('Unlock Register'), .select-cashier, .product-list, article.product-card").first
for attempt in range(1, 4):
for _ in range(90):
if pos_ready.is_visible() or pos_load_failed or "/web/login" in page.url:
break
page.wait_for_timeout(1000)
if pos_ready.is_visible():
break
if "/web/login" in page.url:
raise AssertionError("[ERROR] Odoo sent the browser back to the login page. Check the login email, password and database name.")
if attempt == 3:
raise AssertionError("[ERROR] The POS screen did not finish loading after 3 tries. The Odoo server cut off the POS startup data; ask your IT team to raise the web proxy timeout and size limit.")
reason = "the server cut off the POS data download" if pos_load_failed else "it is taking too long"
print(f"[Warning] POS did not load ({reason}). Reloading, try {attempt + 1} of 3...")
pos_load_failed.clear()
page.reload(wait_until="domcontentloaded")
show_onscreen_status(page, f"Reloading POS (try {attempt + 1} of 3)...")
print("[OK] POS screen loaded.")
page.wait_for_timeout(2000)
# 1. Unlock register button (if present)
unlock_btn = page.locator(".open-register-btn, button:has-text('Unlock Register'), .btn-unlock").first
try:
if unlock_btn.is_visible(timeout=4000):
print("Clicking Unlock Register...")
show_onscreen_status(page, "Unlocking Cashier Register...")
unlock_btn.click()
page.wait_for_timeout(1500)
except Exception:
pass
# 2. People icon (Cashier selection)
cashier_icon = page.locator(".select-cashier, button.select-cashier").first
try:
if cashier_icon.is_visible(timeout=5000):
print("Clicking Select Cashier icon...")
cashier_icon.click()
page.wait_for_timeout(1500)
except Exception:
pass
# 3. Dynamic Cashier selection
print(f"Selecting Cashier: {CASHIER_NAME}...")
try:
cashier_btn = page.locator(f"button.selection-item:has-text('{CASHIER_NAME}'), button:has-text('{CASHIER_NAME}'), div.selection-item:has-text('{CASHIER_NAME}')").first
if cashier_btn.is_visible(timeout=8000):
cashier_btn.click()
print(f"[OK] Cashier '{CASHIER_NAME}' selected successfully!")
page.wait_for_timeout(1500)
except Exception:
print(f"Cashier '{CASHIER_NAME}' already active or not prompted.")
# 3b. Handle Cashier PIN if configured
if CASHIER_PIN:
try:
pin_input = page.locator(".popup-password, .numpad, .pinpad, div.popup input[type='password']").first
if pin_input.is_visible(timeout=3000):
print(f"Entering PIN for Cashier {CASHIER_NAME}...")
for char in CASHIER_PIN:
num_btn = page.locator(f"button.input-button:has-text('{char}'), div.pad-button:has-text('{char}')").first
if num_btn.is_visible(timeout=500):
num_btn.click()
page.wait_for_timeout(150)
else:
page.keyboard.type(char)
page.keyboard.press("Enter")
page.wait_for_timeout(1000)
except Exception:
pass
# 4. Wait for modal/popup to close
try:
page.locator(".modal, .selection-dialog, .popup").wait_for(state="hidden", timeout=5000)
except Exception:
pass
# 5. Confirm product list is genuinely visible
print("Waiting for POS products to load...")
expect(page.locator(".product-list, .products-widget, article, .product, .pos-content").first).to_be_visible(timeout=45000)
dismiss_popups(page)
show_onscreen_status(page, "POS Ready!")
print("\n===========================================")
print(f" STARTING {REPEAT_SALES_COUNT} POS SALES")
print(f" Cashier: {CASHIER_NAME} | Method: {PAYMENT_METHOD}")
print("===========================================\n")
completed_sales = 0
failed_sales = 0
for sale_num in range(1, REPEAT_SALES_COUNT + 1):
status_msg = f"Sale #{sale_num} of {REPEAT_SALES_COUNT}"
print(f"\n>>> [{status_msg}] <<<")
show_onscreen_status(page, f"Running {status_msg}...")
sale_errors = []
items_added_count = 0
for item_data in ITEMS_TO_BUY:
item_name = item_data.get("name", "").strip()
method = item_data.get("method", "type").strip().lower()
barcode = item_data.get("barcode", "").strip()
qty = int(item_data.get("qty", 1))
if method == "barcode":
scan_val = barcode if barcode else item_name
print(f" + [Barcode Scan] Scanning '{scan_val}' (Qty: {qty})...")
# CRITICAL: Defocus/blur search bar and all inputs so keystrokes go to Odoo POS Barcode Reader
page.evaluate("""() => {
let inps = document.querySelectorAll('input');
inps.forEach(i => {
if (i.placeholder && i.placeholder.includes('Search')) i.value = '';
});
if (document.activeElement && document.activeElement.blur) {
document.activeElement.blur();
}
}""")
page.wait_for_timeout(300)
item_failed = False
for q_idx in range(1, qty + 1):
if item_failed:
break
show_onscreen_status(page, f"Scanning: {scan_val} ({q_idx}/{qty})")
for ch in scan_val:
page.keyboard.press(ch)
page.keyboard.press("Enter")
page.wait_for_timeout(600)
# Detect if barcode is unknown (toast notification or modal)
unknown_notif = page.locator(".o_notification:has-text('Unknown Barcode'), [class*='notification']:has-text('Unknown Barcode'), .modal:has-text('Unknown Barcode')").first
if appears(unknown_notif, 1000):
print(f" [ERROR] Barcode '{scan_val}' is not registered in Odoo POS database!")
show_onscreen_status(page, f"Error: Barcode '{scan_val}' not found!")
# Dismiss notification/modal so UI unfreezes
try:
close_btn = unknown_notif.locator("button, i, .btn-close, .o_notification_close").first
if close_btn.is_visible(timeout=1000):
close_btn.click()
else:
page.locator(".modal button:has-text('Ok'), .popup button:has-text('Ok')").first.click()
except Exception:
pass
sale_errors.append(f"Barcode '{scan_val}' not found in Odoo database")
item_failed = True
break
dismiss_popups(page)
if not item_failed:
items_added_count += 1
display_lbl = item_name if item_name else scan_val
print(f" + Adding item: '{display_lbl}' x{qty}")
else:
# Type by name & Click
print(f" + [Type & Click] Searching '{item_name}' in search box (Qty: {qty})...")
show_onscreen_status(page, f"Searching: {item_name}")
search_input = page.locator("input[placeholder='Search products...'], input.border-0.mx-2, .pos-search-bar input").first
if search_input.is_visible(timeout=5000):
search_input.click(force=True)
search_input.fill("")
page.wait_for_timeout(200)
search_input.type(item_name, delay=25)
search_input.press("Enter")
page.wait_for_timeout(800)
# Locate filtered product card - wait for it to appear on screen
print(f" + Waiting for '{item_name}' to appear on screen...")
product_card = page.locator(f"article:has-text('{item_name}'), .product:has-text('{item_name}')").first
card_visible = False
try:
product_card.wait_for(state="visible", timeout=6000)
card_visible = True
except Exception:
pass
if not card_visible:
print(f" [ERROR] Product '{item_name}' was not found on screen / in catalog!")
show_onscreen_status(page, f"Error: '{item_name}' not found!")
sale_errors.append(f"Product '{item_name}' not found in catalog")
try:
search_input.click(force=True)
search_input.fill("")
search_input.press("Enter")
page.evaluate("() => { if (document.activeElement && document.activeElement.blur) document.activeElement.blur(); }")
except Exception:
pass
continue
item_failed = False
for q_idx in range(1, qty + 1):
if item_failed:
break
# Use force=True to prevent transient toasts/promotions from intercepting
product_card.click(force=True)
page.wait_for_timeout(400)
# Check for out of stock popup
stock_pop = page.locator(".modal:has-text('Unavailable Stock'), .modal:has-text('no stock'), .modal:has-text('out of stock')").first
if appears(stock_pop, 1000):
print(f" [ERROR] Product '{item_name}' is out of stock in Odoo POS!")
show_onscreen_status(page, f"Error: '{item_name}' out of stock!")
try:
page.locator(".modal button:has-text('Ok'), button:has-text('Close')").first.click()
except Exception:
pass
sale_errors.append(f"Product '{item_name}' is out of stock")
item_failed = True
break
dismiss_popups(page)
if not item_failed:
items_added_count += 1
print(f" + Adding item: '{item_name}' x{qty}")
# Clear and BLUR search bar for next item to restore full catalog and release focus
try:
search_input.click(force=True)
search_input.fill("")
search_input.press("Enter")
page.evaluate("() => { if (document.activeElement && document.activeElement.blur) document.activeElement.blur(); }")
page.wait_for_timeout(400)
except Exception:
pass
# Check cart lines before attempting payment
page.wait_for_timeout(500)
cart_lines = page.locator(".orderline, .order-line").all()
if len(cart_lines) == 0:
err_details = "; ".join(sale_errors) if sale_errors else "All items failed to add"
print(f" [FAILED] Sale #{sale_num} skipped: Cart is empty ({err_details}). Moving to next sale...")
show_onscreen_status(page, f"Sale #{sale_num} skipped: Cart empty")
failed_sales += 1
page.wait_for_timeout(1000)
continue
print(" -> Clicking Payment...")
payment_btn = page.locator("button.pay, button.button.pay, button:has-text('Payment')").first
expect(payment_btn).to_be_visible(timeout=10000)
payment_btn.click()
page.wait_for_timeout(1000)
print(f" -> Selecting '{PAYMENT_METHOD}' payment method...")
pay_method_btn = page.locator(
f".paymentmethod:has-text('{PAYMENT_METHOD}'), button:has-text('{PAYMENT_METHOD}'), div.button:has-text('{PAYMENT_METHOD}'), .paymentmethod-name:has-text('{PAYMENT_METHOD}')"
).first
# Check if requested payment method exists, or provide graceful fallback & diagnostic
if not pay_method_btn.is_visible(timeout=4000):
avail_methods = [m.inner_text().strip().replace('\n', ' ') for m in page.locator(".paymentmethod, div.paymentmethod, .paymentmethods button").all() if m.is_visible()]
print(f" [Notice] '{PAYMENT_METHOD}' not found directly. Available on screen: {avail_methods}")
# Try fuzzy match (e.g. CASH matching CASH KJ_1)
first_word = PAYMENT_METHOD.split()[0] if PAYMENT_METHOD.split() else 'CASH'
fuzzy_btn = page.locator(f".paymentmethod:has-text('{first_word}')").first
if fuzzy_btn.is_visible(timeout=2000):
fuzzy_label = fuzzy_btn.inner_text().strip().replace('\n', ' ')
print(f" -> Selecting matched method: '{fuzzy_label}'")
pay_method_btn = fuzzy_btn
elif avail_methods:
fallback_btn = page.locator(".paymentmethod, div.paymentmethod, .paymentmethods button").first
fallback_label = fallback_btn.inner_text().strip().replace('\n', ' ')
print(f" -> Fallback selecting first available payment method: '{fallback_label}'")
pay_method_btn = fallback_btn
expect(pay_method_btn).to_be_visible(timeout=10000)
pay_method_btn.click()
page.wait_for_timeout(800)
print(" -> Validating order...")
validate_btn = page.locator("button.button.next.validation, button.validation-button, button:has-text('Validate')").first
expect(validate_btn).to_be_visible(timeout=10000)
validate_btn.click()
page.wait_for_timeout(1000)
dismiss_popups(page)
try:
page.locator(".o_blockUI").wait_for(state="hidden", timeout=15000)
except Exception:
pass
try:
page.locator(".product-list, .products-widget, article, .product").first.wait_for(state="visible", timeout=15000)
except Exception:
pass
if sale_errors:
print(f" [WARNING] Sale #{sale_num} completed with item issues: {'; '.join(sale_errors)}")
print(f" [OK] Sale #{sale_num} completed!")
completed_sales += 1
page.wait_for_timeout(1000)
if failed_sales == 0:
show_onscreen_status(page, f"All {REPEAT_SALES_COUNT} sales finished successfully!")
print("\n===========================================")
print(f"Finished all {REPEAT_SALES_COUNT} POS sales transactions successfully!")
print("===========================================\n")
else:
show_onscreen_status(page, f"Simulation finished: {completed_sales} completed, {failed_sales} failed/skipped")
print("\n===========================================")
print(f"Finished simulation: {completed_sales} completed, {failed_sales} failed/skipped out of {REPEAT_SALES_COUNT}.")
print("===========================================\n")
if KEEP_BROWSER_MODE == "30":
print("\n[Notice] Keeping browser open for 30 seconds for visual inspection...")
page.wait_for_timeout(30000)
elif KEEP_BROWSER_MODE == "60":
print("\n[Notice] Keeping browser open for 60 seconds for visual inspection...")
page.wait_for_timeout(60000)
elif KEEP_BROWSER_MODE == "infinite":
print("\n[Notice] Browser is kept open indefinitely. Close the browser window or press Stop in dashboard to finish...")
try:
page.wait_for_timeout(3600000)
except Exception:
pass
Odoo POS Cashier Simulation Hub · Real Cashier 2.0