- Add TaskDumpRecorder to save task-related XML dumps and upload failure info to Telegram. - Integrate TaskDumpRecorder into `PayByPhoneScript` and `PayByCardScript` for Dushanbe and Ozon banks. - Extend ADB utilities to record XML dumps with TaskDumpRecorder and refine keyboard dismissal logic. - Enhance freeze detection with consecutive dump checks in CommonScript and payment scripts. - Update mock server profiles and scenario configurations with new material IDs.
663 lines
24 KiB
Python
Executable File
663 lines
24 KiB
Python
Executable File
#!/usr/bin/env python3
|
||
"""
|
||
Generate FETCH_TRANSACTIONS pool tasks from a live device screen.
|
||
|
||
Reads config.ini to find the DB, queries active materials, shows a menu
|
||
to pick which device+bank to scan, then dumps the transactions list from
|
||
the real device, taps into each detail, and generates pool JSON files.
|
||
|
||
Run from project root while the transactions list is open on the device:
|
||
|
||
python tests/mock_server/gen_fetch_transactions.py
|
||
|
||
Or specify config and serial:
|
||
|
||
python tests/mock_server/gen_fetch_transactions.py --config config.ini -s 148697059H000712
|
||
|
||
On Windows:
|
||
|
||
python tests\\mock_server\\gen_fetch_transactions.py
|
||
"""
|
||
|
||
import argparse
|
||
import configparser
|
||
import json
|
||
import os
|
||
import re
|
||
import sqlite3
|
||
import subprocess
|
||
import sys
|
||
import time
|
||
from datetime import datetime, timedelta, timezone
|
||
from pathlib import Path
|
||
|
||
HERE = Path(__file__).parent
|
||
PROJECT_ROOT = (HERE / ".." / "..").resolve()
|
||
POOL_DIR = HERE / "scenarios" / "pool"
|
||
|
||
if sys.platform == "win32":
|
||
ADB = str(PROJECT_ROOT / "tools" / "adb" / "windows" / "adb.exe")
|
||
elif sys.platform == "darwin":
|
||
ADB = str(PROJECT_ROOT / "tools" / "adb" / "macos" / "adb")
|
||
else:
|
||
ADB = "adb"
|
||
BOUNDS_RE = re.compile(r"\[(\d+),(\d+)\]\[(\d+),(\d+)\]")
|
||
|
||
# ---------------------------------------------------------------- config/db
|
||
|
||
def resolve_db_path(config_path: Path) -> Path:
|
||
config = configparser.ConfigParser()
|
||
config.read(str(config_path), encoding="utf-8")
|
||
raw = config.get("db", "dbPath", fallback="")
|
||
if not raw:
|
||
print(f"ERROR: [db] dbPath not found in {config_path}", file=sys.stderr)
|
||
sys.exit(1)
|
||
expanded = os.path.expanduser(raw)
|
||
p = Path(expanded)
|
||
if not p.is_absolute():
|
||
p = config_path.parent / p
|
||
return p.resolve()
|
||
|
||
|
||
def find_config(hint):
|
||
if hint:
|
||
p = Path(hint)
|
||
if p.exists():
|
||
return p
|
||
print(f"ERROR: config not found at {p}", file=sys.stderr)
|
||
sys.exit(1)
|
||
for c in [Path("config.ini"), Path("build/Release/config.ini"),
|
||
Path("build/config.ini"), Path("build/Debug/config.ini")]:
|
||
if c.exists():
|
||
return c.resolve()
|
||
print("ERROR: config.ini not found. Run from project root or use --config.", file=sys.stderr)
|
||
sys.exit(1)
|
||
|
||
|
||
def query_active_materials(db_path: Path):
|
||
conn = sqlite3.connect(str(db_path))
|
||
conn.row_factory = sqlite3.Row
|
||
rows = conn.execute("""
|
||
SELECT d.id AS device_id, d.api_id, d.name AS device_name,
|
||
d.adb_serial,
|
||
bp.code AS bank_name, bp.bank_profile_id, bp.full_name,
|
||
m.material_id, m.last_numbers, m.card_number
|
||
FROM devices d
|
||
JOIN bank_profiles bp ON bp.device_id = d.id
|
||
JOIN materials m ON m.device_id = d.id AND m.app_code = bp.code
|
||
WHERE bp.pin_code_checked = 'yes' AND m.status = 'active'
|
||
AND m.material_id != '' AND bp.bank_profile_id != ''
|
||
ORDER BY d.name, bp.code
|
||
""").fetchall()
|
||
conn.close()
|
||
return [dict(r) for r in rows]
|
||
|
||
|
||
# ---------------------------------------------------------------- ADB helpers
|
||
|
||
def adb(serial, *args):
|
||
cmd = [ADB]
|
||
if serial:
|
||
cmd += ["-s", serial]
|
||
cmd += list(args)
|
||
return subprocess.run(cmd, check=True, capture_output=True)
|
||
|
||
|
||
PACKAGE_TO_BANK = {
|
||
"ru.ozon.fintech.finance": "ozon",
|
||
"tj.dc.next1": "dushanbe_city_bank",
|
||
"app.blackwallet.ar": "black",
|
||
}
|
||
|
||
|
||
def get_foreground_package(serial) -> str | None:
|
||
"""Return the package name of the currently focused app."""
|
||
try:
|
||
r = adb(serial, "shell", "dumpsys", "window", "windows")
|
||
for line in r.stdout.decode("utf-8", errors="replace").splitlines():
|
||
if "mCurrentFocus" in line or "mFocusedApp" in line:
|
||
# e.g. mCurrentFocus=Window{... u0 tj.dc.next1/tj.dc.next1.MainActivity}
|
||
m = re.search(r"u\d+\s+(\S+)/", line)
|
||
if m:
|
||
return m.group(1)
|
||
except Exception:
|
||
pass
|
||
return None
|
||
|
||
|
||
def dump_xml(serial) -> str:
|
||
try:
|
||
adb(serial, "shell", "uiautomator", "dump", "/sdcard/_gen_ft.xml")
|
||
r = adb(serial, "exec-out", "cat", "/sdcard/_gen_ft.xml")
|
||
return r.stdout.decode("utf-8", errors="replace")
|
||
except subprocess.CalledProcessError as e:
|
||
print(f" WARNING: dump_xml failed: {e}")
|
||
return ""
|
||
|
||
|
||
def get_device_tz(serial) -> timezone:
|
||
r = adb(serial, "shell", "date", "+%z")
|
||
s = r.stdout.decode().strip()
|
||
try:
|
||
sign = 1 if s[0] == "+" else -1
|
||
return timezone(timedelta(hours=sign * int(s[1:3]), minutes=sign * int(s[3:5])))
|
||
except (ValueError, IndexError):
|
||
print(f" warning: can't parse tz '{s}', assuming UTC+7")
|
||
return timezone(timedelta(hours=7))
|
||
|
||
|
||
def get_adb_serials():
|
||
r = subprocess.run([ADB, "devices"], check=True, capture_output=True)
|
||
serials = []
|
||
for line in r.stdout.decode().splitlines()[1:]:
|
||
parts = line.split()
|
||
if len(parts) >= 2 and parts[1] == "device":
|
||
serials.append(parts[0])
|
||
return serials
|
||
|
||
|
||
def tap(serial, x, y):
|
||
adb(serial, "shell", "input", "tap", str(x), str(y))
|
||
|
||
|
||
def back(serial):
|
||
adb(serial, "shell", "input", "keyevent", "KEYCODE_BACK")
|
||
|
||
|
||
def swipe_up(serial, duration=500):
|
||
"""Swipe up to scroll the list down (small step)."""
|
||
adb(serial, "shell", "input", "swipe", "540", "1400", "540", "900", str(duration))
|
||
|
||
|
||
# ---------------------------------------------------------------- Ozon
|
||
|
||
MONTH_MAP = {
|
||
"января": 1, "февраля": 2, "марта": 3, "апреля": 4,
|
||
"мая": 5, "июня": 6, "июля": 7, "августа": 8,
|
||
"сентября": 9, "октября": 10, "ноября": 11, "декабря": 12,
|
||
}
|
||
DATE_HEADER_RE = re.compile(r"(\d{1,2})\s+(\S+),\s+\S+")
|
||
|
||
|
||
def parse_ozon_date_headers(xml, device_tz):
|
||
"""Parse date headers (TextView) and return list of (y, date_str 'dd.MM.yyyy')."""
|
||
today = datetime.now(tz=device_tz).date()
|
||
headers = []
|
||
for m in re.finditer(
|
||
r'text="([^"]*)"[^>]*class="android.widget.TextView"'
|
||
r'[^>]*bounds="(\[\d+,\d+\]\[\d+,\d+\])"', xml):
|
||
text, bounds = m.group(1).strip(), m.group(2)
|
||
bm = BOUNDS_RE.match(bounds)
|
||
if not bm:
|
||
continue
|
||
x1, y1, x2, y2 = map(int, bm.groups())
|
||
h = y2 - y1
|
||
if h < 5 or y1 < 700:
|
||
continue # за экраном или в зоне шапки
|
||
d = None
|
||
if text == "Сегодня":
|
||
d = today
|
||
elif text == "Вчера":
|
||
d = today - timedelta(days=1)
|
||
else:
|
||
dm = DATE_HEADER_RE.match(text)
|
||
if dm:
|
||
day = int(dm.group(1))
|
||
month = MONTH_MAP.get(dm.group(2), 0)
|
||
if month:
|
||
d = today.replace(month=month, day=day)
|
||
if d > today:
|
||
d = d.replace(year=today.year - 1)
|
||
if d:
|
||
headers.append({"y": y1, "date": d.strftime("%d.%m.%Y")})
|
||
headers.sort(key=lambda h: h["y"])
|
||
return headers
|
||
|
||
|
||
def parse_ozon_rows(xml):
|
||
rows = []
|
||
for m in re.finditer(
|
||
r'text="([^"]*)"'
|
||
r'[^>]*?bounds="(\[\d+,\d+\]\[\d+,\d+\])"', xml):
|
||
text, bounds = m.group(1), m.group(2)
|
||
bm = BOUNDS_RE.match(bounds)
|
||
if not bm:
|
||
continue
|
||
x1, y1, x2, y2 = map(int, bm.groups())
|
||
h = y2 - y1
|
||
cy = (y1 + y2) // 2
|
||
if h < 50 or h < 5 or cy < 800:
|
||
continue
|
||
if "Расход" in text or "Доход" in text:
|
||
continue
|
||
am = re.search(r"([+\-−])\s*(\d[\d\s\u202f]*(?:[,.]\d{1,2})?)\s*₽", text)
|
||
if not am:
|
||
continue
|
||
sign = -1 if am.group(1) in ("-", "−") else 1
|
||
raw = re.sub(r"[\s\u202f]", "", am.group(2)).replace(",", ".")
|
||
amount = sign * (float(raw) if "." in raw else int(raw))
|
||
rows.append({"text": text, "cx": (x1+x2)//2, "cy": cy, "amount": amount})
|
||
rows.sort(key=lambda r: r["cy"])
|
||
return rows
|
||
|
||
|
||
def assign_dates_to_rows(rows, headers):
|
||
"""Assign date from nearest preceding header to each row."""
|
||
for row in rows:
|
||
row["date"] = None
|
||
for h in reversed(headers):
|
||
if h["y"] < row["cy"]:
|
||
row["date"] = h["date"]
|
||
break
|
||
|
||
|
||
def is_ozon_list(xml):
|
||
return "Операции" in xml
|
||
|
||
|
||
def extract_ozon_detail_time(xml):
|
||
for m in re.finditer(r'text="([^"]+)"', xml):
|
||
dm = re.match(r"(\d{2}\.\d{2}\.\d{4}),?\s+(\d{2}:\d{2})", m.group(1))
|
||
if dm:
|
||
return dm.group(1), dm.group(2) + ":00"
|
||
return None, None
|
||
|
||
|
||
# ---------------------------------------------------------------- Dushanbe
|
||
|
||
def parse_dushanbe_rows(xml):
|
||
rows = []
|
||
for m in re.finditer(
|
||
r'<node\s[^>]*?content-desc="([^"]*часть P2P/операции[^"]*)"'
|
||
r'[^>]*?bounds="(\[(\d+),(\d+)\]\[(\d+),(\d+)\])"', xml):
|
||
cd = m.group(1)
|
||
x1, y1, x2, y2 = int(m.group(3)), int(m.group(4)), int(m.group(5)), int(m.group(6))
|
||
h = y2 - y1
|
||
if h < 80 or h > 400:
|
||
continue
|
||
parts = cd.split("\n") if "\n" in cd else cd.split(" ")
|
||
amount, time_str = None, None
|
||
for p in parts:
|
||
p = p.strip()
|
||
try:
|
||
amount = float(p.replace(",", "."))
|
||
except ValueError:
|
||
pass
|
||
if re.match(r"^\s*\d{1,2}:\d{2}(:\d{2})?\s*$", p):
|
||
time_str = p.strip()
|
||
rows.append({"cx": (x1+x2)//2, "cy": (y1+y2)//2, "amount": amount, "time": time_str})
|
||
rows.sort(key=lambda r: r["cy"])
|
||
return rows
|
||
|
||
|
||
def is_dushanbe_list(xml):
|
||
return "Операции" in xml and "часть P2P" in xml
|
||
|
||
|
||
def extract_dushanbe_detail(xml):
|
||
m = re.search(r'content-desc="([^"]{30,})"', xml)
|
||
if not m:
|
||
return None, None, None
|
||
parts = [x.strip() for x in m.group(1).split(" ")]
|
||
info = {}
|
||
for i, x in enumerate(parts):
|
||
if x.endswith(":") and i + 1 < len(parts):
|
||
info[x] = parts[i + 1]
|
||
return info.get("Сумма:"), info.get("Дата:"), info.get("Время:")
|
||
|
||
|
||
# ---------------------------------------------------------------- Main
|
||
|
||
def short_label(amount, time_str):
|
||
sign = "plus" if amount >= 0 else "minus"
|
||
t = time_str.replace(":", "") if time_str else "unk"
|
||
a = abs(amount)
|
||
if isinstance(a, float):
|
||
rub = int(a)
|
||
kop = int(round((a - rub) * 100))
|
||
amt = f"{rub}_{kop:02d}" if kop else f"{rub}"
|
||
else:
|
||
amt = f"{a}"
|
||
return f"{sign}_{amt}_{t}"
|
||
|
||
|
||
def collect_tasks(serial, bank, mid, device_tz, kopecks, prefix):
|
||
xml = dump_xml(serial)
|
||
# Detect bank by foreground app package first, fall back to XML heuristics
|
||
fg_pkg = get_foreground_package(serial)
|
||
fg_bank = PACKAGE_TO_BANK.get(fg_pkg) if fg_pkg else None
|
||
if fg_bank:
|
||
print(f"Foreground app: {fg_pkg} -> {fg_bank}")
|
||
is_dush = fg_bank == "dushanbe_city_bank" or "dushanbe" in bank or is_dushanbe_list(xml)
|
||
is_ozon = not is_dush and (fg_bank == "ozon" or bank == "ozon" or is_ozon_list(xml))
|
||
|
||
if not is_ozon and not is_dush:
|
||
print("ERROR: not on a recognized transactions list", file=sys.stderr)
|
||
return []
|
||
|
||
tasks = []
|
||
seen = set()
|
||
|
||
try:
|
||
return _collect_tasks_inner(serial, bank, mid, device_tz, kopecks, prefix,
|
||
xml, is_ozon, is_dush, tasks, seen)
|
||
except (subprocess.CalledProcessError, OSError) as e:
|
||
print(f"\n ERROR: ADB/device failure: {e}")
|
||
print(f" Saving {len(tasks)} tasks collected before crash")
|
||
return tasks
|
||
|
||
|
||
def _collect_tasks_inner(serial, bank, mid, device_tz, kopecks, prefix,
|
||
xml, is_ozon, is_dush, tasks, seen):
|
||
if is_ozon:
|
||
print("Detected: Ozon")
|
||
headers = parse_ozon_date_headers(xml, device_tz)
|
||
baseline = parse_ozon_rows(xml)
|
||
assign_dates_to_rows(baseline, headers)
|
||
print(f"date headers: {[h['date'] for h in headers]}")
|
||
print(f"visible rows: {len(baseline)}")
|
||
for i, r in enumerate(baseline, 1):
|
||
print(f" {i}. ({r['cx']},{r['cy']}) {r['amount']:+.2f} ₽ date={r.get('date','-')} | {r['text'][:40]}")
|
||
|
||
task_num = 0
|
||
processed_keys = set() # (amount, created_at) — финальная дедупликация
|
||
completed_dates = set() # даты где все строки уже обработаны
|
||
date_row_counts = {} # date -> сколько строк видели для этой даты (макс)
|
||
max_scrolls = 20
|
||
scroll_count = 0
|
||
|
||
while True:
|
||
xml = dump_xml(serial)
|
||
if not is_ozon_list(xml):
|
||
print("Left list, stop"); break
|
||
|
||
headers = parse_ozon_date_headers(xml, device_tz)
|
||
rows = parse_ozon_rows(xml)
|
||
assign_dates_to_rows(rows, headers)
|
||
|
||
if not rows:
|
||
print("No rows found, stop"); break
|
||
|
||
# Фильтруем строки от уже завершённых дат
|
||
rows = [r for r in rows if r.get("date") and r["date"] not in completed_dates]
|
||
if not rows:
|
||
print("All visible dates already processed, scrolling...")
|
||
scroll_count += 1
|
||
if scroll_count >= max_scrolls:
|
||
print(f"\nReached max scrolls ({max_scrolls}), stop"); break
|
||
swipe_up(serial)
|
||
time.sleep(1.5)
|
||
continue
|
||
|
||
# Считаем строки по датам на текущем экране
|
||
screen_date_counts = {}
|
||
for row in rows:
|
||
d = row["date"]
|
||
screen_date_counts[d] = screen_date_counts.get(d, 0) + 1
|
||
|
||
new_on_screen = 0
|
||
for row in rows:
|
||
date_from_header = row["date"]
|
||
|
||
print(f"\n [{date_from_header}] tap ({row['cx']},{row['cy']}) {row['amount']:+.2f}")
|
||
tap(serial, row["cx"], row["cy"])
|
||
time.sleep(3)
|
||
|
||
# Ждём деталей — нужно только время (дата уже из заголовка)
|
||
time_s = None
|
||
for attempt in range(3):
|
||
d_xml = dump_xml(serial)
|
||
_, time_s = extract_ozon_detail_time(d_xml)
|
||
if time_s:
|
||
break
|
||
if is_ozon_list(d_xml):
|
||
print(f" retry {attempt+1}: still on list, re-tapping")
|
||
tap(serial, row["cx"], row["cy"])
|
||
time.sleep(2)
|
||
else:
|
||
print(f" retry {attempt+1}: waiting for time...")
|
||
time.sleep(2)
|
||
|
||
back(serial); time.sleep(1.5)
|
||
|
||
if not time_s:
|
||
print(f" SKIP (no time from detail)"); continue
|
||
|
||
print(f" {date_from_header} {time_s}")
|
||
|
||
dt = datetime.strptime(f"{date_from_header} {time_s}", "%d.%m.%Y %H:%M:%S").replace(tzinfo=device_tz)
|
||
cat = dt.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||
|
||
dedup_key = (row["amount"], cat)
|
||
if dedup_key in processed_keys:
|
||
print(" DUPLICATE, skip"); continue
|
||
processed_keys.add(dedup_key)
|
||
new_on_screen += 1
|
||
|
||
task_num += 1
|
||
label = short_label(row["amount"], time_s)
|
||
while label in seen: label += "_2"
|
||
seen.add(label)
|
||
tasks.append({"bank_name": bank, "material_id": mid, "type": "FETCH_TRANSACTIONS",
|
||
"body": {"amount": int(round(row["amount"] * kopecks)), "created_at": cat},
|
||
"_tag": f"{prefix}_{task_num:02d}_{label}"})
|
||
|
||
# Помечаем даты как завершённые: все даты кроме последней на экране
|
||
# (последняя может быть обрезана скроллом)
|
||
visible_dates = list(dict.fromkeys(r["date"] for r in rows)) # ordered unique
|
||
if len(visible_dates) > 1:
|
||
for d in visible_dates[:-1]:
|
||
completed_dates.add(d)
|
||
|
||
if new_on_screen == 0:
|
||
# Последняя дата на экране — тоже завершена
|
||
if visible_dates:
|
||
completed_dates.add(visible_dates[-1])
|
||
print("\nNo new transactions on screen, scrolling past...")
|
||
scroll_count += 1
|
||
if scroll_count >= max_scrolls:
|
||
print(f"\nReached max scrolls ({max_scrolls}), stop"); break
|
||
swipe_up(serial)
|
||
time.sleep(1.5)
|
||
continue
|
||
|
||
scroll_count += 1
|
||
if scroll_count >= max_scrolls:
|
||
print(f"\nReached max scrolls ({max_scrolls}), stop"); break
|
||
print(f"\n--- scroll {scroll_count}/{max_scrolls} ---")
|
||
swipe_up(serial)
|
||
time.sleep(1.5)
|
||
|
||
elif is_dush:
|
||
print("Detected: Dushanbe")
|
||
baseline = parse_dushanbe_rows(xml)
|
||
print(f"visible rows: {len(baseline)}")
|
||
for i, r in enumerate(baseline, 1):
|
||
print(f" {i}. ({r['cx']},{r['cy']}) amt={r['amount']} time={r['time']}")
|
||
|
||
task_num = 0
|
||
processed_keys = set()
|
||
max_scrolls = 30
|
||
scroll_count = 0
|
||
|
||
while True:
|
||
xml = dump_xml(serial)
|
||
if not is_dushanbe_list(xml):
|
||
print("Left list, stop"); break
|
||
rows = parse_dushanbe_rows(xml)
|
||
if not rows:
|
||
print("No rows found, stop"); break
|
||
|
||
tapped_any = False
|
||
for row in rows:
|
||
print(f"\n tap ({row['cx']},{row['cy']}) amt={row['amount']}")
|
||
tap(serial, row["cx"], row["cy"])
|
||
time.sleep(2.5)
|
||
d_xml = dump_xml(serial)
|
||
amt_s, date_s, time_s = extract_dushanbe_detail(d_xml)
|
||
print(f" amt={amt_s} {date_s} {time_s}")
|
||
back(serial); time.sleep(1.3)
|
||
if not date_s or not time_s:
|
||
print(" SKIP"); continue
|
||
|
||
amount = float(amt_s.replace(",", ".")) if amt_s else (row["amount"] or 0)
|
||
fmt = "%d.%m.%Y %H:%M:%S" if len(time_s) > 5 else "%d.%m.%Y %H:%M"
|
||
# Душанбе показывает время в Asia/Dushanbe (UTC+5), не в таймзоне устройства
|
||
dushanbe_tz = timezone(timedelta(hours=5))
|
||
dt = datetime.strptime(f"{date_s} {time_s}", fmt).replace(tzinfo=dushanbe_tz)
|
||
cat = dt.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||
dedup_key = (amount, cat)
|
||
if dedup_key in processed_keys:
|
||
print(" DUPLICATE, skip"); continue
|
||
processed_keys.add(dedup_key)
|
||
tapped_any = True
|
||
|
||
task_num += 1
|
||
label = short_label(amount, time_s)
|
||
while label in seen: label += "_2"
|
||
seen.add(label)
|
||
tasks.append({"bank_name": bank, "material_id": mid, "type": "FETCH_TRANSACTIONS",
|
||
"body": {"amount": int(round(amount * kopecks)), "created_at": cat},
|
||
"_tag": f"{prefix}_{task_num:02d}_{label}"})
|
||
|
||
scroll_count += 1
|
||
if scroll_count >= max_scrolls:
|
||
print(f"\nReached max scrolls ({max_scrolls}), stop"); break
|
||
if not tapped_any:
|
||
print("\nNo new transactions on screen, stop"); break
|
||
print(f"\n--- scrolling down ({scroll_count}/{max_scrolls}) ---")
|
||
swipe_up(serial)
|
||
time.sleep(1.5)
|
||
|
||
return tasks
|
||
|
||
|
||
def main():
|
||
parser = argparse.ArgumentParser(description="Generate FETCH_TRANSACTIONS pool from live device")
|
||
parser.add_argument("--config", "-c", default=None, help="Path to config.ini")
|
||
parser.add_argument("--serial", "-s", default=None, help="ADB serial (prompted if multiple)")
|
||
parser.add_argument("--kopecks", type=int, default=100, help="Amount multiplier (default 100)")
|
||
parser.add_argument("--mock-host", default="127.0.0.1")
|
||
parser.add_argument("--mock-port", type=int, default=8888)
|
||
args = parser.parse_args()
|
||
|
||
# Find config and DB
|
||
for c in ([Path(args.config)] if args.config else
|
||
[Path("config.ini"), Path("build/Release/config.ini"),
|
||
Path("build/config.ini")]):
|
||
if c.exists():
|
||
config_path = c.resolve(); break
|
||
else:
|
||
print("ERROR: config.ini not found", file=sys.stderr); return 1
|
||
|
||
db_path = resolve_db_path(config_path)
|
||
print(f"config: {config_path}")
|
||
print(f"db: {db_path}")
|
||
|
||
materials = query_active_materials(db_path)
|
||
if not materials:
|
||
print("No active materials in DB"); return 1
|
||
|
||
# Find connected ADB devices
|
||
serials = get_adb_serials()
|
||
if not serials:
|
||
print("ERROR: no ADB devices connected"); return 1
|
||
|
||
# Match DB devices to connected ADB serials
|
||
options = []
|
||
for mat in materials:
|
||
adb_serial = mat["adb_serial"]
|
||
if adb_serial not in serials:
|
||
continue
|
||
dev_label = re.sub(r"-[a-f0-9]{10,}$", "", mat["device_name"])
|
||
options.append({**mat, "serial": adb_serial, "label": dev_label})
|
||
|
||
if not options:
|
||
print("No active materials match connected ADB devices.")
|
||
print(f" DB serials: {[m['adb_serial'] for m in materials]}")
|
||
print(f" ADB serials: {serials}")
|
||
return 1
|
||
|
||
# Menu
|
||
print(f"\nAvailable materials ({len(options)}):")
|
||
for i, o in enumerate(options, 1):
|
||
print(f" {i}. {o['label']} / {o['bank_name']} / {o['full_name'] or '?'} "
|
||
f"(material {o['material_id'][:8]}… serial={o['serial']})")
|
||
|
||
if len(options) == 1:
|
||
choice = 0
|
||
print(f"\n→ auto-selected: {options[0]['label']} / {options[0]['bank_name']}")
|
||
else:
|
||
try:
|
||
inp = input(f"\nPick [1-{len(options)}] or 'all': ").strip()
|
||
except (EOFError, KeyboardInterrupt):
|
||
return 0
|
||
if inp.lower() == "all":
|
||
choice = -1
|
||
else:
|
||
choice = int(inp) - 1
|
||
if choice < 0 or choice >= len(options):
|
||
print("Invalid choice"); return 1
|
||
|
||
to_scan = options if choice == -1 else [options[choice]]
|
||
all_tasks = []
|
||
|
||
for o in to_scan:
|
||
serial = args.serial or o["serial"]
|
||
bank = o["bank_name"]
|
||
mid = o["material_id"]
|
||
card = o.get('last_numbers') or o['material_id'][:8]
|
||
prefix = f"{o['label'].lower().replace(' ', '_')}_{bank}_{card}"
|
||
device_tz = get_device_tz(serial)
|
||
print(f"\n{'='*60}")
|
||
print(f"Scanning: {o['label']} / {bank} ({mid[:8]}…)")
|
||
print(f"Serial: {serial} TZ: UTC{device_tz.utcoffset(None)}")
|
||
print(f"{'='*60}")
|
||
|
||
input(f"\nOpen {bank} transactions list on {o['label']} and press Enter...")
|
||
|
||
tasks = collect_tasks(serial, bank, mid, device_tz, args.kopecks, prefix)
|
||
all_tasks.extend(tasks)
|
||
|
||
if not all_tasks:
|
||
print("\nNo tasks generated"); return 1
|
||
|
||
# Write pool
|
||
POOL_DIR.mkdir(parents=True, exist_ok=True)
|
||
# Remove old files for scanned prefixes
|
||
scanned_prefixes = set()
|
||
for o in to_scan:
|
||
card = o.get('last_numbers') or o['material_id'][:8]
|
||
scanned_prefixes.add(f"{o['label'].lower().replace(' ', '_')}_{o['bank_name']}_{card}")
|
||
for old in POOL_DIR.glob("*.json"):
|
||
for pfx in scanned_prefixes:
|
||
if old.name.startswith(pfx + "_ft_"):
|
||
old.unlink(); break
|
||
|
||
for i, t in enumerate(all_tasks, 1):
|
||
tag = t["_tag"]
|
||
fname = f"{tag.rsplit('_', 1)[0]}_ft_{i:02d}.json"
|
||
(POOL_DIR / fname).write_text(json.dumps(t, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
||
|
||
print(f"\n{len(all_tasks)} tasks written to {POOL_DIR}/")
|
||
for t in all_tasks:
|
||
b = t["body"]
|
||
print(f" {t['_tag']}: amount={b['amount']:+.2f} created_at={b['created_at']}")
|
||
|
||
# Inject into mock if running
|
||
try:
|
||
import urllib.request
|
||
url = f"http://{args.mock_host}:{args.mock_port}/_admin/inject"
|
||
req = urllib.request.Request(url, data=json.dumps(all_tasks).encode(),
|
||
headers={"Content-Type": "application/json"}, method="POST")
|
||
with urllib.request.urlopen(req, timeout=3) as resp:
|
||
print(f"\nInjected {len(all_tasks)} tasks into mock -> {resp.status}")
|
||
except Exception as e:
|
||
print(f"\nMock not reachable ({e}) — tasks saved to files only")
|
||
|
||
return 0
|
||
|
||
|
||
if __name__ == "__main__":
|
||
sys.exit(main())
|