Портал технической службы: наряды и чек-листы, задачи, отказы и неисправности, анализ надёжности, карты, склады, приложение для Android

This commit is contained in:
darkorion
2026-10-13 09:09:53 +03:00
commit c298f5a830
247 changed files with 23200 additions and 0 deletions
+341
View File
@@ -0,0 +1,341 @@
"""Скриншоты Android-приложения на эмуляторе: управление через adb, элементы для разметки — через uiautomator.
Запуск: python tools/guide/shots_android.py
Нужны: запущенный эмулятор с установленным приложением и сервер с тестовыми данными на localhost:8000.
"""
import os
import re
import subprocess
import sys
import time
import xml.etree.ElementTree as ET
from pathlib import Path
from PIL import Image
sys.path.insert(0, str(Path(__file__).parent))
from annotate import annotate_side # noqa: E402
from spec import M # noqa: E402
ADB = r"C:\Users\darko\AppData\Local\Android\Sdk\platform-tools\adb.exe"
PKG = "ru.checklists.app"
OUT = Path(__file__).resolve().parents[2] / "docs" / "guide" / "img"
ANDROID = [] # (имя, метки) — заполняется при съёмке, нужен PDF-сборщику
MARKS = {}
def adb(*a, binary=False):
r = subprocess.run([ADB, *a], capture_output=True)
return r.stdout if binary else r.stdout.decode("utf-8", "replace")
def sleep(s):
time.sleep(s)
def dump():
for attempt in range(4): # uiautomator иногда отдаёт пустой файл, пока экран перерисовывается
adb("shell", "uiautomator", "dump", "/sdcard/ui.xml")
xml = adb("exec-out", "cat", "/sdcard/ui.xml")
try:
root = ET.fromstring(xml)
break
except ET.ParseError:
if attempt == 3:
raise
sleep(1)
out = []
for n in root.iter("node"):
m = re.match(r"\[(\d+),(\d+)\]\[(\d+),(\d+)\]", n.get("bounds", ""))
if not m:
continue
x1, y1, x2, y2 = map(int, m.groups())
out.append({"text": n.get("text", ""), "desc": n.get("content-desc", ""), "cls": n.get("class", ""), "b": (x1, y1, x2, y2),
"click": n.get("clickable") == "true"})
return out
def find(text=None, desc=None, cls=None, nth=0, exact=False, retry=6):
for _ in range(retry):
res = []
for n in dump():
if cls and n["cls"] != cls:
continue
if text is not None and not ((n["text"] == text) if exact else (text in n["text"])):
continue
if desc is not None and desc not in n["desc"]:
continue
if n["b"][2] - n["b"][0] > 0 and n["b"][3] - n["b"][1] > 0:
res.append(n)
if len(res) > nth:
return res[nth]
sleep(1)
raise RuntimeError(f"не найден элемент text={text!r} desc={desc!r} cls={cls!r} nth={nth}")
def pick(nodes, text, nth=0):
res = [n for n in nodes if text in n["text"] and n["b"][2] > n["b"][0] and n["b"][3] > n["b"][1]]
return res[nth] if len(res) > nth else None
def btn(text, nth=0, exact=False):
"""Кликабельный контейнер (кнопка, чип, карточка) по тексту внутри него."""
t = find(text, nth=nth, exact=exact)
best = None
for n in dump():
if n["click"] and n["b"][0] <= t["b"][0] and n["b"][1] <= t["b"][1] and n["b"][2] >= t["b"][2] and n["b"][3] >= t["b"][3]:
area = (n["b"][2] - n["b"][0]) * (n["b"][3] - n["b"][1])
if best is None or area < best[0]:
best = (area, n)
return best[1] if best else t
def scroll_to(text, tries=6):
for _ in range(tries):
try:
return btn(text) if find(text, retry=1) else None
except RuntimeError:
swipe_up(700)
raise RuntimeError(f"не найден при прокрутке: {text!r}")
def tap(n):
x1, y1, x2, y2 = n["b"]
adb("shell", "input", "tap", str((x1 + x2) // 2), str((y1 + y2) // 2))
def type_text(s):
adb("shell", "input", "text", s.replace(" ", "%s"))
def drawer_item(text):
"""Пункт бокового меню: если он ниже края экрана, меню прокручивается."""
for _ in range(4):
n = btn(text, exact=True)
if n["b"][3] < 2250:
return n
adb("shell", "input", "swipe", "300", "2000", "300", "900", "450")
sleep(0.8)
return btn(text, exact=True)
def swipe_up(dist=1100):
adb("shell", "input", "swipe", "540", "1900", "540", str(1900 - dist), "450")
sleep(0.8)
def swipe_down(dist=1100):
adb("shell", "input", "swipe", "540", "700", "540", str(700 + dist), "450")
sleep(0.8)
def back():
adb("shell", "input", "keyevent", "4")
sleep(0.8)
def shot(name, marks):
"""marks: [(M, node)] — M даёт подпись и направление, node — найденный элемент."""
sleep(0.8)
OUT.mkdir(parents=True, exist_ok=True)
raw = OUT / f"{name}.raw.png"
raw.write_bytes(adb("exec-out", "screencap", "-p", binary=True))
rects = []
for i, (m, n) in enumerate(marks, 1):
x1, y1, x2, y2 = n["b"]
gx, gy = m.grow
rects.append((i, (x1, y1, x2 - x1 + gx, y2 - y1 + gy), m.dir))
img = annotate_side(Image.open(raw), rects, margin=170, scale=2.2)
img.save(OUT / f"{name}.png", optimize=True)
raw.unlink()
MARKS[name] = [m.label for m, _ in marks]
print("ok", name, img.size, flush=True)
def login(user, pw="demo1234"):
fields = [n for n in dump() if n["cls"] == "android.widget.EditText"]
tap(fields[1]); sleep(0.5)
type_text(user)
adb("shell", "input", "keyevent", "61"); type_text(pw)
back() # скрыть клавиатуру
tap(btn("Войти", exact=True))
sleep(7)
PORT = os.environ.get("GUIDE_PORT", "8000") # порт сервера на ПК; в приложении адрес всегда localhost:8000
def restart(clear=True):
adb("reverse", "tcp:8000", f"tcp:{PORT}")
if clear:
adb("shell", "pm", "clear", PKG)
adb("shell", "pm", "grant", PKG, "android.permission.POST_NOTIFICATIONS")
adb("shell", "am", "force-stop", PKG)
adb("shell", "am", "start", "-n", f"{PKG}/.MainActivity")
sleep(4)
def run():
# 1. вход
restart()
fields = [n for n in dump() if n["cls"] == "android.widget.EditText"]
tap(fields[0]); sleep(0.4); type_text("http://localhost:8000"); back()
fields = [n for n in dump() if n["cls"] == "android.widget.EditText"]
shot("a_login", [
(M("Адрес сервера — его сообщит администратор (например, https://checklists.example.ru)"), fields[0]),
(M("Ваш логин и пароль"), {"b": (fields[1]["b"][0], fields[1]["b"][1], fields[2]["b"][2], fields[2]["b"][3])}),
(M("«Войти»"), btn("Войти", exact=True)),
])
login("sidorov")
# 2. вкладка «Задачи» (открывается после входа), затем список нарядов
shot("a_work", [
(M("Шапка: быстрые действия (задача, авария, неисправность, заявка) и сводка"), btn("Задача себе")),
(M("Сводка: мои наряды и задачи, открытые аварии и неисправности"), find("мои наряды")),
(M("Блок «Наряды»: фильтры и ближайшие наряды; «Все →» — полный список"), find("Мои ·")),
(M("Меню: все разделы приложения"), find(desc="Меню")),
])
tap(find(desc="Меню")); sleep(1.2)
tap(btn("Наряды", exact=True)); sleep(3)
shot("a_list", [
(M("Время последней синхронизации"), find("Обновлено")),
(M("Меню: все разделы приложения"), find(desc="Меню")),
(M("Поиск и обновление"), {"b": (find(desc="Поиск")["b"][0], find(desc="Поиск")["b"][1], find(desc="Обновить")["b"][2], find(desc="Обновить")["b"][3])}),
(M("Фильтры: активные, мои, завершённые"), btn("Активные")),
(M("Карточка наряда: статус, срок, прогресс. «▶ можно выполнять» — наряд ваш"), btn("Проверка насосной")),
])
# 3. наряд в работе
tap(scroll_to("Насос Н-2")); sleep(3)
shot("a_order_top", [
(M("Назад к списку"), find(desc="Назад")),
(M("Статус наряда и кто назначен"), find("В работе", exact=True)),
(M("Число: введите значение, ниже — допустимая норма"), find(cls="android.widget.EditText", nth=0)),
])
swipe_up(1300)
shot("a_order_bottom", [
(M("Выбор варианта — нажатием на нужный"), btn("нет", exact=True)),
(M("Фото: откроется камера телефона"), btn("Фото")),
(M("«Сдать наряд» — когда всё заполнено"), btn("Сдать наряд", exact=True)),
])
# 4. без связи: убираем проброс порта и выбираем вариант ответа
adb("reverse", "--remove", "tcp:8000")
tap(btn("есть, незначительные", exact=True)); sleep(2)
back(); sleep(2.5) # к списку
tap(find(desc="Обновить")); sleep(5) # попытка синхронизации без связи
swipe_up(900); sleep(1)
D = dump()
shot("a_offline", [x for x in [
(M("Сверху видно, сколько нарядов ещё не отправлено: данные сохранены на телефоне и уйдут сами"), pick(D, "Не отправлено")),
(M("Приложение сообщает, что связи нет"), pick(D, "Нет связи")),
(M("Наряд помечен «не отправлено»"), pick(D, "не отправлено", 1) or pick(D, "не отправлено")),
] if x[1]])
adb("reverse", "tcp:8000", f"tcp:{PORT}")
# 5. боковое меню и разделы портала
tap(find(desc="Меню")); sleep(1.5)
shot("a_menu", [
(M("Кто вошёл: ФИО и должность"), find("Сидоров")),
(M("Работа: вкладка «Задачи», наряды, задачи и поручения"), {"b": (btn("Задачи — всё")["b"][0], btn("Задачи — всё")["b"][1], btn("Задачи и поручения")["b"][2], btn("Задачи и поручения")["b"][3])}),
(M("Оборудование: состояние машин и фиксация инцидентов"), btn("Оборудование и инциденты")),
(M("Инструмент: что выдано вам и заявки на покупку"), {"b": (btn("Мой инструмент")["b"][0], btn("Мой инструмент")["b"][1], btn("Заявки на инструмент")["b"][2], btn("Заявки на инструмент")["b"][3])}),
(M("Карта завода и сотрудники открываются в полной версии сайта (в браузере)"), btn("Карта завода")),
])
tap(btn("Задачи и поручения")); sleep(3)
shot("a_tasks", [
(M("Кнопка меню — переход к другим разделам"), find(desc="Меню")),
(M("Показать все задания или только активные"), btn("Все", exact=True)),
(M("Карточка задания: статус, день, оборудование"), btn("Замер вибрации")),
(M("Поставить задание себе"), btn("+")),
])
tap(btn("Замер вибрации")); sleep(3)
comment = find(cls="android.widget.EditText", nth=0)
shot("a_task", [
(M("Статус задания"), find("Назначен", exact=True)),
(M("Комментарий: обязателен для «приостановлен», «выполнен», «отменён»"), comment),
(M("Кнопки смены статуса"), {"b": (btn("В работу")["b"][0], btn("В работу")["b"][1], btn("Отменить")["b"][2], btn("Отменить")["b"][3])}),
(M("Фото к заданию — с камеры телефона"), btn("Сфотографировать")),
])
back(); sleep(1)
tap(find(desc="Меню")); sleep(1.2)
tap(btn("Оборудование и инциденты")); sleep(3)
tap(btn("Насос Н-2")); sleep(3)
shot("a_equipment", [
(M("Состояние машины: «Авария» — есть открытый инцидент"), find("Авария", exact=True)),
(M("Зафиксировать аварию (машина остановлена)"), btn("Зафиксировать инцидент")),
(M("Сообщить о неисправности (машина работает)"), btn("Сообщить о неисправности")),
(M("Инцидент: что случилось, узлы, была ли замена; «Закрыть» — после устранения"), btn("Течь по торцевому")),
])
tap(btn("История машины")); sleep(3)
shot("a_history", [
(M("Фильтры по типам событий"), btn("Инциденты")),
(M("События: что произошло, когда, кто"), find(cls="android.widget.TextView", nth=8)),
])
back(); sleep(1.5)
tap(btn("Зафиксировать инцидент")); sleep(1.5)
shot("a_incident", [
(M("Что произошло и подробности"), find(cls="android.widget.EditText", nth=0)),
(M("Затронутые узлы (заведены в карточке машины); отметка «заменён» сбрасывает ресурс узла"), find("Подшипники")),
(M("«Зафиксировать» — машина получит статус «Авария»"), btn("Зафиксировать", exact=True)),
])
tap(btn("Отмена", exact=True)); sleep(1)
back()
# 6. помощник (если включён на сервере)
tap(find(desc="Меню")); sleep(1.2)
try:
tap(drawer_item("Помощник")); sleep(2)
tap(btn("Как зафиксировать аварию?")); sleep(4)
shot("a_assistant", [
(M("Вопрос пользователя"), find("Как зафиксировать аварию?")),
(M("Ответ со ссылками: нажмите — откроется нужный экран"), find("Чтобы зафиксировать")),
(M("Поле вопроса"), find(cls="android.widget.EditText", nth=0)),
])
back(); sleep(1)
tap(find(desc="Меню")); sleep(1.2)
except RuntimeError as e:
print("помощник не снят:", e)
back(); sleep(1)
tap(find(desc="Меню")); sleep(1.2)
# 7. настройки
tap(drawer_item("Настройки")); sleep(2)
shot("a_settings", [
(M("Ваша должность и статус смены"), find("Дежурный механик")),
(M("Принудительная синхронизация"), btn("Синхронизировать")),
(M("Выход из аккаунта"), btn("Выйти", exact=True)),
])
# 8. инженер: проверка
tap(btn("Выйти", exact=True)); sleep(1.5)
dlg = [n for n in dump() if n["text"] in ("Выйти", "Всё равно выйти")]
tap(dlg[-1]); sleep(2.5)
login("morozova")
tap(find(desc="Меню")); sleep(1.2)
tap(btn("Наряды", exact=True)); sleep(3)
tap(btn("На проверку")); sleep(1.5)
shot("a_review_list", [
(M("Фильтр «На проверку» — наряды, которые ждут вашего решения"), btn("На проверку")),
(M("«✔ ждёт вашей проверки» — откройте наряд"), btn("ждёт вашей проверки")),
])
tap(btn("ждёт вашей проверки")); sleep(3)
for _ in range(4):
try:
find("Принять", exact=True, retry=1)
break
except RuntimeError:
swipe_up(900)
accept = btn("Принять", exact=True)
comment = [n for n in dump() if n["cls"] == "android.widget.EditText" and n["b"][3] < accept["b"][1]][-1]
shot("a_review", [
(M("Комментарий проверяющего (обязателен при возврате)"), comment),
(M("«Принять» — наряд завершён"), accept),
(M("«Вернуть» — на доработку"), btn("Вернуть", exact=True)),
])
if __name__ == "__main__":
run()
import json
(OUT / "android_marks.json").write_text(json.dumps(MARKS, ensure_ascii=False, indent=1), encoding="utf-8")