diff --git a/.env.example b/.env.example index 1e94d56..87a95bd 100644 --- a/.env.example +++ b/.env.example @@ -16,3 +16,45 @@ APP_PORT=8080 # там виден адрес самого прокси (на том же хосте — обычно шлюз сети Docker, например 172.18.0.1). Впишите его и выполните # docker compose up -d — после этого в той строке появится ваш собственный адрес. FORWARDED_ALLOW_IPS=127.0.0.1 + +# ---------- Дополнительные сервисы (необязательно) ---------- +# Включаются профилями docker-compose.yml: docs — Nextcloud, office — ONLYOFFICE, ai — локальная модель Ollama. +# Портал сам найдёт запущенные сервисы во внутренней сети Docker и заполнит их адреса в настройках. +# Пример: COMPOSE_PROFILES=docs,office,ai +COMPOSE_PROFILES= + +# --- docs: Nextcloud (документация оборудования) --- +# Администратор Nextcloud создаётся при первом запуске; без пароля Nextcloud покажет мастер установки в браузере. +NEXTCLOUD_ADMIN_USER=ncadmin +NEXTCLOUD_ADMIN_PASSWORD= +# Порт веб-интерфейса Nextcloud на хосте (сюда загружают документы) и его доменное имя за прокси (если есть). +NEXTCLOUD_PORT=8081 +NEXTCLOUD_DOMAIN= +# https — если Nextcloud открывают через прокси с HTTPS. +NEXTCLOUD_PROTOCOL= +# Пароль базы Nextcloud (пусто — как DB_PASSWORD). +NEXTCLOUD_DB_PASSWORD= +# Под какой учётной записью портал читает документацию. Пусто — администратор Nextcloud выше. +# Надёжнее завести отдельного пользователя только с чтением папки документации и пароль приложения для него. +NC_USER= +NC_PASSWORD= +# Папка с документацией внутри учётной записи, например Документация/Оборудование. +NC_ROOT= +# Свой Nextcloud вне этого docker-compose: укажите его адрес (тогда профиль docs не нужен). +NC_URL= + +# --- office: ONLYOFFICE Document Server (просмотр Word/Excel/PowerPoint в портале) --- +# Общий секрет портала и ONLYOFFICE. Обязательно задайте свой: openssl rand -hex 24 +ONLYOFFICE_JWT= +# Свой ONLYOFFICE вне этого docker-compose: его адрес и адрес портала, по которому ONLYOFFICE его видит. +ONLYOFFICE_URL= +PORTAL_INTERNAL_URL= + +# --- ai: локальная модель Ollama для AI-помощника --- +# Модель скачивается при первом запуске (qwen2.5:7b — около 5 ГБ на диске и 6–8 ГБ памяти; поменьше — qwen2.5:3b). +OLLAMA_MODEL=qwen2.5:7b +# Внешний провайдер вместо Ollama (необязательно): openai | anthropic | gigachat, адрес, модель, ключ. +ASSISTANT_PROVIDER= +ASSISTANT_BASE_URL= +ASSISTANT_MODEL= +ASSISTANT_API_KEY= diff --git a/README.md b/README.md index 7c37d33..8a9be7b 100644 --- a/README.md +++ b/README.md @@ -133,6 +133,12 @@ python -m venv .venv && .venv\Scripts\pip install -r requirements.txt Пошаговая установка — [docs/DEPLOY.md](docs/DEPLOY.md). +## Дополнительные сервисы +Рядом с порталом `docker-compose.yml` может поднять **Nextcloud** (документация оборудования), **ONLYOFFICE** (просмотр +Word/Excel/PowerPoint в портале) и **Ollama** (локальная модель для AI-помощника) — профилями `docs`, `office`, `ai` +(`COMPOSE_PROFILES=docs,office,ai` в `.env`). Портал сам находит их во внутренней сети Docker и заполняет адреса в настройках. +Подробно — [docs/DEPLOY.md](docs/DEPLOY.md), раздел 7а. + ## Установка без Coolify (Docker Compose) Пошаговая инструкция для любого Linux-сервера с Docker: **[docs/DEPLOY.md](docs/DEPLOY.md)** (шаблон настроек — `.env.example`). Коротко: `cp .env.example .env` → заполнить → `docker compose up -d --build` → обратный прокси с HTTPS на `APP_PORT`. diff --git a/app/assistant.py b/app/assistant.py index 97e03d1..f8e76e3 100644 --- a/app/assistant.py +++ b/app/assistant.py @@ -16,10 +16,12 @@ import re import time from pathlib import Path +from urllib.parse import quote + import httpx from sqlalchemy.orm import Session -from . import config, settings_store as ss +from . import autoconf, config, settings_store as ss from .models import User log = logging.getLogger("assistant") @@ -59,7 +61,7 @@ def public_cfg(db: Session) -> dict: def ready(db: Session) -> bool: c = cfg(db) - local = c["provider"] == "openai" and any(h in c["base_url"] for h in ("localhost", "127.0.0.1")) + local = c["provider"] == "openai" and autoconf.is_local(c["base_url"]) # своя модель в сети (Ollama и т. п.) — без ключа return c["enabled"] and bool(c["api_key"] or local) and bool(c["model"]) @@ -78,11 +80,18 @@ TOOLS = { "equipment_history": ("История машины. args: {\"id\": id машины}", "GET", "/equipment/{id}/history?limit=30", "id"), "find_people": ("Сотрудники: ФИО, должность, на смене ли", "GET", "/users", None), "warehouses": ("Склады и сколько на них позиций", "GET", "/warehouses", None), + # опция «Работа с документацией» (включается в настройках сервера) + "search_docs": ("Поиск по документации машин: файлы и фрагменты текста. args: {\"q\": \"2-5 ключевых слов\"}", "GET", "/docs/search?q={q}", "q"), + "equipment_docs": ("Папки и файлы документации машины. args: {\"id\": id машины, \"sub\": \"вложенная папка или пусто\"}", + "GET", "/equipment/{id}/docs?sub={sub}", "id"), } +DOC_TOOLS = {"search_docs", "equipment_docs"} +TEXT_ARGS = ("q", "sub") KEEP = {"id", "title", "name", "full_name", "status", "state", "day", "due_at", "is_overdue", "equipment_name", "equipment_id", "unit_name", "assignee_name", "executor_name", "item_name", "qty", "unit", "what", "answer", "occurred_at", "inventory_no", "location_id", "remaining_hours", "remaining_days", "percent", "on_shift", "position", "type_ru", "at", "text", "open_incidents", "due", "soon", - "positions", "can_execute", "can_review", "last_comment", "description", "total", "is_crane", "labor_hours", "kind", "open_faults"} + "positions", "can_execute", "can_review", "last_comment", "description", "total", "is_crane", "labor_hours", "kind", "open_faults", + "path", "snippet", "items", "folder", "sub", "is_dir", "note"} def _slim(x, depth=0): @@ -101,11 +110,18 @@ def _slim(x, depth=0): return x -async def run_tool(app, token: str, name: str, args: dict) -> str: - if name not in TOOLS: +async def run_tool(app, token: str, name: str, args: dict, docs: bool = False) -> str: + if name not in TOOLS or (name in DOC_TOOLS and not docs): return json.dumps({"error": f"нет инструмента {name}"}, ensure_ascii=False) _, method, path, arg = TOOLS[name] - if arg: + for t in TEXT_ARGS: # текстовые параметры — в строку запроса, экранированными + if "{" + t + "}" in path: + v = args.get(t) + v = str(v).strip()[:200] if isinstance(v, (str, int, float)) else "" + if t == arg and len(v) < 2: + return json.dumps({"error": f"нужен параметр {t}"}, ensure_ascii=False) + path = path.replace("{" + t + "}", quote(v, safe="")) + if arg and arg not in TEXT_ARGS: v = args.get(arg) if not isinstance(v, int) and not (isinstance(v, str) and v.isdigit()): return json.dumps({"error": f"нужен числовой параметр {arg}"}, ensure_ascii=False) @@ -129,9 +145,21 @@ def who(u: User) -> str: f"роли: {', '.join(roles) or 'нет особых ролей'}.") -def system_prompt(u: User, scope: str) -> str: - tools = "\n".join(f"- {k}: {v[0]}" for k, v in TOOLS.items()) +DOCS_RULES = """ +ДОКУМЕНТАЦИЯ (это тоже тема портала, не OFFTOPIC). К машинам привязаны папки с документацией (паспорта, руководства, +схемы). Если спрашивают, где найти +сведения по машине (схема, параметры, порядок обслуживания, коды ошибок), найди файл через search_docs или equipment_docs +и подскажи, какой файл открыть и что в нём искать. Давай ссылку вида [имя файла](#/docs/ID?p=путь) — ID машины и путь +файла из результата, пробелы в пути замени на %20. Фрагменты текста — только подсказка, где искать: не выдавай их за полный ответ и не +придумывай содержимое, которого нет во фрагменте. Если ничего не нашлось — так и скажи и предложи открыть папку +документации машины [Документация](#/docs/ID). +""" + + +def system_prompt(u: User, scope: str, docs: bool = False) -> str: + tools = "\n".join(f"- {k}: {v[0]}" for k, v in TOOLS.items() if docs or k not in DOC_TOOLS) extra = f"\nДополнительно от администратора: {scope.strip()}\n" if scope and scope.strip() else "" + extra += DOCS_RULES if docs else "" return f"""Ты — помощник внутри «Портала технической службы» (ремонтная служба предприятия). Отвечай по-русски, кратко, по шагам. ПРАВИЛА: @@ -226,7 +254,8 @@ async def chat(app, db: Session, u: User, token: str, history: list[dict], cfg_o while msgs and msgs[0]["role"] != "user": # некоторые провайдеры требуют начинать с пользователя msgs.pop(0) rate_check(u.id) - system = system_prompt(u, c["scope"]) + docs = ss.get(db, "docs_ai") == "1" + system = system_prompt(u, c["scope"], docs) used = [] for _ in range(MAX_STEPS + 1): text = (await complete(c, system, msgs)).strip() @@ -240,7 +269,7 @@ async def chat(app, db: Session, u: User, token: str, history: list[dict], cfg_o args = json.loads(raw) except ValueError: args = {} - result = await run_tool(app, token, name, args if isinstance(args, dict) else {}) + result = await run_tool(app, token, name, args if isinstance(args, dict) else {}, docs) used.append(name) msgs += [{"role": "assistant", "content": text}, {"role": "user", "content": f"РЕЗУЛЬТАТ {name}: {result}\nОтветь пользователю."}] return {"reply": "Не получилось собрать ответ. Попробуйте переформулировать вопрос.", "offtopic": False, "tools": used} diff --git a/app/assistant_kb.txt b/app/assistant_kb.txt index de55e03..641059b 100644 --- a/app/assistant_kb.txt +++ b/app/assistant_kb.txt @@ -26,7 +26,8 @@ - Сотрудники — #/people — все: поиск, фильтр по отделу; карточка #/user/: ФИО, фото, должность, отдел, график, «что умею», задания, инструмент (инструмент коллеги видят только склад и инженеры). Своё фото, телефон и навыки — в своей карточке (нажать своё имя вверху меню). - Отчёты — #/reports — инженер+: «Выполненные наряды», «Текущее состояние» узлов, «Выполнение работ» (% по машинам, отклонения), «По сотрудникам» #/reports/people (сколько и как качественно выполнено за период: в срок, просрочки, возвраты с проверки, отклонения, трудоёмкость; списком и календарём по дням); PDF и CSV. - Администрирование — #/admin — модератор, админ: пользователи #/users, должности #/positions, графики #/schedules (конструктор), отделы #/departments, места #/locations. -- Только администратор портала: настройки сервера #/server (фото, публичная форма, «Краны — отдельная служба», ключи API, AI-помощник) и обратная связь #/feedback. Только администратор: резервные копии #/backups, мастер первичной настройки #/setup, демонстрационный режим #/demo. +- Документация машины (если подключён Nextcloud): блок «📁 Документация» в карточке машины, кнопка «Открыть» — #/docs/. PDF, картинки и текст открываются в портале, Word/Excel/PowerPoint — после перевода в PDF (ONLYOFFICE), любой файл можно скачать. Привязывают папку администратор или модератор: «Привязать папку» в том же блоке, выбор в проводнике. Видно документацию только тех машин, которые видит сотрудник. +- Только администратор портала: настройки сервера #/server (фото, публичная форма, «Краны — отдельная служба», ключи API, документация из Nextcloud, AI-помощник и его опция «Работа с документацией») и обратная связь #/feedback. Только администратор: резервные копии #/backups, мастер первичной настройки #/setup, демонстрационный режим #/demo. - Демонстрационный режим — личный: включается только у того, кто нажал «Войти в демо», остальные работают с рабочими данными. Пока он включён, вверху жёлтая полоса «ДЕМО-РЕЖИМ (только у вас)» с кнопкой «Выйти из демо». Администратор на странице #/demo может включить или выключить демо любому сотруднику. - Обратная связь по порталу — ссылка «Предложить улучшение портала или сообщить об ошибке» внизу каждой страницы (в мобильной версии и приложении — внизу бокового меню): идея, ошибка или другое. Ответы видят только администраторы портала — #/feedback. - Администратор портала — право администратора на настройки сервера и обратную связь. У admin оно есть всегда; другим администраторам его включает admin в карточке пользователя («Администратор портала»). @@ -41,7 +42,12 @@ - Администратор: всё, включая настройки сервера, копии, демо-режим. ## Частые вопросы -- Не вижу наряд: наряд на должность виден только сотрудникам этой должности на смене; проверьте график. +- Не вижу наряд: наряд на должность виден только сотрудникам этой должности, которые сейчас на смене по своему графику; проверьте график в своей карточке. +- График работы задаётся сотруднику (#/users → «Изменить» → «График работы»), не должности. Одну должность могут занимать сменщики: наряды и уведомления должности получает только тот, кто на смене. Без графика — всегда на смене. +- Фото к пункту чек-листа или к заданию можно отметить «хранить более года» (в конструкторе чек-листа рядом с режимом фото, в форме задания) — такие фото не удаляются автоочисткой. Автоочистку (удалять фото старше N дней) включает администратор портала в #/server. +- Мониторинг сервера — #/monitor (администратор портала): загрузка процессора, память, место на дисках. +- Фильтр «Место» над списком оборудования #/equipment и кранов: показывает машины места и вложенных мест. +- Мастер первичной настройки: «Завершить и проверить» открывает проверку готовности #/setup/done — что мешает работе и где поправить. - Не могу сдать наряд: не заполнены обязательные пункты или нет обязательного фото. - Нет связи: в мобильной версии и приложении ответы сохраняются на телефоне и уйдут сами. - Не вижу машину или кран: оборудование закреплено за другим отделом, или краны вынесены в раздел «Краны». diff --git a/app/autoconf.py b/app/autoconf.py new file mode 100644 index 0000000..dda1801 --- /dev/null +++ b/app/autoconf.py @@ -0,0 +1,71 @@ +"""Автонастройка из окружения и внутренней сети Docker. + +Пока администратор сам не заполнил поле в настройках, значение берётся: +1) из переменной окружения (docker-compose.yml передаёт их из .env), иначе +2) из внутренней сети Docker: если рядом запущен сервис с известным именем (nextcloud, onlyoffice, ollama — профили + docker-compose.yml), подставляется его внутренний адрес. +Значение, сохранённое в интерфейсе, всегда важнее. Имена сервисов проверяются через DNS Docker с кэшем на минуту.""" +import ipaddress +import os +import socket +import time +from urllib.parse import urlsplit + +CACHE_SEC = 60 +_seen: dict[str, tuple[float, bool]] = {} + +# настройка -> переменная окружения +ENV = { + "nc_url": "NC_URL", "nc_user": "NC_USER", "nc_password": "NC_PASSWORD", "nc_root": "NC_ROOT", + "onlyoffice_url": "ONLYOFFICE_URL", "onlyoffice_jwt": "ONLYOFFICE_JWT", "portal_internal_url": "PORTAL_INTERNAL_URL", + "assistant_provider": "ASSISTANT_PROVIDER", "assistant_base_url": "ASSISTANT_BASE_URL", + "assistant_model": "ASSISTANT_MODEL", "assistant_api_key": "ASSISTANT_API_KEY", +} +# настройка -> (имя сервиса в сети Docker, адрес, который подставить) +SERVICES = { + "nc_url": ("nextcloud", "http://nextcloud"), + "onlyoffice_url": ("onlyoffice", "http://onlyoffice"), + "assistant_base_url": ("ollama", "http://ollama:11434/v1"), +} + + +def resolves(host: str) -> bool: + now = time.monotonic() + hit = _seen.get(host) + if hit and now - hit[0] < CACHE_SEC: + return hit[1] + try: + socket.getaddrinfo(host, None) + ok = True + except OSError: + ok = False + _seen[host] = (now, ok) + return ok + + +def default(key: str) -> str | None: + """Значение по умолчанию из окружения или сети Docker; None — автонастройки для ключа нет.""" + env = ENV.get(key) + if env and os.getenv(env): + return os.getenv(env, "").strip() + svc = SERVICES.get(key) + if svc and resolves(svc[0]): + return svc[1] + if key == "assistant_model" and resolves("ollama"): + return os.getenv("OLLAMA_MODEL", "qwen2.5:7b") + if key == "portal_internal_url" and resolves("onlyoffice"): + return "http://app:8000" # по этому адресу ONLYOFFICE забирает документы у портала + return None + + +def is_local(url: str) -> bool: + """Адрес модели внутри своей сети (ключ API не нужен): localhost, имя сервиса Docker без точки, частный IP.""" + host = (urlsplit(url or "").hostname or "").lower() + if not host: + return False + if host in ("localhost", "127.0.0.1", "::1") or "." not in host: + return True + try: + return ipaddress.ip_address(host).is_private + except ValueError: + return False diff --git a/app/backup.py b/app/backup.py index 25cdb39..f203597 100644 --- a/app/backup.py +++ b/app/backup.py @@ -22,11 +22,11 @@ from .db import Base log = logging.getLogger("backup") FORMAT = 1 NAME_RE = re.compile(r"backup_\d{8}_\d{6}(_[a-z0-9-]{1,20})?\.zip") -SKIP_TABLES = {"refresh_tokens"} # сессии не переносим: после восстановления все входят заново +SKIP_TABLES = {"refresh_tokens", "doc_index"} # индекс документации строится заново # сессии не переносим: после восстановления все входят заново ENV_PUBLIC = ("APP_ENV", "APP_TZ", "APP_PORT", "FORWARDED_ALLOW_IPS", "DATA_DIR", "PHOTO_DIR", "ACCESS_TTL_MIN", "REFRESH_TTL_DAYS", "LOGIN_MAX_FAILS", "LOGIN_WINDOW_SEC", "SCHEDULER_INTERVAL", "ADMIN_USERNAME") ENV_SECRET = ("SECRET_KEY", "DATABASE_URL", "DB_PASSWORD", "ADMIN_PASSWORD") -SECRET_SETTINGS = {"assistant_api_key"} +SECRET_SETTINGS = {"assistant_api_key", "nc_password", "onlyoffice_jwt"} DEFAULTS = {"backup_auto_hours": "24", "backup_keep": "14", "backup_media": "0"} diff --git a/app/demo.py b/app/demo.py index d75150f..4201228 100644 --- a/app/demo.py +++ b/app/demo.py @@ -335,6 +335,7 @@ def seed(db: Session, admin: dict, photo_dir: str) -> None: # --- портал: склады, инструмент, заявки, инциденты, суточные задания, карта --- demo_portal.seed(db, users=users, eq=eq, pos=pos, sch_store=sch_d, pw=pw, now=now, rnd=rnd) db.commit() + logic.schedules_to_users(db) # графики демо-должностей — сотрудникам def demo_accounts(db: Session) -> list[dict]: @@ -443,3 +444,5 @@ def activate_on_startup() -> None: e = _engine_for(mode.State.db_file) ensure_schema(e) _db.set_demo_engine(e) + with sessionmaker(bind=e)() as db: # демо-база прежней версии: графики должностей — сотрудникам + logic.schedules_to_users(db) diff --git a/app/docs_index.py b/app/docs_index.py new file mode 100644 index 0000000..5cc8caf --- /dev/null +++ b/app/docs_index.py @@ -0,0 +1,160 @@ +"""Индекс документации для опции AI-помощника «Работа с документацией». + +Раз в сутки (и по кнопке) обходим папки, привязанные к машинам, и для новых или изменившихся файлов (по etag) +сохраняем имя и начало текста: PDF — через pypdf, docx/xlsx/pptx/odt — из XML внутри архива, текстовые файлы как есть. +Поиск — по словам в имени и тексте, только по машинам, которые видит пользователь. Помощник получает короткие +фрагменты и путь к файлу, а не документы целиком.""" +import io +import logging +import re +import threading +import zipfile +from datetime import timedelta + +from sqlalchemy import delete, or_, select +from sqlalchemy.orm import Session + +from . import config, nextcloud as nc, scope, settings_store as ss +from .models import DocIndex, Equipment, User + +log = logging.getLogger("docs_index") +MAX_TEXT = 20_000 # символов текста на файл +MAX_INDEX_MB = 60 # файлы крупнее — только по имени +MAX_PDF_PAGES = 40 +EVERY = timedelta(hours=24) +_lock = threading.Lock() +state = {"running": False, "done": 0, "total": 0, "error": ""} + + +def enabled(db: Session) -> bool: + return ss.get(db, "docs_ai") == "1" + + +def norm(s: str) -> str: + return re.sub(r"\s+", " ", s.lower().replace("ё", "е")).strip() + + +def _xml_text(z: zipfile.ZipFile, names: list[str]) -> str: + parts = [] + for n in names: + raw = z.read(n).decode("utf-8", "ignore") + parts.append(re.sub(r"<[^>]+>", " ", re.sub(r"", "\n", raw))) + return " ".join(parts) + + +def extract(name: str, data: bytes) -> str: + """Текст документа (может быть пустым — например, скан без текстового слоя).""" + ext = nc.ext_of(name) + if ext in nc.TEXT: + return data[: MAX_TEXT * 4].decode("utf-8", "ignore") + if ext == "pdf": + try: + from pypdf import PdfReader + except ImportError: + return "" + reader = PdfReader(io.BytesIO(data)) + out = [] + for page in reader.pages[:MAX_PDF_PAGES]: + out.append(page.extract_text() or "") + if sum(map(len, out)) > MAX_TEXT: + break + return "\n".join(out) + if ext in ("docx", "docm", "dotx", "xlsx", "xlsm", "pptx", "odt", "ods", "odp"): + with zipfile.ZipFile(io.BytesIO(data)) as z: + files = z.namelist() + pick = ([n for n in files if n == "word/document.xml"] or [n for n in files if n == "xl/sharedStrings.xml"] + or sorted(n for n in files if re.fullmatch(r"ppt/slides/slide\d+\.xml", n)) or [n for n in files if n == "content.xml"]) + return _xml_text(z, pick) + return "" + + +def index_equipment(db: Session, c: nc.Conf, eq: Equipment) -> int: + """Обновить индекс одной машины; вернуть число прочитанных заново файлов.""" + files = nc.walk(c, eq.docs_path) + have = {r.path: r for r in db.scalars(select(DocIndex).where(DocIndex.equipment_id == eq.id))} + seen, fresh = set(), 0 + base = len(nc.clean(eq.docs_path)) + for f in files: + rel = f["path"][base:].strip("/") + seen.add(rel) + row = have.get(rel) + if row and row.etag == f["etag"]: + continue + text, note = "", "" + if f["size"] > MAX_INDEX_MB * 1024 * 1024: + note = "большой файл — только имя" + elif nc.ext_of(f["name"]) in nc.TEXT | {"pdf"} | nc.OFFICE: + try: + text = extract(f["name"], nc.fetch(c, f).read_bytes()) + note = "" if text.strip() else "текст не найден (скан?)" + except Exception as e: # повреждённый или защищённый файл не должен останавливать индекс + note = f"не прочитан: {type(e).__name__}"[:200] + row = row or DocIndex(equipment_id=eq.id, path=rel) + row.name, row.etag, row.size = f["name"][:300], f["etag"][:200], f["size"] + row.text, row.note, row.indexed_at = norm(rel + " " + text)[:MAX_TEXT], note, config.now() # путь тоже ищется + db.add(row) + fresh += 1 + state["done"] += 1 + gone = [p for p in have if p not in seen] + if gone: + db.execute(delete(DocIndex).where(DocIndex.equipment_id == eq.id, DocIndex.path.in_(gone))) + db.commit() + return fresh + + +def rebuild(db: Session) -> dict: + """Обойти все привязанные папки. Ошибка одной машины не мешает остальным.""" + if not _lock.acquire(blocking=False): + return {"running": True} + try: + state.update(running=True, done=0, total=0, error="") + c = nc.conf(db) + eqs = db.scalars(select(Equipment).where(Equipment.docs_path != "")).all() + db.execute(delete(DocIndex).where(DocIndex.equipment_id.not_in([e.id for e in eqs] or [0]))) + errors = [] + for eq in eqs: + try: + index_equipment(db, c, eq) + except nc.DocsError as e: + db.rollback() + errors.append(f"{eq.name}: {e}") + ss.set_(db, "docs_index_at", config.now().isoformat()) + db.commit() + state["error"] = "; ".join(errors)[:500] + return {"files": db.query(DocIndex).count(), "errors": errors} + finally: + state["running"] = False + _lock.release() + + +def due(db: Session) -> bool: + if not enabled(db) or not nc.conf(db).ready: + return False + at = ss.get(db, "docs_index_at") + try: + from datetime import datetime + return not at or config.now() - datetime.fromisoformat(at) >= EVERY + except ValueError: + return True + + +def search(db: Session, u: User, q: str, limit: int = 10) -> list[dict]: + words = [w for w in re.findall(r"\w+", norm(q)) if len(w) >= 3][:8] + if not words: + return [] + cond = or_(*[DocIndex.text.contains(w) for w in words]) + stmt = select(DocIndex, Equipment).join(Equipment, Equipment.id == DocIndex.equipment_id).where(cond) + vis = scope.equipment_filter(db, u) + if vis is not None: + stmt = stmt.where(vis) + scored = [] + for row, eq in db.execute(stmt.limit(500)): + name = norm(row.path) + score = sum(5 * (w in name) + min(row.text.count(w), 20) for w in words) + body = row.text[len(name) + 1:] + hit = min((i for i in (body.find(w) for w in words) if i >= 0), default=-1) + snippet = body[max(0, hit - 120): hit + 220] if hit >= 0 else "" + scored.append((score, {"equipment_id": eq.id, "equipment_name": eq.name, "path": row.path, "name": row.name, + "snippet": snippet, "note": row.note})) + scored.sort(key=lambda x: -x[0]) + return [x for _, x in scored[:limit]] diff --git a/app/logic.py b/app/logic.py index acebf06..a2b0c0b 100644 --- a/app/logic.py +++ b/app/logic.py @@ -67,8 +67,26 @@ def shift_active(s: ShiftSchedule | None, dt: datetime) -> bool: def user_on_shift(u: User, dt: datetime | None = None) -> bool: + """На смене ли сотрудник: занимает должность и сейчас по СВОЕМУ графику работает (без графика — всегда).""" dt = dt or config.now() - return bool(u.position) and shift_active(u.position.schedule, dt) + return bool(u.position) and shift_active(u.schedule, dt) + + +def schedules_to_users(db) -> int: + """Однократный перенос: раньше график задавался должности — теперь он у каждого сотрудника. + Сотрудникам без своего графика ставится график их должности; повторно не выполняется (отметка в настройках).""" + from . import settings_store as ss + from .models import Position + if ss.get(db, "schedule_per_user") == "1": + return 0 + n = 0 + for u, sid in db.execute(select(User, Position.schedule_id).join(Position, Position.id == User.position_id) + .where(User.schedule_id.is_(None), Position.schedule_id.is_not(None))): + u.schedule_id = sid + n += 1 + ss.set_(db, "schedule_per_user", "1") + db.commit() + return n # ---------- расписание запуска нарядов ---------- @@ -120,7 +138,7 @@ def create_order(db: Session, *, title, checklist_id, equipment_id=None, locatio for it in cl.items: o.items.append(OrderItem(position=it.position, text=it.text, answer_type=it.answer_type, options=it.options, unit=it.unit, min_value=it.min_value, - max_value=it.max_value, required=it.required, photo=it.photo)) + max_value=it.max_value, required=it.required, photo=it.photo, photo_keep=it.photo_keep)) db.add(o) return o @@ -141,7 +159,7 @@ def _regular_items(db: Session, o: Order, cl: Checklist) -> None: pos += 1 o.items.append(OrderItem(position=pos, text=it.text, answer_type=it.answer_type, options=it.options, unit=it.unit, min_value=it.min_value, max_value=it.max_value, - required=it.required, photo=it.photo)) + required=it.required, photo=it.photo, photo_keep=it.photo_keep)) for u, ev in units: pos += 1 o.items.append(OrderItem(position=pos, text=service.describe(u, ev) + " — обслужено?", answer_type="status", diff --git a/app/main.py b/app/main.py index 83bc869..dcee711 100644 --- a/app/main.py +++ b/app/main.py @@ -9,13 +9,13 @@ from fastapi.responses import FileResponse from fastapi.staticfiles import StaticFiles from sqlalchemy import select -from . import backup, config, demo, logic, mode, push +from . import backup, config, demo, docs_index, logic, mode, photo_cleanup, push from .db import SessionLocal, real_engine from .migrate import ensure_schema from .models import User from .routers import catalog, orders, extras, reports, service as service_router, demo as demo_router from .routers import inventory, incidents, tasks, portal, external, history, backups, assistant as assistant_router -from .routers import reliability as reliability_router, feedback as feedback_router +from .routers import reliability as reliability_router, feedback as feedback_router, docs as docs_router from .security import hash_password log = logging.getLogger("scheduler") @@ -49,6 +49,14 @@ def bootstrap(): db.commit() log.warning("Создан администратор '%s' — смените пароль!", config.ADMIN_USERNAME) push.ensure_vapid(db) + moved = logic.schedules_to_users(db) + if moved: + log.warning("Графики работы перенесены с должностей на сотрудников: %s", moved) + + +def _reindex_docs(): + with SessionLocal() as db: + docs_index.rebuild(db) async def scheduler_loop(): @@ -67,6 +75,18 @@ async def scheduler_loop(): backup.auto_backup(db) except Exception: log.exception("Ошибка автоматического резервного копирования") + try: + with SessionLocal() as db: + if photo_cleanup.due(db): # старые фото — раз в сутки + photo_cleanup.run(db) + except Exception: + log.exception("Ошибка очистки старых фото") + try: + with SessionLocal() as db: + if docs_index.due(db): # индекс документации для AI — раз в сутки, в фоне + asyncio.get_running_loop().run_in_executor(None, _reindex_docs) + except Exception: + log.exception("Ошибка индекса документации") await asyncio.sleep(config.SCHEDULER_INTERVAL) @@ -123,6 +143,7 @@ api.include_router(backups.router) api.include_router(assistant_router.router) api.include_router(reliability_router.router) api.include_router(feedback_router.router) +api.include_router(docs_router.router) @api.get("/health", tags=["service"]) diff --git a/app/media.py b/app/media.py index c7c69fb..7ef9ee9 100644 --- a/app/media.py +++ b/app/media.py @@ -13,7 +13,7 @@ from . import settings_store as ss MAGIC = [(b"\xff\xd8\xff", "image/jpeg", ".jpg"), (b"\x89PNG\r\n\x1a\n", "image/png", ".png")] CT = {".jpg": "image/jpeg", ".png": "image/png", ".webp": "image/webp"} -NAME_RE = re.compile(r"[0-9a-f]{32}\.(jpg|png|webp)") +NAME_RE = re.compile(r"(keep/)?[0-9a-f]{32}\.(jpg|png|webp)") # keep/ — хранить долго (очистка не трогает) KINDS = ("equipment", "users", "map", "tasks") @@ -47,9 +47,11 @@ async def read_image(db: Session, request: Request, max_mb: int | None = None) - return data, kind[0], kind[1] -def save(db: Session, kind: str, data: bytes, ext: str) -> str: - name = ss.new_name() + ext - (root(db, kind) / name).write_bytes(data) +def save(db: Session, kind: str, data: bytes, ext: str, keep: bool = False) -> str: + name = ("keep/" if keep else "") + ss.new_name() + ext + f = root(db, kind) / name + f.parent.mkdir(parents=True, exist_ok=True) + f.write_bytes(data) return name diff --git a/app/models.py b/app/models.py index 8d6a0ec..c982f88 100644 --- a/app/models.py +++ b/app/models.py @@ -27,8 +27,8 @@ class Position(Base): id: Mapped[int] = mapped_column(primary_key=True) name: Mapped[str] = mapped_column(String(100), unique=True) kind: Mapped[str] = mapped_column(String(12), default="technician") + # устарело: график теперь у сотрудника (User.schedule_id); значение перенесено сотрудникам при обновлении и больше не используется schedule_id: Mapped[int | None] = mapped_column(ForeignKey("schedules.id"), nullable=True) - schedule: Mapped[ShiftSchedule | None] = relationship(lazy="joined") class Department(Base): @@ -55,6 +55,9 @@ class User(Base): is_active: Mapped[bool] = mapped_column(Boolean, default=True) position_id: Mapped[int | None] = mapped_column(ForeignKey("positions.id"), nullable=True) position: Mapped[Position | None] = relationship(lazy="joined") + # график работы сотрудника: должность выполняет тот, кто её занимает и сейчас на смене. Пусто — всегда на смене + schedule_id: Mapped[int | None] = mapped_column(ForeignKey("schedules.id"), nullable=True) + schedule: Mapped[ShiftSchedule | None] = relationship(lazy="joined") department_id: Mapped[int | None] = mapped_column(ForeignKey("departments.id"), nullable=True) department: Mapped[Department | None] = relationship(lazy="joined", foreign_keys=[department_id]) # карточка сотрудника @@ -89,6 +92,8 @@ class Equipment(Base): department_id: Mapped[int | None] = mapped_column(ForeignKey("departments.id"), nullable=True) # кран (грузоподъёмное оборудование): при включённой карте кранов показывается на ней, а не на карте завода is_crane: Mapped[bool] = mapped_column(Boolean, default=False) + # папка документации в Nextcloud (путь от корня документации в настройках сервера); пусто — не привязана + docs_path: Mapped[str] = mapped_column(String(500), default="") class ServiceUnit(Base): @@ -160,6 +165,7 @@ class ChecklistItem(Base): max_value: Mapped[float | None] = mapped_column(Float, nullable=True) required: Mapped[bool] = mapped_column(Boolean, default=True) photo: Mapped[str] = mapped_column(String(10), default="none") # none|optional|required + photo_keep: Mapped[bool] = mapped_column(Boolean, default=False) # фото хранить более года (каталог keep/, очистка не трогает) class OrderTemplate(Base): @@ -232,6 +238,7 @@ class OrderItem(Base): answered_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) out_of_range: Mapped[bool] = mapped_column(Boolean, default=False) photo: Mapped[str] = mapped_column(String(10), default="none") + photo_keep: Mapped[bool] = mapped_column(Boolean, default=False) service_unit_id: Mapped[int | None] = mapped_column(ForeignKey("service_units.id"), nullable=True) # «узел обслужен» is_meter: Mapped[bool] = mapped_column(Boolean, default=False) # пункт «наработка, м/ч» — обновляет счётчик машины photos: Mapped[list["Photo"]] = relationship( @@ -276,6 +283,20 @@ class Feedback(Base): answered_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) +class DocIndex(Base): + """Индекс документации для AI-помощника: имя файла и фрагмент текста (в нижнем регистре). Строится заново из Nextcloud.""" + __tablename__ = "doc_index" + id: Mapped[int] = mapped_column(primary_key=True) + equipment_id: Mapped[int] = mapped_column(ForeignKey("equipment.id", ondelete="CASCADE"), index=True) + path: Mapped[str] = mapped_column(String(1000)) # путь файла от папки машины + name: Mapped[str] = mapped_column(String(300)) + etag: Mapped[str] = mapped_column(String(200), default="") + size: Mapped[int] = mapped_column(Integer, default=0) + text: Mapped[str] = mapped_column(Text, default="") # извлечённый текст, нижний регистр, обрезан + note: Mapped[str] = mapped_column(String(200), default="") # почему текста нет (большой файл, ошибка чтения) + indexed_at: Mapped[datetime] = mapped_column(DateTime, default=config.now) + + class Setting(Base): __tablename__ = "settings" key: Mapped[str] = mapped_column(String(64), primary_key=True) @@ -464,6 +485,7 @@ class DailyTask(Base): updated_at: Mapped[datetime] = mapped_column(DateTime, default=config.now) finished_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) labor_hours: Mapped[float | None] = mapped_column(Float, nullable=True) # трудоёмкость (норма), ч — для отчёта по сотрудникам + photo_keep: Mapped[bool] = mapped_column(Boolean, default=False) # фото к заданию хранить более года class TaskEvent(Base): diff --git a/app/nextcloud.py b/app/nextcloud.py new file mode 100644 index 0000000..f3d2fa8 --- /dev/null +++ b/app/nextcloud.py @@ -0,0 +1,316 @@ +"""Документация оборудования из Nextcloud. + +Портал ходит в Nextcloud сам, по WebDAV, от служебной учётной записи (пароль приложения, только чтение). Сотрудники +Nextcloud не видят: список папок и файлы отдаёт портал, проверяя права на машину. Документы Office для просмотра +переводятся в PDF через ONLYOFFICE Document Server (ConvertService): сервер документов скачивает исходник по короткой +подписанной ссылке портала (/api/v1/docs/raw/...), результат кэшируется в DATA_DIR/docs_cache.""" +import base64 +import hashlib +import hmac +import logging +import os +import time +import xml.etree.ElementTree as ET +from dataclasses import dataclass +from email.utils import parsedate_to_datetime +from pathlib import Path +from urllib.parse import quote, unquote, urlsplit + +import httpx +import jwt +from sqlalchemy.orm import Session + +from . import config, settings_store as ss + +log = logging.getLogger("nextcloud") +TIMEOUT = httpx.Timeout(30, read=120) +CACHE_DIR = Path(config.DATA_DIR) / "docs_cache" +CACHE_MAX_MB = int(os.getenv("DOCS_CACHE_MB", "2048")) +MAX_FILE_MB = int(os.getenv("DOCS_MAX_FILE_MB", "300")) # больше — не открываем через портал +RAW_TTL = 600 # ссылка для ONLYOFFICE живёт 10 минут +DAV = "{DAV:}" +IMAGES = {"png": "image/png", "jpg": "image/jpeg", "jpeg": "image/jpeg", "gif": "image/gif", "webp": "image/webp", "bmp": "image/bmp"} +TEXT = {"txt", "csv", "log", "md", "ini", "cfg"} +OFFICE = {"doc", "docx", "docm", "dotx", "rtf", "odt", "xls", "xlsx", "xlsm", "ods", "ppt", "pptx", "odp"} + + +class DocsError(Exception): + pass + + +@dataclass(frozen=True) +class Conf: + url: str + user: str + password: str + root: str + oo_url: str + oo_jwt: str + internal_url: str + + @property + def ready(self) -> bool: + return bool(self.url and self.user and self.password) + + +def conf(db: Session) -> Conf: + g = lambda k: (ss.get(db, k) or "").strip() + return Conf(g("nc_url").rstrip("/"), g("nc_user"), ss.get(db, "nc_password") or "", clean(g("nc_root")), + g("onlyoffice_url").rstrip("/"), ss.get(db, "onlyoffice_jwt") or "", g("portal_internal_url").rstrip("/")) + + +def clean(path: str | None, allow_empty: bool = True) -> str: + """Относительный путь без «..», обратных слешей и управляющих символов. Пустой — корень.""" + parts = [x for x in (path or "").replace("\\", "/").strip().split("/") if x] + for x in parts: + if x in (".", "..") or any(ord(ch) < 32 for ch in x) or len(x) > 255: + raise DocsError("Недопустимый путь") + if not parts and not allow_empty: + raise DocsError("Не указан путь") + return "/".join(parts) + + +def join(*parts: str) -> str: + return "/".join(x for x in (clean(p) for p in parts) if x) + + +def ext_of(name: str) -> str: + return name.rsplit(".", 1)[-1].lower() if "." in name else "" + + +def kind(name: str) -> str: + ext = ext_of(name) + if ext == "pdf": + return "pdf" + if ext in IMAGES: + return "image" + if ext in TEXT: + return "text" + if ext in OFFICE: + return "office" + return "file" + + +# ---------- WebDAV ---------- +def _base(c: Conf) -> str: + return f"{c.url}/remote.php/dav/files/{quote(c.user, safe='')}" + + +def _href(c: Conf, rel: str) -> str: + full = join(c.root, rel) + return _base(c) + ("/" + quote(full) if full else "") + + +def _oo_client(timeout) -> httpx.Client: + return httpx.Client(timeout=timeout) + + +def _ready(c: Conf): + if not c.ready: + raise DocsError("Nextcloud не настроен: укажите адрес, учётную запись и пароль приложения в настройках сервера") + + +def _client(c: Conf) -> httpx.Client: + return httpx.Client(auth=(c.user, c.password), timeout=TIMEOUT, follow_redirects=False) + + +PROPS = """ +""" + + +def _propfind(c: Conf, rel: str, depth: int) -> list[dict]: + _ready(c) + try: + with _client(c) as h: + r = h.request("PROPFIND", _href(c, rel), headers={"Depth": str(depth), "Content-Type": "application/xml"}, content=PROPS) + except httpx.HTTPError as e: + raise DocsError(f"Nextcloud недоступен: {type(e).__name__}") + if r.status_code == 401: + raise DocsError("Nextcloud отклонил учётную запись: проверьте логин и пароль приложения") + if r.status_code == 404: + raise DocsError("Папка или файл не найдены в Nextcloud") + if r.status_code != 207: + raise DocsError(f"Nextcloud ответил {r.status_code}") + return parse_multistatus(r.content, urlsplit(_href(c, "")).path) + + +def parse_multistatus(body: bytes, prefix: str) -> list[dict]: + """Ответ PROPFIND → [{path (от корня документации), name, is_dir, size, mtime, etag, mime, kind}].""" + prefix = unquote(prefix).rstrip("/") + out = [] + try: + root = ET.fromstring(body) + except ET.ParseError: + raise DocsError("Nextcloud ответил непонятно — проверьте адрес") + for resp in root.iter(DAV + "response"): + href = unquote(urlsplit(resp.findtext(DAV + "href") or "").path).rstrip("/") + if href != prefix and not href.startswith(prefix + "/"): + continue + rel = href[len(prefix):].strip("/") + prop = next((p.find(DAV + "prop") for p in resp.iter(DAV + "propstat") if "200" in (p.findtext(DAV + "status") or "")), None) + if prop is None: + continue + is_dir = prop.find(DAV + "resourcetype/" + DAV + "collection") is not None + lm = prop.findtext(DAV + "getlastmodified") + try: + mtime = parsedate_to_datetime(lm).astimezone(config.TZ).isoformat() if lm else None + except (TypeError, ValueError): + mtime = None + name = rel.rsplit("/", 1)[-1] + out.append({"path": rel, "name": name, "is_dir": is_dir, "size": int(prop.findtext(DAV + "getcontentlength") or 0), + "mtime": mtime, "etag": (prop.findtext(DAV + "getetag") or "").strip('"'), + "mime": prop.findtext(DAV + "getcontenttype") or "", "kind": "dir" if is_dir else kind(name)}) + return out + + +def listdir(c: Conf, rel: str) -> list[dict]: + """Содержимое папки (без самой папки и скрытых файлов): сначала папки, потом файлы, по имени.""" + rel = clean(rel) + items = [x for x in _propfind(c, rel, 1) if x["path"] != rel and not x["name"].startswith(".")] + return sorted(items, key=lambda x: (not x["is_dir"], x["name"].lower())) + + +def stat(c: Conf, rel: str) -> dict: + rel = clean(rel, allow_empty=False) + items = [x for x in _propfind(c, rel, 0) if x["path"] == rel] + if not items: + raise DocsError("Файл не найден в Nextcloud") + return items[0] + + +def walk(c: Conf, rel: str, max_depth: int = 6, limit: int = 3000) -> list[dict]: + """Все файлы в папке и вложенных (для индекса).""" + out, stack = [], [(clean(rel), 0)] + while stack and len(out) < limit: + p, d = stack.pop() + for x in listdir(c, p): + if x["is_dir"]: + if d < max_depth: + stack.append((x["path"], d + 1)) + else: + out.append(x) + return out[:limit] + + +# ---------- кэш файлов ---------- +def cache_path(rel: str, etag: str, suffix: str) -> Path: + return CACHE_DIR / (hashlib.sha1(f"{rel}|{etag}".encode()).hexdigest() + suffix) + + +def _prune(): + files = sorted((p for p in CACHE_DIR.glob("*") if p.is_file()), key=lambda p: p.stat().st_mtime) + total = sum(p.stat().st_size for p in files) + while files and total > CACHE_MAX_MB * 1024 * 1024: + p = files.pop(0) + total -= p.stat().st_size + p.unlink(missing_ok=True) + + +def original_path(info: dict) -> Path: + ext = ext_of(info["name"]) + return cache_path(info["path"], info["etag"], "." + ext if ext else "") + + +def fetch(c: Conf, info: dict) -> Path: + """Скачать файл в кэш (если ещё нет этой версии) и вернуть путь к нему.""" + if info["size"] > MAX_FILE_MB * 1024 * 1024: + raise DocsError(f"Файл больше {MAX_FILE_MB} МБ — его нельзя открыть через портал") + _ready(c) + dest = original_path(info) + if dest.exists(): + os.utime(dest) + return dest + CACHE_DIR.mkdir(parents=True, exist_ok=True) + tmp = dest.with_name(dest.name + f".{os.getpid()}.part") + try: + with _client(c) as h, h.stream("GET", _href(c, info["path"])) as r: + if r.status_code != 200: + raise DocsError(f"Nextcloud не отдал файл ({r.status_code})") + with open(tmp, "wb") as f: + for chunk in r.iter_bytes(1 << 16): + f.write(chunk) + tmp.replace(dest) + except httpx.HTTPError as e: + raise DocsError(f"Nextcloud недоступен: {type(e).__name__}") + finally: + tmp.unlink(missing_ok=True) + _prune() + return dest + + +# ---------- ONLYOFFICE: Office → PDF ---------- +def _sign(msg: str) -> str: + return hmac.new(config.SECRET_KEY.encode(), msg.encode(), hashlib.sha256).hexdigest()[:32] + + +def raw_token(info: dict, now: float | None = None) -> str: + """Короткая подписанная ссылка на исходник в кэше — по ней ONLYOFFICE скачивает документ без входа в портал.""" + exp = int((now or time.time()) + RAW_TTL) + p = base64.urlsafe_b64encode(f"{info['path']}\n{info['etag']}\n{info['name']}".encode()).decode().rstrip("=") + return f"{p}.{exp}.{_sign(f'raw|{p}|{exp}')}" + + +def check_raw_token(token: str) -> Path: + try: + p, exp, sig = token.split(".") + if not hmac.compare_digest(sig, _sign(f"raw|{p}|{exp}")) or int(exp) < time.time(): + raise ValueError + path, etag, name = base64.urlsafe_b64decode(p + "=" * (-len(p) % 4)).decode().split("\n") + except (ValueError, UnicodeDecodeError): + raise DocsError("Ссылка недействительна") + f = original_path({"path": path, "etag": etag, "name": name}) + if not f.exists(): + raise DocsError("Файл не найден") + return f + + +def to_pdf(c: Conf, info: dict) -> Path: + """Документ Office → PDF через ONLYOFFICE ConvertService (результат кэшируется по версии файла).""" + dest = cache_path(info["path"], info["etag"], ".view.pdf") + if dest.exists(): + os.utime(dest) + return dest + if not (c.oo_url and c.internal_url): + raise DocsError("Просмотр документов Office не настроен: укажите адрес ONLYOFFICE и внутренний адрес портала") + fetch(c, info) # исходник должен лежать в кэше: его отдаст /docs/raw + body = {"async": False, "filetype": ext_of(info["name"]), "outputtype": "pdf", "title": info["name"][:200], + "key": hashlib.sha1(f"{info['path']}|{info['etag']}".encode()).hexdigest(), + "url": f"{c.internal_url}/api/v1/docs/raw/{raw_token(info)}"} + headers = {"Accept": "application/json"} + if c.oo_jwt: # новые версии читают token в теле, старые — заголовок Authorization + headers["Authorization"] = "Bearer " + jwt.encode({"payload": body}, c.oo_jwt, algorithm="HS256") + body = {**body, "token": jwt.encode(body, c.oo_jwt, algorithm="HS256")} + try: + with _oo_client(httpx.Timeout(30, read=180)) as h: + r = h.post(f"{c.oo_url}/ConvertService.ashx", json=body, headers=headers) + data = r.json() if r.status_code == 200 else {} + if data.get("error") or not data.get("endConvert") or not data.get("fileUrl"): + code = data.get("error", r.status_code) + hint = ": проверьте секрет JWT" if code == -8 else ": ONLYOFFICE не смог скачать файл с портала — проверьте внутренний адрес портала" if code == -4 else "" + raise DocsError(f"ONLYOFFICE не смог преобразовать документ (код {code}){hint}") + out = h.get(data["fileUrl"]) + if out.status_code != 200: + raise DocsError(f"ONLYOFFICE не отдал результат ({out.status_code})") + except httpx.HTTPError as e: + raise DocsError(f"ONLYOFFICE недоступен: {type(e).__name__}") + except ValueError: + raise DocsError("ONLYOFFICE ответил непонятно — проверьте адрес Document Server") + CACHE_DIR.mkdir(parents=True, exist_ok=True) + dest.write_bytes(out.content) + _prune() + return dest + + +def check(c: Conf) -> dict: + """Проверка подключения: Nextcloud (корень документации) и ONLYOFFICE (healthcheck).""" + items = listdir(c, "") + out = {"nextcloud": f"в корне документации {sum(x['is_dir'] for x in items)} папок и {sum(not x['is_dir'] for x in items)} файлов", + "onlyoffice": None} + if c.oo_url: + try: + with _oo_client(10) as h: + r = h.get(f"{c.oo_url}/healthcheck") + out["onlyoffice"] = "работает" if r.status_code == 200 and r.text.strip().lower() == "true" else f"ответ {r.status_code}" + except httpx.HTTPError as e: + out["onlyoffice"] = f"недоступен: {type(e).__name__}" + return out diff --git a/app/photo_cleanup.py b/app/photo_cleanup.py new file mode 100644 index 0000000..7927990 --- /dev/null +++ b/app/photo_cleanup.py @@ -0,0 +1,63 @@ +"""Самоочистка сервера от старых фото нарядов и заданий. + +Фото, которые нужно хранить дольше (у пункта чек-листа или задания отмечено «хранить более года»), с самого начала +кладутся в отдельный каталог keep/ и очисткой не трогаются. Остальные удаляются, когда старше заданного числа дней +(настройка photo_keep_days, 0 — не удалять). Проверка — раз в сутки из планировщика, вручную — из настроек сервера.""" +import logging +from datetime import datetime, timedelta + +from sqlalchemy import select +from sqlalchemy.orm import Session + +from . import config, media, settings_store as ss +from .models import Photo, TaskPhoto + +log = logging.getLogger("photo_cleanup") +KEEP = "keep/" +EVERY = timedelta(hours=24) + + +def days(db: Session) -> int: + try: + return max(0, int(ss.get(db, "photo_keep_days") or 0)) + except ValueError: + return 0 + + +def _old(db: Session, model, cut: datetime): + return db.scalars(select(model).where(model.created_at < cut, model.path.not_like(KEEP + "%"))).all() + + +def run(db: Session, dry: bool = False, now: datetime | None = None) -> dict: + """Удалить старые фото (dry — только посчитать). Возвращает число фото и освобождённые байты.""" + n = days(db) + if n <= 0: + return {"enabled": False, "days": 0, "photos": 0, "bytes": 0} + cut = (now or config.now()) - timedelta(days=n) + root = ss.photo_root(db) + order_ph, task_ph = _old(db, Photo, cut), _old(db, TaskPhoto, cut) + out = {"enabled": True, "days": n, "before": config.iso(cut), "photos": len(order_ph) + len(task_ph), + "bytes": sum(p.size or 0 for p in order_ph + task_ph)} + if dry: + return out + for p in order_ph: + (root / p.path).unlink(missing_ok=True) + db.delete(p) + for p in task_ph: + media.remove(db, "tasks", p.path) + db.delete(p) + ss.set_(db, "photo_cleanup_at", config.now().isoformat()) + db.commit() + if out["photos"]: + log.warning("Удалено старых фото: %s (%.1f МБ), старше %s дней", out["photos"], out["bytes"] / 1048576, n) + return out + + +def due(db: Session) -> bool: + if days(db) <= 0: + return False + at = ss.get(db, "photo_cleanup_at") + try: + return not at or config.now() - datetime.fromisoformat(at) >= EVERY + except ValueError: + return True diff --git a/app/push.py b/app/push.py index c70daf3..9f89883 100644 --- a/app/push.py +++ b/app/push.py @@ -122,13 +122,14 @@ def notify(user_ids, title: str, body: str, url: str = "/"): def users_for_assignment(db: Session, user_id, position_id) -> list[int]: - """Кого уведомлять о назначении: конкретного человека или тех, кто сейчас на смене в должности.""" + """Кого уведомлять о назначении: конкретного человека или тех, кто занимает должность и сейчас на смене + по своему графику (сменщики, которые дома, уведомлений не получают).""" if user_id: return [user_id] if position_id: - pos = db.get(Position, position_id) us = db.scalars(select(User).where(User.position_id == position_id, User.is_active.is_(True))).all() - return [u.id for u in us if pos and logic.shift_active(pos.schedule, logic.config.now())] + now = logic.config.now() + return [u.id for u in us if logic.user_on_shift(u, now)] return [] diff --git a/app/routers/assistant.py b/app/routers/assistant.py index 1dfd6ae..f385192 100644 --- a/app/routers/assistant.py +++ b/app/routers/assistant.py @@ -62,7 +62,7 @@ def put_config(data: ConfigIn, db: Session = Depends(get_db), _: User = Depends( ss.set_(db, "assistant_" + k, ("1" if v else "0") if isinstance(v, bool) else str(v).strip()) commit(db) c = A.cfg(db) - if c["enabled"] and (not c["model"] or not (c["api_key"] or "localhost" in c["base_url"] or "127.0.0.1" in c["base_url"])): + if c["enabled"] and (not c["model"] or not (c["api_key"] or A.autoconf.is_local(c["base_url"]))): ss.set_(db, "assistant_enabled", "0") commit(db) raise HTTPException(422, "Для включения укажите модель и ключ API (без ключа — только локальная модель)") diff --git a/app/routers/catalog.py b/app/routers/catalog.py index f2b3f84..459869a 100644 --- a/app/routers/catalog.py +++ b/app/routers/catalog.py @@ -190,6 +190,7 @@ def me(db: Session = Depends(get_db), u: User = Depends(current_user)): out.crane_map = scope.crane_map_enabled(db) from ..security import is_engineer, is_manager out.reliability = ss.get(db, "reliability") != "0" and (is_manager(u) or is_engineer(u)) + out.docs = bool(ss.get(db, "nc_url") and ss.get(db, "nc_user") and ss.get(db, "nc_password")) # документация из Nextcloud подключена return out @@ -357,6 +358,8 @@ def check_user_refs(db: Session, fields: dict): raise HTTPException(422, "Отдел не найден") if fields.get("position_id") and not db.get(Position, fields["position_id"]): raise HTTPException(422, "Должность не найдена") + if fields.get("schedule_id") and not db.get(ShiftSchedule, fields["schedule_id"]): + raise HTTPException(422, "График не найден") def guard_user_edit(me: User, target: User | None, fields: dict): diff --git a/app/routers/docs.py b/app/routers/docs.py new file mode 100644 index 0000000..6cd3d6d --- /dev/null +++ b/app/routers/docs.py @@ -0,0 +1,191 @@ +"""Документация оборудования (Nextcloud): настройки и проверка (администратор портала), проводник для привязки папок +(администратор и модератор), просмотр документов машины (кто видит машину), индекс и поиск для AI-помощника.""" +import asyncio +from typing import Optional + +from fastapi import APIRouter, Depends, HTTPException, Query +from fastapi.responses import FileResponse +from pydantic import BaseModel, Field +from sqlalchemy.orm import Session + +from .. import docs_index as DI, nextcloud as nc, scope, settings_store as ss +from ..db import SessionLocal, get_db +from ..models import DocIndex, Equipment, User +from ..security import current_user, require_manager, require_portal_admin +from .catalog import commit, get_or_404 + +router = APIRouter(tags=["docs"]) +SECRETS = ("nc_password", "onlyoffice_jwt") +NOSNIFF = {"X-Content-Type-Options": "nosniff", "Cache-Control": "private, max-age=300"} + + +class DocsConfigIn(BaseModel): + nc_url: Optional[str] = Field(default=None, max_length=300) + nc_user: Optional[str] = Field(default=None, max_length=200) + nc_password: Optional[str] = Field(default=None, max_length=500) # пустая строка — удалить; null — не менять + nc_root: Optional[str] = Field(default=None, max_length=500) + onlyoffice_url: Optional[str] = Field(default=None, max_length=300) + onlyoffice_jwt: Optional[str] = Field(default=None, max_length=500) + portal_internal_url: Optional[str] = Field(default=None, max_length=300) + docs_ai: Optional[bool] = None + + +class LinkIn(BaseModel): + path: str = Field(default="", max_length=500) # пусто — отвязать + + +def public_cfg(db: Session) -> dict: + out = {k: ss.get(db, k) for k in ("nc_url", "nc_user", "nc_root", "onlyoffice_url", "portal_internal_url", "docs_index_at")} + out.update({k + "_set": bool(ss.get(db, k)) for k in SECRETS}) + out["docs_ai"] = DI.enabled(db) + out["index"] = {**DI.state, "files": db.query(DocIndex).count(), "linked": db.query(Equipment).filter(Equipment.docs_path != "").count()} + return out + + +def _fail(e: nc.DocsError): + msg = str(e) + code = 502 if any(w in msg for w in ("недоступен", "ответил", "отклонил", "не отдал", "не смог")) else 404 if "не найден" in msg else 422 + raise HTTPException(code, msg) + + +@router.get("/docs/config") +def get_config(db: Session = Depends(get_db), _: User = Depends(require_portal_admin)): + return public_cfg(db) + + +@router.put("/docs/config") +def put_config(data: DocsConfigIn, db: Session = Depends(get_db), _: User = Depends(require_portal_admin)): + d = data.model_dump(exclude_unset=True) + for k in ("nc_url", "onlyoffice_url", "portal_internal_url"): + if d.get(k) and not d[k].strip().lower().startswith(("https://", "http://")): + raise HTTPException(422, "Адрес должен начинаться с http:// или https://") + try: + if d.get("nc_root") is not None: + d["nc_root"] = nc.clean(d["nc_root"]) + except nc.DocsError as e: + raise HTTPException(422, str(e)) + for k, v in d.items(): + if v is None: + continue + if isinstance(v, bool): + v = "1" if v else "0" + ss.set_(db, k, v.strip().rstrip("/") if k.endswith("url") else v.strip()) + commit(db) + return public_cfg(db) + + +@router.post("/docs/test") +def test(db: Session = Depends(get_db), _: User = Depends(require_portal_admin)): + try: + return nc.check(nc.conf(db)) + except nc.DocsError as e: + _fail(e) + + +@router.get("/docs/browse") +def browse(path: str = "", db: Session = Depends(get_db), _: User = Depends(require_manager)): + """Проводник по документации для привязки папок к машинам: только папки и сколько в папке файлов.""" + try: + rel = nc.clean(path) + items = nc.listdir(nc.conf(db), rel) + except nc.DocsError as e: + _fail(e) + linked: dict[str, list[str]] = {} + for name, p in db.query(Equipment.name, Equipment.docs_path).filter(Equipment.docs_path != ""): + linked.setdefault(p, []).append(name) + return {"path": rel, "items": [{**x, "linked": linked.get(x["path"], [])} for x in items if x["is_dir"]], + "files": sum(not x["is_dir"] for x in items)} + + +@router.put("/equipment/{id}/docs") +def link(id: int, data: LinkIn, db: Session = Depends(get_db), _: User = Depends(require_manager)): + eq = get_or_404(db, Equipment, id) + try: + rel = nc.clean(data.path) + if rel: + nc.listdir(nc.conf(db), rel) # папка должна существовать + except nc.DocsError as e: + _fail(e) + if rel != eq.docs_path: + db.query(DocIndex).filter(DocIndex.equipment_id == id).delete() + eq.docs_path = rel + commit(db) + return {"id": eq.id, "docs_path": eq.docs_path} + + +def _equipment(db: Session, u: User, id: int) -> Equipment: + eq = get_or_404(db, Equipment, id) + if not scope.user_sees(db, u, eq): + raise HTTPException(404, "Не найдено") + if not eq.docs_path: + raise HTTPException(404, "К машине не привязана папка документации") + return eq + + +@router.get("/equipment/{id}/docs") +def equipment_docs(id: int, sub: str = "", db: Session = Depends(get_db), u: User = Depends(current_user)): + """Содержимое папки документации машины (sub — вложенная папка).""" + eq = _equipment(db, u, id) + base = nc.clean(eq.docs_path) + try: + sub = nc.clean(sub) + items = nc.listdir(nc.conf(db), nc.join(base, sub)) + except nc.DocsError as e: + _fail(e) + return {"equipment_id": eq.id, "folder": base.rsplit("/", 1)[-1], "sub": sub, + "items": [{**x, "path": x["path"][len(base):].strip("/")} for x in items]} + + +@router.get("/equipment/{id}/docs/file") +def equipment_file(id: int, path: str = Query(min_length=1, max_length=1000), view: bool = False, + db: Session = Depends(get_db), u: User = Depends(current_user)): + """Файл документации. view=1 — для просмотра: PDF, картинки и текст как есть, Office — PDF через ONLYOFFICE.""" + eq = _equipment(db, u, id) + c = nc.conf(db) + try: + info = nc.stat(c, nc.join(eq.docs_path, nc.clean(path, allow_empty=False))) + if info["is_dir"]: + raise nc.DocsError("Это папка, а не файл") + k = info["kind"] + if view and k == "office": + return FileResponse(nc.to_pdf(c, info), media_type="application/pdf", headers=NOSNIFF, + filename=info["name"].rsplit(".", 1)[0] + ".pdf", content_disposition_type="inline") + f = nc.fetch(c, info) + except nc.DocsError as e: + _fail(e) + inline = view and k in ("pdf", "image", "text") + media = {"pdf": "application/pdf", "text": "text/plain; charset=utf-8"}.get(k) or nc.IMAGES.get(nc.ext_of(info["name"])) + return FileResponse(f, media_type=media if inline else "application/octet-stream", filename=info["name"], headers=NOSNIFF, + content_disposition_type="inline" if inline else "attachment") + + +@router.get("/docs/raw/{token}", include_in_schema=False) +def raw(token: str): + """Исходник для ONLYOFFICE по короткой подписанной ссылке (без входа).""" + try: + f = nc.check_raw_token(token) + except nc.DocsError as e: + raise HTTPException(404, str(e)) + return FileResponse(f, media_type="application/octet-stream", headers={"X-Content-Type-Options": "nosniff"}) + + +# ---------- индекс и поиск (опция «Работа с документацией») ---------- +def _rebuild_bg(): + with SessionLocal() as db: + DI.rebuild(db) + + +@router.post("/docs/reindex", status_code=202) +async def reindex(db: Session = Depends(get_db), _: User = Depends(require_portal_admin)): + if not DI.enabled(db): + raise HTTPException(422, "Сначала включите «Работа с документацией»") + if not DI.state["running"]: + asyncio.get_running_loop().run_in_executor(None, _rebuild_bg) + return {"started": True} + + +@router.get("/docs/search") +def search(q: str = Query(min_length=2, max_length=200), db: Session = Depends(get_db), u: User = Depends(current_user)): + if not DI.enabled(db): + raise HTTPException(404, "Работа с документацией выключена") + return DI.search(db, u, q) diff --git a/app/routers/extras.py b/app/routers/extras.py index bc49964..6c11c25 100644 --- a/app/routers/extras.py +++ b/app/routers/extras.py @@ -9,10 +9,10 @@ from fastapi.responses import FileResponse from sqlalchemy import select, func from sqlalchemy.orm import Session -from .. import schemas as S, config, logic, push, settings_store as ss +from .. import schemas as S, config, logic, photo_cleanup, push, settings_store as ss, sysinfo from ..db import get_db from ..models import Comment, Photo, Order, OrderItem, User, PushSubscription, DeviceToken -from ..security import current_user, require_admin, is_manager, is_portal_admin +from ..security import current_user, require_admin, require_portal_admin, is_manager, is_portal_admin from .catalog import commit from .orders import load, exec_guard, start, names, can_execute, to_out @@ -94,7 +94,7 @@ async def upload_photo(id: int, item_id: int, request: Request, db: Session = De raise HTTPException(415, "Допустимы JPEG, PNG, WebP") ct, ext = kind root = ss.photo_root(db) - rel = Path(f"{config.now():%Y/%m}") / (ss.new_name() + ext) + rel = Path("keep" if item.photo_keep else "") / f"{config.now():%Y/%m}" / (ss.new_name() + ext) # keep/ — не удаляются очисткой (root / rel).parent.mkdir(parents=True, exist_ok=True) (root / rel).write_bytes(data) db.add(Photo(order_id=o.id, item_id=item.id, path=rel.as_posix(), content_type=ct, size=len(data), @@ -142,8 +142,30 @@ def dir_stats(root: Path) -> dict: return {"files": len(files), "bytes": sum(x.stat().st_size for x in files), "disk_free": free} +def keep_stats(root: Path) -> dict: + a, b = dir_stats(root / "keep"), dir_stats(root / "media" / "tasks" / "keep") + return {"files": a["files"] + b["files"], "bytes": a["bytes"] + b["bytes"]} + + +@router.get("/photo-cleanup") +def cleanup_preview(db: Session = Depends(get_db), _: User = Depends(require_portal_admin)): + """Сколько фото удалит очистка сейчас (ничего не удаляет).""" + return {**photo_cleanup.run(db, dry=True), "last_run": ss.get(db, "photo_cleanup_at") or None} + + +@router.post("/photo-cleanup") +def cleanup_now(db: Session = Depends(get_db), _: User = Depends(require_portal_admin)): + return photo_cleanup.run(db) + + +@router.get("/server/monitor") +def monitor(db: Session = Depends(get_db), _: User = Depends(require_portal_admin)): + """Мониторинг сервера: загрузка процессора, память, место на дисках (данные портала и фото).""" + return {**sysinfo.snapshot({"Данные портала": Path(config.DATA_DIR), "Фото": ss.photo_root(db)}), "now": config.iso(config.now())} + + def settings_payload(db: Session) -> dict: - return {**ss.all_editable(db), "photo_stats": dir_stats(ss.photo_root(db)), + return {**ss.all_editable(db), "photo_stats": dir_stats(ss.photo_root(db)), "photo_keep_stats": keep_stats(ss.photo_root(db)), "vapid_public": push.public_key(db), "now": config.iso(config.now()), "fcm_enabled": push.fcm_enabled(db), "timezone": str(config.TZ)} diff --git a/app/routers/portal.py b/app/routers/portal.py index 459a79a..7473ad7 100644 --- a/app/routers/portal.py +++ b/app/routers/portal.py @@ -137,7 +137,7 @@ def user_card(id: int, db: Session = Depends(get_db), me: User = Depends(current """ФИО, фото, должность, график (ближайшие смены), что умеет, текущие задачи и наряды, выданный инвентарь.""" u = get_or_404(db, User, id) now = config.now() - sch = u.position.schedule if u.position else None + sch = u.schedule shifts = [] if sch: for i in range(14): diff --git a/app/routers/tasks.py b/app/routers/tasks.py index 9e816b8..ba3b685 100644 --- a/app/routers/tasks.py +++ b/app/routers/tasks.py @@ -86,7 +86,7 @@ def to_out(db: Session, t: DailyTask, u: User, names: dict, with_events: bool = assignee_name=names.get(t.assignee_id), created_by_id=t.created_by_id, created_by_name=names.get(t.created_by_id), equipment_id=t.equipment_id, equipment_name=eq.name if eq else None, unit_id=t.unit_id, unit_name=su.name if su else None, status=t.status, created_at=t.created_at, updated_at=t.updated_at, - finished_at=t.finished_at, labor_hours=t.labor_hours, last_comment=last, photos=photos, can_change=can_change(u, t), can_edit=can_edit(u, t), + finished_at=t.finished_at, labor_hours=t.labor_hours, photo_keep=bool(t.photo_keep), last_comment=last, photos=photos, can_change=can_change(u, t), can_edit=can_edit(u, t), can_transfer=can_transfer(u, t), events=[S.TaskEventOut(at=e.at, user_name=names.get(e.user_id), from_status=e.from_status, to_status=e.to_status, comment=e.comment, kind=e.kind or "status", from_user_name=names.get(e.from_user_id), @@ -241,7 +241,7 @@ async def add_photo(id: int, request: Request, db: Session = Depends(get_db), u: if not (is_boss(u) or u.id in (t.assignee_id, t.created_by_id)): raise HTTPException(403, "Нет прав добавлять фото") data, ct, ext = await media.read_image(db, request) - name = media.save(db, "tasks", data, ext) + name = media.save(db, "tasks", data, ext, keep=t.photo_keep) db.add(TaskPhoto(task_id=t.id, path=name, content_type=ct, size=len(data), user_id=u.id)) t.updated_at = config.now() commit(db) diff --git a/app/schemas.py b/app/schemas.py index 2bc97e4..b924f46 100644 --- a/app/schemas.py +++ b/app/schemas.py @@ -85,6 +85,7 @@ class UserIn(BaseModel): portal_admin: bool = False is_active: bool = True position_id: Optional[int] = None + schedule_id: Optional[int] = None # график работы сотрудника (пусто — всегда на смене) department_id: Optional[int] = None skills: str = Field(default="", max_length=4000) phone: str = Field(default="", max_length=40) @@ -100,6 +101,7 @@ class UserPatch(BaseModel): portal_admin: Optional[bool] = None is_active: Optional[bool] = None position_id: Optional[int] = None + schedule_id: Optional[int] = None department_id: Optional[int] = None skills: Optional[str] = Field(default=None, max_length=4000) phone: Optional[str] = Field(default=None, max_length=40) @@ -138,6 +140,7 @@ class UserOut(ORM): is_active: bool position_id: Optional[int] position: Optional[PositionBrief] = None + schedule_id: Optional[int] = None department_id: Optional[int] = None department: Optional["DepartmentBrief"] = None has_photo: bool = False @@ -147,6 +150,7 @@ class UserOut(ORM): demo_mode: bool = False crane_map: bool = False # включена отдельная карта кранов reliability: bool = False # доступен раздел «Анализ надёжности» (включён и у пользователя есть права) + docs: bool = False # подключена документация из Nextcloud portal_admin: bool = False # администратор портала: настройки сервера и обратная связь is_root: bool = False # администратор по умолчанию (admin): выдаёт права администратора портала @@ -222,7 +226,7 @@ class PreviewDay(BaseModel): class PositionIn(BaseModel): name: str kind: Literal["technician", "engineer"] = "technician" - schedule_id: Optional[int] = None + schedule_id: Optional[int] = None # устарело и не используется: график задаётся сотруднику class PositionOut(ORM): @@ -265,6 +269,7 @@ class EquipmentOut(EquipmentIn, ORM): meter_updated_at: Optional[datetime] = None map_x: Optional[float] = None map_y: Optional[float] = None + docs_path: str = "" # папка документации в Nextcloud (привязывается через PUT /equipment/{id}/docs) # ----- регламентное обслуживание: узлы машины ----- @@ -373,6 +378,7 @@ class ChecklistItemIn(BaseModel): max_value: Optional[float] = None required: bool = True photo: Literal["none", "optional", "required"] = "none" + photo_keep: bool = False # фото хранить более года (не удаляются автоочисткой) class ChecklistItemOut(ChecklistItemIn, ORM): @@ -460,6 +466,7 @@ class OrderItemOut(ORM): answered_at: Optional[datetime] out_of_range: bool photo: str + photo_keep: bool = False service_unit_id: Optional[int] = None is_meter: bool = False photos: list[PhotoOut] = [] @@ -532,6 +539,7 @@ class SettingsIn(BaseModel): setup_done: Optional[str] = Field(default=None, max_length=1000) crane_map: Optional[bool] = None # отдельная карта кранов reliability: Optional[bool] = None # раздел «Анализ надёжности» + photo_keep_days: Optional[int] = Field(default=None, ge=0, le=36500) # удалять фото старше N дней (0 — не удалять) class PushSubIn(BaseModel): @@ -841,6 +849,7 @@ class TaskIn(BaseModel): equipment_id: Optional[int] = None unit_id: Optional[int] = None labor_hours: Optional[float] = Field(default=None, ge=0, le=1000) + photo_keep: bool = False # фото к заданию хранить более года class TaskPatch(BaseModel): @@ -851,6 +860,7 @@ class TaskPatch(BaseModel): equipment_id: Optional[int] = None unit_id: Optional[int] = None labor_hours: Optional[float] = Field(default=None, ge=0, le=1000) + photo_keep: Optional[bool] = None class TaskStatusIn(BaseModel): @@ -892,6 +902,7 @@ class TaskOut(BaseModel): updated_at: datetime finished_at: Optional[datetime] labor_hours: Optional[float] = None + photo_keep: bool = False last_comment: str = "" photos: list[int] = [] can_change: bool = False diff --git a/app/settings_store.py b/app/settings_store.py index 3c36814..298f800 100644 --- a/app/settings_store.py +++ b/app/settings_store.py @@ -4,7 +4,7 @@ from pathlib import Path from sqlalchemy.orm import Session -from . import config +from . import autoconf, config from .models import Setting DEFAULTS = { @@ -19,6 +19,8 @@ DEFAULTS = { "setup_done": "", "crane_map": "0", # отдельная карта кранов "reliability": "1", # раздел «Анализ надёжности» для инженеров и руководителей + "photo_keep_days": "0", # автоочистка: удалять фото нарядов и заданий старше N дней (0 — не удалять; keep/ не трогается) + "photo_cleanup_at": "", # когда очистка выполнялась последний раз "crane_map_image": "", # картинка карты кранов (пусто — используется карта завода) # мастер первичной настройки: отмеченные шаги (через запятую) # AI-помощник (не входит в EDITABLE: ключ не отдаётся в общих настройках, правится через /assistant/config) "assistant_enabled": "0", @@ -27,14 +29,27 @@ DEFAULTS = { "assistant_model": "", "assistant_api_key": "", "assistant_scope": "", + # документация из Nextcloud (не входит в EDITABLE: пароль и секрет не отдаются, правится через /docs/config) + "nc_url": "", # адрес Nextcloud, лучше внутренний в локальной сети + "nc_user": "", # служебная учётная запись (только чтение) + "nc_password": "", # пароль приложения этой учётной записи + "nc_root": "", # корневая папка документации внутри учётной записи + "onlyoffice_url": "", # ONLYOFFICE Document Server — переводит документы Office в PDF для просмотра + "onlyoffice_jwt": "", # секрет JWT Document Server (если включён) + "portal_internal_url": "", # адрес портала, по которому его видит ONLYOFFICE (локальная сеть) + "docs_ai": "0", # «Работа с документацией» для AI-помощника + "docs_index_at": "", # когда последний раз строился индекс } EDITABLE = ("photo_dir", "max_photo_mb", "vapid_contact", "fcm_credentials_path", "public_incidents", - "backup_auto_hours", "backup_keep", "backup_media", "setup_done", "crane_map", "reliability") + "backup_auto_hours", "backup_keep", "backup_media", "setup_done", "crane_map", "reliability", "photo_keep_days") def get(db: Session, key: str) -> str: row = db.get(Setting, key) - return row.value if row and row.value != "" else DEFAULTS.get(key, "") + if row and row.value != "": + return row.value + auto = autoconf.default(key) # окружение (.env) или сервис во внутренней сети Docker + return auto if auto is not None else DEFAULTS.get(key, "") def set_(db: Session, key: str, value: str): diff --git a/app/static/admin.js b/app/static/admin.js index ce6a4f1..2c4dee6 100644 --- a/app/static/admin.js +++ b/app/static/admin.js @@ -93,5 +93,5 @@ async function assistantSettings(box) { window.assistantRefresh && window.assistantRefresh(); }) }, "Проверить"), h("button", { onclick: guard(async () => { await api("PUT", "/assistant/config", body()); toast("Сохранено"); key.value = ""; window.assistantRefresh && window.assistantRefresh(); assistantSettings(box); }) }, "Сохранить")), - out); + out, await docsAiSection()); } diff --git a/app/static/app.js b/app/static/app.js index 5333342..b49e85c 100644 --- a/app/static/app.js +++ b/app/static/app.js @@ -154,6 +154,9 @@ async function route() { else if (page === "admin") await adminPage(); else if (page === "server") await serverPage(); else if (page === "feedback") await feedbackPage(id); + else if (page === "docs") await docsPage(+id); + else if (page === "monitor") await monitorPage(); + else if (page === "monitor") await monitorPage(); else if (page === "backups") await backupsPage(); else if (page === "setup") await setupPage(id); else if (page === "new-order") await newOrderPage(); @@ -350,13 +353,14 @@ const CRUD = { { k: "notes", label: "Примечание", type: "textarea" }, { k: "is_active", label: "Активно", type: "bool" }], init: { is_active: true }, filter: r => !(me.crane_map && r.is_crane), // при отдельной службе кранов краны живут в своём разделе - canEdit: () => isEng(), icon: true, + canEdit: () => isEng(), icon: true, locFilter: true, actions: r => h("a", { class: "btn sec sm", style: "background:transparent;color:var(--text);border:1px solid var(--line)", href: "#/equipment/" + r.id }, "Карточка") }, locations: { title: "Места", path: "locations", cols: [["name", "Название"], ["parent_id", "Входит в", "locations"]], fields: [{ k: "name", label: "Название", type: "text", req: 1 }, { k: "parent_id", label: "Входит в", type: "ref", ref: "locations" }], init: {} }, - positions: { title: "Должности", path: "positions", cols: [["name", "Название"], ["kind", "Тип", null, v => v === "engineer" ? "Инженер" : "Техник"], ["schedule_id", "График", "schedules"]], - fields: [{ k: "name", label: "Название", type: "text", req: 1 }, { k: "kind", label: "Тип", type: "enum", options: [["technician", "Техник (выполняет наряды)"], ["engineer", "Инженер (планирует, проверяет)"]] }, - { k: "schedule_id", label: "График работы", hint: "Пусто — всегда на смене", type: "ref", ref: "schedules" }], init: { kind: "technician" } }, + positions: { title: "Должности", path: "positions", cols: [["name", "Название"], ["kind", "Тип", null, v => v === "engineer" ? "Инженер" : "Техник"]], + fields: [{ k: "name", label: "Название", type: "text", req: 1 }, { k: "kind", label: "Тип", type: "enum", options: [["technician", "Техник (выполняет наряды)"], ["engineer", "Инженер (планирует, проверяет)"]] }], + note: "Должность выполняет функции по графику того, кто её занимает: график задаётся каждому сотруднику в «Пользователях». Наряд или задание на должность видят и получают уведомление только те, кто сейчас на смене.", + init: { kind: "technician" } }, schedules: { title: "Графики работы", path: "schedules", cols: [["name", "Название"], ["kind", "Тип", null, (v, r) => v === "cycle" ? `${r.work_days}/${r.off_days}` : r.weekdays.map(i => DAYS[i]).join(" ")], ["start_time", "Смена", null, (v, r) => `${v.slice(0, 5)}–${r.end_time.slice(0, 5)}`]], fields: [{ k: "name", label: "Название", type: "text", req: 1 }, { k: "kind", label: "Тип", type: "enum", options: [["weekly", "По дням недели"], ["cycle", "Цикл (N через M)"]], reload: 1 }, @@ -370,10 +374,12 @@ const CRUD = { fields: [{ k: "name", label: "Название отдела", type: "text", req: 1 }, { k: "parent_id", label: "Входит в отдел", hint: "Для вложенных служб и участков; пусто — верхний уровень", type: "ref", ref: "departments" }, { k: "head_user_id", label: "Руководитель", type: "ref", ref: "users" }, { k: "notes", label: "Примечание", type: "textarea" }], init: {}, actions: r => h("a", { class: "btn sec sm", href: "#/people?dep=" + r.id }, "Сотрудники") }, - users: { title: "Пользователи", path: "users", cols: [["username", "Логин"], ["full_name", "ФИО"], ["position_id", "Должность", "positions"], ["department_id", "Отдел", "departments"], ["is_admin", "Роль", null, (v, r) => [v ? "Админ" : r.is_moderator ? "Модератор" : "", r.is_storekeeper ? "Склад" : ""].filter(Boolean).join(", ") || "—"], ["is_active", "Активен"], ["on_shift", "На смене"]], + users: { title: "Пользователи", path: "users", cols: [["username", "Логин"], ["full_name", "ФИО"], ["position_id", "Должность", "positions"], ["schedule_id", "График", "schedules"], ["department_id", "Отдел", "departments"], ["is_admin", "Роль", null, (v, r) => [v ? "Админ" : r.is_moderator ? "Модератор" : "", r.is_storekeeper ? "Склад" : ""].filter(Boolean).join(", ") || "—"], ["is_active", "Активен"], ["on_shift", "На смене"]], fields: [{ k: "username", label: "Логин", type: "text", req: 1 }, { k: "full_name", label: "ФИО", type: "text" }, { k: "password", label: "Пароль", hint: "При правке оставьте пустым, чтобы не менять", type: "password" }, - { k: "position_id", label: "Должность", type: "ref", ref: "positions" }, { k: "department_id", label: "Отдел", type: "ref", ref: "departments" }, { k: "is_moderator", label: "Модератор", hint: "Управляет пользователями, должностями, чек-листами; наряды сам не выполняет", type: "bool", show: () => me.is_admin }, + { k: "position_id", label: "Должность", type: "ref", ref: "positions" }, + { k: "schedule_id", label: "График работы", hint: "По нему сотрудник выполняет свою должность: на смене он видит наряды должности и получает уведомления, сменщики дома — нет. Пусто — всегда на смене", type: "ref", ref: "schedules" }, + { k: "department_id", label: "Отдел", type: "ref", ref: "departments" }, { k: "is_moderator", label: "Модератор", hint: "Управляет пользователями, должностями, чек-листами; наряды сам не выполняет", type: "bool", show: () => me.is_admin }, { k: "is_admin", label: "Администратор", hint: "Полный доступ к администрированию", type: "bool", show: () => me.is_admin }, { k: "portal_admin", label: "Администратор портала", hint: "Настройки сервера и ответы обратной связи. Выдаёт только администратор по умолчанию (admin); у самого admin эти права всегда", type: "bool", show: () => me.is_root }, { k: "is_storekeeper", label: "Администратор склада", hint: "Каталог инструмента, склады, выдача сотрудникам, заявки на инструмент", type: "bool" }, @@ -403,12 +409,40 @@ CRUD.cranes = { ...CRUD.equipment, title: "Краны", init: { is_active: true, cols: CRUD.equipment.cols.filter(c => c[0] !== "is_crane"), fields: CRUD.equipment.fields.map(f => f.k === "is_crane" ? { ...f, hint: "Снимите, чтобы перенести машину в обычное оборудование" } : f) }; +const crudLoc = {}; // выбранное место в фильтре списка (по разделу) +// место и все вложенные в него места +function locSubtree(locs, root) { + const out = new Set([root]); + for (let grew = true; grew;) { grew = false; for (const l of locs) if (l.parent_id && out.has(l.parent_id) && !out.has(l.id)) { out.add(l.id); grew = true; } } + return out; +} +// места деревом: «Цех №1», «— Компрессорная» … +const NBSP3 = String.fromCharCode(160).repeat(3); +function locOptions(locs) { + const kids = p => locs.filter(l => (l.parent_id || null) === p).sort((a, b) => a.name.localeCompare(b.name, "ru")); + const out = [], walk = (p, d) => { for (const l of kids(p)) { if (out.some(x => x[0] === l.id)) continue; out.push([l.id, NBSP3.repeat(d) + (d ? "— " : "") + l.name]); walk(l.id, d + 1); } }; + walk(null, 0); + for (const l of locs) if (!out.some(x => x[0] === l.id)) out.push([l.id, l.name]); + return out; +} + async function crudPage(page) { const C = CRUD[page], box = h("div"), formBox = h("div"), can = C.canEdit ? C.canEdit() : true; + const bar = h("div"); $app.replaceChildren(h("div", { class: "row" }, h("h1", { style: "flex:1" }, C.title), - can ? h("button", { onclick: () => edit(null) }, "+ Добавить") : null), formBox, box); + can ? h("button", { onclick: () => edit(null) }, "+ Добавить") : null), ...(C.note ? [h("p", { class: "small muted" }, C.note)] : []), bar, formBox, box); + if (C.locFilter) { + const locs = await ref("locations", true); + if (locs.length) bar.replaceChildren(h("div", { class: "row", style: "margin-bottom:10px" }, h("label", { style: "margin:0" }, "Место:"), + h("select", { style: "max-width:360px", onchange: e => { crudLoc[page] = e.target.value ? +e.target.value : null; load(); } }, + h("option", { value: "" }, "Все места"), ...locOptions(locs).map(([id, n]) => h("option", { value: id, selected: crudLoc[page] === id }, n))))); + } async function load() { - const rows = (await api("GET", "/" + C.path)).filter(C.filter || (() => true)); + let rows = (await api("GET", "/" + C.path)).filter(C.filter || (() => true)); + if (C.locFilter && crudLoc[page]) { + const sub = locSubtree(await ref("locations"), crudLoc[page]); + rows = rows.filter(r => sub.has(r.location_id)); + } const lookups = {}; for (const c of C.cols) if (c[2]) lookups[c[2]] = await ref(c[2], true); const cell = (r, [k, , rf, fn]) => { @@ -490,7 +524,9 @@ async function checklistEditor(id) { h("input", { type: t, step: "any", value: it[k] ?? "", onchange: e => it[k] = e.target.value === "" ? (k === "unit" ? "" : null) : (t === "number" ? +e.target.value : e.target.value) })))) : null, h("div", { class: "row", style: "margin-top:8px" }, h("label", { style: "display:flex;gap:5px;align-items:center;margin:0;color:var(--text)" }, h("input", { type: "checkbox", checked: it.required, onchange: e => it.required = e.target.checked }), "Обязательный"), - h("select", { style: "width:auto", onchange: e => it.photo = e.target.value }, ...[["none", "📷 без фото"], ["optional", "📷 фото по желанию"], ["required", "📷 фото обязательно"]].map(([k, t]) => h("option", { value: k, selected: (it.photo || "none") === k }, t))), + h("select", { style: "width:auto", onchange: e => { it.photo = e.target.value; draw(); } }, ...[["none", "📷 без фото"], ["optional", "📷 фото по желанию"], ["required", "📷 фото обязательно"]].map(([k, t]) => h("option", { value: k, selected: (it.photo || "none") === k }, t))), + (it.photo || "none") !== "none" ? h("label", { class: "chk", title: "Фото этого пункта кладутся в отдельный каталог и не удаляются автоочисткой сервера" }, + h("input", { type: "checkbox", checked: !!it.photo_keep, onchange: e => it.photo_keep = e.target.checked }), "хранить более года") : null, h("span", { style: "flex:1" }), h("button", { class: "sec sm", disabled: n === 0, onclick: () => { [items[n - 1], items[n]] = [items[n], items[n - 1]]; draw(); } }, "↑"), h("button", { class: "sec sm", disabled: n === items.length - 1, onclick: () => { [items[n + 1], items[n]] = [items[n], items[n + 1]]; draw(); } }, "↓"), @@ -503,7 +539,7 @@ async function checklistEditor(id) { h("h2", {}, "Задачи"), box, h("div", { class: "row" }, h("button", { class: "sec", onclick: add }, "+ Добавить задачу"), h("button", { onclick: guard(async () => { const body = { name: name.value, description: desc.value, kind: kv.kind, only_due: kv.only_due, items: items.map(i => ({ - text: i.text, answer_type: i.answer_type, required: i.required, photo: i.photo || "none", + text: i.text, answer_type: i.answer_type, required: i.required, photo: i.photo || "none", photo_keep: (i.photo || "none") !== "none" && !!i.photo_keep, options: i.answer_type === "choice" ? i.optText.split("\n").map(s => s.trim()).filter(Boolean) : null, unit: i.answer_type === "number" ? (i.unit || "") : "", min_value: i.answer_type === "number" ? i.min_value ?? null : null, max_value: i.answer_type === "number" ? i.max_value ?? null : null })) }; @@ -572,6 +608,7 @@ async function adminPage() { const [users, pos, sch] = await Promise.all([api("GET", "/users"), api("GET", "/positions"), api("GET", "/schedules")]); const tile = (href, title, text) => h("a", { href, class: "tile" }, h("b", {}, title), h("span", { class: "muted small" }, text)); const eqCount = me.is_admin && !me.demo_mode ? (await api("GET", "/equipment").catch(() => [])).length : 1; + const setupFinished = me.is_admin && ((await api("GET", "/settings").catch(() => ({}))).setup_done || "").split(",").includes("finished"); $app.replaceChildren(h("h1", {}, me.is_admin ? "Панель администратора" : "Панель модератора"), ...(eqCount === 0 || (me.is_admin && users.length <= 1) ? [h("div", { class: "card banner-setup" }, h("b", { style: "font-size:17px" }, "🧭 Похоже, система ещё не настроена."), h("span", { class: "muted" }, "Мастер по шагам проведёт через графики, должности, отделы, места, оборудование, сотрудников, карту и склады."), @@ -586,17 +623,19 @@ async function adminPage() { h("div", { class: "tiles" }, tile("#/checklists", "Чек-листы", "шаблоны проверок"), tile("#/equipment", "Оборудование", "справочник"), tile("#/locations", "Места", "расположение"), tile("#/templates", "Расписание нарядов", "автозапуск")), - ...(me.is_admin ? [h("h2", {}, "Сервер (только администратор)"), h("div", { class: "tiles" }, tile("#/setup", "🧭 Мастер первичной настройки", "пошагово: графики, люди, оборудование, карта, склады"), ...(me.portal_admin ? [tile("#/server", "Настройки сервера", "каталог фото, лимиты, уведомления, внешние обращения, AI-помощник"), tile("#/feedback", "Обратная связь", "идеи и замечания сотрудников по порталу")] : []), tile("#/backups", "Резервные копии", "база и конфигурация, автокопии, восстановление"), tile("#/demo", "Демонстрационный режим", me.demo_mode ? "ВКЛЮЧЁН — управление" : "тестовые данные для презентации"))] + ...(me.is_admin ? [h("h2", {}, "Сервер (только администратор)"), h("div", { class: "tiles" }, tile(setupFinished ? "#/setup/done" : "#/setup", "🧭 Мастер первичной настройки", setupFinished ? "пройден — проверка готовности и что поправить" : "пошагово: графики, люди, оборудование, карта, склады"), ...(me.portal_admin ? [tile("#/server", "Настройки сервера", "каталог фото, лимиты, уведомления, внешние обращения, AI-помощник"), tile("#/feedback", "Обратная связь", "идеи и замечания сотрудников по порталу"), tile("#/monitor", "Мониторинг сервера", "процессор, память, место на дисках"), tile("#/monitor", "Мониторинг сервера", "процессор, память, место на дисках")] : []), tile("#/backups", "Резервные копии", "база и конфигурация, автокопии, восстановление"), tile("#/demo", "Демонстрационный режим", me.demo_mode ? "ВКЛЮЧЁН — управление" : "тестовые данные для презентации"))] : [h("p", { class: "small muted" }, "Параметры сервера доступны только администратору. Модератор сам наряды не выполняет.")])); } async function serverPage() { if (!me.portal_admin) throw new Error("Настройки сервера доступны только администраторам портала"); - const keysBox = h("div", { style: "margin-top:14px" }), aiBox = h("div", { class: "card" }); + const cleanupBox = h("div", {}, "…"); + const keysBox = h("div", { style: "margin-top:14px" }), aiBox = h("div", { class: "card" }), docsBox = h("div", { class: "card" }); const s = await api("GET", "/settings"); const f = formFields([ { k: "photo_dir", label: "Каталог для фото", hint: "Абсолютный путь на сервере. В Docker — примонтированный том", type: "text" }, { k: "max_photo_mb", label: "Максимальный размер фото, МБ", type: "number" }, + { k: "photo_keep_days", label: "Автоочистка: удалять фото нарядов и заданий старше, дней", hint: "Например, 365 — раз в сутки удаляются фото старше года. 0 — не удалять. Фото пунктов и заданий с отметкой «хранить более года» лежат в отдельном каталоге keep/ и не удаляются", type: "number" }, { k: "vapid_contact", label: "Контакт для push-уведомлений", hint: "mailto:… или https://…", type: "text" }, { k: "fcm_credentials_path", label: "Android push (FCM): ключ Firebase", hint: "Путь к JSON-ключу сервисного аккаунта на сервере. Пусто — выключено", type: "text" }, { k: "public_incidents", label: "Публичная форма «Сообщить о неисправности» (/report)", hint: "Сторонние люди без входа сообщают об аварии: место, машина, описание, контакты", type: "bool" }, @@ -606,12 +645,14 @@ async function serverPage() { const mb = n => (n / 1048576).toFixed(1) + " МБ"; $app.replaceChildren(h("a", { href: "#/admin" }, "← Администрирование"), h("h1", {}, "Настройки сервера"), h("div", { class: "card" }, f.el, h("div", { style: "margin-top:12px" }, h("button", { onclick: guard(async () => { - await api("PUT", "/settings", { photo_dir: f.vals.photo_dir, max_photo_mb: f.vals.max_photo_mb, vapid_contact: f.vals.vapid_contact, fcm_credentials_path: f.vals.fcm_credentials_path ?? "", public_incidents: !!f.vals.public_incidents, crane_map: !!f.vals.crane_map, reliability: !!f.vals.reliability }); + await api("PUT", "/settings", { photo_dir: f.vals.photo_dir, max_photo_mb: f.vals.max_photo_mb, photo_keep_days: +(f.vals.photo_keep_days || 0), vapid_contact: f.vals.vapid_contact, fcm_credentials_path: f.vals.fcm_credentials_path ?? "", public_incidents: !!f.vals.public_incidents, crane_map: !!f.vals.crane_map, reliability: !!f.vals.reliability }); me = await api("GET", "/me"); toast("Сохранено"); serverPage(); }) }, "Сохранить")), h("p", { class: "small muted" }, "Смена каталога не переносит уже загруженные фото — скопируйте их вручную в новый каталог.")), h("div", { class: "card" }, h("h2", { style: "margin-top:0" }, "Состояние"), h("div", { class: "grid" }, - h("div", {}, h("label", {}, "Фото на сервере"), `${s.photo_stats.files} шт., ${mb(s.photo_stats.bytes)}`), + h("div", {}, h("label", {}, "Фото на сервере"), `${s.photo_stats.files} шт., ${mb(s.photo_stats.bytes)}`, + h("div", { class: "small muted" }, `из них хранить долго (keep/): ${s.photo_keep_stats.files} шт., ${mb(s.photo_keep_stats.bytes)}`)), + h("div", {}, h("label", {}, "Автоочистка фото"), cleanupBox), h("div", {}, h("label", {}, "Свободно на диске"), mb(s.photo_stats.disk_free)), h("div", {}, h("label", {}, "Время сервера"), `${fmt(s.now)} (${s.timezone})`), h("div", {}, h("label", {}, "Адрес клиента, каким его видит сервер"), s.client_ip || "—", @@ -620,11 +661,25 @@ async function serverPage() { h("div", {}, h("label", {}, "Браузерные уведомления"), location.protocol === "https:" || location.hostname === "localhost" ? "доступны" : "нужен HTTPS"))), h("div", { class: "card" }, h("h2", { style: "margin-top:0" }, "Сообщения об отказах и неисправностях извне"), h("p", { class: "small" }, "Публичная форма: ", h("a", { href: "/report", target: "_blank" }, location.origin + "/report"), - " — ссылку можно разместить на QR-коде у оборудования или на сайте."), keysBox), aiBox); + " — ссылку можно разместить на QR-коде у оборудования или на сайте."), keysBox), docsBox, aiBox); apiKeys(keysBox); + docsSettings(docsBox); + photoCleanupInfo(cleanupBox); assistantSettings(aiBox); } +async function photoCleanupInfo(box) { + const c = await api("GET", "/photo-cleanup"); + const mb = n => (n / 1048576).toFixed(1) + " МБ"; + if (!c.enabled) return box.replaceChildren("выключена"); + box.replaceChildren(`старше ${c.days} дн.`, h("div", { class: "small muted" }, c.photos ? `сейчас к удалению: ${c.photos} фото, ${mb(c.bytes)}` : "сейчас удалять нечего", + c.last_run ? ` · последний запуск ${fmt(c.last_run)}` : ""), + c.photos ? h("button", { class: "sec sm", style: "margin-top:4px", onclick: guard(async () => { + if (!confirm(`Удалить ${c.photos} фото старше ${c.days} дней? Это необратимо.`)) return; + const r = await api("POST", "/photo-cleanup"); toast(`Удалено фото: ${r.photos}`); serverPage(); + }) }, "Очистить сейчас") : null); +} + async function apiKeys(box) { const keys = await api("GET", "/api-keys"); const name = h("input", { placeholder: "Название системы, например «Диспетчерская» или «SCADA цех 1»" }); diff --git a/app/static/docs.js b/app/static/docs.js new file mode 100644 index 0000000..2546368 --- /dev/null +++ b/app/static/docs.js @@ -0,0 +1,155 @@ +"use strict"; +// ---------- Документация машин из Nextcloud ---------- +// Nextcloud сотрудникам не виден: папки и файлы отдаёт портал. PDF, картинки и текст открываются прямо здесь, +// документы Office — после перевода в PDF на сервере ONLYOFFICE. Привязывают папки администратор и модератор. +const DOC_ICON = { dir: "📁", pdf: "📕", image: "🖼", text: "📄", office: "📝", file: "📎" }; +const docSize = n => n >= 1048576 ? (n / 1048576).toFixed(1) + " МБ" : Math.max(1, Math.round(n / 1024)) + " КБ"; +const docUrl = (id, path, view) => `${API}/equipment/${id}/docs/file?` + new URLSearchParams({ path, ...(view ? { view: 1 } : {}) }); +const hashQuery = () => new URLSearchParams((location.hash.split("?")[1]) || ""); + +// блок в карточке машины +function docsBlock(eq, reload) { + if (!me.docs || (!eq.docs_path && !isMgr())) return null; + return h("div", { class: "card" }, + h("div", { class: "row" }, h("h2", { style: "flex:1;margin:0" }, "📁 Документация"), + eq.docs_path ? h("a", { class: "btn sm", href: "#/docs/" + eq.id }, "Открыть") : null, + isMgr() ? h("button", { class: "sec sm", onclick: () => docsPicker(eq, reload) }, eq.docs_path ? "Сменить папку" : "Привязать папку") : null), + h("div", { class: "small muted", style: "margin-top:4px" }, eq.docs_path ? "Папка: " + eq.docs_path + : "Папка с документацией ещё не привязана. Выберите её в проводнике — сотрудники будут открывать документы прямо в портале.")); +} + +// проводник для привязки папки +async function docsPicker(eq, done) { + if (document.querySelector(".dcmodal")) return; + let path = eq.docs_path ? eq.docs_path.split("/").slice(0, -1).join("/") : ""; + const body = h("div"), close = () => wrap.remove(); + const wrap = h("div", { class: "dcmodal", onclick: e => { if (e.target === wrap) close(); } }, h("div", { class: "card" }, body)); + const save = guard(async p => { + await api("PUT", `/equipment/${eq.id}/docs`, { path: p }); + delete cache.equipment; close(); toast(p ? "Папка привязана" : "Папка отвязана"); done(); + }); + async function draw() { + body.replaceChildren(h("p", { class: "muted" }, "Загрузка…")); + let d; + try { d = await api("GET", "/docs/browse?" + new URLSearchParams({ path })); } + catch (e) { body.replaceChildren(h("h2", { style: "margin-top:0" }, "Документация"), h("p", { class: "error" }, e.message), h("button", { class: "sec", onclick: close }, "Закрыть")); return; } + const parts = d.path ? d.path.split("/") : []; + const crumbs = h("div", { class: "crumbs" }, h("a", { href: "javascript:void 0", onclick: () => { path = ""; draw(); } }, "Документация"), + ...parts.flatMap((p, i) => [" / ", h("a", { href: "javascript:void 0", onclick: () => { path = parts.slice(0, i + 1).join("/"); draw(); } }, p)])); + body.replaceChildren(h("h2", { style: "margin-top:0" }, "Папка документации: " + eq.name), crumbs, + h("div", { class: "dclist" }, ...(d.items.length ? d.items.map(x => h("div", { class: "dcrow" }, + h("a", { href: "javascript:void 0", style: "flex:1", onclick: () => { path = x.path; draw(); } }, "📁 " + x.name), + x.linked.length ? h("span", { class: "small muted" }, "привязана: " + x.linked.join(", ")) : null, + h("button", { class: "sm", onclick: () => save(x.path) }, "Выбрать"))) : [h("p", { class: "muted small" }, "Вложенных папок нет")])), + h("div", { class: "small muted", style: "margin-top:6px" }, `В этой папке файлов: ${d.files}`), + h("div", { class: "row", style: "margin-top:12px" }, + d.path ? h("button", { onclick: () => save(d.path) }, "Привязать эту папку") : null, + eq.docs_path ? h("button", { class: "sec", onclick: () => confirm("Отвязать папку от машины?") && save("") }, "Отвязать") : null, + h("span", { style: "flex:1" }), h("button", { class: "sec", onclick: close }, "Отмена"))); + } + document.body.append(wrap); + draw(); +} + +// страница документации машины: #/docs/ID?d=папка или ?p=файл +async function docsPage(id) { + const q = hashQuery(), file = q.get("p"), sub = q.get("d") || (file ? file.split("/").slice(0, -1).join("/") : ""); + const eq = await api("GET", "/equipment/" + id); + const d = await api("GET", `/equipment/${id}/docs?` + new URLSearchParams({ sub })); + const viewer = h("div"); + const go = (k, v) => `#/docs/${id}` + (v ? "?" + new URLSearchParams({ [k]: v }) : ""); + const parts = sub ? sub.split("/") : []; + const row = x => h("a", { class: "dcrow" + (x.path === file ? " on" : ""), href: go(x.is_dir ? "d" : "p", x.path) }, + h("span", { style: "flex:1" }, (DOC_ICON[x.kind] || "📎") + " " + x.name), + x.is_dir ? null : h("span", { class: "small muted" }, docSize(x.size) + (x.mtime ? " · " + fmtDate(x.mtime) : ""))); + fill($app, h("a", { href: "#/equipment/" + id }, "← " + eq.name), h("h1", {}, "Документация"), + h("div", { class: "crumbs" }, h("a", { href: go() }, d.folder), ...parts.flatMap((p, i) => [" / ", h("a", { href: go("d", parts.slice(0, i + 1).join("/")) }, p)])), + h("div", { class: "dcwrap" }, h("div", { class: "card dclist" }, ...(d.items.length ? d.items.map(row) : [h("p", { class: "muted" }, "Папка пуста")])), viewer)); + if (file) await docsView(id, d.items.find(x => x.path === file) || { path: file, name: file.split("/").pop(), kind: "file" }, viewer); +} + +async function docsView(id, x, box) { + const dl = h("button", { class: "sec sm", onclick: guard(() => docsDownload(id, x)) }, "⬇ Скачать"); + const head = (...extra) => h("div", { class: "row", style: "margin-bottom:8px" }, h("b", { style: "flex:1" }, x.name), ...extra, dl); + if (x.kind === "file") return box.replaceChildren(h("div", { class: "card" }, head(), h("p", { class: "muted" }, "Этот формат в портале не открывается — скачайте файл."))); + box.replaceChildren(h("div", { class: "card" }, head(), h("p", { class: "muted" }, x.kind === "office" ? "Готовлю документ к просмотру…" : "Загрузка…"))); + const r = await authFetch(docUrl(id, x.path, true)); + if (!r.ok) { let m = r.statusText; try { m = (await r.json()).detail; } catch (_) {} return box.replaceChildren(h("div", { class: "card" }, head(), h("p", { class: "error" }, m))); } + const blob = await r.blob(), url = URL.createObjectURL(blob); + const open = h("button", { class: "sec sm", onclick: () => window.open(url, "_blank", "noopener") }, "↗ В новой вкладке"); + const view = x.kind === "image" ? h("img", { src: url, alt: x.name, class: "dcimg" }) + : x.kind === "text" ? h("pre", { class: "dctext" }, await blob.text()) + : h("iframe", { src: url, class: "dcframe", title: x.name }); + box.replaceChildren(h("div", { class: "card" }, head(open), view)); +} + +async function docsDownload(id, x) { + const r = await authFetch(docUrl(id, x.path, false)); + if (!r.ok) { let m = r.statusText; try { m = (await r.json()).detail; } catch (_) {} throw new Error(m); } + const a = document.createElement("a"); a.href = URL.createObjectURL(await r.blob()); a.download = x.name; + document.body.append(a); a.click(); a.remove(); setTimeout(() => URL.revokeObjectURL(a.href), 5000); +} + +// настройки сервера: подключение к Nextcloud и ONLYOFFICE +async function docsSettings(box) { + const c = await api("GET", "/docs/config"); + const inp = (k, ph, type) => h("input", { value: type === "password" ? "" : c[k] || "", placeholder: ph, ...(type ? { type, autocomplete: "new-password" } : {}) }); + const f = { + nc_url: inp("nc_url", "http://192.168.1.30 или https://cloud.example.ru"), nc_user: inp("nc_user", "служебная учётная запись, например portal-docs"), + nc_password: inp("nc_password", c.nc_password_set ? "пароль задан — оставьте пустым, чтобы не менять" : "пароль приложения", "password"), + nc_root: inp("nc_root", "папка с документацией, например Документация/Оборудование"), + onlyoffice_url: inp("onlyoffice_url", "http://192.168.1.31 — Document Server в локальной сети"), + onlyoffice_jwt: inp("onlyoffice_jwt", c.onlyoffice_jwt_set ? "секрет задан — оставьте пустым, чтобы не менять" : "секрет JWT Document Server (если включён)", "password"), + portal_internal_url: inp("portal_internal_url", "http://192.168.1.20:8000 — адрес портала, видимый для ONLYOFFICE"), + }; + const out = h("div"); + const body = () => { const b = {}; for (const [k, el] of Object.entries(f)) if (!(el.type === "password" && !el.value)) b[k] = el.value.trim(); return b; }; + box.replaceChildren(h("h2", { style: "margin-top:0" }, "📁 Документация из Nextcloud"), + h("p", { class: "small muted" }, "Портал читает папки Nextcloud от служебной учётной записи и сам отдаёт документы сотрудникам — Nextcloud им не нужен и не виден. ", + "Заведите в Nextcloud отдельного пользователя, дайте ему доступ ", h("b", {}, "только на чтение"), " к папке с документацией и создайте для него пароль приложения ", + "(Настройки → Безопасность → «Создать пароль приложения»). Адрес лучше указать внутренний, в локальной сети."), + h("div", { class: "grid" }, field("Адрес Nextcloud", f.nc_url), field("Учётная запись", f.nc_user), field("Пароль приложения", f.nc_password), field("Корневая папка", f.nc_root)), + h("h3", {}, "Просмотр документов Office (ONLYOFFICE)"), + h("p", { class: "small muted" }, "Word, Excel и PowerPoint сервер ONLYOFFICE переводит в PDF, и документ открывается прямо в портале. Без ONLYOFFICE такие файлы можно только скачать. ", + "Внутренний адрес портала нужен, чтобы ONLYOFFICE забрал исходник по одноразовой ссылке."), + h("div", { class: "grid" }, field("Адрес ONLYOFFICE", f.onlyoffice_url), field("Секрет JWT", f.onlyoffice_jwt), field("Внутренний адрес портала", f.portal_internal_url)), + h("div", { class: "row", style: "margin-top:10px" }, h("span", { style: "flex:1" }), + h("button", { class: "sec", onclick: guard(async () => { + await api("PUT", "/docs/config", body()); toast("Проверяю…"); + const r = await api("POST", "/docs/test"); + out.replaceChildren(h("div", { class: "sheet", style: "margin-top:8px" }, h("div", {}, h("b", {}, "Nextcloud: "), r.nextcloud), + h("div", {}, h("b", {}, "ONLYOFFICE: "), r.onlyoffice || "не указан — документы Office только скачиваются"))); + }) }, "Проверить"), + h("button", { onclick: guard(async () => { await api("PUT", "/docs/config", body()); me = await api("GET", "/me"); toast("Сохранено"); docsSettings(box); }) }, "Сохранить")), + out, + h("p", { class: "small muted" }, `Привязано машин: ${c.index.linked}. Папку к машине привязывают администратор или модератор в карточке машины — блок «Документация».`)); +} + +// опция AI-помощника «Работа с документацией» (в карточке помощника) +async function docsAiSection() { + const c = await api("GET", "/docs/config"); + const on = h("input", { type: "checkbox", checked: c.docs_ai }); + const ix = c.index, box = h("div", { class: "docsai" }); + const status = ix.running ? `строится: прочитано ${ix.done} файлов…` : c.docs_index_at ? `в индексе ${ix.files} файлов, обновлён ${fmt(c.docs_index_at)}` : "ещё не строился"; + box.replaceChildren(h("h3", {}, "📚 Работа с документацией"), + h("label", { class: "chk" }, on, "Помощник ищет ответы в документации машин и подсказывает, какой файл открыть"), + h("div", { class: "sheet risk" }, h("b", {}, "Риски — прочитайте перед включением"), + h("ul", {}, + h("li", {}, h("b", {}, "Передача данных провайдеру ИИ. "), "В запрос к модели уходят имена файлов и фрагменты текста документов (паспорта, схемы, регламенты). ", + "С облачным провайдером это значит, что содержимое документации покидает предприятие. Для документов с ограниченным доступом используйте локальную модель (Ollama, LM Studio в своей сети)."), + h("li", {}, h("b", {}, "Большой расход токенов. "), "Каждый вопрос о документации добавляет к запросу результаты поиска — запросы становятся в несколько раз длиннее и дороже. ", + "У платного провайдера следите за расходом; у локальной модели ответы будут медленнее."), + h("li", {}, h("b", {}, "Неточность. "), "Помощник видит только начало каждого документа и подсказывает, где искать, — окончательный ответ проверяйте в самом документе."), + h("li", {}, h("b", {}, "Нагрузка. "), "Раз в сутки портал перечитывает изменившиеся файлы из Nextcloud, чтобы обновить индекс. Сканы без текстового слоя не индексируются — находятся только по имени."))), + h("div", { class: "small muted", style: "margin-top:6px" }, "Индекс: " + status, ix.error ? h("div", { class: "error" }, ix.error) : null), + h("div", { class: "row", style: "margin-top:8px" }, + h("button", { onclick: guard(async () => { + if (on.checked && !c.docs_ai && !confirm("Включить «Работа с документацией»? Фрагменты документов будут уходить провайдеру ИИ.")) return; + await api("PUT", "/docs/config", { docs_ai: on.checked }); + if (on.checked && !c.docs_index_at) await api("POST", "/docs/reindex"); + toast("Сохранено"); box.replaceWith(await docsAiSection()); + }) }, "Сохранить"), + c.docs_ai ? h("button", { class: "sec", onclick: guard(async () => { await api("POST", "/docs/reindex"); toast("Индекс обновляется в фоне"); setTimeout(async () => box.replaceWith(await docsAiSection()), 1500); }) }, "Обновить индекс") : null)); + if (!me.docs) box.append(h("p", { class: "small muted" }, "Сначала подключите Nextcloud в разделе «Документация из Nextcloud» выше.")); + return box; +} diff --git a/app/static/index.html b/app/static/index.html index 1ff4705..1356425 100644 --- a/app/static/index.html +++ b/app/static/index.html @@ -23,6 +23,9 @@ + + + diff --git a/app/static/monitor.js b/app/static/monitor.js new file mode 100644 index 0000000..6fc3a14 --- /dev/null +++ b/app/static/monitor.js @@ -0,0 +1,44 @@ +"use strict"; +// ---------- Мониторинг сервера: ЦП, память, диски (круговые диаграммы, обновление каждые 5 секунд) ---------- +const SVGNS = "http://www.w3.org/2000/svg"; +function svgEl(tag, attrs) { + const e = document.createElementNS(SVGNS, tag); + for (const [k, v] of Object.entries(attrs || {})) e.setAttribute(k, v); + return e; +} +const gb = n => n >= 1073741824 ? (n / 1073741824).toFixed(1) + " ГБ" : (n / 1048576).toFixed(0) + " МБ"; +const levelColor = p => p >= 90 ? "var(--bad)" : p >= 75 ? "var(--warn)" : "var(--ok)"; + +// кольцевая диаграмма: доля p (0–100), подпись в центре +function donut(p, center) { + const r = 52, c = 2 * Math.PI * r, v = Math.max(0, Math.min(100, p ?? 0)); + const svg = svgEl("svg", { viewBox: "0 0 140 140", class: "donut", role: "img", "aria-label": center }); + svg.append(svgEl("circle", { cx: 70, cy: 70, r, fill: "none", stroke: "var(--line)", "stroke-width": 16 }), + svgEl("circle", { cx: 70, cy: 70, r, fill: "none", stroke: levelColor(v), "stroke-width": 16, "stroke-linecap": "round", + "stroke-dasharray": `${c * v / 100} ${c}`, transform: "rotate(-90 70 70)" })); + const t = svgEl("text", { x: 70, y: 77, "text-anchor": "middle", class: "donut-v" }); + t.textContent = p == null ? "—" : Math.round(v) + "%"; + svg.append(t); + return svg; +} + +const gauge = (title, p, lines) => h("div", { class: "card gauge" }, h("h2", {}, title), donut(p, title), ...lines.filter(Boolean).map(l => h("div", { class: "small muted" }, l))); + +async function monitorPage() { + if (!me.portal_admin) throw new Error("Мониторинг доступен администраторам портала"); + const box = h("div", { class: "gauges" }), stamp = h("div", { class: "small muted", style: "margin-bottom:8px" }); + fill($app, h("a", { href: "#/admin" }, "← Администрирование"), h("h1", {}, "Мониторинг сервера"), + h("p", { class: "small muted" }, "Загрузка процессора, память и место на дисках сервера портала. Обновляется каждые 5 секунд, пока страница открыта."), stamp, box); + const load = async () => { + const m = await api("GET", "/server/monitor"); + const mem = m.memory, memP = mem ? 100 * mem.used / mem.total : null; + box.replaceChildren( + gauge("Процессор", m.cpu, [`ядер: ${m.cpu_count || "—"}`, m.load ? `средняя нагрузка: ${m.load.map(x => x.toFixed(2)).join(" / ")}` : ""]), + gauge("Память (ОЗУ)", memP, mem ? [`занято ${gb(mem.used)} из ${gb(mem.total)}`, `свободно ${gb(mem.total - mem.used)}`, mem.scope === "container" ? "лимит контейнера Docker" : ""] : ["нет данных"]), + ...m.disks.map(d => gauge("Диск: " + d.labels.join(", "), 100 * d.used / d.total, + [`свободно ${gb(d.free)} из ${gb(d.total)}`, h("span", { title: d.path }, d.path.length > 40 ? "…" + d.path.slice(-40) : d.path)]))); + stamp.textContent = "Обновлено " + new Date().toLocaleTimeString("ru-RU"); + }; + await load(); + timer = setInterval(guard(load), 5000); +} diff --git a/app/static/portal.js b/app/static/portal.js index df13b24..a806754 100644 --- a/app/static/portal.js +++ b/app/static/portal.js @@ -583,8 +583,10 @@ async function taskForm(t) { field("Трудоёмкость (норма), ч", h("input", { type: "number", min: "0", step: "0.25", value: s.labor_hours ?? "", placeholder: "необязательно", onchange: e => s.labor_hours = e.target.value === "" ? null : +e.target.value }), "Для отчёта по сотрудникам; пусто — учитывается фактическое время в работе")), h("div", { style: "margin-top:8px" }, field("Описание", h("textarea", { onchange: e => s.description = e.target.value }, s.description))), + h("label", { class: "chk", style: "margin-top:8px", title: "Фото к заданию кладутся в отдельный каталог и не удаляются автоочисткой сервера" }, + h("input", { type: "checkbox", checked: !!s.photo_keep, onchange: e => s.photo_keep = e.target.checked }), "📷 Фото к заданию хранить более года"), h("div", { class: "row", style: "margin-top:12px" }, h("button", { onclick: guard(async () => { - const body = { title: s.title, description: s.description, day: s.day || null, assignee_id: s.assignee_id, equipment_id: s.equipment_id, unit_id: s.unit_id, labor_hours: s.labor_hours ?? null }; + const body = { title: s.title, description: s.description, day: s.day || null, assignee_id: s.assignee_id, equipment_id: s.equipment_id, unit_id: s.unit_id, labor_hours: s.labor_hours ?? null, photo_keep: !!s.photo_keep }; const r = t ? await api("PATCH", "/tasks/" + t.id, body) : await api("POST", "/tasks", body); toast("Сохранено"); location.hash = "#/task/" + r.id; }) }, t ? "Сохранить" : "Создать задание")))); diff --git a/app/static/service.js b/app/static/service.js index 025498b..ee3447a 100644 --- a/app/static/service.js +++ b/app/static/service.js @@ -114,6 +114,7 @@ async function equipmentPage(id) { h("h1", {}, eq.name, eq.inventory_no ? h("span", { class: "muted", style: "font-size:16px" }, " № " + eq.inventory_no) : null), h("div", { class: "small muted", style: "margin-bottom:10px" }, loc ? "Место: " + loc.name : "", eq.notes ? " · " + eq.notes : ""), portal.head, + docsBlock(eq, reload), meterCard, h("div", { class: "row", style: "margin:8px 0" }, h("h2", { style: "flex:1;margin:0" }, "Обслуживаемые узлы"), canEdit ? h("button", { onclick: () => unitForm(null) }, "+ Добавить обслуживаемый узел") : null), diff --git a/app/static/setup.js b/app/static/setup.js index 0097b6a..55dabf5 100644 --- a/app/static/setup.js +++ b/app/static/setup.js @@ -27,6 +27,46 @@ const SCHEDULE_PRESETS = [ ["Круглосуточно (дежурная смена)", { kind: "weekly", weekdays: [0, 1, 2, 3, 4, 5, 6], start_time: "00:00", end_time: "00:00" }], ]; +// Итог мастера: проверка готовности по введённым данным — что настроено, что мешает работе, что сделать дальше. +async function setupFinish(d) { + if (!d.done.has("finished")) { d.done.add("finished"); await api("PUT", "/settings", { setup_done: [...d.done].join(",") }); } + const staff = d.users.filter(u => !u.is_admin && u.is_active); + const techPos = d.pos.filter(p => p.kind === "technician"); + const checks = []; + const add = (ok, text, href, fix) => checks.push({ ok, text, href, fix }); + add(staff.length > 0, staff.length ? `Сотрудников: ${staff.length}` : "Нет ни одного сотрудника", "#/setup/users", "Завести сотрудников"); + const noPos = staff.filter(u => !u.position_id); + add(!noPos.length, noPos.length ? `Без должности (не видят наряды должностей): ${noPos.map(u => u.full_name || u.username).join(", ")}` : "У всех сотрудников есть должность", "#/users", "Назначить должности"); + const empty = d.pos.filter(p => !staff.some(u => u.position_id === p.id)); + add(!empty.length, empty.length ? `Должности без сотрудников (наряды на них никто не получит): ${empty.map(p => p.name).join(", ")}` : "Каждую должность кто-то занимает", "#/users", "Назначить сотрудников"); + const noShift = techPos.filter(p => staff.some(u => u.position_id === p.id) && !staff.some(u => u.position_id === p.id && u.on_shift)); + add(!noShift.length, noShift.length ? `Сейчас никого на смене: ${noShift.map(p => p.name).join(", ")} — проверьте графики сотрудников` : "По графикам сейчас на смене есть кто-то на каждой должности техников", "#/users", "Проверить графики"); + const noSch = staff.filter(u => u.position_id && !u.schedule_id && techPos.some(p => p.id === u.position_id)); + if (noSch.length) add(null, `Без графика (всегда на смене, получают все уведомления должности): ${noSch.map(u => u.full_name || u.username).join(", ")}`, "#/users", "Задать графики"); + add(d.eqs.length > 0, d.eqs.length ? `Оборудования: ${d.eqs.length}` : "Оборудование не заведено", "#/setup/equipment", "Завести оборудование"); + const noLoc = d.eqs.filter(e => !e.location_id); + if (d.eqs.length) add(!noLoc.length, noLoc.length ? `Машин без места: ${noLoc.length} (не находятся в публичной форме и фильтре по местам)` : "У всех машин указано место", "#/equipment", "Указать места"); + add(!!d.map.has_image, d.map.has_image ? `Карта завода: на ней ${(d.map.equipment || []).filter(e => e.x != null).length} машин` : "Карта завода не загружена", "#/setup/map", "Загрузить карту"); + add(d.cls.length > 0, d.cls.length ? `Чек-листов: ${d.cls.length}` : "Нет чек-листов — нечего проверять в нарядах", "#/checklists/new", "Создать чек-лист"); + add(d.tpls.length > 0, d.tpls.length ? `Расписаний нарядов: ${d.tpls.length}` : "Нет расписания — наряды не будут создаваться сами", "#/templates", "Настроить расписание"); + add(!!(d.bk.auto_hours), d.bk.auto_hours ? `Автокопии каждые ${d.bk.auto_hours} ч` : "Автоматические резервные копии выключены", "#/backups", "Включить"); + if (d.settings.photo_keep_days !== undefined) add(null, +d.settings.photo_keep_days ? `Старые фото удаляются через ${d.settings.photo_keep_days} дн.` : "Автоочистка старых фото выключена — место на диске со временем закончится", "#/server", "Настроить"); + const bad = checks.filter(c => c.ok === false).length; + const icon = c => c.ok === true ? "✅" : c.ok === false ? "⚠️" : "ℹ️"; + fill($app, h("a", { href: "#/setup" }, "← Мастер"), h("h1", {}, bad ? "🧭 Настройка почти завершена" : "🎉 Портал готов к работе"), + h("p", { class: "muted" }, bad ? `Мастер пройден. Осталось поправить: ${bad}. Пока это не сделано, часть нарядов и уведомлений может никому не прийти.` + : "Мастер пройден, всё основное настроено. Раздайте сотрудникам логины и создайте первый наряд."), + h("div", { class: "card" }, h("h2", { style: "margin-top:0" }, "Проверка готовности"), + ...checks.map(c => h("div", { class: "row", style: "padding:6px 0;border-bottom:1px solid var(--line)" }, h("span", {}, icon(c)), h("span", { style: "flex:1" }, c.text), + c.ok !== true && c.href ? h("a", { class: "btn sec sm", href: c.href }, c.fix) : null))), + h("div", { class: "card" }, h("h2", { style: "margin-top:0" }, "Что дальше"), + h("ol", { class: "small" }, h("li", {}, "Раздайте логины и пароли (таблица на шаге «Сотрудники»), установите приложение: ссылка внизу бокового меню."), + h("li", {}, "Создайте первый наряд вручную и пройдите его с телефона — так проще всего показать работу сотрудникам."), + h("li", {}, "Проверьте уведомления: «🔔» внизу меню у каждого сотрудника.")), + h("div", { class: "row" }, h("a", { class: "btn", href: "#/new-order" }, "Создать наряд"), h("a", { class: "btn sec", href: "#/work" }, "Открыть «Задачи»"), + h("button", { class: "sec", onclick: guard(() => setupPage("done")) }, "↻ Проверить ещё раз")))); +} + async function setupPage(stepKey) { if (!me.is_admin) throw new Error("Мастер доступен администратору"); const G = p => api("GET", p).catch(() => []); @@ -92,21 +132,19 @@ async function setupPage(stepKey) { h("li", {}, `Каталог фото: ${settings.photo_dir}, лимит снимка ${settings.max_photo_mb} МБ.`), h("li", {}, `Время сервера сейчас: ${fmt(settings.now)} — должно совпадать с часами на стене.`)), h("a", { class: "btn sec", href: "#/server" }, "Открыть настройки сервера")) }, - { k: "schedules", t: "Графики работы", n: () => sch.length, why: "По графику система понимает, кто сейчас на смене и кому показывать наряды должности.", + { k: "schedules", t: "Графики работы", n: () => sch.length, why: "График задаётся каждому сотруднику: по нему система понимает, кто сейчас на смене, кому показывать наряды должности и слать уведомления.", body: () => h("div", {}, list(sch, x => x.name), h("p", { class: "small muted" }, "Добавьте типовые графики одним нажатием, а сложные циклы соберите в конструкторе."), h("div", { class: "row" }, ...SCHEDULE_PRESETS.map(([name, body]) => h("button", { class: "sec", disabled: !!byName(sch, name), onclick: guard(async () => { await api("POST", "/schedules", { name, ...body }); toast("Добавлен график «" + name + "»"); reload(); }) }, (byName(sch, name) ? "✔ " : "+ ") + name)), h("a", { class: "btn sec", href: "#/schedules" }, "Конструктор графиков"))) }, - { k: "positions", t: "Должности", n: () => pos.length, why: "Тип должности: техник выполняет наряды, инженер планирует и проверяет. У должности — график.", + { k: "positions", t: "Должности", n: () => pos.length, why: "Тип должности: техник выполняет наряды, инженер планирует и проверяет. График — не у должности, а у сотрудника (шаг «Сотрудники»).", body: () => h("div", {}, list(pos, x => x.name), bulk({ - hint: "Формат: Название; техник или инженер; название графика (необязательно).", - example: "Слесарь-ремонтник; техник; Сутки через трое\nЭлектромонтёр; техник; Сутки через трое\nИнженер-механик; инженер; Пятидневка 08:00–17:00", - run: async ([name, kind, s]) => { + hint: "Формат: Название; техник или инженер.", + example: "Слесарь-ремонтник; техник\nДежурный механик; техник\nЭлектромонтёр; техник\nИнженер-механик; инженер", + run: async ([name, kind]) => { if (!name) throw new Error("нет названия"); - const sc = s ? byName(sch, s) : null; - if (s && !sc) throw new Error(`график «${s}» не найден`); - const p = await api("POST", "/positions", { name, kind: norm(kind).startsWith("инж") ? "engineer" : "technician", schedule_id: sc ? sc.id : null }); + const p = await api("POST", "/positions", { name, kind: norm(kind).startsWith("инж") ? "engineer" : "technician" }); pos.push(p); return p; } })) }, { k: "departments", t: "Отделы", n: () => deps.length, why: "Службы и участки. Отделы можно вкладывать друг в друга.", @@ -145,20 +183,22 @@ async function setupPage(stepKey) { return e; } }), h("p", { class: "small muted" }, "Иконку и обслуживаемые узлы (масло, фильтры, ремни) задайте в карточке машины: «Оборудование» → «Карточка»."), h("a", { class: "btn sec", href: "#/equipment" }, "Открыть оборудование")) }, - { k: "users", t: "Сотрудники", n: () => users.filter(u => !u.is_admin).length, why: "Учётные записи: должность определяет график и права, отдел — принадлежность.", + { k: "users", t: "Сотрудники", n: () => users.filter(u => !u.is_admin).length, why: "Учётные записи: должность определяет права, график — когда сотрудник на смене, отдел — принадлежность.", body: () => { const res = h("div"); return h("div", {}, list(users.filter(u => !u.is_admin), x => x.full_name || x.username), bulk({ - hint: "Формат: ФИО; логин (пусто — придумается сам); должность; отдел; роль (склад / модератор, необязательно). Пароли создаются автоматически — распечатайте таблицу и раздайте.", - example: "Иванов Иван Петрович; ; Слесарь-ремонтник; Механическая служба\nПетров Сергей Алексеевич; ; Электромонтёр; Электрослужба\nКозлов Дмитрий Олегович; ; Слесарь-ремонтник; Механическая служба; склад", - run: async ([fio, login, p, d, role]) => { + hint: "Формат: ФИО; логин (пусто — придумается сам); должность; отдел; график; роль (склад / модератор). График и роль — необязательно; без графика сотрудник всегда на смене. Пароли создаются автоматически — распечатайте таблицу и раздайте.", + example: "Иванов Иван Петрович; ; Дежурный механик; Механическая служба; Сутки через трое\nСидоров Олег Ильич; ; Дежурный механик; Механическая служба; Сутки через трое\nПетров Сергей Алексеевич; ; Электромонтёр; Электрослужба; Пятидневка 08:00–17:00\nКозлов Дмитрий Олегович; ; Слесарь-ремонтник; Механическая служба; ; склад", + run: async ([fio, login, p, d, s, role]) => { if (!fio || fio.length < 3) throw new Error("нет ФИО"); const taken = new Set(users.map(u => u.username)); const position = p ? byName(pos, p) : null, dep = d ? byName(deps, d) : null; if (p && !position) throw new Error(`должность «${p}» не найдена`); if (d && !dep) throw new Error(`отдел «${d}» не найден`); + const sc = s ? byName(sch, s) : null; + if (s && !sc) throw new Error(`график «${s}» не найден`); const password = makePassword(), username = login || makeLogin(fio, taken); - const u = await api("POST", "/users", { username, full_name: fio, password, position_id: position ? position.id : null, department_id: dep ? dep.id : null, + const u = await api("POST", "/users", { username, full_name: fio, password, position_id: position ? position.id : null, schedule_id: sc ? sc.id : null, department_id: dep ? dep.id : null, is_storekeeper: norm(role).startsWith("скл"), is_moderator: norm(role).startsWith("мод"), is_active: true }); users.push(u); return { fio, username, password, position: position ? position.name : "", dep: dep ? dep.name : "" }; @@ -209,6 +249,7 @@ async function setupPage(stepKey) { h("a", { class: "btn sec", href: "#/backups" }, "Резервные копии"))) }, ]; const state = s => done.has(s.k) || s.n() ? "done" : "todo"; + if (stepKey === "done") return setupFinish({ STEPS, state, done, sch, pos, users, eqs, locs, map, cls, tpls, bk, settings }); const cur = STEPS.find(s => s.k === stepKey) || STEPS.find(s => state(s) === "todo") || STEPS[0]; const idx = STEPS.indexOf(cur); const ready = STEPS.filter(s => state(s) === "done").length; @@ -222,6 +263,6 @@ async function setupPage(stepKey) { h("p", { class: "muted" }, cur.why), cur.body(), h("div", { class: "row", style: "margin-top:18px;border-top:1px solid var(--line);padding-top:12px" }, idx > 0 ? h("a", { class: "btn sec", href: "#/setup/" + STEPS[idx - 1].k }, "← Назад") : null, h("span", { style: "flex:1" }), - h("label", { class: "chk" }, h("input", { type: "checkbox", checked: done.has(cur.k), onchange: guard(async e => { await markDone(cur.k, e.target.checked); reload(cur.k); }) }), "шаг выполнен"), - idx < STEPS.length - 1 ? h("a", { class: "btn", href: "#/setup/" + STEPS[idx + 1].k }, "Далее →") : h("a", { class: "btn", href: "#/admin" }, "Завершить"))))); + h("label", { class: "chk" }, h("input", { type: "checkbox", checked: done.has(cur.k), onchange: guard(async e => { await markDone(cur.k, e.target.checked); location.hash = "#/setup/" + (e.target.checked ? (STEPS[idx + 1] || { k: "done" }).k : cur.k); if (!e.target.checked) reload(cur.k); }) }), "шаг выполнен"), + idx < STEPS.length - 1 ? h("a", { class: "btn", href: "#/setup/" + STEPS[idx + 1].k }, "Далее →") : h("a", { class: "btn", href: "#/setup/done" }, "Завершить и проверить"))))); } diff --git a/app/static/style.css b/app/static/style.css index 87e5d7f..6fb2d5c 100644 --- a/app/static/style.css +++ b/app/static/style.css @@ -276,3 +276,29 @@ button.fault{background:var(--fault);border-color:var(--fault);color:#fff} .fbmodal{position:fixed;inset:0;background:rgba(0,0,0,.45);display:flex;align-items:center;justify-content:center;z-index:200;padding:16px} .fbmodal .card{width:min(560px,100%);margin:0}.fbmodal textarea{width:100%} .fbcard.fb-new{border-left:4px solid var(--accent)}.fbcard.fb-taken{border-left:4px solid var(--warn)}.fbcard.fb-done{opacity:.8} + +/* документация из Nextcloud */ +.dcmodal{position:fixed;inset:0;background:rgba(0,0,0,.45);display:flex;align-items:center;justify-content:center;z-index:200;padding:16px} +.dcmodal .card{width:min(640px,100%);max-height:86vh;overflow:auto;margin:0} +.crumbs{font-size:14px;margin:4px 0 10px;word-break:break-word}.crumbs a{text-decoration:none} +.dclist{display:flex;flex-direction:column;gap:2px;margin:0} +.dcrow{display:flex;gap:8px;align-items:center;padding:7px 8px;border-radius:8px;color:var(--text);text-decoration:none;word-break:break-word} +.dcrow:hover,.dcrow.on{background:var(--bg)} +.dcwrap{display:grid;grid-template-columns:minmax(220px,320px) 1fr;gap:12px;align-items:start}.dcwrap>.card{margin:0} +@media (max-width:900px){.dcwrap{grid-template-columns:1fr}} +.dcframe{width:100%;height:78vh;border:1px solid var(--line);border-radius:8px;background:#fff} +.dcimg{max-width:100%;border-radius:8px}.dctext{white-space:pre-wrap;max-height:78vh;overflow:auto;font-size:13px;margin:0} +.docsai{margin-top:16px;border-top:1px solid var(--line);padding-top:6px} +.sheet.risk{border-left:4px solid var(--warn);margin-top:8px}.sheet.risk ul{margin:6px 0 0;padding-left:18px}.sheet.risk li{margin:3px 0} + +/* мониторинг сервера */ +.gauges{display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:12px}.gauges .card{margin:0} +.gauge{text-align:center}.gauge h2{margin:0 0 6px;font-size:16px} +.donut{width:150px;height:150px;display:block;margin:4px auto 8px}.donut circle{transition:stroke-dasharray .6s} +.donut-v{font-size:24px;font-weight:700;fill:var(--text)} + +/* мониторинг сервера */ +.gauges{display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:12px}.gauges .card{margin:0} +.gauge{text-align:center}.gauge h2{margin:0 0 6px;font-size:16px} +.donut{width:150px;height:150px;display:block;margin:4px auto 8px}.donut circle{transition:stroke-dasharray .6s} +.donut-v{font-size:24px;font-weight:700;fill:var(--text)} diff --git a/app/sysinfo.py b/app/sysinfo.py new file mode 100644 index 0000000..468c50a --- /dev/null +++ b/app/sysinfo.py @@ -0,0 +1,109 @@ +"""Мониторинг сервера: загрузка процессора, память, место на дисках. Без внешних библиотек. + +Linux (в том числе Docker): /proc/stat, /proc/meminfo; если контейнеру задан лимит памяти (cgroup v2 memory.max) — +показываем память контейнера. Windows (локальный запуск): GetSystemTimes и GlobalMemoryStatusEx через ctypes.""" +import os +import shutil +import sys +import time +from pathlib import Path + +SAMPLE_SEC = 0.25 + + +def _linux_cpu_times() -> tuple[int, int]: + with open("/proc/stat") as f: + vals = [int(x) for x in f.readline().split()[1:]] + idle = vals[3] + (vals[4] if len(vals) > 4 else 0) # idle + iowait + return idle, sum(vals) + + +def _win_cpu_times() -> tuple[int, int]: + import ctypes + from ctypes import wintypes + idle, kernel, user = wintypes.FILETIME(), wintypes.FILETIME(), wintypes.FILETIME() + ctypes.windll.kernel32.GetSystemTimes(ctypes.byref(idle), ctypes.byref(kernel), ctypes.byref(user)) + v = lambda t: (t.dwHighDateTime << 32) | t.dwLowDateTime + return v(idle), v(kernel) + v(user) # время ядра уже включает простой + + +def cpu_percent() -> float | None: + get = _win_cpu_times if sys.platform == "win32" else _linux_cpu_times + try: + i1, t1 = get() + time.sleep(SAMPLE_SEC) + i2, t2 = get() + except (OSError, ValueError, AttributeError): + return None + dt = t2 - t1 + return round(100.0 * (1 - (i2 - i1) / dt), 1) if dt > 0 else 0.0 + + +def _cgroup_memory() -> tuple[int, int] | None: + try: + limit = Path("/sys/fs/cgroup/memory.max").read_text().strip() + if limit == "max": + return None + return int(Path("/sys/fs/cgroup/memory.current").read_text()), int(limit) + except (OSError, ValueError): + return None + + +def memory() -> dict | None: + """{used, total, scope: host|container} в байтах.""" + if sys.platform == "win32": + try: + import ctypes + + class MS(ctypes.Structure): + _fields_ = [("dwLength", ctypes.c_ulong), ("dwMemoryLoad", ctypes.c_ulong), ("ullTotalPhys", ctypes.c_ulonglong), + ("ullAvailPhys", ctypes.c_ulonglong), ("ullTotalPageFile", ctypes.c_ulonglong), ("ullAvailPageFile", ctypes.c_ulonglong), + ("ullTotalVirtual", ctypes.c_ulonglong), ("ullAvailVirtual", ctypes.c_ulonglong), ("ullAvailExtendedVirtual", ctypes.c_ulonglong)] + m = MS() + m.dwLength = ctypes.sizeof(MS) + ctypes.windll.kernel32.GlobalMemoryStatusEx(ctypes.byref(m)) + return {"used": m.ullTotalPhys - m.ullAvailPhys, "total": m.ullTotalPhys, "scope": "host"} + except (OSError, AttributeError): + return None + cg = _cgroup_memory() + if cg: + return {"used": cg[0], "total": cg[1], "scope": "container"} + try: + info = {} + with open("/proc/meminfo") as f: + for line in f: + k, v = line.split(":", 1) + info[k] = int(v.split()[0]) * 1024 + total = info["MemTotal"] + return {"used": total - info.get("MemAvailable", info.get("MemFree", 0)), "total": total, "scope": "host"} + except (OSError, KeyError, ValueError): + return None + + +def disks(paths: dict[str, Path]) -> list[dict]: + """Место на дисках, где лежат база, фото и т. п. Один и тот же диск показывается один раз.""" + out, seen = [], {} + for label, p in paths.items(): + p = Path(p) + while not p.exists() and p.parent != p: + p = p.parent + try: + u = shutil.disk_usage(p) + except OSError: + continue + key = (u.total, os.stat(p).st_dev) + if key in seen: + seen[key]["labels"].append(label) + continue + seen[key] = {"labels": [label], "path": str(p), "used": u.used, "total": u.total, "free": u.free} + out.append(seen[key]) + return out + + +def snapshot(paths: dict[str, Path]) -> dict: + try: + load = os.getloadavg() + except (OSError, AttributeError): + load = None + return {"cpu": cpu_percent(), "cpu_count": os.cpu_count(), "load": load, "memory": memory(), "disks": disks(paths), + "platform": sys.platform} diff --git a/docker-compose.yml b/docker-compose.yml index 1648e89..7e6009b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,5 +1,13 @@ -# Развёртывание в Coolify (build pack «Docker Compose») или вручную: docker compose up -d --build -# Переменные задаются в интерфейсе Coolify (или в .env): DB_PASSWORD, SECRET_KEY, ADMIN_PASSWORD, при желании APP_TZ, APP_PORT; FORWARDED_ALLOW_IPS — IP вашего NPM. +# Развёртывание: docker compose up -d --build (или Coolify, build pack «Docker Compose») +# Переменные — в .env рядом с этим файлом (образец — .env.example): DB_PASSWORD, SECRET_KEY, ADMIN_PASSWORD и др. +# +# Дополнительные сервисы включаются профилями — по одному или вместе: +# docs — Nextcloud для документации оборудования (+ своя база), +# office — ONLYOFFICE Document Server: просмотр Word/Excel/PowerPoint в портале, +# ai — Ollama: локальная модель для AI-помощника (данные не уходят наружу). +# Пример: в .env COMPOSE_PROFILES=docs,office,ai и затем docker compose up -d +# Портал сам находит запущенные сервисы во внутренней сети Docker и подставляет их адреса в настройки +# (Nextcloud, ONLYOFFICE, адрес модели). Значения из .env и из интерфейса важнее автоматических. services: db: image: postgres:16-alpine @@ -30,9 +38,24 @@ services: APP_ENV: production # слабый SECRET_KEY/ADMIN_PASSWORD -> отказ запуска FORWARDED_ALLOW_IPS: ${FORWARDED_ALLOW_IPS:-127.0.0.1} # адрес NPM (доверенный прокси) PHOTO_DIR: /data/photos # каталог фото можно сменить в админке (он должен лежать на томе) + # --- документация (Nextcloud): пустой адрес — найдётся сам, если запущен профиль docs --- + NC_URL: ${NC_URL:-} + NC_USER: ${NC_USER:-${NEXTCLOUD_ADMIN_USER:-ncadmin}} + NC_PASSWORD: ${NC_PASSWORD:-${NEXTCLOUD_ADMIN_PASSWORD:-}} + NC_ROOT: ${NC_ROOT:-} + # --- ONLYOFFICE: секрет общий с сервисом onlyoffice ниже; пустой адрес — найдётся сам (профиль office) --- + ONLYOFFICE_URL: ${ONLYOFFICE_URL:-} + ONLYOFFICE_JWT: ${ONLYOFFICE_JWT:-change-me-onlyoffice-jwt} + PORTAL_INTERNAL_URL: ${PORTAL_INTERNAL_URL:-} + # --- AI-помощник: пустой адрес — при профиле ai подставится Ollama и модель OLLAMA_MODEL --- + ASSISTANT_PROVIDER: ${ASSISTANT_PROVIDER:-} + ASSISTANT_BASE_URL: ${ASSISTANT_BASE_URL:-} + ASSISTANT_MODEL: ${ASSISTANT_MODEL:-} + OLLAMA_MODEL: ${OLLAMA_MODEL:-qwen2.5:7b} # модель, если найден сервис ollama + ASSISTANT_API_KEY: ${ASSISTANT_API_KEY:-} volumes: - photos:/data/photos # фотографии нарядов - - appdata:/srv/data # демо-режим и файл APK (appdata/apk/*.apk раздаётся по /app.apk) + - appdata:/srv/data # демо-режим, кэш документов и файл APK (appdata/apk/*.apk раздаётся по /app.apk) ports: - "${APP_PORT:-8080}:8000" # на этот порт хоста смотрит прокси (NPM) healthcheck: @@ -42,7 +65,97 @@ services: retries: 5 start_period: 25s + # ---------- профиль docs: Nextcloud ---------- + nextcloud-db: + image: postgres:16-alpine + profiles: ["docs"] + restart: unless-stopped + environment: + POSTGRES_DB: nextcloud + POSTGRES_USER: nextcloud + POSTGRES_PASSWORD: ${NEXTCLOUD_DB_PASSWORD:-${DB_PASSWORD}} + volumes: + - nc_pgdata:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U nextcloud -d nextcloud"] + interval: 5s + timeout: 5s + retries: 20 + + nextcloud: + image: nextcloud:30-apache + profiles: ["docs"] + restart: unless-stopped + depends_on: + nextcloud-db: + condition: service_healthy + environment: + POSTGRES_HOST: nextcloud-db + POSTGRES_DB: nextcloud + POSTGRES_USER: nextcloud + POSTGRES_PASSWORD: ${NEXTCLOUD_DB_PASSWORD:-${DB_PASSWORD}} + NEXTCLOUD_ADMIN_USER: ${NEXTCLOUD_ADMIN_USER:-ncadmin} + NEXTCLOUD_ADMIN_PASSWORD: ${NEXTCLOUD_ADMIN_PASSWORD:-} + # «nextcloud» — имя, по которому к нему обращается портал внутри сети Docker + NEXTCLOUD_TRUSTED_DOMAINS: nextcloud localhost ${NEXTCLOUD_DOMAIN:-} + OVERWRITEPROTOCOL: ${NEXTCLOUD_PROTOCOL:-} # https — если Nextcloud открывают через прокси с HTTPS + volumes: + - nextcloud:/var/www/html + ports: + - "${NEXTCLOUD_PORT:-8081}:80" # веб-интерфейс Nextcloud: сюда загружают документацию + healthcheck: + test: ["CMD", "php", "-r", "exit(strpos((string)@file_get_contents('http://localhost/status.php'), '\"installed\":true') === false ? 1 : 0);"] + interval: 30s + timeout: 10s + retries: 10 + start_period: 120s + + # ---------- профиль office: ONLYOFFICE Document Server (только внутренняя сеть) ---------- + onlyoffice: + image: onlyoffice/documentserver:8.2 + profiles: ["office"] + restart: unless-stopped + environment: + JWT_ENABLED: "true" + JWT_SECRET: ${ONLYOFFICE_JWT:-change-me-onlyoffice-jwt} + ALLOW_PRIVATE_IP_ADDRESS: "true" # иначе не скачает документ с портала по внутреннему адресу Docker + volumes: + - onlyoffice_data:/var/www/onlyoffice/Data + healthcheck: + test: ["CMD-SHELL", "curl -fs http://localhost/healthcheck | grep -q true"] + interval: 30s + timeout: 10s + retries: 10 + start_period: 90s + + # ---------- профиль ai: локальная модель Ollama ---------- + ollama: + image: ollama/ollama:0.5.7 + profiles: ["ai"] + restart: unless-stopped + volumes: + - ollama:/root/.ollama + # Видеокарта NVIDIA (необязательно, нужен nvidia-container-toolkit) — раскомментируйте: + # deploy: + # resources: + # reservations: + # devices: [{driver: nvidia, count: all, capabilities: [gpu]}] + + ollama-pull: # один раз скачивает модель в том ollama и завершается + image: ollama/ollama:0.5.7 + profiles: ["ai"] + depends_on: [ollama] + restart: "no" + environment: + OLLAMA_HOST: http://ollama:11434 + MODEL: ${OLLAMA_MODEL:-qwen2.5:7b} + entrypoint: ["/bin/sh", "-c", "sleep 5 && ollama pull \"$$MODEL\""] + volumes: pgdata: photos: appdata: + nc_pgdata: + nextcloud: + onlyoffice_data: + ollama: diff --git a/docs/API.md b/docs/API.md index 08c536e..8bd1ff2 100644 --- a/docs/API.md +++ b/docs/API.md @@ -81,6 +81,10 @@ Пример «3 дня, отсыпной, 3 ночи, отсыпной, 3 выходных» — 5 шагов, цикл 11 дней. `POST /schedules/preview` `{schedule, from_date, days}` — календарь без сохранения (для конструктора). +График назначается **сотруднику**: `schedule_id` в `POST/PATCH /users` и в `UserOut` (пусто — всегда на смене). `on_shift` +считается по графику сотрудника. Наряд на должность видят и получают уведомление только сотрудники этой должности, которые +сейчас на смене. `schedule_id` у должности устарел и не используется (при обновлении перенесён сотрудникам). + ## Отчёты (инженер/модератор/админ) `GET /reports` — список отчётов. `GET /reports/completed-orders?from=…&to=…` — **период обязателен** (дата и время). Фильтры: `equipment_id`, `position_id` (должность исполнителя **на момент выполнения**), `user_id`, `include_review`. @@ -225,6 +229,37 @@ deviations, tasks_cancelled, tasks_paused, hours, calendar: {"2026-10-12": {n, h - Только администраторам портала доступны `PUT /settings` (кроме отметок мастера `setup_done`), полный `GET /settings`, `/api-keys`, `/assistant/config` и `/assistant/test`. Остальные администраторы получают в `GET /settings` сокращённый ответ (`limited: true`). +## Документация оборудования (Nextcloud) +Портал читает Nextcloud по WebDAV от служебной учётной записи (пароль приложения, только чтение) и сам отдаёт файлы; +Office переводится в PDF через ONLYOFFICE Document Server (ConvertService). +- `GET/PUT /api/v1/docs/config` — **администратор портала**: `nc_url, nc_user, nc_password, nc_root, onlyoffice_url, onlyoffice_jwt, + portal_internal_url, docs_ai`. Пароль и секрет JWT в ответе не возвращаются (`nc_password_set`, `onlyoffice_jwt_set`) и не попадают + в резервные копии. `POST /api/v1/docs/test` — проверка Nextcloud и ONLYOFFICE. +- `GET /api/v1/docs/browse?path=` — администратор или модератор: вложенные папки (с `linked` — к каким машинам привязаны) и число файлов. +- `PUT /api/v1/equipment/{id}/docs` `{path}` — администратор или модератор: привязать папку (пустой `path` — отвязать). В `EquipmentOut` — `docs_path`. +- `GET /api/v1/equipment/{id}/docs?sub=` — кто видит машину: `{folder, sub, items: [{name, path, is_dir, kind: dir|pdf|image|text|office|file, size, mtime}]}`. +- `GET /api/v1/equipment/{id}/docs/file?path=...&view=1` — файл; `view=1` — для просмотра: PDF, картинки, текст — inline, + Office — PDF через ONLYOFFICE (кэшируется). Без `view` — скачивание оригинала. Пути `..` отклоняются (422). +- `GET /api/v1/docs/raw/{token}` — без входа, только для ONLYOFFICE: подписанная ссылка на исходник, живёт 10 минут. +- В `GET /me` — `docs: true`, если Nextcloud подключён. + +Опция AI-помощника **«Работа с документацией»** (`docs_ai`): раз в сутки портал строит индекс (имя и начало текста файлов, +PDF — через pypdf, docx/xlsx/pptx/odt — из XML; файлы больше 60 МБ — только по имени), `POST /api/v1/docs/reindex` — вручную +(администратор портала). `GET /api/v1/docs/search?q=` — поиск по индексу среди машин, которые видит пользователь (только при +включённой опции). Помощник получает инструменты `search_docs` и `equipment_docs`. Фрагменты документов уходят провайдеру ИИ — +для закрытой документации нужна локальная модель. + +## Фото: «хранить более года» и автоочистка +- `photo_keep` у пункта чек-листа (`ChecklistItemIn`), копируется в пункт наряда (`OrderItemOut.photo_keep`); у задания — + `photo_keep` в `POST/PATCH /tasks` и `TaskOut`. Такие фото сохраняются в каталог `keep/` и очисткой не удаляются. +- Настройка `photo_keep_days` (`PUT /settings`, администратор портала): раз в сутки удалять фото нарядов и заданий старше N дней, + 0 — выключено (по умолчанию). `GET /photo-cleanup` — сколько удалится сейчас (ничего не удаляет), `POST /photo-cleanup` — очистить сразу. + В `GET /settings` — `photo_keep_stats` (сколько фото в `keep/`). + +## Мониторинг сервера +`GET /api/v1/server/monitor` (администратор портала) → `{cpu: %, cpu_count, load: [1, 5, 15 мин] | null, +memory: {used, total, scope: host|container}, disks: [{labels, path, used, total, free}], now}`. + ## AI-помощник Включает администратор: «Администрирование → Настройки сервера → AI-помощник» (`GET/PUT /api/v1/assistant/config`: `enabled, provider (openai | anthropic | gigachat), base_url, model, api_key, scope`; ключ в ответах не возвращается и не попадает diff --git a/docs/DEPLOY.md b/docs/DEPLOY.md index 005032e..8e3ab61 100644 --- a/docs/DEPLOY.md +++ b/docs/DEPLOY.md @@ -98,6 +98,36 @@ docker compose cp checklists-1.7.0.apk app:/srv/data/apk/ Версия берётся из имени файла (`checklists-<версия>.apk`); приложения сами предложат обновиться. В приложении на телефоне указывается адрес вашего сервера (`https://portal.example.ru`). +## 7а. Дополнительные сервисы: документация, просмотр Office, локальный ИИ +`docker-compose.yml` умеет поднять рядом с порталом ещё три сервиса. Они включаются **профилями** — строкой в `.env`: + +```bash +COMPOSE_PROFILES=docs,office,ai +``` + +| Профиль | Что запускается | Зачем | Ресурсы сверх портала | +|---|---|---|---| +| `docs` | Nextcloud + своя PostgreSQL | хранилище документации оборудования: паспорта, схемы, руководства | 1 ГБ памяти, место под документы | +| `office` | ONLYOFFICE Document Server | Word, Excel, PowerPoint открываются прямо в портале (переводятся в PDF) | 2–4 ГБ памяти | +| `ai` | Ollama + скачивание модели | локальная модель для AI-помощника: вопросы и документы не уходят в интернет | 6–8 ГБ памяти для 7B-модели, лучше видеокарта | + +Профили независимы: можно взять только `ai` или подключить свой Nextcloud и ONLYOFFICE, указав их адреса в `.env` +(`NC_URL`, `ONLYOFFICE_URL`, `PORTAL_INTERNAL_URL`). + +**Настройки заполняются сами.** Портал ищет сервисы во внутренней сети Docker по именам `nextcloud`, `onlyoffice`, `ollama` +и подставляет адреса в «Настройки сервера»: Nextcloud — `http://nextcloud`, ONLYOFFICE — `http://onlyoffice` (документы +он забирает у портала по `http://app:8000`), модель — `http://ollama:11434/v1` с моделью из `OLLAMA_MODEL`. Учётная запись +Nextcloud и секрет ONLYOFFICE берутся из `.env`. Всё, что администратор поменяет в интерфейсе, важнее автоматических значений. + +Что сделать после запуска: +1. **Nextcloud** откройте на порту `NEXTCLOUD_PORT` (по умолчанию 8081), войдите администратором из `.env`, создайте папку + документации (например, «Документация») с подпапками по машинам и укажите её в `NC_ROOT` или в настройках портала. + Для безопасности заведите отдельного пользователя с доступом только на чтение к этой папке и пароль приложения для + него — и впишите их в `NC_USER` / `NC_PASSWORD`. +2. **ONLYOFFICE** наружу не публикуется: портал общается с ним внутри сети Docker. Задайте свой `ONLYOFFICE_JWT`. +3. **ИИ**: дождитесь, пока `ollama-pull` скачает модель (`docker compose logs -f ollama-pull`), затем в «Настройках сервера → + AI-помощник» нажмите «Проверить» и включите помощника. Опция «Работа с документацией» включается там же. + ## 8. Обновление ```bash cd /opt/portal diff --git a/docs/user-guide.pdf b/docs/user-guide.pdf index 1bc92cc..d11bca6 100644 Binary files a/docs/user-guide.pdf and b/docs/user-guide.pdf differ diff --git a/requirements.txt b/requirements.txt index e2c1aea..25f067e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -11,3 +11,4 @@ google-auth==2.* requests==2.* fpdf2==2.* tzdata +pypdf==5.* diff --git a/tests/test_flow.py b/tests/test_flow.py index 218a589..61ec8d9 100644 --- a/tests/test_flow.py +++ b/tests/test_flow.py @@ -47,14 +47,12 @@ def test_full_flow(): "weekdays": [0, 1, 2, 3, 4, 5, 6], "start_time": "00:00:00", "end_time": "23:59:00"}).json() night = c.post("/api/schedules", headers=adm, json={"name": "Выходной", "kind": "weekly", "weekdays": []}).json() - tech = c.post("/api/positions", headers=adm, json={"name": "Слесарь", "kind": "technician", - "schedule_id": sch["id"]}).json() - tech2 = c.post("/api/positions", headers=adm, json={"name": "Слесарь (выходной)", "kind": "technician", - "schedule_id": night["id"]}).json() + # одну должность занимают сменщики: график у каждого свой + tech = c.post("/api/positions", headers=adm, json={"name": "Слесарь", "kind": "technician"}).json() eng = c.post("/api/positions", headers=adm, json={"name": "Инженер", "kind": "engineer"}).json() - for name, pos in (("ivan", tech), ("petr", tech2), ("eng", eng)): + for name, pos, sid in (("ivan", tech, sch["id"]), ("petr", tech, night["id"]), ("eng", eng, None)): r = c.post("/api/users", headers=adm, json={"username": name, "password": "Pass1234567", - "position_id": pos["id"]}) + "position_id": pos["id"], "schedule_id": sid}) assert r.status_code == 201, r.text ivan, petr, engh = login("ivan", "Pass1234567"), login("petr", "Pass1234567"), login("eng", "Pass1234567") @@ -80,7 +78,13 @@ def test_full_flow(): assert tpl["next_run"] order_id = c.post(f"/api/order-templates/{tpl['id']}/run-now", headers=engh).json()["order_id"] - # видимость: Иван (на смене) видит, Пётр (выходной) — нет + # видимость: Иван (на смене) видит, его сменщик Пётр (выходной) — нет; уведомление — только Ивану + from app import push + from app.db import SessionLocal + with SessionLocal() as db: + ids_ = {u["username"]: u["id"] for u in c.get("/api/users", headers=adm).json()} + assert push.users_for_assignment(db, None, tech["id"]) == [ids_["ivan"]] + assert c.get("/api/me", headers=petr).json()["on_shift"] is False assert [o["id"] for o in c.get("/api/orders", headers=ivan).json()] == [order_id] assert c.get("/api/orders", headers=petr).json() == [] assert c.get(f"/api/orders/{order_id}", headers=petr).status_code == 404 diff --git a/tests/test_scope_autoconf.py b/tests/test_scope_autoconf.py new file mode 100644 index 0000000..4c37520 --- /dev/null +++ b/tests/test_scope_autoconf.py @@ -0,0 +1,56 @@ +"""Автонастройка: значения из окружения и сервисов во внутренней сети Docker; сохранённое в интерфейсе важнее.""" +import os +import tempfile + +os.environ.setdefault("DATABASE_URL", os.environ.get("TEST_DATABASE_URL") or "sqlite:///" + os.path.join(tempfile.mkdtemp(), "ac.db").replace("\\", "/")) +os.environ.setdefault("SCHEDULER_INTERVAL", "3600") +os.environ.setdefault("SECRET_KEY", "test-secret-key-for-pytest-0123456789abcdef") +os.environ.setdefault("DATA_DIR", tempfile.mkdtemp()) + +from fastapi.testclient import TestClient # noqa: E402 + +from app import assistant, autoconf, settings_store as ss # noqa: E402 +from app.db import SessionLocal # noqa: E402 +from app.main import app # noqa: E402 + +V1 = "/api/v1" + + +def test_autoconf_from_docker_network_and_env(monkeypatch): + running = {"nextcloud", "onlyoffice", "ollama"} + monkeypatch.setattr(autoconf, "resolves", lambda host: host in running) + monkeypatch.setenv("NC_USER", "ncadmin") + monkeypatch.setenv("NC_PASSWORD", "nc-secret-pass") + monkeypatch.setenv("OLLAMA_MODEL", "qwen2.5:3b") + monkeypatch.delenv("NC_URL", raising=False) + with TestClient(app) as c: + adm = {"Authorization": "Bearer " + c.post(f"{V1}/auth/login", json={"username": "admin", "password": "admin"}).json()["access_token"]} + cfg = c.get(f"{V1}/docs/config", headers=adm).json() + assert cfg["nc_url"] == "http://nextcloud" and cfg["nc_user"] == "ncadmin" and cfg["nc_password_set"] + assert cfg["onlyoffice_url"] == "http://onlyoffice" and cfg["portal_internal_url"] == "http://app:8000" + assert "nc-secret" not in str(cfg) + assert c.get(f"{V1}/me", headers=adm).json()["docs"] is True + a = c.get(f"{V1}/assistant/config", headers=adm).json() + assert a["base_url"] == "http://ollama:11434/v1" and a["model"] == "qwen2.5:3b" and not a["enabled"] + # локальная модель в сети Docker включается без ключа + assert c.put(f"{V1}/assistant/config", headers=adm, json={"enabled": True}).status_code == 200 + with SessionLocal() as db: + assert assistant.ready(db) + c.put(f"{V1}/assistant/config", headers=adm, json={"enabled": False}) + # значение из интерфейса важнее автоматического + c.put(f"{V1}/docs/config", headers=adm, json={"nc_url": "https://cloud.example.ru"}) + assert c.get(f"{V1}/docs/config", headers=adm).json()["nc_url"] == "https://cloud.example.ru" + with SessionLocal() as db: + ss.set_(db, "nc_url", "") + db.commit() + # сервисов нет — пусто, Nextcloud не подключён + running.clear() + monkeypatch.delenv("NC_USER") + with SessionLocal() as db: + assert ss.get(db, "nc_url") == "" and ss.get(db, "onlyoffice_url") == "" and ss.get(db, "portal_internal_url") == "" + + +def test_is_local(): + assert autoconf.is_local("http://ollama:11434/v1") and autoconf.is_local("http://localhost:11434/v1") + assert autoconf.is_local("http://192.168.1.50:11434/v1") and autoconf.is_local("http://10.0.0.5/v1") + assert not autoconf.is_local("https://api.openai.com/v1") and not autoconf.is_local("http://8.8.8.8/v1") and not autoconf.is_local("") diff --git a/tests/test_scope_docs.py b/tests/test_scope_docs.py new file mode 100644 index 0000000..4f7dd0b --- /dev/null +++ b/tests/test_scope_docs.py @@ -0,0 +1,191 @@ +"""Документация из Nextcloud: настройки (секреты не утекают), проводник и привязка, права на просмотр, просмотр Office +через ONLYOFFICE, индекс и поиск для опции AI «Работа с документацией». Nextcloud и ONLYOFFICE имитируются.""" +import io +import json +import os +import tempfile +import time +import zipfile +from urllib.parse import quote, unquote + +os.environ.setdefault("DATABASE_URL", os.environ.get("TEST_DATABASE_URL") or "sqlite:///" + os.path.join(tempfile.mkdtemp(), "dc.db").replace("\\", "/")) +os.environ.setdefault("SCHEDULER_INTERVAL", "3600") +os.environ.setdefault("SECRET_KEY", "test-secret-key-for-pytest-0123456789abcdef") +os.environ.setdefault("DATA_DIR", tempfile.mkdtemp()) + +import httpx # noqa: E402 +import jwt # noqa: E402 +from fastapi.testclient import TestClient # noqa: E402 + +from app import assistant as A, docs_index as DI, nextcloud as nc # noqa: E402 +from app.main import app # noqa: E402 + +V1 = "/api/v1" +PW = "Pass1234567" +DAV = "/remote.php/dav/files/svc" + + +def docx(text: str) -> bytes: + buf = io.BytesIO() + with zipfile.ZipFile(buf, "w") as z: + z.writestr("word/document.xml", f'{text}') + return buf.getvalue() + + +TREE = { # путь в учётной записи → содержимое (None — папка) + "Документы": None, + "Документы/DC-Компрессор": None, + "Документы/DC-Компрессор/Паспорт.pdf": b"%PDF-1.4 fake", + "Документы/DC-Компрессор/notes.txt": "Давление масла в норме 4 бар".encode(), + "Документы/DC-Компрессор/Схемы": None, + "Документы/DC-Компрессор/Схемы/Электросхема.docx": docx("Схема подключения двигателя М1, пускатель КМ2"), + "Документы/DC-Компрессор/.скрытый": b"x", + "Документы/DC-Пресс": None, +} + + +def multistatus(paths: list[str]) -> bytes: + rows = [] + for p in paths: + body = TREE[p] + rt = "" if body is None else "" + rows.append(f"{quote(DAV + '/' + p)}{'/' if body is None else ''}" + f"{rt}{len(body or b'')}" + f"Mon, 12 Oct 2026 10:00:00 GMT\"e{len(body or b'')}\"" + f"HTTP/1.1 200 OK") + return ('' + "".join(rows) + "").encode() + + +def nextcloud(request: httpx.Request) -> httpx.Response: + if request.headers.get("authorization") != httpx.BasicAuth("svc", "app-pass-1234")._auth_header: + return httpx.Response(401) + path = unquote(request.url.path) + assert path.startswith(DAV) + rel = path[len(DAV):].strip("/") + if rel not in TREE: + return httpx.Response(404) + if request.method == "PROPFIND": + kids = [p for p in TREE if p.startswith(rel + "/") and "/" not in p[len(rel) + 1:]] if request.headers["depth"] == "1" else [] + return httpx.Response(207, content=multistatus([rel] + kids)) + return httpx.Response(200, content=TREE[rel]) + + +converted = [] + + +def onlyoffice(request: httpx.Request) -> httpx.Response: + if request.url.path == "/healthcheck": + return httpx.Response(200, text="true") + if request.url.path == "/ConvertService.ashx": + body = json.loads(request.content) + assert jwt.decode(body["token"], "oo-secret", algorithms=["HS256"])["url"] == body["url"] + assert body["url"].startswith("http://portal.lan:8000/api/v1/docs/raw/") and body["outputtype"] == "pdf" + src = nc.check_raw_token(body["url"].rsplit("/", 1)[1]).read_bytes() # то, что скачал бы ONLYOFFICE + converted.append((body, src)) + return httpx.Response(200, json={"endConvert": True, "fileUrl": "http://oo.lan/cache/out.pdf", "percent": 100}) + if request.url.path == "/cache/out.pdf": + return httpx.Response(200, content=b"%PDF-1.7 converted") + return httpx.Response(404) + + +def test_docs(monkeypatch): + monkeypatch.setattr(nc, "_client", lambda c: httpx.Client(transport=httpx.MockTransport(nextcloud), auth=(c.user, c.password))) + monkeypatch.setattr(nc, "_oo_client", lambda timeout: httpx.Client(transport=httpx.MockTransport(onlyoffice))) + with TestClient(app) as c: + login = lambda u, p=PW: {"Authorization": "Bearer " + c.post(f"{V1}/auth/login", json={"username": u, "password": p}).json()["access_token"]} + adm = login("admin", "admin") + d1 = c.post(f"{V1}/departments", headers=adm, json={"name": "DC-Компрессорная"}).json() + d2 = c.post(f"{V1}/departments", headers=adm, json={"name": "DC-Прессовый"}).json() + comp = c.post(f"{V1}/equipment", headers=adm, json={"name": "DC-Компрессор", "department_id": d1["id"]}).json() + c.post(f"{V1}/users", headers=adm, json={"username": "dc_mod", "full_name": "Модератор", "password": PW, "is_moderator": True}) + c.post(f"{V1}/users", headers=adm, json={"username": "dc_t1", "full_name": "Техник компрессорной", "password": PW, "department_id": d1["id"]}) + c.post(f"{V1}/users", headers=adm, json={"username": "dc_t2", "full_name": "Техник прессового", "password": PW, "department_id": d2["id"]}) + mod, t1, t2 = login("dc_mod"), login("dc_t1"), login("dc_t2") + + # настройки — только администратор портала, секреты не возвращаются + assert c.put(f"{V1}/docs/config", headers=mod, json={"nc_url": "http://nc.lan"}).status_code == 403 + assert c.get(f"{V1}/docs/browse", headers=mod).status_code == 422 # ещё не настроено + assert c.put(f"{V1}/docs/config", headers=adm, json={"nc_url": "ftp://x"}).status_code == 422 + assert c.put(f"{V1}/docs/config", headers=adm, json={"nc_root": "a/../b"}).status_code == 422 + r = c.put(f"{V1}/docs/config", headers=adm, json={"nc_url": "http://nc.lan/", "nc_user": "svc", "nc_password": "app-pass-1234", + "nc_root": "/Документы/", "onlyoffice_url": "http://oo.lan", "onlyoffice_jwt": "oo-secret", + "portal_internal_url": "http://portal.lan:8000"}) + assert r.status_code == 200 and r.json()["nc_password_set"] and r.json()["onlyoffice_jwt_set"] and r.json()["nc_root"] == "Документы" + assert "app-pass" not in r.text and "oo-secret" not in r.text + assert "app-pass" not in c.get(f"{V1}/settings", headers=adm).text + t = c.post(f"{V1}/docs/test", headers=adm).json() + assert "2 папок" in t["nextcloud"] and t["onlyoffice"] == "работает" + + # проводник и привязка: администратор или модератор, техник — нет + assert c.get(f"{V1}/docs/browse", headers=t1).status_code == 403 + b = c.get(f"{V1}/docs/browse", headers=mod).json() + assert [x["name"] for x in b["items"]] == ["DC-Компрессор", "DC-Пресс"] + assert [x["name"] for x in c.get(f"{V1}/docs/browse", headers=mod, params={"path": "DC-Компрессор"}).json()["items"]] == ["Схемы"] + assert c.get(f"{V1}/docs/browse", headers=mod, params={"path": "../etc"}).status_code == 422 + assert c.put(f"{V1}/equipment/{comp['id']}/docs", headers=t1, json={"path": "DC-Компрессор"}).status_code == 403 + assert c.put(f"{V1}/equipment/{comp['id']}/docs", headers=mod, json={"path": "Нет такой"}).status_code == 404 + assert c.put(f"{V1}/equipment/{comp['id']}/docs", headers=mod, json={"path": "DC-Компрессор"}).json()["docs_path"] == "DC-Компрессор" + assert c.get(f"{V1}/docs/browse", headers=mod).json()["items"][0]["linked"] == ["DC-Компрессор"] + + # просмотр: кто видит машину; скрытые файлы не показываются; выйти из папки машины нельзя + assert c.get(f"{V1}/equipment/{comp['id']}/docs", headers=t2).status_code == 404 + ls = c.get(f"{V1}/equipment/{comp['id']}/docs", headers=t1).json() + assert [(x["name"], x["kind"]) for x in ls["items"]] == [("Схемы", "dir"), ("notes.txt", "text"), ("Паспорт.pdf", "pdf")] + assert ls["items"][2]["path"] == "Паспорт.pdf" + sub = c.get(f"{V1}/equipment/{comp['id']}/docs", headers=t1, params={"sub": "Схемы"}).json() + assert sub["items"][0]["path"] == "Схемы/Электросхема.docx" and sub["items"][0]["kind"] == "office" + assert c.get(f"{V1}/equipment/{comp['id']}/docs/file", headers=t1, params={"path": "../DC-Пресс"}).status_code == 422 + f = c.get(f"{V1}/equipment/{comp['id']}/docs/file", headers=t1, params={"path": "Паспорт.pdf", "view": 1}) + assert f.status_code == 200 and f.headers["content-type"] == "application/pdf" and f.content == b"%PDF-1.4 fake" + assert f.headers["content-disposition"].startswith("inline") and f.headers["x-content-type-options"] == "nosniff" + f = c.get(f"{V1}/equipment/{comp['id']}/docs/file", headers=t1, params={"path": "Схемы/Электросхема.docx"}) + assert f.headers["content-disposition"].startswith("attachment") and f.content == TREE["Документы/DC-Компрессор/Схемы/Электросхема.docx"] + # Office → PDF через ONLYOFFICE: исходник отдаётся по подписанной ссылке, результат кэшируется + f = c.get(f"{V1}/equipment/{comp['id']}/docs/file", headers=t1, params={"path": "Схемы/Электросхема.docx", "view": 1}) + assert f.status_code == 200, f.text + assert f.content == b"%PDF-1.7 converted" and converted[0][1] == TREE["Документы/DC-Компрессор/Схемы/Электросхема.docx"] + c.get(f"{V1}/equipment/{comp['id']}/docs/file", headers=t1, params={"path": "Схемы/Электросхема.docx", "view": 1}) + assert len(converted) == 1 + raw = converted[0][0]["url"].split("/api/v1")[1] + assert c.get(V1 + raw).content == converted[0][1] + assert c.get(V1 + raw[:-3] + "abc").status_code == 404 + + # опция AI выключена — поиска нет; включается только администратором портала + assert c.get(f"{V1}/docs/search", headers=t1, params={"q": "схема"}).status_code == 404 + assert c.post(f"{V1}/docs/reindex", headers=adm).status_code == 422 + assert c.put(f"{V1}/docs/config", headers=adm, json={"docs_ai": True}).json()["docs_ai"] is True + assert c.post(f"{V1}/docs/reindex", headers=mod).status_code == 403 + assert c.post(f"{V1}/docs/reindex", headers=adm).status_code == 202 + for _ in range(100): + time.sleep(0.05) + if not DI.state["running"] and c.get(f"{V1}/docs/config", headers=adm).json()["index"]["files"]: + break + idx = c.get(f"{V1}/docs/config", headers=adm).json()["index"] + assert idx["files"] == 3 and idx["linked"] == 1, idx + hits = c.get(f"{V1}/docs/search", headers=t1, params={"q": "схема двигателя"}).json() + assert hits[0]["path"] == "Схемы/Электросхема.docx" and "двигателя м1" in hits[0]["snippet"] + assert c.get(f"{V1}/docs/search", headers=t1, params={"q": "давление масла"}).json()[0]["name"] == "notes.txt" + assert c.get(f"{V1}/docs/search", headers=t2, params={"q": "схема двигателя"}).json() == [] + + # помощник: инструменты документации есть только при включённой опции + c.put(f"{V1}/assistant/config", headers=adm, json={"enabled": True, "provider": "openai", "model": "m", "api_key": "sk-test-0000", + "base_url": "https://llm.example/v1"}) + calls = [] + + async def fake(cfg, system, messages): + calls.append((system, messages[-1]["content"])) + if messages[-1]["content"].startswith("РЕЗУЛЬТАТ"): + return "Смотрите [Электросхема.docx](#/docs/1?p=Схемы/Электросхема.docx)" + return 'CALL search_docs {"q": "схема двигателя"}' + monkeypatch.setattr(A, "complete", fake) + r = c.post(f"{V1}/assistant/chat", headers=t1, json={"messages": [{"role": "user", "content": "Где схема двигателя компрессора?"}]}).json() + assert r["tools"] == ["search_docs"] and "search_docs" in calls[0][0] and "Электросхема.docx" in calls[1][1] + c.put(f"{V1}/docs/config", headers=adm, json={"docs_ai": False}) + c.post(f"{V1}/assistant/chat", headers=t1, json={"messages": [{"role": "user", "content": "Где схема?"}]}) + assert "search_docs" not in calls[-2][0] and "нет инструмента" in calls[-1][1] + c.put(f"{V1}/assistant/config", headers=adm, json={"enabled": False, "api_key": "", "model": "", "base_url": ""}) # как было + + # отвязка чистит индекс + assert c.put(f"{V1}/equipment/{comp['id']}/docs", headers=adm, json={"path": ""}).json()["docs_path"] == "" + assert c.get(f"{V1}/docs/config", headers=adm).json()["index"]["files"] == 0 + assert c.get(f"{V1}/equipment/{comp['id']}/docs", headers=t1).status_code == 404 diff --git a/tests/test_scope_tweaks.py b/tests/test_scope_tweaks.py new file mode 100644 index 0000000..2149c25 --- /dev/null +++ b/tests/test_scope_tweaks.py @@ -0,0 +1,115 @@ +"""График у сотрудника (сменщики на одной должности), автоочистка старых фото с каталогом keep/, мониторинг сервера.""" +import os +import tempfile +from datetime import timedelta + +os.environ.setdefault("DATABASE_URL", os.environ.get("TEST_DATABASE_URL") or "sqlite:///" + os.path.join(tempfile.mkdtemp(), "tw.db").replace("\\", "/")) +os.environ.setdefault("SCHEDULER_INTERVAL", "3600") +os.environ.setdefault("SECRET_KEY", "test-secret-key-for-pytest-0123456789abcdef") +os.environ.setdefault("DATA_DIR", tempfile.mkdtemp()) + +from fastapi.testclient import TestClient # noqa: E402 + +from app import config, logic, photo_cleanup, settings_store as ss # noqa: E402 +from app.db import SessionLocal # noqa: E402 +from app.main import app # noqa: E402 +from app.models import Photo, Position, TaskPhoto, User # noqa: E402 + +V1 = "/api/v1" +PW = "Pass1234567" +PNG = b"\x89PNG\r\n\x1a\n" + b"0" * 64 + + +def test_schedule_moves_from_position_to_user(): + with TestClient(app) as c: + adm = {"Authorization": "Bearer " + c.post(f"{V1}/auth/login", json={"username": "admin", "password": "admin"}).json()["access_token"]} + off = c.post(f"{V1}/schedules", headers=adm, json={"name": "TW-выходной", "kind": "weekly", "weekdays": []}).json() + assert c.post(f"{V1}/users", headers=adm, json={"username": "tw_bad", "password": PW, "schedule_id": 999999}).status_code == 422 + # старые данные: график задан должности — однократный перенос ставит его сотрудникам без своего графика + with SessionLocal() as db: + p = Position(name="TW-Дежурный", kind="technician", schedule_id=off["id"]) + db.add(p) + db.flush() + db.add_all([User(username="tw_old1", password_hash="x", position_id=p.id), User(username="tw_old2", password_hash="x", position_id=p.id)]) + ss.set_(db, "schedule_per_user", "0") + db.commit() + assert logic.schedules_to_users(db) >= 2 + assert logic.schedules_to_users(db) == 0 # повторно не выполняется + u1 = db.query(User).filter_by(username="tw_old1").one() + assert u1.schedule_id == off["id"] and not logic.user_on_shift(u1) + u1.schedule_id = None # свой график убрали — всегда на смене, график должности больше не влияет + db.commit() + db.refresh(u1) + assert logic.user_on_shift(u1) + r = c.get(f"{V1}/users", headers=adm).json() + assert next(u for u in r if u["username"] == "tw_old2")["schedule_id"] == off["id"] + + +def test_photo_cleanup_keeps_marked_photos(): + with TestClient(app) as c: + adm = {"Authorization": "Bearer " + c.post(f"{V1}/auth/login", json={"username": "admin", "password": "admin"}).json()["access_token"]} + cl = c.post(f"{V1}/checklists", headers=adm, json={"name": "TW-фото", "items": [ + {"text": "Общий вид", "answer_type": "status", "photo": "optional"}, + {"text": "Табличка (хранить)", "answer_type": "status", "photo": "optional", "photo_keep": True}]}).json() + assert [i["photo_keep"] for i in cl["items"]] == [False, True] + pos = c.post(f"{V1}/positions", headers=adm, json={"name": "TW-слесарь", "kind": "technician"}).json() + tech = c.post(f"{V1}/users", headers=adm, json={"username": "tw_tech", "password": PW, "position_id": pos["id"]}).json() + th = {"Authorization": "Bearer " + c.post(f"{V1}/auth/login", json={"username": "tw_tech", "password": PW}).json()["access_token"]} + o = c.post(f"{V1}/orders", headers=adm, json={"title": "TW-наряд", "checklist_id": cl["id"], "assignee_user_id": tech["id"]}).json() + a, b = o["items"] + assert b["photo_keep"] is True + for it in (a, b): + r = c.post(f"{V1}/orders/{o['id']}/items/{it['id']}/photos", headers={**th, "Content-Type": "image/png"}, content=PNG) + assert r.status_code in (200, 201), r.text + t = c.post(f"{V1}/tasks", headers=adm, json={"title": "TW-задание", "photo_keep": True}).json() + assert t["photo_keep"] is True + c.post(f"{V1}/tasks/{t['id']}/photos", headers={**adm, "Content-Type": "image/png"}, content=PNG) + t2 = c.post(f"{V1}/tasks", headers=adm, json={"title": "TW-задание 2"}).json() + c.post(f"{V1}/tasks/{t2['id']}/photos", headers={**adm, "Content-Type": "image/png"}, content=PNG) + + with SessionLocal() as db: + root = ss.photo_root(db) + ph = {p.item_id: p for p in db.query(Photo).filter(Photo.order_id == o["id"])} + assert ph[b["id"]].path.startswith("keep/") and not ph[a["id"]].path.startswith("keep/") + tp = {p.task_id: p for p in db.query(TaskPhoto).filter(TaskPhoto.task_id.in_([t["id"], t2["id"]]))} + assert tp[t["id"]].path.startswith("keep/") and not tp[t2["id"]].path.startswith("keep/") + files = [root / ph[a["id"]].path, root / ph[b["id"]].path, root / "media" / "tasks" / tp[t["id"]].path, root / "media" / "tasks" / tp[t2["id"]].path] + assert all(f.is_file() for f in files) + # всё «состарим» на два года + old = config.now() - timedelta(days=730) + for p in list(ph.values()) + list(tp.values()): + p.created_at = old + db.commit() + + # выключено по умолчанию — ничего не удаляется + assert c.get(f"{V1}/photo-cleanup", headers=adm).json()["enabled"] is False + assert c.put(f"{V1}/settings", headers=adm, json={"photo_keep_days": 365}).status_code == 200 + prev = c.get(f"{V1}/photo-cleanup", headers=adm).json() + assert prev["enabled"] and prev["photos"] >= 2 + assert all(f.is_file() for f in files) # предпросмотр ничего не удаляет + r = c.post(f"{V1}/photo-cleanup", headers=adm).json() + assert r["photos"] >= 2 + assert not files[0].exists() and files[1].is_file() and files[2].is_file() and not files[3].exists() + with SessionLocal() as db: + assert not photo_cleanup.due(db) + # фото «хранить» по-прежнему открывается + o2 = c.get(f"{V1}/orders/{o['id']}", headers=adm).json() + assert [len(i["photos"]) for i in o2["items"]] == [0, 1] + assert c.get(f"{V1}/photos/{o2['items'][1]['photos'][0]['id']}", headers=adm).status_code == 200 + tid = c.get(f"{V1}/tasks/{t['id']}", headers=adm).json()["photos"][0] + assert c.get(f"{V1}/task-photos/{tid}", headers=adm).status_code == 200 + s = c.get(f"{V1}/settings", headers=adm).json() + assert s["photo_keep_days"] == "365" and s["photo_keep_stats"]["files"] >= 2 + c.put(f"{V1}/settings", headers=adm, json={"photo_keep_days": 0}) + + +def test_server_monitor(): + with TestClient(app) as c: + adm = {"Authorization": "Bearer " + c.post(f"{V1}/auth/login", json={"username": "admin", "password": "admin"}).json()["access_token"]} + c.post(f"{V1}/users", headers=adm, json={"username": "tw_adm", "password": PW, "is_admin": True}) + adm2 = {"Authorization": "Bearer " + c.post(f"{V1}/auth/login", json={"username": "tw_adm", "password": PW}).json()["access_token"]} + assert c.get(f"{V1}/server/monitor", headers=adm2).status_code == 403 # только администратор портала + m = c.get(f"{V1}/server/monitor", headers=adm).json() + assert m["cpu"] is None or 0 <= m["cpu"] <= 100 + assert m["memory"] is None or 0 < m["memory"]["used"] <= m["memory"]["total"] + assert m["disks"] and all(0 < d["used"] <= d["total"] for d in m["disks"]) diff --git a/tests/test_security.py b/tests/test_security.py index efa1702..0752413 100644 --- a/tests/test_security.py +++ b/tests/test_security.py @@ -77,7 +77,8 @@ def env(): # ---------------------------------------------------------------- аутентификация PUBLIC = {("POST", "/auth/login"), ("POST", "/auth/refresh"), ("POST", "/auth/logout"), ("GET", "/health"), ("GET", "/demo/status"), - ("GET", "/public/report-options"), ("POST", "/public/incidents"), ("GET", "/app/version")} # публичная форма инцидента: без входа, с лимитом + ("GET", "/public/report-options"), ("POST", "/public/incidents"), ("GET", "/app/version"), + ("GET", "/docs/raw/{token}")} # исходник для ONLYOFFICE: только по короткой подписанной ссылке (проверка — test_scope_docs) # публичная форма инцидента: без входа, с лимитом def test_every_api_route_requires_auth(env): @@ -392,7 +393,9 @@ def test_stored_photo_name_is_server_generated(env): from app.models import Photo with SessionLocal() as s: for p in s.query(Photo).all(): - assert ".." not in p.path and not p.path.startswith(("/", "\\")) and len(p.path.split("/")) == 3 + parts = p.path.split("/") + parts = parts[1:] if parts[0] == "keep" else parts # keep/ — фото «хранить более года» + assert ".." not in p.path and not p.path.startswith(("/", "\\")) and len(parts) == 3 def test_static_path_traversal_blocked(env): diff --git a/tools/guide/build_pdf.py b/tools/guide/build_pdf.py index daddb89..0dd6bd0 100644 --- a/tools/guide/build_pdf.py +++ b/tools/guide/build_pdf.py @@ -333,8 +333,9 @@ def build(): ["Наряд", "Одно задание: что проверить или обслужить, на каком оборудовании, кому, до какого срока и кто проверит."], ["Чек-лист", "Список задач внутри наряда. У каждой задачи свой тип ответа: статус, выбор из списка, число или текст; " "у некоторых нужно приложить фото."], - ["Должность и график", "Сотрудник привязан к должности, у должности есть график (смены). Наряд, назначенный на должность, " - "видят и могут взять только те, кто сейчас на смене."], + ["Должность и график", "Сотрудник занимает должность и работает по своему графику (смены). Одну должность могут занимать " + "несколько сменщиков. Наряд, назначенный на должность, видят, берут и получают уведомление только те, " + "кто сейчас на смене, — сменщики дома не беспокоятся."], ["Расписание нарядов", "Наряд настраивается один раз и затем создаётся автоматически: каждый день, по дням недели, " "раз в месяц или каждые N дней."], ["Проверка", "После сдачи наряд уходит проверяющему: он принимает его или возвращает на доработку с комментарием."], @@ -562,7 +563,8 @@ def build(): pdf.bullets([ "для **числа** задайте единицу измерения и границы нормы — отклонения будут подсвечиваться и попадут в отчёт;", "для **списка** — варианты ответа, по одному в строке;", - "для любой задачи — режим фото: без фото, по желанию, обязательно;", + "для любой задачи — режим фото: без фото, по желанию, обязательно; если фото нужно сохранить надолго (табличка, акт, " + "дефект), отметьте **«хранить более года»** — такие снимки лежат в отдельном каталоге и не удаляются автоочисткой сервера;", "стрелками меняется порядок задач, галочкой «Обязательный» отмечаются пункты, без которых наряд не сдать.", ]) pdf.wide("d_checklist", "Редактор чек-листа") @@ -708,6 +710,15 @@ def build(): "В приложении Android история открывается кнопкой «История машины».") pdf.wide("d_history", "История машины с фильтрами") pdf.phone("a_history", "История машины в приложении Android", width=70) + pdf.h2("Документация машины (Nextcloud)") + pdf.p("Если подключён Nextcloud, в карточке машины есть блок **«Документация»**: паспорта, руководства, схемы, хранящиеся " + "в Nextcloud по машинам. Кнопка **«Открыть»** показывает папку машины: слева файлы и вложенные папки, справа — документ. " + "**PDF, картинки и текстовые файлы** открываются прямо в портале, документы **Word, Excel, PowerPoint** — тоже, после перевода в PDF " + "на сервере ONLYOFFICE (первое открытие — несколько секунд). Любой файл можно **скачать**. Заходить в Nextcloud не нужно: " + "портал сам забирает файлы и показывает только документацию машин, которые видит сотрудник.") + pdf.p("**Привязывают папку администратор или модератор**: «Привязать папку» в блоке «Документация» открывает проводник по " + "документации — выберите папку машины и нажмите «Выбрать» или «Привязать эту папку». Рядом с папкой видно, к каким машинам " + "она уже привязана. «Сменить папку» и «Отвязать» — там же.") pdf.h2("Сообщения об отказах извне") pdf.p("Об аварии может сообщить и человек без учётной записи — подрядчик, охрана, смежная служба. Для этого есть **публичная " "форма** по адресу **/report** (её удобно разместить на QR-коде у оборудования). В форме выбирается **место** — после этого " @@ -832,16 +843,22 @@ def build(): "висит приглашение запустить мастер). Мастер проводит по шагам в порядке зависимостей данных:") pdf.bullets([ "**Сервер** — проверить часовой пояс, каталог фото, время сервера.", - "**Графики** — типовые одним нажатием (пятидневка, сутки через трое, 2/2 день-ночь, круглосуточно) или в конструкторе.", + "**Графики** — типовые одним нажатием (пятидневка, сутки через трое, 2/2 день-ночь, круглосуточно) или в конструкторе. " + "График потом назначается каждому сотруднику.", "**Должности → отделы → места → оборудование** — пакетным вводом: одна строка — одна запись, поля через «;». " "Для оборудования: «Название; инв. №; место; наработка; отдел; кран» — отдел закрепляет машину за службой, слово «кран» " "отправляет её в раздел «Краны».", - "**Сотрудники** — строки «ФИО; логин; должность; отдел; роль». Логины и временные пароли создаются сами и показываются " + "**Сотрудники** — строки «ФИО; логин; должность; отдел; график; роль». Логины и временные пароли создаются сами и показываются " "таблицей для печати или CSV — **только один раз**.", "**Карта завода** (и подсказка про отдельную карту кранов), **склады и начальные остатки инструмента**, **чек-листы и расписание**, **внешние обращения**, **резервные копии**.", ]) pdf.wide("d_setup", "Мастер первичной настройки") - pdf.note("Шаг считается выполненным, когда в нём есть записи, или если отметить «шаг выполнен». К мастеру можно возвращаться в любое время.", "info") + pdf.note("Шаг считается выполненным, когда в нём есть записи, или если отметить «шаг выполнен» — мастер сразу переходит к следующему. " + "К мастеру можно возвращаться в любое время.", "info") + pdf.p("Кнопка **«Завершить и проверить»** на последнем шаге открывает **проверку готовности**: мастер смотрит на введённые данные и " + "показывает, что мешает работе — сотрудники без должности, должности, которые никто не занимает, должности, где сейчас по " + "графикам никого нет на смене, машины без места, нет чек-листов или расписания, выключены автокопии или автоочистка фото. " + "Рядом с каждым пунктом — кнопка, чтобы поправить. После завершения плитка мастера ведёт сразу к этой проверке.") pdf.h2("AI-помощник (включает администратор)") pdf.p("В «Настройках сервера» администратор может включить **AI-помощника**: указать провайдера (OpenAI-совместимый — OpenAI, " @@ -855,6 +872,12 @@ def build(): "Ключ API не показывается в настройках и не попадает в резервные копии.", ]) pdf.wide("d_assistant_settings", "Настройка AI-помощника") + pdf.p("**«Работа с документацией»** — отдельная опция помощника в той же карточке. Включённая, она раз в сутки собирает индекс " + "документации машин (имена файлов и начало текста PDF и документов Office), и помощник отвечает на вопросы вида " + "«где схема двигателя компрессора?» ссылкой на нужный файл и подсказкой, что в нём искать.") + pdf.note("Риски опции: имена файлов и фрагменты документов уходят провайдеру ИИ — с облачным провайдером документация покидает " + "предприятие, для закрытых документов используйте локальную модель. Запросы становятся в разы длиннее, расход токенов " + "растёт. Помощник видит только начало документов — ответ проверяйте в самом файле.", "warn") pdf.wide("d_assistant", "Помощник: ответ по данным пользователя и подсказка «как сделать»") pdf.phone("a_assistant", "Помощник в приложении Android (круглая кнопка справа внизу или пункт меню «Помощник»)", width=70) @@ -883,9 +906,13 @@ def build(): "пользователя. Остальным администраторам мастер первичной настройки и резервные копии доступны как обычно.") pdf.h2("Должности и графики работы") - pdf.p("**Должность** имеет тип (**техник** выполняет наряды, **инженер** проверяет и планирует) и **график**. По графику " - "система определяет, кто сейчас «на смене» и кому показывать наряды, назначенные на должность. Должность без графика " - "считается всегда на смене.") + pdf.p("**Должность** имеет тип (**техник** выполняет наряды, **инженер** проверяет и планирует). **График** задаётся не должности, " + "а **сотруднику** («Пользователи» → «Изменить» → «График работы»): должность выполняет тот, кто её занимает и сейчас на смене. " + "Например, «Дежурный механик» — четыре сменщика с графиком «сутки через трое», каждый со своим первым днём цикла: наряд или " + "задание на эту должность увидит и получит уведомление только тот, кто сегодня дежурит. Сотрудник без графика считается " + "всегда на смене.") + pdf.note("При обновлении портала графики, которые раньше были заданы должностям, автоматически перенесены их сотрудникам. " + "Для сменщиков поправьте графики (или первый день цикла), чтобы на смене был один человек.", "info") pdf.p("Графики настраиваются **конструктором**: можно собрать любой цикл — дневные и ночные смены, отсыпные, сутки через трое. " "Каждый шаг цикла — это «Смена» или «Выходной», число дней подряд, время начала и конца и метка (например, «день», " "«ночь», «отсыпной»). Если конец смены раньше начала (20:00–08:00), смена ночная; одинаковое время (08:00–08:00) — сутки.") @@ -901,6 +928,8 @@ def build(): pdf.p("Разделы **«Оборудование»** и **«Места»** — справочники для нарядов и отчётов. Оборудование можно привязать к месту " "(например, «Цех №1»), указать инвентарный номер, **отдел** (кто его видит) и отметку **«Кран»**. Неиспользуемое оборудование " "отключается флажком «Активно».") + pdf.p("Над списком оборудования (и кранов) — фильтр **«Место»**: выберите цех или участок, и в списке останутся машины этого " + "места и всех вложенных в него мест (например, «Цех №1» покажет и машины «Компрессорной», входящей в цех).") pdf.h2("Обратная связь по порталу") pdf.p("Внизу каждой страницы сайта — неприметная ссылка **«Предложить улучшение портала или сообщить об ошибке»**; в мобильной версии и " @@ -914,6 +943,15 @@ def build(): pdf.p("Раздел **«Настройки сервера»** позволяет изменить каталог для хранения фотографий и максимальный размер снимка, а также " "ключ для мгновенных уведомлений Android, включить публичную форму заявок и **«Краны — отдельная служба»**. Внизу виден " "расход места: сколько фото хранится и сколько свободно на диске.") + pdf.p("**Автоочистка фото**: поле «удалять фото нарядов и заданий старше, дней» (например, 365). Раз в сутки сервер удаляет " + "фото старше этого срока; 0 — не удалять. Снимки с отметкой **«хранить более года»** (в чек-листе у пункта или в задании) " + "с самого начала лежат в отдельном каталоге **keep/** и не удаляются. В блоке «Состояние» видно, сколько фото будет удалено " + "при следующей очистке; кнопка «Очистить сейчас» запускает её сразу.") + pdf.note("Удалённые фото не восстановить, кроме как из резервной копии с фото. По умолчанию автоочистка выключена.", "warn") + pdf.h2("Мониторинг сервера (только администратор портала)") + pdf.p("«Администрирование → **Мониторинг сервера**»: круговые диаграммы загрузки **процессора**, занятой **памяти** и места на " + "**дисках** с данными портала и фото. Значения обновляются каждые 5 секунд, пока страница открыта; от 75 % диаграмма " + "становится жёлтой, от 90 % — красной. В Docker с лимитом памяти показывается память контейнера.") pdf.wide("d_server", "Настройки сервера", width=150) pdf.note("При смене каталога фото старые снимки автоматически **не переносятся** — скопируйте файлы вручную.", "warn") @@ -935,6 +973,19 @@ def build(): pdf.note("Рабочие данные в демо-режиме не изменяются и не удаляются.", "info") # ================= 7 ================= + pdf.h2("Подключение Nextcloud и ONLYOFFICE") + pdf.p("«Настройки сервера» → **«Документация из Nextcloud»** (только администратор портала).") + pdf.bullets([ + "В Nextcloud заведите служебного пользователя (например, portal-docs), дайте ему **доступ только на чтение** к папке " + "с документацией и создайте для него **пароль приложения** (Настройки → Безопасность).", + "В портале укажите адрес Nextcloud (лучше внутренний, в локальной сети), учётную запись, пароль приложения и корневую папку " + "документации. Кнопка «Проверить» покажет, сколько папок видно в корне.", + "Для документов Office укажите адрес **ONLYOFFICE Document Server** в локальной сети, его **секрет JWT** (если включён) и " + "**внутренний адрес портала** — по нему ONLYOFFICE забирает исходник по одноразовой ссылке. Без ONLYOFFICE документы " + "Office только скачиваются.", + "Сотрудникам Nextcloud не нужен: портал сам ходит в него и проверяет, кто какую машину видит. Пароль приложения и секрет " + "JWT не показываются в настройках и не попадают в резервные копии.", + ]) pdf.h1("14. Частые вопросы") faq = [ ("Я не вижу машину (или кран), которая точно есть.", @@ -944,8 +995,8 @@ def build(): "Вы в демонстрационном режиме — это тестовые данные. Нажмите **«Выйти из демо»** в полосе. Если кнопки нет или не помогает, " "попросите администратора выключить демо для вас."), ("Я не вижу наряд, который мне должны были назначить.", - "Наряд, назначенный на должность, виден только сотрудникам этой должности **на смене**. Проверьте график смены и должность в " - "настройках; если вы вне смены — наряд появится, когда смена начнётся. Нажмите обновление списка. Если наряд назначен " + "Наряд, назначенный на должность, виден только сотрудникам этой должности **на смене** по их графику. Проверьте свой график " + "и должность в карточке; если вы вне смены — наряд появится, когда смена начнётся. Нажмите обновление списка. Если наряд назначен " "другому человеку или уже взят коллегой, вы его не увидите."), ("Пишет «Нет связи» или «не отправлено» и не исчезает.", "Проверьте интернет на телефоне. Данные сохранены и не пропадут: они уйдут сами. Для немедленной попытки нажмите кнопку "