运行控制台
只调整本地 UI 布局,所有按钮和接口保持原功能。
每组换新二维码时,先粘贴截图保存到手机,再点“重新进入开票页”
保存设置
配置成功后的处理方式和本地保存目录。
当前页队列
| ✓ | 状态 | 原因 | 脱敏名 | 姓名 | 后8位 |
|---|
运行日志
本二维码核查表 下载本表
按当前二维码列表出现顺序展示,方便跑完后人工核查失败项。
| # | 状态 | 脱敏名 | 姓名 | 身份证后8位 | 更新时间 |
|---|
总进度 下载进度表
| 状态 | 姓名 | 脱敏名 | 身份证后8位 | 更新时间 |
|---|
#!/usr/bin/env python3 """Local web UI for railway invoice automation.""" from __future__ import annotations import base64 import cgi import csv import json import os import re import sys import shutil import subprocess import threading import time from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path from urllib.parse import urlparse import invoice_tool import calibrate PROJECT_ROOT = invoice_tool.PROJECT_ROOT PYTHON = sys.executable RUNNER: subprocess.Popen[str] | None = None RUNNER_LOCK = threading.Lock() RUNNER_LOG = PROJECT_ROOT / "work" / "web_runner.log" RUNNER_STOPPED_BY_USER: dict[str, object] | None = None UPLOAD_DIR = PROJECT_ROOT / "data" / "input" def clear_runner_log(reason: str = "") -> None: RUNNER_LOG.parent.mkdir(parents=True, exist_ok=True) header = f"[log cleared] {reason}\n" if reason else "" RUNNER_LOG.write_text(header, encoding="utf-8") def csv_rows(path: Path) -> list[dict[str, str]]: if not path.exists(): return [] with path.open("r", encoding="utf-8-sig", newline="") as fh: return list(csv.DictReader(fh)) def backup_runtime_tables(label: str = "start") -> dict[str, object]: """启动批处理前备份运行期三张表。""" stamp = time.strftime("%Y%m%d-%H%M%S") backup_dir = PROJECT_ROOT / "archive" / "runtime-backups" / f"{stamp}-{label}" copied: list[str] = [] for path in [invoice_tool.PROGRESS_CSV, invoice_tool.QR_ORDER_CSV, invoice_tool.PAGE_QUEUE_CSV]: if not path.exists(): continue backup_dir.mkdir(parents=True, exist_ok=True) dest = backup_dir / path.name shutil.copy2(path, dest) copied.append(str(dest)) return {"dir": str(backup_dir), "files": copied} def write_json(handler: BaseHTTPRequestHandler, data: object, status: int = 200) -> None: body = json.dumps(data, ensure_ascii=False).encode("utf-8") handler.send_response(status) handler.send_header("Content-Type", "application/json; charset=utf-8") handler.send_header("Content-Length", str(len(body))) handler.end_headers() handler.wfile.write(body) def device_status() -> dict[str, object]: state = invoice_tool.run_adb(["get-state"], check=False) connected = state.returncode == 0 and state.stdout.strip() == "device" status: dict[str, object] = { "connected": connected, "state": state.stdout.strip() or "unknown", "current_package": "", "current_activity": "", "in_12306": False, "message": "手机已连接" if connected else (state.stderr.strip() or state.stdout.strip() or "未检测到手机"), } if not connected: return status window = invoice_tool.run_adb(["shell", "dumpsys", "window"], check=False) focus_lines = [line.strip() for line in window.stdout.splitlines() if "mCurrentFocus=" in line or "mFocusedApp=" in line] focus_text = " ".join(focus_lines) match = re.search(r"u0\s+([^/\s]+)/([^\s}]+)", focus_text) if match: status["current_package"] = match.group(1) status["current_activity"] = match.group(2) status["in_12306"] = match.group(1) == "com.MobileTicket" status["message"] = "当前在 12306" if status["in_12306"] else f"当前在 {match.group(1)}" else: status["message"] = "手机已连接,未识别当前前台 App" return status PROGRESS_LABELS = { "completed": {"text": "已开票", "tone": "ok", "reason": ""}, "no_ticket": {"text": "无可开票", "tone": "muted", "reason": "12306 未查询到可开发票客票"}, "in_progress": {"text": "处理中", "tone": "info", "reason": ""}, "failed": {"text": "失败", "tone": "bad", "reason": "上次运行未完成"}, "error": {"text": "运行错误", "tone": "bad", "reason": "脚本异常"}, "pending": {"text": "未操作", "tone": "pending", "reason": ""}, } MATCH_REASON = { "no_match": "名单里没有这个脱敏名", "ambiguous": "同名多人,无法唯一匹配", "no_button": "未识别到「开具」按钮", } def label_for_queue_row(row: dict[str, str], progress_by_key: dict, progress_by_masked: dict) -> dict[str, str]: masked = row.get("masked_name", "") real = row.get("real_name", "") id8 = row.get("id_last8", "") queue_status = row.get("queue_status", "") match_status = row.get("match_status", "") progress_row: dict[str, str] | None = None if real and id8: progress_row = progress_by_key.get((real, id8)) if progress_row is None and masked: progress_row = progress_by_masked.get(masked) pstatus = progress_row.get("status", "") if progress_row else "" progress_reason = progress_row.get("reason", "") if progress_row else "" if pstatus == "completed": return {"display": "已开票", "tone": "ok", "reason": progress_reason or "总进度表已完成"} if pstatus == "no_ticket": return {"display": "无可开票", "tone": "muted", "reason": progress_reason or "12306 未查询到可开发票客票"} if pstatus == "in_progress" or queue_status == "clicked": return {"display": "处理中", "tone": "info", "reason": progress_reason or "脚本正在处理这行"} if pstatus == "failed": return {"display": "失败", "tone": "bad", "reason": progress_reason or "上次运行未完成"} if pstatus == "error": return {"display": "运行错误", "tone": "bad", "reason": progress_reason or "脚本异常"} if match_status in MATCH_REASON: return {"display": "识别问题", "tone": "warn", "reason": MATCH_REASON[match_status]} if match_status == "runtime_error": return {"display": "运行错误", "tone": "bad", "reason": "上次处理异常"} if match_status == "skip_progress": return {"display": "已处理", "tone": "muted", "reason": "进度表已记录,跳过"} if match_status == "resume_in_progress": return {"display": "继续中", "tone": "info", "reason": "上次未完成,本次继续"} if queue_status == "pending": return {"display": "待处理", "tone": "pending", "reason": ""} if queue_status == "skipped": return {"display": "跳过", "tone": "muted", "reason": match_status or ""} return {"display": queue_status or "未知", "tone": "muted", "reason": match_status or ""} def label_for_progress_status(status: str) -> dict[str, str]: return PROGRESS_LABELS.get(status, {"text": status or "未操作", "tone": "pending", "reason": ""}) def status_payload() -> dict[str, object]: roster = csv_rows(invoice_tool.ROSTER_CSV) progress = csv_rows(invoice_tool.PROGRESS_CSV) queue_raw = csv_rows(invoice_tool.PAGE_QUEUE_CSV) qr_order_raw = csv_rows(invoice_tool.QR_ORDER_CSV) by_person_key = { (row.get("real_name", ""), row.get("id_last8", "")): row for row in progress if row.get("real_name") or row.get("id_last8") } by_real_name = {row.get("real_name", ""): row for row in progress if row.get("real_name")} by_masked = {row.get("masked_name", ""): row for row in progress if row.get("masked_name")} roster_status = [] counts = {"total": len(roster), "completed": 0, "failed": 0, "in_progress": 0, "pending": 0} queue = [] for row in queue_raw: label = label_for_queue_row(row, by_person_key, by_masked) item = dict(row) item["display_status"] = label["display"] item["display_tone"] = label["tone"] item["display_reason"] = label["reason"] queue.append(item) qr_order = [] for row in qr_order_raw: real = row.get("real_name", "") id8 = row.get("id_last8", "") masked = row.get("masked_name", "") progress_row = by_person_key.get((real, id8)) if (real or id8) else None if progress_row is None and masked: progress_row = by_masked.get(masked) status = progress_row.get("status", "pending") if progress_row else "pending" if progress_row is None and not (real or id8): label = {"text": "名单未匹配", "tone": "warn", "reason": ""} status = "no_match" else: label = label_for_progress_status(status) item = dict(row) item["status"] = status item["display_status"] = label["text"] item["display_tone"] = label["tone"] item["display_reason"] = progress_row.get("reason", label.get("reason", "")) if progress_row else label.get("reason", "") item["updated_at"] = progress_row.get("updated_at", "") if progress_row else "" qr_order.append(item) counts["no_ticket"] = 0 for idx, person in enumerate(roster): person_key = (person.get("姓名", ""), person.get("身份证后8位", "")) progress_row = by_person_key.get(person_key) or by_real_name.get(person.get("姓名", "")) status = progress_row.get("status", "pending") if progress_row else "pending" label = PROGRESS_LABELS.get(status, {"text": status or "未操作", "tone": "pending", "reason": ""}) if status == "completed": counts["completed"] += 1 elif status == "no_ticket": counts["no_ticket"] += 1 elif status in {"failed", "error"}: counts["failed"] += 1 elif status == "in_progress": counts["in_progress"] += 1 else: counts["pending"] += 1 # progress 行号 = 开票处理顺序;未在 progress 的用大数排最后 progress_idx = next((i for i, r in enumerate(progress) if r is progress_row), len(progress)) roster_status.append( { "name": person.get("姓名", ""), "id_last8": person.get("身份证后8位", ""), "status": status, "display_status": label["text"], "display_tone": label["tone"], "masked_name": progress_row.get("masked_name", "") if progress_row else "", "updated_at": progress_row.get("updated_at", "") if progress_row else "", "reason": progress_row.get("reason", "") if progress_row else label.get("reason", ""), "_progress_idx": progress_idx, "_roster_idx": idx, } ) # 完全按开票处理顺序(progress 行号)排列,未处理的按名单顺序放最后 roster_status.sort(key=lambda r: (r.get("_progress_idx", 0), r.get("_roster_idx", 0))) for r in roster_status: r.pop("_progress_idx", None) r.pop("_roster_idx", None) with RUNNER_LOCK: running = RUNNER is not None and RUNNER.poll() is None return_code = None if RUNNER is None else RUNNER.poll() stopped_by_user = bool( RUNNER_STOPPED_BY_USER and RUNNER is not None and RUNNER_STOPPED_BY_USER.get("pid") == RUNNER.pid ) log_tail = "" if RUNNER_LOG.exists(): log_tail = "\n".join(RUNNER_LOG.read_text(encoding="utf-8", errors="ignore").splitlines()[-80:]) runner_status = "running" if running else "stopped" if (not running) and return_code not in (None, 0) and not stopped_by_user: runner_status = "failed" config = invoice_tool.load_config(invoice_tool.CONFIG_LOCAL_PATH) return { "counts": counts, "roster": roster_status, "queue": queue, "qr_order": qr_order, "progress": progress, "runner": {"running": running, "status": runner_status, "return_code": return_code, "stopped_by_user": stopped_by_user, "log_tail": log_tail}, "device": device_status(), "config": { "company_title": config.company_title, "tax_id": config.tax_id, "receiver_email": config.receiver_email, "invoice_output_dir": config.invoice_output_dir, "invoice_success_action": config.invoice_success_action, "invoice_output_absolute_dir": str(invoice_tool.project_path(config.invoice_output_dir)), }, "files": { "roster": str(invoice_tool.ROSTER_CSV), "progress": str(invoice_tool.PROGRESS_CSV), "queue": str(invoice_tool.PAGE_QUEUE_CSV), "qr_order": str(invoice_tool.QR_ORDER_CSV), }, } def save_invoice_settings(invoice_success_action: str, invoice_output_dir: str) -> dict[str, object]: allowed_actions = {"download", "email", "continue"} action = invoice_success_action if invoice_success_action in allowed_actions else "download" output_dir = invoice_output_dir.strip() or "output/invoices" config_path = invoice_tool.CONFIG_LOCAL_PATH data: dict[str, object] = {} if config_path.exists(): data = json.loads(config_path.read_text(encoding="utf-8")) data["invoice_success_action"] = action data["invoice_output_dir"] = output_dir config_path.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") project_dir = invoice_tool.project_path(output_dir) project_dir.mkdir(parents=True, exist_ok=True) return {"saved": True, "invoice_success_action": action, "invoice_output_dir": output_dir, "absolute_dir": str(project_dir)} def start_runner(max_batches: int, max_actions: int, send_email: bool, fast: bool, dry_run: bool = False, scroll_top_first: bool = True, mode: str = "h5", invoice_success_action: str = "", invoice_output_dir: str = "", final_retry_pass: bool = True) -> dict[str, object]: global RUNNER, RUNNER_STOPPED_BY_USER with RUNNER_LOCK: if RUNNER is not None and RUNNER.poll() is None: return {"started": False, "reason": "runner_already_running"} backup = backup_runtime_tables("start") RUNNER_STOPPED_BY_USER = None RUNNER_LOG.parent.mkdir(parents=True, exist_ok=True) RUNNER_LOG.write_text("", encoding="utf-8") log_fh = RUNNER_LOG.open("a", encoding="utf-8") if backup["files"]: log_fh.write(f"[prelude] runtime_backup dir={backup['dir']} files={len(backup['files'])}\n") log_fh.flush() prelude: list[str] = [] if scroll_top_first: top_result = subprocess.run( [PYTHON, "scripts/invoice_tool.py", "android-page-top"], cwd=PROJECT_ROOT, capture_output=True, text=True, check=False, ) log_fh.write(f"[prelude] android-page-top rc={top_result.returncode}\n{top_result.stdout}{top_result.stderr}\n") log_fh.flush() prelude.append(f"scroll_top: {top_result.stdout.strip().splitlines()[-1] if top_result.stdout.strip() else 'no_output'}") if mode == "h5": sub_cmd = "android-h5-page-run" else: sub_cmd = "android-vision-page-run" cmd = [PYTHON, "-u", "scripts/invoice_tool.py", sub_cmd, "--max-batches", str(max_batches)] if mode == "h5": cmd.append("--no-resume-after-last-progress") if max_actions > 0: cmd += ["--max-actions", str(max_actions)] if send_email: cmd.append("--send-email") if invoice_success_action: cmd += ["--invoice-success-action", invoice_success_action] if invoice_output_dir: cmd += ["--invoice-output-dir", invoice_output_dir] if fast: cmd.append("--fast") if dry_run: cmd.append("--dry-run") if not final_retry_pass: cmd.append("--no-final-retry-pass") RUNNER = subprocess.Popen(cmd, cwd=PROJECT_ROOT, stdout=log_fh, stderr=subprocess.STDOUT, text=True) return {"started": True, "pid": RUNNER.pid, "cmd": cmd, "prelude": prelude, "mode": mode, "backup": backup} def stop_runner() -> dict[str, object]: global RUNNER, RUNNER_STOPPED_BY_USER with RUNNER_LOCK: if RUNNER is None or RUNNER.poll() is not None: return {"stopped": False, "reason": "runner_not_running"} RUNNER_STOPPED_BY_USER = {"pid": RUNNER.pid, "at": time.strftime("%Y-%m-%d %H:%M:%S")} RUNNER.terminate() for _ in range(10): if RUNNER.poll() is not None: return {"stopped": True, "stopped_by_user": True} time.sleep(0.2) RUNNER.kill() return {"stopped": True, "killed": True, "stopped_by_user": True} def build_vision_page_queue() -> dict[str, object]: """用视觉 API 截屏识别当前页乘客,匹配名单+进度,返回队列数据。""" try: parsed = invoice_tool.vision_list_rows() except Exception as exc: return {"error": str(exc), "rows": [], "meta": f"视觉识别失败: {exc}"} if not parsed.get("page_ok"): return {"error": "not_on_list_page", "rows": [], "meta": "当前不在扫码开票单列表页"} rows = parsed.get("rows") or [] roster = invoice_tool.read_roster() skip_names = invoice_tool.progress_names({"completed", "no_ticket", "failed", "in_progress"}) queue = [] for row in rows: masked = row.get("masked_name", "") id_first = row.get("id_first", "") or "" id_last = row.get("id_last", "") or "" real = "" id8 = "" if masked in skip_names: status, tone, reason = "已处理", "muted", "进度表已完成或跳过" else: matches = invoice_tool.match_roster(masked, roster, id_first, id_last) if not matches and (id_first or id_last): matches = invoice_tool.match_roster(masked, roster) if len(matches) == 1: status, tone, reason = "待处理", "pending", f"匹配: {matches[0].get('姓名', '')}" real = matches[0].get("姓名", "") id8 = matches[0].get("身份证后8位", "") elif len(matches) > 1: status, tone, reason = "识别问题", "warn", f"同名 {len(matches)} 人" else: status, tone, reason = "识别问题", "warn", "不在名单中" queue.append({ "masked_name": masked, "real_name": real, "id_last8": id8, "id_first": id_first, "id_last": id_last, "display_status": status, "display_tone": tone, "display_reason": reason, }) # 同步写入 page_queue CSV,防止定时 refresh() 覆盖 now = invoice_tool.datetime.now().strftime("%Y-%m-%d %H:%M:%S") csv_rows = [] for q in queue: csv_rows.append({ "page_id": f"vision_{now}", "masked_name": q["masked_name"], "id_first": q.get("id_first", ""), "id_last": q.get("id_last", ""), "real_name": q.get("real_name", ""), "id_last8": q.get("id_last8", ""), "button_bounds": "", "match_status": "matched" if q["display_tone"] == "pending" else "skip_progress", "queue_status": "pending" if q["display_tone"] == "pending" else "skipped", "attempts": "0", "updated_at": now, }) invoice_tool.write_page_queue(csv_rows) return { "rows": queue, "meta": f"本页 {len(rows)} 人 · scroll={parsed.get('scroll_position', '?')} · pending={sum(1 for q in queue if q['display_tone'] == 'pending')}", "scroll_position": parsed.get("scroll_position"), } def launch_railway_app() -> dict[str, object]: result = invoice_tool.run_adb( ["shell", "monkey", "-p", "com.MobileTicket", "-c", "android.intent.category.LAUNCHER", "1"], check=False, ) if result.returncode == 0: time.sleep(1) return { "return_code": result.returncode, "stdout": result.stdout.strip(), "stderr": result.stderr.strip(), "package": "com.MobileTicket", "device": device_status(), } INDEX_HTML = r"""
名单导入 → 手机页面识别 → 自动开票 → 进度记录
只调整本地 UI 布局,所有按钮和接口保持原功能。
配置成功后的处理方式和本地保存目录。
| ✓ | 状态 | 原因 | 脱敏名 | 姓名 | 后8位 |
|---|
按当前二维码列表出现顺序展示,方便跑完后人工核查失败项。
| # | 状态 | 脱敏名 | 姓名 | 身份证后8位 | 更新时间 |
|---|
| 状态 | 姓名 | 脱敏名 | 身份证后8位 | 更新时间 |
|---|