Initial commit: plan, spec, tickets and dashboard skill templates
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
49f8f49b65
39 files changed
+10736
No files matched your search
@@ -0,0 +1,81 @@
|
||||
"""Minimal PyQt6 host for dashboard.html (desktop mode). Pattern from Tebra main_window.py.
|
||||
|
||||
The bot runs as a child process that prints lines, and this window tails them.
|
||||
Fill in STEP_MARKERS, FRIENDLY_RULES and BOT_CMD for the project.
|
||||
"""
|
||||
import json, subprocess, sys, threading
|
||||
from pathlib import Path
|
||||
from PyQt6.QtCore import QObject, pyqtSlot, pyqtSignal, QUrl, QSettings
|
||||
from PyQt6.QtWidgets import QApplication, QMainWindow
|
||||
from PyQt6.QtWebEngineWidgets import QWebEngineView
|
||||
from PyQt6.QtWebChannel import QWebChannel
|
||||
|
||||
HTML_PATH = Path(__file__).with_name("dashboard.html")
|
||||
BOT_CMD = [sys.executable, "-m", "{{BOT_MODULE}}"] # child process
|
||||
STEPS = {{STEPS_PY}} # same list as STEPS in dashboard.html
|
||||
STEP_MARKERS = [("[step 1]", 0, 10), ("[all done]", len(STEPS) - 1, 100)] # (substring, step idx, pct)
|
||||
FRIENDLY_RULES = [("[login] done", "Logged in.", "success")] # (substring, friendly msg | None, type)
|
||||
|
||||
|
||||
def classify(line):
|
||||
s = line.lower()
|
||||
return "error" if ("error" in s or "traceback" in s) else "warn" if "warn" in s else "info"
|
||||
|
||||
|
||||
class Bridge(QObject):
|
||||
_emit = pyqtSignal(str) # thread-safe hop to the GUI thread
|
||||
|
||||
def __init__(self, view):
|
||||
super().__init__()
|
||||
self.view = view
|
||||
self._emit.connect(lambda js: self.view.page().runJavaScript(f"window.__pyMsg({js})"))
|
||||
|
||||
def send_to_js(self, event, data):
|
||||
self._emit.emit(json.dumps(json.dumps({"event": event, "data": data})))
|
||||
|
||||
@pyqtSlot()
|
||||
def load_dashboard(self):
|
||||
self.send_to_js("dashboard_data", {"batches": []}) # TODO: read from DB on a worker thread
|
||||
self.send_to_js("apply_dark", {"on": QSettings("{{ORG}}", "{{APP_NAME}}").value("dark", False, bool)})
|
||||
|
||||
@pyqtSlot(bool)
|
||||
def set_dark(self, on):
|
||||
QSettings("{{ORG}}", "{{APP_NAME}}").setValue("dark", on)
|
||||
|
||||
@pyqtSlot()
|
||||
def start_automation(self):
|
||||
threading.Thread(target=self._run, daemon=True).start()
|
||||
|
||||
def _run(self):
|
||||
self.send_to_js("status", {"state": "running", "msg": "Running…"})
|
||||
p = subprocess.Popen(BOT_CMD, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, bufsize=1)
|
||||
for raw in p.stdout:
|
||||
self._on_line(raw.rstrip())
|
||||
ok = p.wait() == 0
|
||||
self.send_to_js("status", {"state": "done" if ok else "error", "msg": "Completed" if ok else "Failed"})
|
||||
|
||||
def _on_line(self, line):
|
||||
# ponytail: raw line only printed here; persist to file/DB if audit is needed
|
||||
low, kind = line.lower(), classify(line)
|
||||
for marker, idx, pct in STEP_MARKERS:
|
||||
if marker in low:
|
||||
self.send_to_js("step", {"label": STEPS[idx], "pct": pct})
|
||||
break
|
||||
for marker, msg, t in FRIENDLY_RULES:
|
||||
if marker in low:
|
||||
if msg:
|
||||
self.send_to_js("log", {"msg": msg, "type": t})
|
||||
return
|
||||
if kind == "error": # never hide a real failure
|
||||
self.send_to_js("log", {"msg": line, "type": "error"})
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
app = QApplication(sys.argv)
|
||||
win, view = QMainWindow(), QWebEngineView()
|
||||
bridge, channel = Bridge(view), QWebChannel()
|
||||
channel.registerObject("pyBridge", bridge)
|
||||
view.page().setWebChannel(channel)
|
||||
view.setUrl(QUrl.fromLocalFile(str(HTML_PATH)))
|
||||
win.setCentralWidget(view); win.resize(1280, 820); win.setWindowTitle("{{APP_NAME}}"); win.show()
|
||||
sys.exit(app.exec())
|
||||
@@ -0,0 +1,31 @@
|
||||
# Dashboard data contract (no-AI)
|
||||
|
||||
All updates use one envelope: `{"event": "<name>", "data": {...}}`.
|
||||
- **Desktop (PyQt):** Python calls `window.__pyMsg(json)` via `runJavaScript` (see `bridge.py`).
|
||||
- **Web:** the page polls `GET {API_BASE}/dashboard` (returns `dashboard_data`) and
|
||||
`GET {API_BASE}/run/status` → `{"running": bool, "events": [envelope, ...]}`. Poll fast (2s) only while running.
|
||||
|
||||
## Bot/backend → page
|
||||
|
||||
| event | data |
|
||||
|---|---|
|
||||
| `status` | `{state: "ready"\|"running"\|"done"\|"error", msg}` |
|
||||
| `step` | `{label: <one of STEPS>, pct: 0-100}` |
|
||||
| `log` | `{msg: <plain English>, type: "info"\|"success"\|"warn"\|"error"}` |
|
||||
| `dashboard_data` | `{batches: [{batch_id, date, state: "completed"\|"inprogress"\|"notcompleted"\|"cleared", items_total, items_done, ...}]}` |
|
||||
| `report_data` | `{rows: [{...REPORT_COLUMNS keys, status, bucket: "success"\|"attention"\|"failed"}]}` |
|
||||
| `set_user` | `{username, emp_id, role}` |
|
||||
| `apply_dark` | `{on: bool}` |
|
||||
|
||||
Optional (only if the section was chosen): `cpstate {kind: pending|active|done|stuck, cp: int}`, `admin_data`, `batch_detail`.
|
||||
|
||||
## Page → bot/backend (Qt `@pyqtSlot` names / web `POST {API_BASE}/<slot-with-dashes>`)
|
||||
|
||||
`load_dashboard`, `start_automation`, `request_stop`, `load_latest_report`, `download_report`, `set_dark(on)`, `logout`.
|
||||
|
||||
## Rules (learned on Tebra)
|
||||
- Log the raw bot line to a file/DB; show only **friendly** text on screen. Never hide an error line.
|
||||
- Step markers: match the bot's printed lines by **specific** substring (e.g. `[step 3]`), not a bare word. A bare word matches unrelated lines.
|
||||
- Derive `state`: Pending + today = `inprogress`; Pending + older day = `notcompleted`.
|
||||
- Report `bucket`: success = {processed successfully, posted, completed}; failed = {error, failed}; everything else = attention.
|
||||
- Run the bot in a **separate process** that writes to a log file, and have the UI tail that file. When pywinauto/UIA runs inside a Qt worker thread, its reads are unreliable (Tebra bug-094).
|
||||
@@ -0,0 +1,273 @@
|
||||
<!DOCTYPE html>
|
||||
<!--
|
||||
Generic RPA dashboard (no AI). Derived from Tebra ERA (reference/tebra_dashboard.html).
|
||||
One file: works inside PyQt6 QWebEngineView (QWebChannel bridge) OR served by any web
|
||||
backend (falls back to polling GET {{API_BASE}}/dashboard and /run/status).
|
||||
Replace every {{PLACEHOLDER}}. Theme = the :root / body.dark token blocks only.
|
||||
-->
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>{{APP_NAME}}</title>
|
||||
<script src="qrc:///qtwebchannel/qwebchannel.js"></script>
|
||||
<style>
|
||||
/* ===== THEME TOKENS (swap these for a preset/custom palette) ===== */
|
||||
:root{
|
||||
--bg-top:#8fc9e8; --bg-mid:#bfe3f2; --bg-bottom:#eaf6fb;
|
||||
--glass:rgba(255,255,255,.60); --glass-strong:rgba(255,255,255,.78); --border:rgba(255,255,255,.9);
|
||||
--ink:#1c2b33; --muted:#5c7382; --accent-dark:#16202a;
|
||||
--accent:#3d9bd0; --accent-2:#5cb2e2; --accent-soft:#d4edfa; --accent-soft-2:#a9d8ee;
|
||||
--ok:#1f9d57; --ok-bg:#e6f6ec; --warn:#b7791f; --warn-bg:#fbf1dd; --bad:#c0392b; --bad-bg:#fdecea;
|
||||
--font:'Segoe UI',-apple-system,BlinkMacSystemFont,sans-serif;
|
||||
}
|
||||
body.dark{
|
||||
--bg-top:#0f1b26; --bg-mid:#13212e; --bg-bottom:#182a38;
|
||||
--glass:rgba(22,34,44,.72); --glass-strong:rgba(30,45,58,.85); --border:rgba(120,160,190,.22);
|
||||
--ink:#e6eef4; --muted:#8fa6b4; --accent-dark:#2e5d80;
|
||||
--accent-soft:#2e5d90; --accent-soft-2:#1d3d5e;
|
||||
--ok:#2fbf72; --ok-bg:rgba(31,157,87,.16); --warn:#d9a13c; --warn-bg:rgba(183,121,31,.16);
|
||||
--bad:#e05a4b; --bad-bg:rgba(192,57,43,.16);
|
||||
}
|
||||
/* ===== BASE ===== */
|
||||
*{box-sizing:border-box;margin:0;padding:0}
|
||||
html,body{height:100%;font-family:var(--font);color:var(--ink);overflow:hidden;
|
||||
background:linear-gradient(180deg,var(--bg-top) 0%,var(--bg-mid) 45%,var(--bg-bottom) 100%)}
|
||||
.app{display:flex;height:100vh}
|
||||
.hidden{display:none!important}
|
||||
/* sidebar */
|
||||
.sidebar{width:230px;min-width:230px;background:var(--glass);border-right:1px solid var(--border);backdrop-filter:blur(14px);display:flex;flex-direction:column}
|
||||
.logo-area{padding:22px 20px;display:flex;align-items:center;gap:11px;border-bottom:1px solid var(--border)}
|
||||
.logo-box{width:40px;height:40px;border-radius:12px;background:var(--glass-strong);display:flex;align-items:center;justify-content:center;border:1px solid var(--border)}
|
||||
.logo-box img{width:28px;height:28px;object-fit:contain}
|
||||
.logo-title{font-size:15px;font-weight:700}
|
||||
.logo-sub{font-size:10px;color:var(--muted);margin-top:2px;letter-spacing:.5px;text-transform:uppercase;font-weight:600}
|
||||
.nav{padding:16px 12px;flex:1}
|
||||
.nav-item{display:flex;align-items:center;gap:11px;padding:11px 13px;border-radius:10px;cursor:pointer;margin-bottom:3px;color:var(--muted);font-size:13.5px;font-weight:600;transition:all .15s;user-select:none}
|
||||
.nav-item svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:2}
|
||||
.nav-item:hover{background:var(--glass-strong);color:var(--ink)}
|
||||
.nav-item.active{background:linear-gradient(135deg,var(--accent),var(--accent-dark));color:#fff;box-shadow:0 4px 12px rgba(20,50,70,.22)}
|
||||
.sidebar-foot{padding:14px;border-top:1px solid var(--border);display:flex;align-items:center;gap:10px}
|
||||
.avatar{width:34px;height:34px;border-radius:50%;background:linear-gradient(135deg,var(--accent),var(--accent-dark));display:flex;align-items:center;justify-content:center;font-size:12px;font-weight:700;color:#fff}
|
||||
.u-name{font-size:13px;font-weight:700}.u-role{font-size:10.5px;color:var(--muted)}
|
||||
/* main + topbar */
|
||||
.main{flex:1;display:flex;flex-direction:column;overflow:hidden}
|
||||
.topbar{background:var(--glass-strong);border-bottom:1px solid var(--border);backdrop-filter:blur(14px);padding:20px 28px;display:flex;align-items:center;gap:14px;box-shadow:0 6px 18px rgba(20,50,70,.10)}
|
||||
.tb-title{font-size:21px;font-weight:800}.tb-sub{font-size:12.5px;color:var(--muted);margin-top:3px}
|
||||
.tb-right{margin-left:auto;display:flex;align-items:center;gap:12px}
|
||||
.indicator{display:flex;align-items:center;gap:7px;background:var(--glass-strong);padding:7px 13px;border-radius:20px;border:1px solid var(--border);font-size:12px;font-weight:700}
|
||||
.dot{width:8px;height:8px;border-radius:50%}
|
||||
.dot.ready,.dot.done{background:var(--ok);box-shadow:0 0 7px var(--ok)}
|
||||
.dot.running{background:var(--warn);box-shadow:0 0 7px var(--warn);animation:blink 1.2s infinite}
|
||||
.dot.error{background:var(--bad);box-shadow:0 0 7px var(--bad)}
|
||||
@keyframes blink{50%{opacity:.4}}
|
||||
.mode-btn{width:38px;height:38px;border-radius:11px;border:1px solid var(--border);background:var(--glass-strong);cursor:pointer;font-size:16px}
|
||||
.content{flex:1;overflow-y:auto;padding:26px 28px;border-top-left-radius:30px}
|
||||
/* cards, buttons, pills */
|
||||
.section-label{font-size:11px;text-transform:uppercase;letter-spacing:1px;color:var(--muted);font-weight:700;margin-bottom:14px}
|
||||
.card{background:var(--glass);border:1px solid var(--border);border-radius:16px;padding:22px;margin-bottom:16px;backdrop-filter:blur(14px);box-shadow:0 10px 30px rgba(20,50,70,.08)}
|
||||
.c-title{font-size:15px;font-weight:700;margin-bottom:14px}
|
||||
.btn{padding:9px 15px;border-radius:11px;border:1px solid var(--accent-soft-2);background:linear-gradient(180deg,var(--accent-soft),var(--accent-soft-2));color:var(--ink);font-size:12.5px;font-weight:600;cursor:pointer;transition:all .18s}
|
||||
.btn:hover{transform:translateY(-1px);box-shadow:0 4px 12px rgba(20,50,70,.2)}
|
||||
.btn.primary{background:linear-gradient(180deg,var(--accent-2),var(--accent));color:#fff;border-color:var(--accent)}
|
||||
.btn:disabled{opacity:.45;cursor:not-allowed;transform:none;box-shadow:none}
|
||||
.pill{display:inline-block;padding:4px 11px;border-radius:20px;font-size:11px;font-weight:700;color:#fff}
|
||||
.pill.success{background:var(--ok)}.pill.attention{background:var(--warn)}.pill.failed{background:var(--bad)}
|
||||
/* KPI */
|
||||
.kpi-row{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin-bottom:16px}
|
||||
.kpi{display:flex;align-items:center;gap:13px;padding:16px 18px;border-radius:16px;background:var(--glass-strong);border:1px solid var(--border);box-shadow:0 4px 14px rgba(20,50,70,.10)}
|
||||
.kpi-ic{width:42px;height:42px;border-radius:12px;position:relative}
|
||||
.kpi.total .kpi-ic{background:linear-gradient(135deg,var(--accent-2),var(--accent))}
|
||||
.kpi.done .kpi-ic{background:var(--ok)}.kpi.prog .kpi-ic{background:var(--warn)}.kpi.notdone .kpi-ic{background:var(--bad)}
|
||||
.kpi.prog.live .kpi-ic::after{content:'';position:absolute;inset:-3px;border-radius:14px;border:2px solid var(--warn);animation:kpulse 1.6s ease-out infinite}
|
||||
@keyframes kpulse{0%{transform:scale(.9);opacity:.6}100%{transform:scale(1.25);opacity:0}}
|
||||
.kpi-val{font-size:26px;font-weight:800;line-height:1.1;font-variant-numeric:tabular-nums}
|
||||
.kpi-lbl{font-size:11.5px;color:var(--muted);font-weight:600;margin-top:2px}
|
||||
/* donut */
|
||||
.dash-grid{display:grid;grid-template-columns:1fr 1fr;gap:14px}
|
||||
.donut-wrap{display:flex;align-items:center;gap:26px;flex-wrap:wrap}
|
||||
.donut{width:150px;height:150px;border-radius:50%;position:relative;background:conic-gradient(var(--border) 0 100%)}
|
||||
.donut-hole{position:absolute;inset:22px;border-radius:50%;background:var(--bg-bottom);display:flex;flex-direction:column;align-items:center;justify-content:center}
|
||||
.dh-num{font-size:26px;font-weight:800}.dh-lbl{font-size:10px;color:var(--muted);font-weight:700;text-transform:uppercase}
|
||||
.dl-row{display:flex;align-items:center;gap:9px;font-size:12.5px;font-weight:600;margin-bottom:9px}
|
||||
.dl-dot{width:10px;height:10px;border-radius:3px}
|
||||
/* run panel */
|
||||
.prog-head{display:flex;gap:12px;margin-bottom:8px;font-size:13px;font-weight:700}
|
||||
.prog-head .pct{margin-left:auto;color:var(--accent)}
|
||||
.prog-bg{height:8px;background:var(--glass-strong);border-radius:8px;overflow:hidden;margin-bottom:18px}
|
||||
.prog-fill{height:100%;width:0;background:linear-gradient(90deg,var(--ok),var(--ok));transition:width .5s}
|
||||
.step{display:flex;align-items:center;gap:12px;padding:10px 13px;border-radius:10px;margin-bottom:6px;font-size:13px;font-weight:600}
|
||||
.step.done{background:var(--ok-bg);color:var(--ok)}.step.active{background:var(--warn-bg);color:var(--warn)}.step.pending{opacity:.5}
|
||||
.step-dot{width:22px;height:22px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:11px;background:var(--glass-strong)}
|
||||
.step.done .step-dot{background:var(--ok);color:#fff}.step.active .step-dot{background:var(--warn);color:#fff}
|
||||
.log{background:var(--accent-dark);border-radius:12px;padding:12px 14px;max-height:220px;overflow-y:auto;font-family:Consolas,'Courier New',monospace;font-size:13px;line-height:1.85;color:#9fb6c4}
|
||||
.log .info{color:#7fc4f0}.log .success{color:#5fe0a0}.log .warn{color:#f5c563}.log .error{color:#f68c82}
|
||||
/* report table */
|
||||
.toolbar{display:flex;gap:10px;align-items:center;margin-bottom:14px;flex-wrap:wrap}
|
||||
.toolbar input{padding:8px 10px;border:1px solid var(--border);border-radius:10px;background:var(--glass-strong);color:var(--ink);font:inherit;font-size:12.5px}
|
||||
.tbl-wrap{border:1px solid var(--border);border-radius:14px;overflow:auto;max-height:60vh;background:var(--glass)}
|
||||
table{border-collapse:collapse;width:100%;font-size:12px;white-space:nowrap}
|
||||
th{background:var(--glass-strong);text-align:left;padding:11px 14px;position:sticky;top:0;font-weight:700}
|
||||
td{padding:10px 14px;border-bottom:1px solid var(--border)}
|
||||
.empty{text-align:center;padding:40px;color:var(--muted);font-size:13px}
|
||||
@media (max-width:820px){.sidebar{width:66px;min-width:66px}.logo-title,.logo-sub,.nav-item span,.u-name,.u-role{display:none}.kpi-row{grid-template-columns:1fr 1fr}.dash-grid{grid-template-columns:1fr}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="app">
|
||||
<aside class="sidebar">
|
||||
<div class="logo-area">
|
||||
<div class="logo-box"><img src="{{LOGO_SRC}}" alt=""></div>
|
||||
<div><div class="logo-title">{{APP_NAME}}</div><div class="logo-sub">{{APP_SUBTITLE}}</div></div>
|
||||
</div>
|
||||
<nav class="nav">
|
||||
<div class="nav-item active" data-tab="dashboard"><svg viewBox="0 0 24 24"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/></svg><span>Dashboard</span></div>
|
||||
<div class="nav-item" data-tab="run"><svg viewBox="0 0 24 24"><polygon points="6 4 20 12 6 20 6 4"/></svg><span>Run</span></div>
|
||||
<div class="nav-item" data-tab="report"><svg viewBox="0 0 24 24"><path d="M4 4h16v16H4z"/><path d="M8 9h8M8 13h8M8 17h5"/></svg><span>Report</span></div>
|
||||
<!-- OPTIONAL: Admin nav item (roles) goes here -->
|
||||
</nav>
|
||||
<div class="sidebar-foot"><div class="avatar" id="avatar">U</div><div><div class="u-name" id="u-name">User</div><div class="u-role" id="u-role">Operator</div></div></div>
|
||||
</aside>
|
||||
|
||||
<main class="main">
|
||||
<header class="topbar">
|
||||
<div><div class="tb-title" id="tb-title">Dashboard</div><div class="tb-sub" id="tb-sub">Overview of every batch</div></div>
|
||||
<div class="tb-right">
|
||||
<button class="btn primary" id="run-btn" onclick="startRun()">▶ Run</button>
|
||||
<button class="mode-btn" id="mode-btn" title="Dark mode" onclick="toggleDark()">🌙</button>
|
||||
<div class="indicator"><span class="dot ready" id="st-dot"></span><span id="st-text">System ready</span></div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="content">
|
||||
<!-- DASHBOARD -->
|
||||
<div id="tab-dashboard">
|
||||
<div class="kpi-row">
|
||||
<div class="kpi total"><div class="kpi-ic"></div><div><div class="kpi-val" id="k-total">0</div><div class="kpi-lbl">Total batches</div></div></div>
|
||||
<div class="kpi done"><div class="kpi-ic"></div><div><div class="kpi-val" id="k-done">0</div><div class="kpi-lbl">Completed</div></div></div>
|
||||
<div class="kpi prog" id="kpi-prog"><div class="kpi-ic"></div><div><div class="kpi-val" id="k-prog">0</div><div class="kpi-lbl">In progress</div></div></div>
|
||||
<div class="kpi notdone"><div class="kpi-ic"></div><div><div class="kpi-val" id="k-fail">0</div><div class="kpi-lbl">Not completed</div></div></div>
|
||||
</div>
|
||||
<div class="dash-grid">
|
||||
<div class="card"><div class="c-title">Batch status split</div>
|
||||
<div class="donut-wrap"><div class="donut" id="donut"><div class="donut-hole"><div class="dh-num" id="dh-num">0</div><div class="dh-lbl">batches</div></div></div><div id="legend"></div></div>
|
||||
</div>
|
||||
<div class="card"><div class="c-title">Recent batches</div><div id="recent" class="empty">No batches yet</div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- RUN -->
|
||||
<div id="tab-run" class="hidden">
|
||||
<div class="card"><div class="c-title">Run automation</div>
|
||||
<div class="prog-head"><span id="p-label">Waiting to start</span><span id="p-time"></span><span class="pct" id="p-pct">0%</span></div>
|
||||
<div class="prog-bg"><div class="prog-fill" id="p-fill"></div></div>
|
||||
<div id="steps"></div>
|
||||
<!-- OPTIONAL: checkpoint tracker (see reference/tebra_dashboard.html .cp-track) -->
|
||||
<div class="log" id="log"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- REPORT -->
|
||||
<div id="tab-report" class="hidden">
|
||||
<div class="card">
|
||||
<div class="toolbar"><input id="r-search" placeholder="Search…" oninput="renderReport()"><span style="margin-left:auto"></span><button class="btn primary" onclick="api('download_report')">⬇ Download Excel</button></div>
|
||||
<div class="tbl-wrap"><table><thead id="r-head"></thead><tbody id="r-body"></tbody></table></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// ===== CONFIG (fill per project) =====
|
||||
const STEPS = {{STEPS_JSON}}; // e.g. ["Initializing","Login","Process items","Completed"]
|
||||
const REPORT_COLUMNS = {{REPORT_COLUMNS_JSON}}; // e.g. [{key:"item",label:"Item"},{key:"status",label:"Status"}]
|
||||
const API_BASE = "{{API_BASE}}"; // web mode only, e.g. "/api"
|
||||
const TABS = {dashboard:["Dashboard","Overview of every batch"],run:["Run","Live progress and log"],report:["Report","Result of every processed item"]};
|
||||
|
||||
// ===== TRANSPORT: Qt bridge if present, else HTTP polling =====
|
||||
let bridge = null, reportRows = [];
|
||||
function api(slot, ...args){
|
||||
if(bridge) return bridge[slot](...args);
|
||||
return fetch(`${API_BASE}/${slot.replace(/_/g,'-')}`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(args)}).catch(()=>{});
|
||||
}
|
||||
window.__pyMsg = (json)=>{ const m=JSON.parse(json); handle(m.event,m.data); };
|
||||
if(window.qt && window.qt.webChannelTransport){
|
||||
new QWebChannel(qt.webChannelTransport, ch=>{ bridge=ch.objects.pyBridge; bridge.load_dashboard(); });
|
||||
}else{
|
||||
const poll = async()=>{
|
||||
try{
|
||||
const d = await (await fetch(`${API_BASE}/dashboard`)).json(); handle('dashboard_data',d);
|
||||
const s = await (await fetch(`${API_BASE}/run/status`)).json();
|
||||
(s.events||[]).forEach(e=>handle(e.event,e.data));
|
||||
setTimeout(poll, s.running ? 2000 : 10000); // poll fast only while busy
|
||||
}catch(e){ setTimeout(poll, 10000); }
|
||||
};
|
||||
poll();
|
||||
}
|
||||
|
||||
// ===== EVENT DISPATCH (see contract.md) =====
|
||||
function handle(event, d){
|
||||
switch(event){
|
||||
case 'status': setStatus(d.state, d.msg); break;
|
||||
case 'step': setStep(d.label, d.pct); break;
|
||||
case 'log': addLog(d.msg, d.type); break;
|
||||
case 'dashboard_data': renderDashboard(d.batches||[]); break;
|
||||
case 'report_data': reportRows = d.rows||[]; renderReport(); break;
|
||||
case 'set_user': setUser(d); break;
|
||||
case 'apply_dark': document.body.classList.toggle('dark', !!d.on); break;
|
||||
}
|
||||
}
|
||||
|
||||
// ===== UI =====
|
||||
document.querySelectorAll('.nav-item').forEach(n=>n.onclick=()=>showTab(n.dataset.tab));
|
||||
function showTab(t){
|
||||
document.querySelectorAll('.nav-item').forEach(n=>n.classList.toggle('active',n.dataset.tab===t));
|
||||
Object.keys(TABS).forEach(k=>document.getElementById('tab-'+k).classList.toggle('hidden',k!==t));
|
||||
tbTitle.textContent=TABS[t][0]; tbSub.textContent=TABS[t][1];
|
||||
if(t==='dashboard') api('load_dashboard');
|
||||
if(t==='report') api('load_latest_report');
|
||||
}
|
||||
const $=id=>document.getElementById(id), tbTitle=$('tb-title'), tbSub=$('tb-sub');
|
||||
function esc(s){return String(s??'').replace(/[&<>"]/g,c=>({'&':'&','<':'<','>':'>','"':'"'}[c]));}
|
||||
function toggleDark(){ const on=document.body.classList.toggle('dark'); api('set_dark',on); }
|
||||
function setUser(u){ $('u-name').textContent=u.username; $('u-role').textContent=u.role; $('avatar').textContent=(u.username||'U')[0].toUpperCase(); }
|
||||
function setStatus(state,msg){ $('st-dot').className='dot '+state; $('st-text').textContent=msg||state; $('run-btn').disabled=state==='running'; }
|
||||
function startRun(){ showTab('run'); $('log').innerHTML=''; setStep(STEPS[0],0); api('start_automation'); }
|
||||
|
||||
function renderSteps(active=-1){
|
||||
$('steps').innerHTML=STEPS.map((s,i)=>{const c=i<active?'done':i===active?'active':'pending';
|
||||
return `<div class="step ${c}"><div class="step-dot">${c==='done'?'✓':i+1}</div>${esc(s)}</div>`}).join('');
|
||||
}
|
||||
function setStep(label,pct){ $('p-label').textContent=label; $('p-pct').textContent=pct+'%'; $('p-fill').style.width=pct+'%';
|
||||
renderSteps(pct>=100?STEPS.length:STEPS.indexOf(label)); }
|
||||
function addLog(msg,type='info'){ const l=$('log'); l.insertAdjacentHTML('beforeend',`<div class="${type}">${esc(msg)}</div>`); l.scrollTop=l.scrollHeight; }
|
||||
|
||||
function countUp(el,to){ const from=+el.textContent||0,t0=performance.now();
|
||||
const f=t=>{const p=Math.min((t-t0)/500,1); el.textContent=Math.round(from+(to-from)*p); if(p<1)requestAnimationFrame(f)}; requestAnimationFrame(f); }
|
||||
function renderDashboard(b){
|
||||
const n=s=>b.filter(x=>x.state===s).length;
|
||||
const c={completed:n('completed'),inprogress:n('inprogress'),notcompleted:n('notcompleted')};
|
||||
countUp($('k-total'),b.length); countUp($('k-done'),c.completed); countUp($('k-prog'),c.inprogress); countUp($('k-fail'),c.notcompleted);
|
||||
$('kpi-prog').classList.toggle('live',c.inprogress>0); $('dh-num').textContent=b.length;
|
||||
const css=getComputedStyle(document.body), col={completed:css.getPropertyValue('--ok'),inprogress:css.getPropertyValue('--warn'),notcompleted:css.getPropertyValue('--bad')};
|
||||
const lbl={completed:'Completed',inprogress:'In progress',notcompleted:'Not completed'};
|
||||
let acc=0; const stops=[];
|
||||
Object.keys(c).forEach(k=>{ if(!c[k])return; const a=acc/b.length*100; acc+=c[k]; stops.push(`${col[k]} ${a}% ${acc/b.length*100}%`); });
|
||||
$('donut').style.background = b.length?`conic-gradient(${stops.join(',')})`:'conic-gradient(var(--border) 0 100%)';
|
||||
$('legend').innerHTML=Object.keys(c).map(k=>`<div class="dl-row"><span class="dl-dot" style="background:${col[k]}"></span>${lbl[k]} · ${c[k]}</div>`).join('');
|
||||
$('recent').className = b.length?'':'empty';
|
||||
$('recent').innerHTML = b.length? b.slice(0,6).map(x=>`<div class="dl-row">#${esc(x.batch_id)} · ${esc(x.date)} <span class="pill ${x.state==='completed'?'success':x.state==='inprogress'?'attention':'failed'}" style="margin-left:auto">${lbl[x.state]||esc(x.state)}</span></div>`).join('') : 'No batches yet';
|
||||
}
|
||||
function renderReport(){
|
||||
const q=$('r-search').value.toLowerCase();
|
||||
$('r-head').innerHTML='<tr>'+REPORT_COLUMNS.map(c=>`<th>${esc(c.label)}</th>`).join('')+'</tr>';
|
||||
const rows=reportRows.filter(r=>!q||Object.values(r).join(' ').toLowerCase().includes(q));
|
||||
$('r-body').innerHTML = rows.length? rows.map(r=>'<tr>'+REPORT_COLUMNS.map(c=>c.key==='status'
|
||||
?`<td><span class="pill ${esc(r.bucket||'attention')}">${esc(r.status)}</span></td>`:`<td>${esc(r[c.key])}</td>`).join('')+'</tr>').join('')
|
||||
: `<tr><td class="empty" colspan="${REPORT_COLUMNS.length}">No rows yet</td></tr>`;
|
||||
}
|
||||
renderSteps();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,251 @@
|
||||
def categorize_status(status):
|
||||
s = (status or "").strip().lower()
|
||||
if s in SUCCESS_STATUSES:
|
||||
return "success"
|
||||
if s in FAILED_STATUSES:
|
||||
return "failed"
|
||||
return "attention"
|
||||
|
||||
|
||||
# ── Progress heuristic: map printed markers -> step index + % ─────────────────
|
||||
# (run_multi is one long call, so we infer progress from the lines it prints.)
|
||||
STEP_MARKERS = [
|
||||
("multi-company / multi-location", 0, 5),
|
||||
("[input] login sheet", 1, 12),
|
||||
("########## company", 2, 25),
|
||||
("[choose company]", 2, 32),
|
||||
("practice home", 3, 45),
|
||||
("clearinghouse", 4, 55),
|
||||
("unreviewed", 5, 62),
|
||||
("posting", 5, 70),
|
||||
("processed successfully", 5, 74),
|
||||
("test-notposted", 5, 74),
|
||||
("medicalperformer", 6, 82), # ERA -> EOB (PDF) phase
|
||||
("era import ->", 6, 84),
|
||||
("print-to-pdf", 6, 84),
|
||||
("[all done]", 7, 100),
|
||||
]
|
||||
|
||||
STEPS = [
|
||||
"Initializing",
|
||||
"Reading Excel",
|
||||
"Login to Tebra",
|
||||
"Select company",
|
||||
"Open Clearinghouse",
|
||||
"Post payments",
|
||||
"ERA → EOB conversion",
|
||||
"Completed",
|
||||
]
|
||||
|
||||
|
||||
# ── Friendly log translation ──────────────────────────────────────────────────
|
||||
# The on-screen log is read by medical-billing users, not developers, so raw
|
||||
# automation lines are translated to plain English here. Every RAW line is
|
||||
# still stored in full in the BatchLog table (per batch) for debugging.
|
||||
#
|
||||
# Rules are (marker-substring, friendly-message, type) matched on the
|
||||
# lowercased line, first match wins. friendly-message None = keep the line off
|
||||
# the screen. Unmatched lines are hidden too, UNLESS they are errors — a real
|
||||
# failure must never be invisible.
|
||||
# IMPORTANT: markers are matched by SUBSTRING against the lowercased line, first
|
||||
# match wins, so they MUST be specific enough not to catch unrelated lines. e.g.
|
||||
# a bare "clearinghouse" wrongly matched "Total unreviewed clearinghouse reports:
|
||||
# 10852" and produced a duplicate "Opening Clearinghouse" — always anchor on the
|
||||
# distinct "[step N]" / "[medicalperformer] <verb>" prefix instead of a bare word.
|
||||
_FRIENDLY_RULES = [
|
||||
("multi-company / multi-location", "Starting the automation…", "info"),
|
||||
("[input] login sheet", "Reading the Excel input file…", "info"),
|
||||
("[choose company] screen", "Selecting the company in Tebra…", "info"),
|
||||
("[login] done", "Logged in to Tebra.", "success"),
|
||||
("[step 3] confirmed", "Practice opened.", "info"),
|
||||
("[step 4+5] encounters", "Opening Clearinghouse Reports…", "info"),
|
||||
("[step 6] checking", "Searching for unreviewed ERAs…", "info"),
|
||||
("[step 9] downloading + posting", "Checking for ERAs to post…", "info"),
|
||||
("no more unprocessed era rows", "No more ERAs to post here.", "info"),
|
||||
("grid still not found", "The ERA list did not load — moving on.", "error"),
|
||||
("grid not found", "The ERA list is slow to load — retrying…", "warn"),
|
||||
("[step 9] downloaded:", "ERA file downloaded.", "info"),
|
||||
("download failed", "Could not download the ERA file — continuing.", "warn"),
|
||||
("didn't open", "The ERA is slow to open — trying again…", "warn"),
|
||||
("still could not open view", "Could not open the ERA — marked as Error.", "error"),
|
||||
("clicking 'post payment", "Clicking Post Payment…", "info"),
|
||||
("post payment button not found", "Post Payment button not found.", "error"),
|
||||
("-> era errors screen", "Tebra reported ERA Errors for this payment — cancelling it.", "warn"),
|
||||
("-> payment screen", "Payment screen opened.", "info"),
|
||||
("post-payment retry", "The payment screen didn't load — trying again…", "warn"),
|
||||
("unrecognized screen after post payment", "Unexpected screen after Post Payment — retrying…", "warn"),
|
||||
("batch # field / reference # not ready", "Batch # field not ready — retrying…", "warn"),
|
||||
("batch# field or reference# value not found", "Batch # field not found — marked as Error.", "error"),
|
||||
("filling batch #", "Entering the Batch number…", "info"),
|
||||
("test mode — would have posted", "Test mode: payment ready — clicking Cancel instead of Save.", "warn"),
|
||||
("save button not found", "Save button element not found.", "error"),
|
||||
("[step 9] posted era", "Payment saved — ERA posted.", "success"),
|
||||
("done posting era payments", "Finished posting for this practice.", "info"),
|
||||
("[medicalperformer] launching", "Converting ERA files to EOB PDFs…", "info"),
|
||||
("[medicalperformer] no .txt", "No ERA files to convert.", "info"),
|
||||
("[done] no-location company complete", "Company finished.", "success"),
|
||||
# The final line is emitted by Python (_run_automation) from the real run
|
||||
# summary — hide the raw markers so a run that FAILED can never print a
|
||||
# hardcoded green "completed".
|
||||
("[run summary]", None, "info"),
|
||||
("[all done]", None, "info"),
|
||||
]
|
||||
|
||||
# Dynamic rules that pull a value out of the line (checked before the static
|
||||
# list). Each is (compiled-regex, lambda match -> (msg, type)).
|
||||
_FRIENDLY_DYNAMIC = [
|
||||
(re.compile(r"#+\s*COMPANY\s+(\d+)/(\d+):\s*(.+?)\s*#*\s*$", re.I),
|
||||
lambda m: (f"Working on company {m.group(1)} of {m.group(2)}: "
|
||||
f"{m.group(3)}", "info")),
|
||||
(re.compile(r"PROCESSING LOCATION:\s*(.+)", re.I),
|
||||
lambda m: (f"Working on location: {m.group(1).strip()}", "info")),
|
||||
# Login failure — make it clearly visible (the COM/network error the user
|
||||
# sees when Tebra pops "connection error" and they click OK).
|
||||
(re.compile(r"\[login\] FAILED for company '(.+?)':", re.I),
|
||||
lambda m: (f"Could not log in to Tebra for {m.group(1)} — connection or "
|
||||
f"Tebra error. This company was not processed.", "error")),
|
||||
(re.compile(r"Unreviewed reports total\s*:\s*(\d+)", re.I),
|
||||
lambda m: (f"Found {m.group(1)} unreviewed report(s) to check.", "info")),
|
||||
(re.compile(r"\[Step 9\] Opening ERA row id=(\S+)", re.I),
|
||||
lambda m: (f"Opening ERA {m.group(1)}…", "info")),
|
||||
(re.compile(r"Check ID=(\S*)\s+Date=(\S*)\s+Method=.*Amount=(\S*)", re.I),
|
||||
lambda m: (f"Check {m.group(1) or '—'} · Date {m.group(2) or '—'} · "
|
||||
f"Amount {m.group(3) or '—'}", "info")),
|
||||
(re.compile(r"\[Step 9\] Skipping\s+\S+:\s*(.+)", re.I),
|
||||
lambda m: (f"Skipped — {m.group(1).strip()}", "warn")),
|
||||
(re.compile(r"\[Done\] Location '(.+?)' complete", re.I),
|
||||
lambda m: (f"Location finished: {m.group(1)}", "success")),
|
||||
]
|
||||
|
||||
|
||||
# Error-level lines that are pure developer noise — the friendly login-error
|
||||
# line and the Python run-summary already tell the user what failed, so these
|
||||
# raw dumps are kept in the BatchLog table but never shown on screen.
|
||||
_ERROR_NOISE = (
|
||||
"traceback (most recent call last)",
|
||||
"during handling of the above",
|
||||
"another exception occurred",
|
||||
"comerror",
|
||||
"[error] company '", # per-company COM dump — summary covers it
|
||||
)
|
||||
|
||||
|
||||
def _is_error_noise(low_line):
|
||||
return any(marker in low_line for marker in _ERROR_NOISE)
|
||||
|
||||
|
||||
def friendly_log_line(line):
|
||||
"""Translate one raw automation line for the user-facing log.
|
||||
Returns (message, type) to display, or None to keep it off the screen."""
|
||||
for rx, fmt in _FRIENDLY_DYNAMIC:
|
||||
m = rx.search(line)
|
||||
if m:
|
||||
return fmt(m)
|
||||
low = line.lower()
|
||||
for marker, msg, ltype in _FRIENDLY_RULES:
|
||||
if marker in low:
|
||||
return (msg, ltype) if msg else None
|
||||
return None
|
||||
|
||||
|
||||
class _StreamToBridge:
|
||||
"""A stdout/stderr replacement that tees to the real stream AND forwards
|
||||
every completed line to the UI as a log event."""
|
||||
|
||||
def __init__(self, bridge, original, level="info"):
|
||||
self.bridge = bridge
|
||||
self.original = original
|
||||
self.level = level
|
||||
self._buf = ""
|
||||
|
||||
def write(self, text):
|
||||
try:
|
||||
if self.original:
|
||||
self.original.write(text)
|
||||
except Exception:
|
||||
pass
|
||||
self._buf += text
|
||||
while "\n" in self._buf:
|
||||
line, self._buf = self._buf.split("\n", 1)
|
||||
self._emit_line(line)
|
||||
|
||||
def _emit_line(self, line):
|
||||
line = line.rstrip("\r")
|
||||
if not line.strip():
|
||||
return
|
||||
self.bridge._on_automation_line(line, self.level)
|
||||
|
||||
def flush(self):
|
||||
try:
|
||||
if self.original:
|
||||
self.original.flush()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def isatty(self):
|
||||
return False
|
||||
|
||||
|
||||
class Bridge(QObject):
|
||||
"""Exposed to JavaScript as 'pyBridge'."""
|
||||
|
||||
js_message = pyqtSignal(str)
|
||||
|
||||
def __init__(self, window):
|
||||
super().__init__()
|
||||
self.window = window
|
||||
self.username = "User"
|
||||
self._max_pct = 0
|
||||
self._cur_step = 0
|
||||
self.batch_id = None # set on a fresh Trigger or from a resume check
|
||||
self._batch_log = None # BatchLogWriter while a run is active
|
||||
self._run_log_file = None # plain-text run log (DB-independent) while running
|
||||
self._last_friendly = None # dedupe consecutive identical friendly lines
|
||||
self._auto_proc = None # the separate automation process (bug-094) while running
|
||||
|
||||
# ── Python -> JS ────────────────────────────────────────────────────────────
|
||||
def send_to_js(self, event, data):
|
||||
self.js_message.emit(json.dumps({"event": event, "data": data}))
|
||||
|
||||
def _log(self, msg, type="info"):
|
||||
self.send_to_js("log", {"msg": msg, "type": type})
|
||||
|
||||
def _step(self, label, pct):
|
||||
self.send_to_js("step", {"label": label, "pct": pct})
|
||||
|
||||
def _batch_chip(self, batch_id, live=False):
|
||||
"""Persistent Run-tab chip showing the batch this run is tracked under.
|
||||
`batch_id` None/0 CLEARS it. A stale number is worse than none, so every
|
||||
site that assigns self.batch_id must call this in the same breath."""
|
||||
self.send_to_js("batch_state", {
|
||||
"batch_id": str(batch_id) if batch_id else "",
|
||||
"live": bool(live)})
|
||||
|
||||
# ── JS -> Python ────────────────────────────────────────────────────────────
|
||||
@pyqtSlot(str)
|
||||
def log(self, msg):
|
||||
print(f"[JS] {msg}")
|
||||
|
||||
@pyqtSlot(bool)
|
||||
def set_dark(self, on):
|
||||
"""Persist the dark-mode choice so the login page (a separate window)
|
||||
can pick it up too."""
|
||||
try:
|
||||
from src.ui.prefs import set_dark
|
||||
set_dark(on)
|
||||
except Exception as e:
|
||||
print(f"[prefs] could not save dark mode: {e}")
|
||||
|
||||
@pyqtSlot()
|
||||
def logout(self):
|
||||
"""JS clicked Logout — defer to the next event-loop tick so we don't
|
||||
run a nested dialog from inside the web-channel callback."""
|
||||
from PyQt6.QtCore import QTimer
|
||||
QTimer.singleShot(0, self.window.do_logout)
|
||||
|
||||
@pyqtSlot()
|
||||
def browse_file(self):
|
||||
path, _ = QFileDialog.getOpenFileName(
|
||||
self.window, "Select Input Excel File",
|
||||
os.path.expanduser("~"),
|
||||
"Excel files (*.xlsx *.xlsm *.xls)"
|
||||
@@ -0,0 +1,171 @@
|
||||
"""Drive the Run tab's 7-box checkpoint tracker.
|
||||
kind: 'active' (box cp is now running) | 'done' (box cp finished) |
|
||||
'stuck' (box cp overran its max time)."""
|
||||
self.send_to_js("cpstate", {"kind": kind, "cp": int(cp)})
|
||||
|
||||
def _on_automation_line(self, line, level):
|
||||
"""Called (from the worker thread) for every printed line."""
|
||||
# Watchdog checkpoint state marker -> 7-box tracker in the Run tab
|
||||
# (raw line still stored in the DB log below).
|
||||
if "[CPSTATE]" in line:
|
||||
try:
|
||||
kind, cp = line.split("[CPSTATE]", 1)[1].split()
|
||||
self._cpstate(kind, cp)
|
||||
# Feed the parent-side stall watchdog. The automation runs in a
|
||||
# CHILD process, so the in-process performer.WD_STATE the old
|
||||
# watchdog read is ALWAYS empty here (it never fired — a stuck
|
||||
# checkpoint never restarted). Track the active checkpoint + its
|
||||
# start time from the STREAMED [CPSTATE] line instead.
|
||||
if kind == "active":
|
||||
self._wd_cp = int(cp)
|
||||
self._wd_since = time.time()
|
||||
except Exception:
|
||||
pass
|
||||
low = line.lower()
|
||||
ltype = "info"
|
||||
if "[error]" in low or "[fatal]" in low or "traceback" in low or "exception" in low:
|
||||
ltype = "error"
|
||||
elif "warning" in low or "[warn" in low:
|
||||
ltype = "warn"
|
||||
elif "processed successfully" in low or "[all done]" in low or "success" in low:
|
||||
ltype = "success"
|
||||
|
||||
# The FULL raw technical line always goes to the per-batch DB log AND to
|
||||
# the DB-independent file log (the file works even when the DB is down).
|
||||
if self._batch_log is not None:
|
||||
self._batch_log.log(ltype, line)
|
||||
if self._run_log_file is not None:
|
||||
try:
|
||||
self._run_log_file.write(line + "\n")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# The on-screen log gets the plain-English translation only; raw lines
|
||||
# stay off the screen — except untranslated ERRORS, which are shown
|
||||
# as-is (a failure must never be invisible to the user).
|
||||
shown = friendly_log_line(line)
|
||||
if shown is not None:
|
||||
if shown[0] != self._last_friendly: # collapse repeated polling lines
|
||||
self._log(shown[0], shown[1])
|
||||
self._last_friendly = shown[0]
|
||||
elif ltype == "error" and not _is_error_noise(low):
|
||||
# An untranslated real error still shows (a failure must never be
|
||||
# invisible) — but raw traceback/COM noise is filtered out.
|
||||
self._log(line, "error")
|
||||
self._last_friendly = None
|
||||
|
||||
# advance the progress bar / step list on known markers
|
||||
for marker, step_idx, pct in STEP_MARKERS:
|
||||
if marker in low:
|
||||
if step_idx > self._cur_step:
|
||||
self._cur_step = step_idx
|
||||
if pct > self._max_pct:
|
||||
self._max_pct = pct
|
||||
self._step(STEPS[self._cur_step], self._max_pct)
|
||||
break
|
||||
|
||||
def _resolve_input_path(self, p):
|
||||
"""Drag-drop in QWebEngine often hides the real disk path, so we may only
|
||||
get a bare filename. Resolve it against the known input folders so the
|
||||
automation always receives a real, absolute path."""
|
||||
if p and os.path.isabs(p) and os.path.exists(p):
|
||||
return p
|
||||
base = os.path.basename(p) if p else ""
|
||||
for folder in (INPUT_DIR, _ROOT):
|
||||
cand = os.path.join(folder, base) if base else ""
|
||||
if cand and os.path.exists(cand):
|
||||
return cand
|
||||
return p # unresolved — caller will report a clear error
|
||||
|
||||
def _run_multi_subprocess(self, excel_path, emp):
|
||||
"""Run performer.run_multi in a SEPARATE PROCESS (its own MAIN thread,
|
||||
no Qt) and stream its stdout back through this run's log pipeline.
|
||||
|
||||
Why a subprocess: pywinauto/UIA reads are reliable on a process MAIN
|
||||
thread but flaky on a Qt BACKGROUND thread (STA-without-a-message-pump
|
||||
for cross-process UIA; forcing MTA dead-locks against the Qt main
|
||||
thread's UIA object). The reference tool works because it is a separate
|
||||
process with no Qt — this reproduces that. Every child stdout line is
|
||||
re-printed here so it flows through _StreamToBridge → _on_automation_line
|
||||
exactly as an in-process print did (friendly log, [CPSTATE] tracker,
|
||||
per-batch DB log, file log). Returns the run_multi summary dict.
|
||||
"""
|
||||
import subprocess
|
||||
import time as _time
|
||||
proj_root = os.path.dirname(os.path.dirname(os.path.dirname(
|
||||
os.path.abspath(__file__))))
|
||||
|
||||
# The child writes ALL its output to THIS file and we tail it live. A
|
||||
# --windowed PyInstaller child's stdout PIPE is block-buffered/unreliable
|
||||
# (v1.1.5: nothing streamed back), but a line-buffered file always
|
||||
# flushes per line and can be read while the process runs.
|
||||
try:
|
||||
os.makedirs(LOGS_DIR, exist_ok=True)
|
||||
except Exception:
|
||||
pass
|
||||
child_log = os.path.join(
|
||||
LOGS_DIR,
|
||||
f"auto_child_{datetime.now().strftime('%Y%m%d_%H%M%S')}"
|
||||
f"_{self.batch_id or 0}.log")
|
||||
try:
|
||||
open(child_log, "w", encoding="utf-8").close() # start empty
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if getattr(sys, "frozen", False):
|
||||
cmd = [sys.executable, "--run-automation"]
|
||||
else:
|
||||
cmd = [sys.executable, os.path.join(proj_root, "run_app.py"),
|
||||
"--run-automation"]
|
||||
cmd += ["--excel", str(excel_path),
|
||||
"--batch", str(self.batch_id or ""),
|
||||
"--emp", str(emp or ""),
|
||||
"--log", child_log,
|
||||
# Parent-death watchdog: the child exits itself the instant this
|
||||
# GUI process dies (crash, taskkill, or a closeEvent we couldn't
|
||||
# run) so automation can never keep driving Tebra orphaned.
|
||||
"--parent-pid", str(os.getpid())]
|
||||
|
||||
env = dict(os.environ)
|
||||
env["PYTHONUNBUFFERED"] = "1"
|
||||
creationflags = getattr(subprocess, "CREATE_NO_WINDOW", 0) \
|
||||
if sys.platform.startswith("win") else 0
|
||||
|
||||
print(f"[Automation] Launching automation in its own process "
|
||||
f"(reliable UIA) — batch {self.batch_id} …")
|
||||
try:
|
||||
proc = subprocess.Popen(
|
||||
cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
||||
cwd=proj_root, env=env, creationflags=creationflags)
|
||||
except Exception as e:
|
||||
print(f"[Automation] Could not launch automation process: {e}")
|
||||
return {"total": 0, "ok": [],
|
||||
"failed": [("Automation", f"launch failed: {e}")],
|
||||
"stopped": False}
|
||||
self._auto_proc = proc
|
||||
|
||||
summary = {}
|
||||
got_summary = False
|
||||
|
||||
def _handle(line):
|
||||
nonlocal summary, got_summary
|
||||
if line.startswith("__WF_SUMMARY__"):
|
||||
try:
|
||||
summary = json.loads(line[len("__WF_SUMMARY__"):]) or {}
|
||||
got_summary = True
|
||||
except Exception:
|
||||
pass
|
||||
elif line.strip():
|
||||
print(line) # → _StreamToBridge tee → _on_automation_line
|
||||
|
||||
# Tail the child log until the process exits, then drain the rest.
|
||||
try:
|
||||
with open(child_log, "r", encoding="utf-8", errors="replace") as f:
|
||||
while True:
|
||||
pos = f.tell()
|
||||
line = f.readline()
|
||||
if line and line.endswith("\n"):
|
||||
_handle(line.rstrip("\r\n"))
|
||||
elif line: # partial line (no newline yet)
|
||||
if proc.poll() is not None:
|
||||
_handle(line.rstrip("\r\n"))
|
||||
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user