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:

  1. 1Install PythonPython 3 with “Add to PATH” ticked. That's all you need.
  2. 2Download 2 filesGet pos_agent.py + launch_odoo_pos_hub.bat (step 1).
  3. 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.

launch_odoo_pos_hub.bat
@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:5050 and 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.

Barcode Scan mode

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.

Type & Click mode

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.

Selectable cashier + PIN

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.

Selectable payment method

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.

Out of stock & unknown items

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.

Skips sales it can't ring up

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:

PowerShell setup
# 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 chromium

4 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.

Launch Codegen
python -m playwright codegen https://teststaging.ddns.net/web/login

Step-by-step recording procedure

  1. Browser opens: a Chromium window and the Playwright Inspector appear side-by-side.
  2. Log in & open POS: select database test161026, enter credentials, and open your POS terminal session.
  3. Pick the cashier: click 'Unlock Register', click the cashier icon and select Administrator.
  4. Add products: type a name in the search bar or scan a barcode, then click the product.
  5. Pay: click 'Payment', choose CASH KJ_1 and click 'Validate'.
  6. 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:

conftest.py
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.

test_odoo_pos.py
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
Agent ready? Set your cashier and products, then launch.
Open Real Cashier Controller

Odoo POS Cashier Simulation Hub · Real Cashier 2.0