feat: документация из Nextcloud, график у сотрудника, автоочистка фото, мониторинг, итог мастера, фильтр по местам; доп. сервисы в docker-compose

- docker-compose.yml: профили docs (Nextcloud), office (ONLYOFFICE Document Server), ai (Ollama + скачивание модели)
- автонастройка: адреса Nextcloud, ONLYOFFICE, портала и модели подставляются из .env или по именам сервисов
  во внутренней сети Docker; значения из интерфейса важнее
- локальная модель в своей сети (имя сервиса, частный IP) подключается без ключа API
- .env.example, docs/DEPLOY.md (раздел 7а), README
This commit is contained in:
darkorion
2026-10-13 13:27:20 +03:00
parent 3a6e1f444c
commit 0f239d5015
44 changed files with 2131 additions and 94 deletions
+42
View File
@@ -16,3 +16,45 @@ APP_PORT=8080
# там виден адрес самого прокси (на том же хосте — обычно шлюз сети Docker, например 172.18.0.1). Впишите его и выполните # там виден адрес самого прокси (на том же хосте — обычно шлюз сети Docker, например 172.18.0.1). Впишите его и выполните
# docker compose up -d — после этого в той строке появится ваш собственный адрес. # docker compose up -d — после этого в той строке появится ваш собственный адрес.
FORWARDED_ALLOW_IPS=127.0.0.1 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=
+6
View File
@@ -133,6 +133,12 @@ python -m venv .venv && .venv\Scripts\pip install -r requirements.txt
Пошаговая установка — [docs/DEPLOY.md](docs/DEPLOY.md). Пошаговая установка — [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) ## Установка без Coolify (Docker Compose)
Пошаговая инструкция для любого Linux-сервера с Docker: **[docs/DEPLOY.md](docs/DEPLOY.md)** (шаблон настроек — `.env.example`). Пошаговая инструкция для любого Linux-сервера с Docker: **[docs/DEPLOY.md](docs/DEPLOY.md)** (шаблон настроек — `.env.example`).
Коротко: `cp .env.example .env` → заполнить → `docker compose up -d --build` → обратный прокси с HTTPS на `APP_PORT`. Коротко: `cp .env.example .env` → заполнить → `docker compose up -d --build` → обратный прокси с HTTPS на `APP_PORT`.
+39 -10
View File
@@ -16,10 +16,12 @@ import re
import time import time
from pathlib import Path from pathlib import Path
from urllib.parse import quote
import httpx import httpx
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from . import config, settings_store as ss from . import autoconf, config, settings_store as ss
from .models import User from .models import User
log = logging.getLogger("assistant") log = logging.getLogger("assistant")
@@ -59,7 +61,7 @@ def public_cfg(db: Session) -> dict:
def ready(db: Session) -> bool: def ready(db: Session) -> bool:
c = cfg(db) 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"]) 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"), "equipment_history": ("История машины. args: {\"id\": id машины}", "GET", "/equipment/{id}/history?limit=30", "id"),
"find_people": ("Сотрудники: ФИО, должность, на смене ли", "GET", "/users", None), "find_people": ("Сотрудники: ФИО, должность, на смене ли", "GET", "/users", None),
"warehouses": ("Склады и сколько на них позиций", "GET", "/warehouses", 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", 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", "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", "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): def _slim(x, depth=0):
@@ -101,11 +110,18 @@ def _slim(x, depth=0):
return x return x
async def run_tool(app, token: str, name: str, args: dict) -> str: async def run_tool(app, token: str, name: str, args: dict, docs: bool = False) -> str:
if name not in TOOLS: if name not in TOOLS or (name in DOC_TOOLS and not docs):
return json.dumps({"error": f"нет инструмента {name}"}, ensure_ascii=False) return json.dumps({"error": f"нет инструмента {name}"}, ensure_ascii=False)
_, method, path, arg = TOOLS[name] _, 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) v = args.get(arg)
if not isinstance(v, int) and not (isinstance(v, str) and v.isdigit()): if not isinstance(v, int) and not (isinstance(v, str) and v.isdigit()):
return json.dumps({"error": f"нужен числовой параметр {arg}"}, ensure_ascii=False) return json.dumps({"error": f"нужен числовой параметр {arg}"}, ensure_ascii=False)
@@ -129,9 +145,21 @@ def who(u: User) -> str:
f"роли: {', '.join(roles) or 'нет особых ролей'}.") f"роли: {', '.join(roles) or 'нет особых ролей'}.")
def system_prompt(u: User, scope: str) -> str: DOCS_RULES = """
tools = "\n".join(f"- {k}: {v[0]}" for k, v in TOOLS.items()) ДОКУМЕНТАЦИЯ (это тоже тема портала, не 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 = f"\nДополнительно от администратора: {scope.strip()}\n" if scope and scope.strip() else ""
extra += DOCS_RULES if docs else ""
return f"""Ты — помощник внутри «Портала технической службы» (ремонтная служба предприятия). Отвечай по-русски, кратко, по шагам. 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": # некоторые провайдеры требуют начинать с пользователя while msgs and msgs[0]["role"] != "user": # некоторые провайдеры требуют начинать с пользователя
msgs.pop(0) msgs.pop(0)
rate_check(u.id) 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 = [] used = []
for _ in range(MAX_STEPS + 1): for _ in range(MAX_STEPS + 1):
text = (await complete(c, system, msgs)).strip() 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) args = json.loads(raw)
except ValueError: except ValueError:
args = {} 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) used.append(name)
msgs += [{"role": "assistant", "content": text}, {"role": "user", "content": f"РЕЗУЛЬТАТ {name}: {result}\nОтветь пользователю."}] msgs += [{"role": "assistant", "content": text}, {"role": "user", "content": f"РЕЗУЛЬТАТ {name}: {result}\nОтветь пользователю."}]
return {"reply": "Не получилось собрать ответ. Попробуйте переформулировать вопрос.", "offtopic": False, "tools": used} return {"reply": "Не получилось собрать ответ. Попробуйте переформулировать вопрос.", "offtopic": False, "tools": used}
+8 -2
View File
@@ -26,7 +26,8 @@
- Сотрудники — #/people — все: поиск, фильтр по отделу; карточка #/user/<id>: ФИО, фото, должность, отдел, график, «что умею», задания, инструмент (инструмент коллеги видят только склад и инженеры). Своё фото, телефон и навыки — в своей карточке (нажать своё имя вверху меню). - Сотрудники — #/people — все: поиск, фильтр по отделу; карточка #/user/<id>: ФИО, фото, должность, отдел, график, «что умею», задания, инструмент (инструмент коллеги видят только склад и инженеры). Своё фото, телефон и навыки — в своей карточке (нажать своё имя вверху меню).
- Отчёты — #/reports — инженер+: «Выполненные наряды», «Текущее состояние» узлов, «Выполнение работ» (% по машинам, отклонения), «По сотрудникам» #/reports/people (сколько и как качественно выполнено за период: в срок, просрочки, возвраты с проверки, отклонения, трудоёмкость; списком и календарём по дням); PDF и CSV. - Отчёты — #/reports — инженер+: «Выполненные наряды», «Текущее состояние» узлов, «Выполнение работ» (% по машинам, отклонения), «По сотрудникам» #/reports/people (сколько и как качественно выполнено за период: в срок, просрочки, возвраты с проверки, отклонения, трудоёмкость; списком и календарём по дням); PDF и CSV.
- Администрирование — #/admin — модератор, админ: пользователи #/users, должности #/positions, графики #/schedules (конструктор), отделы #/departments, места #/locations. - Администрирование — #/admin — модератор, админ: пользователи #/users, должности #/positions, графики #/schedules (конструктор), отделы #/departments, места #/locations.
- Только администратор портала: настройки сервера #/server (фото, публичная форма, «Краны — отдельная служба», ключи API, AI-помощник) и обратная связь #/feedback. Только администратор: резервные копии #/backups, мастер первичной настройки #/setup, демонстрационный режим #/demo. - Документация машины (если подключён Nextcloud): блок «📁 Документация» в карточке машины, кнопка «Открыть» — #/docs/<id машины>. PDF, картинки и текст открываются в портале, Word/Excel/PowerPoint — после перевода в PDF (ONLYOFFICE), любой файл можно скачать. Привязывают папку администратор или модератор: «Привязать папку» в том же блоке, выбор в проводнике. Видно документацию только тех машин, которые видит сотрудник.
- Только администратор портала: настройки сервера #/server (фото, публичная форма, «Краны — отдельная служба», ключи API, документация из Nextcloud, AI-помощник и его опция «Работа с документацией») и обратная связь #/feedback. Только администратор: резервные копии #/backups, мастер первичной настройки #/setup, демонстрационный режим #/demo.
- Демонстрационный режим — личный: включается только у того, кто нажал «Войти в демо», остальные работают с рабочими данными. Пока он включён, вверху жёлтая полоса «ДЕМО-РЕЖИМ (только у вас)» с кнопкой «Выйти из демо». Администратор на странице #/demo может включить или выключить демо любому сотруднику. - Демонстрационный режим — личный: включается только у того, кто нажал «Войти в демо», остальные работают с рабочими данными. Пока он включён, вверху жёлтая полоса «ДЕМО-РЕЖИМ (только у вас)» с кнопкой «Выйти из демо». Администратор на странице #/demo может включить или выключить демо любому сотруднику.
- Обратная связь по порталу — ссылка «Предложить улучшение портала или сообщить об ошибке» внизу каждой страницы (в мобильной версии и приложении — внизу бокового меню): идея, ошибка или другое. Ответы видят только администраторы портала — #/feedback. - Обратная связь по порталу — ссылка «Предложить улучшение портала или сообщить об ошибке» внизу каждой страницы (в мобильной версии и приложении — внизу бокового меню): идея, ошибка или другое. Ответы видят только администраторы портала — #/feedback.
- Администратор портала — право администратора на настройки сервера и обратную связь. У admin оно есть всегда; другим администраторам его включает admin в карточке пользователя («Администратор портала»). - Администратор портала — право администратора на настройки сервера и обратную связь. У admin оно есть всегда; другим администраторам его включает admin в карточке пользователя («Администратор портала»).
@@ -41,7 +42,12 @@
- Администратор: всё, включая настройки сервера, копии, демо-режим. - Администратор: всё, включая настройки сервера, копии, демо-режим.
## Частые вопросы ## Частые вопросы
- Не вижу наряд: наряд на должность виден только сотрудникам этой должности на смене; проверьте график. - Не вижу наряд: наряд на должность виден только сотрудникам этой должности, которые сейчас на смене по своему графику; проверьте график в своей карточке.
- График работы задаётся сотруднику (#/users → «Изменить» → «График работы»), не должности. Одну должность могут занимать сменщики: наряды и уведомления должности получает только тот, кто на смене. Без графика — всегда на смене.
- Фото к пункту чек-листа или к заданию можно отметить «хранить более года» (в конструкторе чек-листа рядом с режимом фото, в форме задания) — такие фото не удаляются автоочисткой. Автоочистку (удалять фото старше N дней) включает администратор портала в #/server.
- Мониторинг сервера — #/monitor (администратор портала): загрузка процессора, память, место на дисках.
- Фильтр «Место» над списком оборудования #/equipment и кранов: показывает машины места и вложенных мест.
- Мастер первичной настройки: «Завершить и проверить» открывает проверку готовности #/setup/done — что мешает работе и где поправить.
- Не могу сдать наряд: не заполнены обязательные пункты или нет обязательного фото. - Не могу сдать наряд: не заполнены обязательные пункты или нет обязательного фото.
- Нет связи: в мобильной версии и приложении ответы сохраняются на телефоне и уйдут сами. - Нет связи: в мобильной версии и приложении ответы сохраняются на телефоне и уйдут сами.
- Не вижу машину или кран: оборудование закреплено за другим отделом, или краны вынесены в раздел «Краны». - Не вижу машину или кран: оборудование закреплено за другим отделом, или краны вынесены в раздел «Краны».
+71
View File
@@ -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
+2 -2
View File
@@ -22,11 +22,11 @@ from .db import Base
log = logging.getLogger("backup") log = logging.getLogger("backup")
FORMAT = 1 FORMAT = 1
NAME_RE = re.compile(r"backup_\d{8}_\d{6}(_[a-z0-9-]{1,20})?\.zip") 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", 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") "REFRESH_TTL_DAYS", "LOGIN_MAX_FAILS", "LOGIN_WINDOW_SEC", "SCHEDULER_INTERVAL", "ADMIN_USERNAME")
ENV_SECRET = ("SECRET_KEY", "DATABASE_URL", "DB_PASSWORD", "ADMIN_PASSWORD") 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"} DEFAULTS = {"backup_auto_hours": "24", "backup_keep": "14", "backup_media": "0"}
+3
View File
@@ -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) demo_portal.seed(db, users=users, eq=eq, pos=pos, sch_store=sch_d, pw=pw, now=now, rnd=rnd)
db.commit() db.commit()
logic.schedules_to_users(db) # графики демо-должностей — сотрудникам
def demo_accounts(db: Session) -> list[dict]: def demo_accounts(db: Session) -> list[dict]:
@@ -443,3 +444,5 @@ def activate_on_startup() -> None:
e = _engine_for(mode.State.db_file) e = _engine_for(mode.State.db_file)
ensure_schema(e) ensure_schema(e)
_db.set_demo_engine(e) _db.set_demo_engine(e)
with sessionmaker(bind=e)() as db: # демо-база прежней версии: графики должностей — сотрудникам
logic.schedules_to_users(db)
+160
View File
@@ -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"</(w:p|a:p|text:p|si|row)>", "\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]]
+21 -3
View File
@@ -67,8 +67,26 @@ def shift_active(s: ShiftSchedule | None, dt: datetime) -> bool:
def user_on_shift(u: User, dt: datetime | None = None) -> bool: def user_on_shift(u: User, dt: datetime | None = None) -> bool:
"""На смене ли сотрудник: занимает должность и сейчас по СВОЕМУ графику работает (без графика — всегда)."""
dt = dt or config.now() 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: for it in cl.items:
o.items.append(OrderItem(position=it.position, text=it.text, answer_type=it.answer_type, 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, 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) db.add(o)
return o return o
@@ -141,7 +159,7 @@ def _regular_items(db: Session, o: Order, cl: Checklist) -> None:
pos += 1 pos += 1
o.items.append(OrderItem(position=pos, text=it.text, answer_type=it.answer_type, options=it.options, 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, 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: for u, ev in units:
pos += 1 pos += 1
o.items.append(OrderItem(position=pos, text=service.describe(u, ev) + " — обслужено?", answer_type="status", o.items.append(OrderItem(position=pos, text=service.describe(u, ev) + " — обслужено?", answer_type="status",
+23 -2
View File
@@ -9,13 +9,13 @@ from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from sqlalchemy import select 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 .db import SessionLocal, real_engine
from .migrate import ensure_schema from .migrate import ensure_schema
from .models import User from .models import User
from .routers import catalog, orders, extras, reports, service as service_router, demo as demo_router 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 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 from .security import hash_password
log = logging.getLogger("scheduler") log = logging.getLogger("scheduler")
@@ -49,6 +49,14 @@ def bootstrap():
db.commit() db.commit()
log.warning("Создан администратор '%s' — смените пароль!", config.ADMIN_USERNAME) log.warning("Создан администратор '%s' — смените пароль!", config.ADMIN_USERNAME)
push.ensure_vapid(db) 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(): async def scheduler_loop():
@@ -67,6 +75,18 @@ async def scheduler_loop():
backup.auto_backup(db) backup.auto_backup(db)
except Exception: except Exception:
log.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) await asyncio.sleep(config.SCHEDULER_INTERVAL)
@@ -123,6 +143,7 @@ api.include_router(backups.router)
api.include_router(assistant_router.router) api.include_router(assistant_router.router)
api.include_router(reliability_router.router) api.include_router(reliability_router.router)
api.include_router(feedback_router.router) api.include_router(feedback_router.router)
api.include_router(docs_router.router)
@api.get("/health", tags=["service"]) @api.get("/health", tags=["service"])
+6 -4
View File
@@ -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")] 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"} 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") 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] return data, kind[0], kind[1]
def save(db: Session, kind: str, data: bytes, ext: str) -> str: def save(db: Session, kind: str, data: bytes, ext: str, keep: bool = False) -> str:
name = ss.new_name() + ext name = ("keep/" if keep else "") + ss.new_name() + ext
(root(db, kind) / name).write_bytes(data) f = root(db, kind) / name
f.parent.mkdir(parents=True, exist_ok=True)
f.write_bytes(data)
return name return name
+23 -1
View File
@@ -27,8 +27,8 @@ class Position(Base):
id: Mapped[int] = mapped_column(primary_key=True) id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(String(100), unique=True) name: Mapped[str] = mapped_column(String(100), unique=True)
kind: Mapped[str] = mapped_column(String(12), default="technician") 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_id: Mapped[int | None] = mapped_column(ForeignKey("schedules.id"), nullable=True)
schedule: Mapped[ShiftSchedule | None] = relationship(lazy="joined")
class Department(Base): class Department(Base):
@@ -55,6 +55,9 @@ class User(Base):
is_active: Mapped[bool] = mapped_column(Boolean, default=True) is_active: Mapped[bool] = mapped_column(Boolean, default=True)
position_id: Mapped[int | None] = mapped_column(ForeignKey("positions.id"), nullable=True) position_id: Mapped[int | None] = mapped_column(ForeignKey("positions.id"), nullable=True)
position: Mapped[Position | None] = relationship(lazy="joined") 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_id: Mapped[int | None] = mapped_column(ForeignKey("departments.id"), nullable=True)
department: Mapped[Department | None] = relationship(lazy="joined", foreign_keys=[department_id]) 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) department_id: Mapped[int | None] = mapped_column(ForeignKey("departments.id"), nullable=True)
# кран (грузоподъёмное оборудование): при включённой карте кранов показывается на ней, а не на карте завода # кран (грузоподъёмное оборудование): при включённой карте кранов показывается на ней, а не на карте завода
is_crane: Mapped[bool] = mapped_column(Boolean, default=False) is_crane: Mapped[bool] = mapped_column(Boolean, default=False)
# папка документации в Nextcloud (путь от корня документации в настройках сервера); пусто — не привязана
docs_path: Mapped[str] = mapped_column(String(500), default="")
class ServiceUnit(Base): class ServiceUnit(Base):
@@ -160,6 +165,7 @@ class ChecklistItem(Base):
max_value: Mapped[float | None] = mapped_column(Float, nullable=True) max_value: Mapped[float | None] = mapped_column(Float, nullable=True)
required: Mapped[bool] = mapped_column(Boolean, default=True) required: Mapped[bool] = mapped_column(Boolean, default=True)
photo: Mapped[str] = mapped_column(String(10), default="none") # none|optional|required 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): class OrderTemplate(Base):
@@ -232,6 +238,7 @@ class OrderItem(Base):
answered_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) answered_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
out_of_range: Mapped[bool] = mapped_column(Boolean, default=False) out_of_range: Mapped[bool] = mapped_column(Boolean, default=False)
photo: Mapped[str] = mapped_column(String(10), default="none") 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) # «узел обслужен» service_unit_id: Mapped[int | None] = mapped_column(ForeignKey("service_units.id"), nullable=True) # «узел обслужен»
is_meter: Mapped[bool] = mapped_column(Boolean, default=False) # пункт «наработка, м/ч» — обновляет счётчик машины is_meter: Mapped[bool] = mapped_column(Boolean, default=False) # пункт «наработка, м/ч» — обновляет счётчик машины
photos: Mapped[list["Photo"]] = relationship( photos: Mapped[list["Photo"]] = relationship(
@@ -276,6 +283,20 @@ class Feedback(Base):
answered_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) 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): class Setting(Base):
__tablename__ = "settings" __tablename__ = "settings"
key: Mapped[str] = mapped_column(String(64), primary_key=True) 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) updated_at: Mapped[datetime] = mapped_column(DateTime, default=config.now)
finished_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) finished_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
labor_hours: Mapped[float | None] = mapped_column(Float, nullable=True) # трудоёмкость (норма), ч — для отчёта по сотрудникам labor_hours: Mapped[float | None] = mapped_column(Float, nullable=True) # трудоёмкость (норма), ч — для отчёта по сотрудникам
photo_keep: Mapped[bool] = mapped_column(Boolean, default=False) # фото к заданию хранить более года
class TaskEvent(Base): class TaskEvent(Base):
+316
View File
@@ -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 = """<?xml version="1.0"?><d:propfind xmlns:d="DAV:"><d:prop><d:resourcetype/><d:getcontentlength/><d:getlastmodified/>
<d:getetag/><d:getcontenttype/></d:prop></d:propfind>"""
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
+63
View File
@@ -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
+4 -3
View File
@@ -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]: def users_for_assignment(db: Session, user_id, position_id) -> list[int]:
"""Кого уведомлять о назначении: конкретного человека или тех, кто сейчас на смене в должности.""" """Кого уведомлять о назначении: конкретного человека или тех, кто занимает должность и сейчас на смене
по своему графику (сменщики, которые дома, уведомлений не получают)."""
if user_id: if user_id:
return [user_id] return [user_id]
if position_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() 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 [] return []
+1 -1
View File
@@ -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()) ss.set_(db, "assistant_" + k, ("1" if v else "0") if isinstance(v, bool) else str(v).strip())
commit(db) commit(db)
c = A.cfg(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") ss.set_(db, "assistant_enabled", "0")
commit(db) commit(db)
raise HTTPException(422, "Для включения укажите модель и ключ API (без ключа — только локальная модель)") raise HTTPException(422, "Для включения укажите модель и ключ API (без ключа — только локальная модель)")
+3
View File
@@ -190,6 +190,7 @@ def me(db: Session = Depends(get_db), u: User = Depends(current_user)):
out.crane_map = scope.crane_map_enabled(db) out.crane_map = scope.crane_map_enabled(db)
from ..security import is_engineer, is_manager from ..security import is_engineer, is_manager
out.reliability = ss.get(db, "reliability") != "0" and (is_manager(u) or is_engineer(u)) 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 return out
@@ -357,6 +358,8 @@ def check_user_refs(db: Session, fields: dict):
raise HTTPException(422, "Отдел не найден") raise HTTPException(422, "Отдел не найден")
if fields.get("position_id") and not db.get(Position, fields["position_id"]): if fields.get("position_id") and not db.get(Position, fields["position_id"]):
raise HTTPException(422, "Должность не найдена") 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): def guard_user_edit(me: User, target: User | None, fields: dict):
+191
View File
@@ -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)
+26 -4
View File
@@ -9,10 +9,10 @@ from fastapi.responses import FileResponse
from sqlalchemy import select, func from sqlalchemy import select, func
from sqlalchemy.orm import Session 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 ..db import get_db
from ..models import Comment, Photo, Order, OrderItem, User, PushSubscription, DeviceToken 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 .catalog import commit
from .orders import load, exec_guard, start, names, can_execute, to_out 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") raise HTTPException(415, "Допустимы JPEG, PNG, WebP")
ct, ext = kind ct, ext = kind
root = ss.photo_root(db) 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).parent.mkdir(parents=True, exist_ok=True)
(root / rel).write_bytes(data) (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), 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} 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: 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()), "vapid_public": push.public_key(db), "now": config.iso(config.now()),
"fcm_enabled": push.fcm_enabled(db), "timezone": str(config.TZ)} "fcm_enabled": push.fcm_enabled(db), "timezone": str(config.TZ)}
+1 -1
View File
@@ -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) u = get_or_404(db, User, id)
now = config.now() now = config.now()
sch = u.position.schedule if u.position else None sch = u.schedule
shifts = [] shifts = []
if sch: if sch:
for i in range(14): for i in range(14):
+2 -2
View File
@@ -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), 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, 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, 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), 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, 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), 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)): if not (is_boss(u) or u.id in (t.assignee_id, t.created_by_id)):
raise HTTPException(403, "Нет прав добавлять фото") raise HTTPException(403, "Нет прав добавлять фото")
data, ct, ext = await media.read_image(db, request) 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)) db.add(TaskPhoto(task_id=t.id, path=name, content_type=ct, size=len(data), user_id=u.id))
t.updated_at = config.now() t.updated_at = config.now()
commit(db) commit(db)
+12 -1
View File
@@ -85,6 +85,7 @@ class UserIn(BaseModel):
portal_admin: bool = False portal_admin: bool = False
is_active: bool = True is_active: bool = True
position_id: Optional[int] = None position_id: Optional[int] = None
schedule_id: Optional[int] = None # график работы сотрудника (пусто — всегда на смене)
department_id: Optional[int] = None department_id: Optional[int] = None
skills: str = Field(default="", max_length=4000) skills: str = Field(default="", max_length=4000)
phone: str = Field(default="", max_length=40) phone: str = Field(default="", max_length=40)
@@ -100,6 +101,7 @@ class UserPatch(BaseModel):
portal_admin: Optional[bool] = None portal_admin: Optional[bool] = None
is_active: Optional[bool] = None is_active: Optional[bool] = None
position_id: Optional[int] = None position_id: Optional[int] = None
schedule_id: Optional[int] = None
department_id: Optional[int] = None department_id: Optional[int] = None
skills: Optional[str] = Field(default=None, max_length=4000) skills: Optional[str] = Field(default=None, max_length=4000)
phone: Optional[str] = Field(default=None, max_length=40) phone: Optional[str] = Field(default=None, max_length=40)
@@ -138,6 +140,7 @@ class UserOut(ORM):
is_active: bool is_active: bool
position_id: Optional[int] position_id: Optional[int]
position: Optional[PositionBrief] = None position: Optional[PositionBrief] = None
schedule_id: Optional[int] = None
department_id: Optional[int] = None department_id: Optional[int] = None
department: Optional["DepartmentBrief"] = None department: Optional["DepartmentBrief"] = None
has_photo: bool = False has_photo: bool = False
@@ -147,6 +150,7 @@ class UserOut(ORM):
demo_mode: bool = False demo_mode: bool = False
crane_map: bool = False # включена отдельная карта кранов crane_map: bool = False # включена отдельная карта кранов
reliability: bool = False # доступен раздел «Анализ надёжности» (включён и у пользователя есть права) reliability: bool = False # доступен раздел «Анализ надёжности» (включён и у пользователя есть права)
docs: bool = False # подключена документация из Nextcloud
portal_admin: bool = False # администратор портала: настройки сервера и обратная связь portal_admin: bool = False # администратор портала: настройки сервера и обратная связь
is_root: bool = False # администратор по умолчанию (admin): выдаёт права администратора портала is_root: bool = False # администратор по умолчанию (admin): выдаёт права администратора портала
@@ -222,7 +226,7 @@ class PreviewDay(BaseModel):
class PositionIn(BaseModel): class PositionIn(BaseModel):
name: str name: str
kind: Literal["technician", "engineer"] = "technician" kind: Literal["technician", "engineer"] = "technician"
schedule_id: Optional[int] = None schedule_id: Optional[int] = None # устарело и не используется: график задаётся сотруднику
class PositionOut(ORM): class PositionOut(ORM):
@@ -265,6 +269,7 @@ class EquipmentOut(EquipmentIn, ORM):
meter_updated_at: Optional[datetime] = None meter_updated_at: Optional[datetime] = None
map_x: Optional[float] = None map_x: Optional[float] = None
map_y: 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 max_value: Optional[float] = None
required: bool = True required: bool = True
photo: Literal["none", "optional", "required"] = "none" photo: Literal["none", "optional", "required"] = "none"
photo_keep: bool = False # фото хранить более года (не удаляются автоочисткой)
class ChecklistItemOut(ChecklistItemIn, ORM): class ChecklistItemOut(ChecklistItemIn, ORM):
@@ -460,6 +466,7 @@ class OrderItemOut(ORM):
answered_at: Optional[datetime] answered_at: Optional[datetime]
out_of_range: bool out_of_range: bool
photo: str photo: str
photo_keep: bool = False
service_unit_id: Optional[int] = None service_unit_id: Optional[int] = None
is_meter: bool = False is_meter: bool = False
photos: list[PhotoOut] = [] photos: list[PhotoOut] = []
@@ -532,6 +539,7 @@ class SettingsIn(BaseModel):
setup_done: Optional[str] = Field(default=None, max_length=1000) setup_done: Optional[str] = Field(default=None, max_length=1000)
crane_map: Optional[bool] = None # отдельная карта кранов crane_map: Optional[bool] = None # отдельная карта кранов
reliability: Optional[bool] = None # раздел «Анализ надёжности» reliability: Optional[bool] = None # раздел «Анализ надёжности»
photo_keep_days: Optional[int] = Field(default=None, ge=0, le=36500) # удалять фото старше N дней (0 — не удалять)
class PushSubIn(BaseModel): class PushSubIn(BaseModel):
@@ -841,6 +849,7 @@ class TaskIn(BaseModel):
equipment_id: Optional[int] = None equipment_id: Optional[int] = None
unit_id: Optional[int] = None unit_id: Optional[int] = None
labor_hours: Optional[float] = Field(default=None, ge=0, le=1000) labor_hours: Optional[float] = Field(default=None, ge=0, le=1000)
photo_keep: bool = False # фото к заданию хранить более года
class TaskPatch(BaseModel): class TaskPatch(BaseModel):
@@ -851,6 +860,7 @@ class TaskPatch(BaseModel):
equipment_id: Optional[int] = None equipment_id: Optional[int] = None
unit_id: Optional[int] = None unit_id: Optional[int] = None
labor_hours: Optional[float] = Field(default=None, ge=0, le=1000) labor_hours: Optional[float] = Field(default=None, ge=0, le=1000)
photo_keep: Optional[bool] = None
class TaskStatusIn(BaseModel): class TaskStatusIn(BaseModel):
@@ -892,6 +902,7 @@ class TaskOut(BaseModel):
updated_at: datetime updated_at: datetime
finished_at: Optional[datetime] finished_at: Optional[datetime]
labor_hours: Optional[float] = None labor_hours: Optional[float] = None
photo_keep: bool = False
last_comment: str = "" last_comment: str = ""
photos: list[int] = [] photos: list[int] = []
can_change: bool = False can_change: bool = False
+18 -3
View File
@@ -4,7 +4,7 @@ from pathlib import Path
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from . import config from . import autoconf, config
from .models import Setting from .models import Setting
DEFAULTS = { DEFAULTS = {
@@ -19,6 +19,8 @@ DEFAULTS = {
"setup_done": "", "setup_done": "",
"crane_map": "0", # отдельная карта кранов "crane_map": "0", # отдельная карта кранов
"reliability": "1", # раздел «Анализ надёжности» для инженеров и руководителей "reliability": "1", # раздел «Анализ надёжности» для инженеров и руководителей
"photo_keep_days": "0", # автоочистка: удалять фото нарядов и заданий старше N дней (0 — не удалять; keep/ не трогается)
"photo_cleanup_at": "", # когда очистка выполнялась последний раз
"crane_map_image": "", # картинка карты кранов (пусто — используется карта завода) # мастер первичной настройки: отмеченные шаги (через запятую) "crane_map_image": "", # картинка карты кранов (пусто — используется карта завода) # мастер первичной настройки: отмеченные шаги (через запятую)
# AI-помощник (не входит в EDITABLE: ключ не отдаётся в общих настройках, правится через /assistant/config) # AI-помощник (не входит в EDITABLE: ключ не отдаётся в общих настройках, правится через /assistant/config)
"assistant_enabled": "0", "assistant_enabled": "0",
@@ -27,14 +29,27 @@ DEFAULTS = {
"assistant_model": "", "assistant_model": "",
"assistant_api_key": "", "assistant_api_key": "",
"assistant_scope": "", "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", 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: def get(db: Session, key: str) -> str:
row = db.get(Setting, key) 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): def set_(db: Session, key: str, value: str):
+1 -1
View File
@@ -93,5 +93,5 @@ async function assistantSettings(box) {
window.assistantRefresh && window.assistantRefresh(); window.assistantRefresh && window.assistantRefresh();
}) }, "Проверить"), }) }, "Проверить"),
h("button", { onclick: guard(async () => { await api("PUT", "/assistant/config", body()); toast("Сохранено"); key.value = ""; window.assistantRefresh && window.assistantRefresh(); assistantSettings(box); }) }, "Сохранить")), h("button", { onclick: guard(async () => { await api("PUT", "/assistant/config", body()); toast("Сохранено"); key.value = ""; window.assistantRefresh && window.assistantRefresh(); assistantSettings(box); }) }, "Сохранить")),
out); out, await docsAiSection());
} }
+70 -15
View File
@@ -154,6 +154,9 @@ async function route() {
else if (page === "admin") await adminPage(); else if (page === "admin") await adminPage();
else if (page === "server") await serverPage(); else if (page === "server") await serverPage();
else if (page === "feedback") await feedbackPage(id); 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 === "backups") await backupsPage();
else if (page === "setup") await setupPage(id); else if (page === "setup") await setupPage(id);
else if (page === "new-order") await newOrderPage(); else if (page === "new-order") await newOrderPage();
@@ -350,13 +353,14 @@ const CRUD = {
{ k: "notes", label: "Примечание", type: "textarea" }, { k: "is_active", label: "Активно", type: "bool" }], { k: "notes", label: "Примечание", type: "textarea" }, { k: "is_active", label: "Активно", type: "bool" }],
init: { is_active: true }, init: { is_active: true },
filter: r => !(me.crane_map && r.is_crane), // при отдельной службе кранов краны живут в своём разделе 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 }, "Карточка") }, 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"]], 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: {} }, 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"]], 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", "Инженер (планирует, проверяет)"]] }, 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" } }, 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)}`]], 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 }, fields: [{ k: "name", label: "Название", type: "text", req: 1 },
{ k: "kind", label: "Тип", type: "enum", options: [["weekly", "По дням недели"], ["cycle", "Цикл (N через M)"]], reload: 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" }, 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" }], { 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 }, "Сотрудники") }, 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" }, fields: [{ k: "username", label: "Логин", type: "text", req: 1 }, { k: "full_name", label: "ФИО", type: "text" },
{ k: "password", label: "Пароль", hint: "При правке оставьте пустым, чтобы не менять", type: "password" }, { 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: "is_admin", label: "Администратор", hint: "Полный доступ к администрированию", type: "bool", show: () => me.is_admin },
{ k: "portal_admin", label: "Администратор портала", hint: "Настройки сервера и ответы обратной связи. Выдаёт только администратор по умолчанию (admin); у самого admin эти права всегда", type: "bool", show: () => me.is_root }, { k: "portal_admin", label: "Администратор портала", hint: "Настройки сервера и ответы обратной связи. Выдаёт только администратор по умолчанию (admin); у самого admin эти права всегда", type: "bool", show: () => me.is_root },
{ k: "is_storekeeper", label: "Администратор склада", hint: "Каталог инструмента, склады, выдача сотрудникам, заявки на инструмент", type: "bool" }, { 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"), cols: CRUD.equipment.cols.filter(c => c[0] !== "is_crane"),
fields: CRUD.equipment.fields.map(f => f.k === "is_crane" ? { ...f, hint: "Снимите, чтобы перенести машину в обычное оборудование" } : f) }; 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) { async function crudPage(page) {
const C = CRUD[page], box = h("div"), formBox = h("div"), can = C.canEdit ? C.canEdit() : true; 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), $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() { 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 = {}; const lookups = {};
for (const c of C.cols) if (c[2]) lookups[c[2]] = await ref(c[2], true); for (const c of C.cols) if (c[2]) lookups[c[2]] = await ref(c[2], true);
const cell = (r, [k, , rf, fn]) => { 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("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("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("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("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 === 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(); } }, "↓"), 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("h2", {}, "Задачи"), box,
h("div", { class: "row" }, h("button", { class: "sec", onclick: add }, "+ Добавить задачу"), h("button", { onclick: guard(async () => { 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 => ({ 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, 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, 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 })) }; 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 [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 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 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 ? "Панель администратора" : "Панель модератора"), $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" }, "🧭 Похоже, система ещё не настроена."), ...(eqCount === 0 || (me.is_admin && users.length <= 1) ? [h("div", { class: "card banner-setup" }, h("b", { style: "font-size:17px" }, "🧭 Похоже, система ещё не настроена."),
h("span", { class: "muted" }, "Мастер по шагам проведёт через графики, должности, отделы, места, оборудование, сотрудников, карту и склады."), h("span", { class: "muted" }, "Мастер по шагам проведёт через графики, должности, отделы, места, оборудование, сотрудников, карту и склады."),
@@ -586,17 +623,19 @@ async function adminPage() {
h("div", { class: "tiles" }, h("div", { class: "tiles" },
tile("#/checklists", "Чек-листы", "шаблоны проверок"), tile("#/equipment", "Оборудование", "справочник"), tile("#/checklists", "Чек-листы", "шаблоны проверок"), tile("#/equipment", "Оборудование", "справочник"),
tile("#/locations", "Места", "расположение"), tile("#/templates", "Расписание нарядов", "автозапуск")), 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" }, "Параметры сервера доступны только администратору. Модератор сам наряды не выполняет.")])); : [h("p", { class: "small muted" }, "Параметры сервера доступны только администратору. Модератор сам наряды не выполняет.")]));
} }
async function serverPage() { async function serverPage() {
if (!me.portal_admin) throw new Error("Настройки сервера доступны только администраторам портала"); 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 s = await api("GET", "/settings");
const f = formFields([ const f = formFields([
{ k: "photo_dir", label: "Каталог для фото", hint: "Абсолютный путь на сервере. В Docker — примонтированный том", type: "text" }, { k: "photo_dir", label: "Каталог для фото", hint: "Абсолютный путь на сервере. В Docker — примонтированный том", type: "text" },
{ k: "max_photo_mb", label: "Максимальный размер фото, МБ", type: "number" }, { 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: "vapid_contact", label: "Контакт для push-уведомлений", hint: "mailto:… или https://…", type: "text" },
{ k: "fcm_credentials_path", label: "Android push (FCM): ключ Firebase", hint: "Путь к JSON-ключу сервисного аккаунта на сервере. Пусто — выключено", type: "text" }, { k: "fcm_credentials_path", label: "Android push (FCM): ключ Firebase", hint: "Путь к JSON-ключу сервисного аккаунта на сервере. Пусто — выключено", type: "text" },
{ k: "public_incidents", label: "Публичная форма «Сообщить о неисправности» (/report)", hint: "Сторонние люди без входа сообщают об аварии: место, машина, описание, контакты", type: "bool" }, { k: "public_incidents", label: "Публичная форма «Сообщить о неисправности» (/report)", hint: "Сторонние люди без входа сообщают об аварии: место, машина, описание, контакты", type: "bool" },
@@ -606,12 +645,14 @@ async function serverPage() {
const mb = n => (n / 1048576).toFixed(1) + " МБ"; const mb = n => (n / 1048576).toFixed(1) + " МБ";
$app.replaceChildren(h("a", { href: "#/admin" }, "← Администрирование"), h("h1", {}, "Настройки сервера"), $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 () => { 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(); me = await api("GET", "/me"); toast("Сохранено"); serverPage();
}) }, "Сохранить")), h("p", { class: "small muted" }, "Смена каталога не переносит уже загруженные фото — скопируйте их вручную в новый каталог.")), }) }, "Сохранить")), h("p", { class: "small muted" }, "Смена каталога не переносит уже загруженные фото — скопируйте их вручную в новый каталог.")),
h("div", { class: "card" }, h("h2", { style: "margin-top:0" }, "Состояние"), h("div", { class: "card" }, h("h2", { style: "margin-top:0" }, "Состояние"),
h("div", { class: "grid" }, 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", {}, "Свободно на диске"), mb(s.photo_stats.disk_free)),
h("div", {}, h("label", {}, "Время сервера"), `${fmt(s.now)} (${s.timezone})`), h("div", {}, h("label", {}, "Время сервера"), `${fmt(s.now)} (${s.timezone})`),
h("div", {}, h("label", {}, "Адрес клиента, каким его видит сервер"), s.client_ip || "—", 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", {}, h("label", {}, "Браузерные уведомления"), location.protocol === "https:" || location.hostname === "localhost" ? "доступны" : "нужен HTTPS"))),
h("div", { class: "card" }, h("h2", { style: "margin-top:0" }, "Сообщения об отказах и неисправностях извне"), h("div", { class: "card" }, h("h2", { style: "margin-top:0" }, "Сообщения об отказах и неисправностях извне"),
h("p", { class: "small" }, "Публичная форма: ", h("a", { href: "/report", target: "_blank" }, location.origin + "/report"), h("p", { class: "small" }, "Публичная форма: ", h("a", { href: "/report", target: "_blank" }, location.origin + "/report"),
" — ссылку можно разместить на QR-коде у оборудования или на сайте."), keysBox), aiBox); " — ссылку можно разместить на QR-коде у оборудования или на сайте."), keysBox), docsBox, aiBox);
apiKeys(keysBox); apiKeys(keysBox);
docsSettings(docsBox);
photoCleanupInfo(cleanupBox);
assistantSettings(aiBox); 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) { async function apiKeys(box) {
const keys = await api("GET", "/api-keys"); const keys = await api("GET", "/api-keys");
const name = h("input", { placeholder: "Название системы, например «Диспетчерская» или «SCADA цех 1»" }); const name = h("input", { placeholder: "Название системы, например «Диспетчерская» или «SCADA цех 1»" });
+155
View File
@@ -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;
}
+3
View File
@@ -23,6 +23,9 @@
<script src="/static/assistant.js"></script> <script src="/static/assistant.js"></script>
<script src="/static/demo.js"></script> <script src="/static/demo.js"></script>
<script src="/static/feedback.js"></script> <script src="/static/feedback.js"></script>
<script src="/static/docs.js"></script>
<script src="/static/monitor.js"></script>
<script src="/static/monitor.js"></script>
<script src="/static/app.js"></script> <script src="/static/app.js"></script>
</body> </body>
</html> </html>
+44
View File
@@ -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);
}
+3 -1
View File
@@ -583,8 +583,10 @@ async function taskForm(t) {
field("Трудоёмкость (норма), ч", h("input", { type: "number", min: "0", step: "0.25", value: s.labor_hours ?? "", placeholder: "необязательно", 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 }), "Для отчёта по сотрудникам; пусто — учитывается фактическое время в работе")), 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("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 () => { 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); const r = t ? await api("PATCH", "/tasks/" + t.id, body) : await api("POST", "/tasks", body);
toast("Сохранено"); location.hash = "#/task/" + r.id; toast("Сохранено"); location.hash = "#/task/" + r.id;
}) }, t ? "Сохранить" : "Создать задание")))); }) }, t ? "Сохранить" : "Создать задание"))));
+1
View File
@@ -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("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 : ""), h("div", { class: "small muted", style: "margin-bottom:10px" }, loc ? "Место: " + loc.name : "", eq.notes ? " · " + eq.notes : ""),
portal.head, portal.head,
docsBlock(eq, reload),
meterCard, meterCard,
h("div", { class: "row", style: "margin:8px 0" }, h("h2", { style: "flex:1;margin:0" }, "Обслуживаемые узлы"), h("div", { class: "row", style: "margin:8px 0" }, h("h2", { style: "flex:1;margin:0" }, "Обслуживаемые узлы"),
canEdit ? h("button", { onclick: () => unitForm(null) }, "+ Добавить обслуживаемый узел") : null), canEdit ? h("button", { onclick: () => unitForm(null) }, "+ Добавить обслуживаемый узел") : null),
+56 -15
View File
@@ -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" }], ["Круглосуточно (дежурная смена)", { 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) { async function setupPage(stepKey) {
if (!me.is_admin) throw new Error("Мастер доступен администратору"); if (!me.is_admin) throw new Error("Мастер доступен администратору");
const G = p => api("GET", p).catch(() => []); 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", {}, `Каталог фото: ${settings.photo_dir}, лимит снимка ${settings.max_photo_mb} МБ.`),
h("li", {}, `Время сервера сейчас: ${fmt(settings.now)} — должно совпадать с часами на стене.`)), h("li", {}, `Время сервера сейчас: ${fmt(settings.now)} — должно совпадать с часами на стене.`)),
h("a", { class: "btn sec", href: "#/server" }, "Открыть настройки сервера")) }, 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), body: () => h("div", {}, list(sch, x => x.name),
h("p", { class: "small muted" }, "Добавьте типовые графики одним нажатием, а сложные циклы соберите в конструкторе."), 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 () => { 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(); await api("POST", "/schedules", { name, ...body }); toast("Добавлен график «" + name + "»"); reload();
}) }, (byName(sch, name) ? "✔ " : "+ ") + name)), h("a", { class: "btn sec", href: "#/schedules" }, "Конструктор графиков"))) }, }) }, (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({ body: () => h("div", {}, list(pos, x => x.name), bulk({
hint: "Формат: Название; техник или инженер; название графика (необязательно).", hint: "Формат: Название; техник или инженер.",
example: "Слесарь-ремонтник; техник; Сутки через трое\nЭлектромонтёр; техник; Сутки через трое\nИнженер-механик; инженер; Пятидневка 08:00–17:00", example: "Слесарь-ремонтник; техник\nДежурный механик; техник\nЭлектромонтёр; техник\nИнженер-механик; инженер",
run: async ([name, kind, s]) => { run: async ([name, kind]) => {
if (!name) throw new Error("нет названия"); if (!name) throw new Error("нет названия");
const sc = s ? byName(sch, s) : null; const p = await api("POST", "/positions", { name, kind: norm(kind).startsWith("инж") ? "engineer" : "technician" });
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 });
pos.push(p); return p; pos.push(p); return p;
} })) }, } })) },
{ k: "departments", t: "Отделы", n: () => deps.length, why: "Службы и участки. Отделы можно вкладывать друг в друга.", { k: "departments", t: "Отделы", n: () => deps.length, why: "Службы и участки. Отделы можно вкладывать друг в друга.",
@@ -145,20 +183,22 @@ async function setupPage(stepKey) {
return e; return e;
} }), h("p", { class: "small muted" }, "Иконку и обслуживаемые узлы (масло, фильтры, ремни) задайте в карточке машины: «Оборудование» → «Карточка»."), } }), h("p", { class: "small muted" }, "Иконку и обслуживаемые узлы (масло, фильтры, ремни) задайте в карточке машины: «Оборудование» → «Карточка»."),
h("a", { class: "btn sec", href: "#/equipment" }, "Открыть оборудование")) }, 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: () => { body: () => {
const res = h("div"); const res = h("div");
return h("div", {}, list(users.filter(u => !u.is_admin), x => x.full_name || x.username), bulk({ return h("div", {}, list(users.filter(u => !u.is_admin), x => x.full_name || x.username), bulk({
hint: "Формат: ФИО; логин (пусто — придумается сам); должность; отдел; роль (склад / модератор, необязательно). Пароли создаются автоматически — распечатайте таблицу и раздайте.", hint: "Формат: ФИО; логин (пусто — придумается сам); должность; отдел; график; роль (склад / модератор). График и роль — необязательно; без графика сотрудник всегда на смене. Пароли создаются автоматически — распечатайте таблицу и раздайте.",
example: "Иванов Иван Петрович; ; Слесарь-ремонтник; Механическая служба\nПетров Сергей Алексеевич; ; Электромонтёр; Электрослужба\nКозлов Дмитрий Олегович; ; Слесарь-ремонтник; Механическая служба; склад", example: "Иванов Иван Петрович; ; Дежурный механик; Механическая служба; Сутки через трое\nСидоров Олег Ильич; ; Дежурный механик; Механическая служба; Сутки через трое\nПетров Сергей Алексеевич; ; Электромонтёр; Электрослужба; Пятидневка 08:00–17:00\nКозлов Дмитрий Олегович; ; Слесарь-ремонтник; Механическая служба; ; склад",
run: async ([fio, login, p, d, role]) => { run: async ([fio, login, p, d, s, role]) => {
if (!fio || fio.length < 3) throw new Error("нет ФИО"); if (!fio || fio.length < 3) throw new Error("нет ФИО");
const taken = new Set(users.map(u => u.username)); const taken = new Set(users.map(u => u.username));
const position = p ? byName(pos, p) : null, dep = d ? byName(deps, d) : null; const position = p ? byName(pos, p) : null, dep = d ? byName(deps, d) : null;
if (p && !position) throw new Error(`должность «${p}» не найдена`); if (p && !position) throw new Error(`должность «${p}» не найдена`);
if (d && !dep) throw new Error(`отдел «${d}» не найден`); 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 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 }); is_storekeeper: norm(role).startsWith("скл"), is_moderator: norm(role).startsWith("мод"), is_active: true });
users.push(u); users.push(u);
return { fio, username, password, position: position ? position.name : "", dep: dep ? dep.name : "" }; 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" }, "Резервные копии"))) }, h("a", { class: "btn sec", href: "#/backups" }, "Резервные копии"))) },
]; ];
const state = s => done.has(s.k) || s.n() ? "done" : "todo"; 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 cur = STEPS.find(s => s.k === stepKey) || STEPS.find(s => state(s) === "todo") || STEPS[0];
const idx = STEPS.indexOf(cur); const idx = STEPS.indexOf(cur);
const ready = STEPS.filter(s => state(s) === "done").length; 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("p", { class: "muted" }, cur.why), cur.body(),
h("div", { class: "row", style: "margin-top:18px;border-top:1px solid var(--line);padding-top:12px" }, 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" }), 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); }) }), "шаг выполнен"), 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: "#/admin" }, "Завершить"))))); idx < STEPS.length - 1 ? h("a", { class: "btn", href: "#/setup/" + STEPS[idx + 1].k }, "Далее →") : h("a", { class: "btn", href: "#/setup/done" }, "Завершить и проверить")))));
} }
+26
View File
@@ -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{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%} .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} .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)}
+109
View File
@@ -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}
+116 -3
View File
@@ -1,5 +1,13 @@
# Развёртывание в Coolify (build pack «Docker Compose») или вручную: docker compose up -d --build # Развёртывание: docker compose up -d --build (или Coolify, build pack «Docker Compose»)
# Переменные задаются в интерфейсе Coolify (или в .env): DB_PASSWORD, SECRET_KEY, ADMIN_PASSWORD, при желании APP_TZ, APP_PORT; FORWARDED_ALLOW_IPS — IP вашего NPM. # Переменные — в .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: services:
db: db:
image: postgres:16-alpine image: postgres:16-alpine
@@ -30,9 +38,24 @@ services:
APP_ENV: production # слабый SECRET_KEY/ADMIN_PASSWORD -> отказ запуска APP_ENV: production # слабый SECRET_KEY/ADMIN_PASSWORD -> отказ запуска
FORWARDED_ALLOW_IPS: ${FORWARDED_ALLOW_IPS:-127.0.0.1} # адрес NPM (доверенный прокси) FORWARDED_ALLOW_IPS: ${FORWARDED_ALLOW_IPS:-127.0.0.1} # адрес NPM (доверенный прокси)
PHOTO_DIR: /data/photos # каталог фото можно сменить в админке (он должен лежать на томе) 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: volumes:
- photos:/data/photos # фотографии нарядов - photos:/data/photos # фотографии нарядов
- appdata:/srv/data # демо-режим и файл APK (appdata/apk/*.apk раздаётся по /app.apk) - appdata:/srv/data # демо-режим, кэш документов и файл APK (appdata/apk/*.apk раздаётся по /app.apk)
ports: ports:
- "${APP_PORT:-8080}:8000" # на этот порт хоста смотрит прокси (NPM) - "${APP_PORT:-8080}:8000" # на этот порт хоста смотрит прокси (NPM)
healthcheck: healthcheck:
@@ -42,7 +65,97 @@ services:
retries: 5 retries: 5
start_period: 25s 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: volumes:
pgdata: pgdata:
photos: photos:
appdata: appdata:
nc_pgdata:
nextcloud:
onlyoffice_data:
ollama:
+35
View File
@@ -81,6 +81,10 @@
Пример «3 дня, отсыпной, 3 ночи, отсыпной, 3 выходных» — 5 шагов, цикл 11 дней. Пример «3 дня, отсыпной, 3 ночи, отсыпной, 3 выходных» — 5 шагов, цикл 11 дней.
`POST /schedules/preview` `{schedule, from_date, days}` — календарь без сохранения (для конструктора). `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=…` — **период обязателен** (дата и время). `GET /reports` — список отчётов. `GET /reports/completed-orders?from=…&to=…` — **период обязателен** (дата и время).
Фильтры: `equipment_id`, `position_id` (должность исполнителя **на момент выполнения**), `user_id`, `include_review`. Фильтры: `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`, - Только администраторам портала доступны `PUT /settings` (кроме отметок мастера `setup_done`), полный `GET /settings`, `/api-keys`,
`/assistant/config` и `/assistant/test`. Остальные администраторы получают в `GET /settings` сокращённый ответ (`limited: true`). `/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-помощник
Включает администратор: «Администрирование → Настройки сервера → AI-помощник» (`GET/PUT /api/v1/assistant/config`: Включает администратор: «Администрирование → Настройки сервера → AI-помощник» (`GET/PUT /api/v1/assistant/config`:
`enabled, provider (openai | anthropic | gigachat), base_url, model, api_key, scope`; ключ в ответах не возвращается и не попадает `enabled, provider (openai | anthropic | gigachat), base_url, model, api_key, scope`; ключ в ответах не возвращается и не попадает
+30
View File
@@ -98,6 +98,36 @@ docker compose cp checklists-1.7.0.apk app:/srv/data/apk/
Версия берётся из имени файла (`checklists-<версия>.apk`); приложения сами предложат обновиться. В приложении на телефоне Версия берётся из имени файла (`checklists-<версия>.apk`); приложения сами предложат обновиться. В приложении на телефоне
указывается адрес вашего сервера (`https://portal.example.ru`). указывается адрес вашего сервера (`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. Обновление ## 8. Обновление
```bash ```bash
cd /opt/portal cd /opt/portal
Binary file not shown.
+1
View File
@@ -11,3 +11,4 @@ google-auth==2.*
requests==2.* requests==2.*
fpdf2==2.* fpdf2==2.*
tzdata tzdata
pypdf==5.*
+11 -7
View File
@@ -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() "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", night = c.post("/api/schedules", headers=adm, json={"name": "Выходной", "kind": "weekly",
"weekdays": []}).json() "weekdays": []}).json()
tech = c.post("/api/positions", headers=adm, json={"name": "Слесарь", "kind": "technician", # одну должность занимают сменщики: график у каждого свой
"schedule_id": sch["id"]}).json() tech = c.post("/api/positions", headers=adm, json={"name": "Слесарь", "kind": "technician"}).json()
tech2 = c.post("/api/positions", headers=adm, json={"name": "Слесарь (выходной)", "kind": "technician",
"schedule_id": night["id"]}).json()
eng = c.post("/api/positions", headers=adm, json={"name": "Инженер", "kind": "engineer"}).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", 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 assert r.status_code == 201, r.text
ivan, petr, engh = login("ivan", "Pass1234567"), login("petr", "Pass1234567"), login("eng", "Pass1234567") ivan, petr, engh = login("ivan", "Pass1234567"), login("petr", "Pass1234567"), login("eng", "Pass1234567")
@@ -80,7 +78,13 @@ def test_full_flow():
assert tpl["next_run"] assert tpl["next_run"]
order_id = c.post(f"/api/order-templates/{tpl['id']}/run-now", headers=engh).json()["order_id"] 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 [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("/api/orders", headers=petr).json() == []
assert c.get(f"/api/orders/{order_id}", headers=petr).status_code == 404 assert c.get(f"/api/orders/{order_id}", headers=petr).status_code == 404
+56
View File
@@ -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("")
+191
View File
@@ -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'<w:document xmlns:w="w"><w:body><w:p><w:r><w:t>{text}</w:t></w:r></w:p></w:body></w:document>')
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 = "<d:collection/>" if body is None else ""
rows.append(f"<d:response><d:href>{quote(DAV + '/' + p)}{'/' if body is None else ''}</d:href><d:propstat><d:prop>"
f"<d:resourcetype>{rt}</d:resourcetype><d:getcontentlength>{len(body or b'')}</d:getcontentlength>"
f"<d:getlastmodified>Mon, 12 Oct 2026 10:00:00 GMT</d:getlastmodified><d:getetag>\"e{len(body or b'')}\"</d:getetag>"
f"</d:prop><d:status>HTTP/1.1 200 OK</d:status></d:propstat></d:response>")
return ('<?xml version="1.0"?><d:multistatus xmlns:d="DAV:">' + "".join(rows) + "</d:multistatus>").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
+115
View File
@@ -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"])
+5 -2
View File
@@ -77,7 +77,8 @@ def env():
# ---------------------------------------------------------------- аутентификация # ---------------------------------------------------------------- аутентификация
PUBLIC = {("POST", "/auth/login"), ("POST", "/auth/refresh"), ("POST", "/auth/logout"), PUBLIC = {("POST", "/auth/login"), ("POST", "/auth/refresh"), ("POST", "/auth/logout"),
("GET", "/health"), ("GET", "/demo/status"), ("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): 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 from app.models import Photo
with SessionLocal() as s: with SessionLocal() as s:
for p in s.query(Photo).all(): 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): def test_static_path_traversal_blocked(env):
+62 -11
View File
@@ -333,8 +333,9 @@ def build():
["Наряд", "Одно задание: что проверить или обслужить, на каком оборудовании, кому, до какого срока и кто проверит."], ["Наряд", "Одно задание: что проверить или обслужить, на каком оборудовании, кому, до какого срока и кто проверит."],
["Чек-лист", "Список задач внутри наряда. У каждой задачи свой тип ответа: статус, выбор из списка, число или текст; " ["Чек-лист", "Список задач внутри наряда. У каждой задачи свой тип ответа: статус, выбор из списка, число или текст; "
"у некоторых нужно приложить фото."], "у некоторых нужно приложить фото."],
["Должность и график", "Сотрудник привязан к должности, у должности есть график (смены). Наряд, назначенный на должность, " ["Должность и график", "Сотрудник занимает должность и работает по своему графику (смены). Одну должность могут занимать "
"видят и могут взять только те, кто сейчас на смене."], "несколько сменщиков. Наряд, назначенный на должность, видят, берут и получают уведомление только те, "
"кто сейчас на смене, — сменщики дома не беспокоятся."],
["Расписание нарядов", "Наряд настраивается один раз и затем создаётся автоматически: каждый день, по дням недели, " ["Расписание нарядов", "Наряд настраивается один раз и затем создаётся автоматически: каждый день, по дням недели, "
"раз в месяц или каждые N дней."], "раз в месяц или каждые N дней."],
["Проверка", "После сдачи наряд уходит проверяющему: он принимает его или возвращает на доработку с комментарием."], ["Проверка", "После сдачи наряд уходит проверяющему: он принимает его или возвращает на доработку с комментарием."],
@@ -562,7 +563,8 @@ def build():
pdf.bullets([ pdf.bullets([
"для **числа** задайте единицу измерения и границы нормы — отклонения будут подсвечиваться и попадут в отчёт;", "для **числа** задайте единицу измерения и границы нормы — отклонения будут подсвечиваться и попадут в отчёт;",
"для **списка** — варианты ответа, по одному в строке;", "для **списка** — варианты ответа, по одному в строке;",
"для любой задачи — режим фото: без фото, по желанию, обязательно;", "для любой задачи — режим фото: без фото, по желанию, обязательно; если фото нужно сохранить надолго (табличка, акт, "
"дефект), отметьте **«хранить более года»** — такие снимки лежат в отдельном каталоге и не удаляются автоочисткой сервера;",
"стрелками меняется порядок задач, галочкой «Обязательный» отмечаются пункты, без которых наряд не сдать.", "стрелками меняется порядок задач, галочкой «Обязательный» отмечаются пункты, без которых наряд не сдать.",
]) ])
pdf.wide("d_checklist", "Редактор чек-листа") pdf.wide("d_checklist", "Редактор чек-листа")
@@ -708,6 +710,15 @@ def build():
"В приложении Android история открывается кнопкой «История машины».") "В приложении Android история открывается кнопкой «История машины».")
pdf.wide("d_history", "История машины с фильтрами") pdf.wide("d_history", "История машины с фильтрами")
pdf.phone("a_history", "История машины в приложении Android", width=70) 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.h2("Сообщения об отказах извне")
pdf.p("Об аварии может сообщить и человек без учётной записи — подрядчик, охрана, смежная служба. Для этого есть **публичная " pdf.p("Об аварии может сообщить и человек без учётной записи — подрядчик, охрана, смежная служба. Для этого есть **публичная "
"форма** по адресу **/report** (её удобно разместить на QR-коде у оборудования). В форме выбирается **место** — после этого " "форма** по адресу **/report** (её удобно разместить на QR-коде у оборудования). В форме выбирается **место** — после этого "
@@ -832,16 +843,22 @@ def build():
"висит приглашение запустить мастер). Мастер проводит по шагам в порядке зависимостей данных:") "висит приглашение запустить мастер). Мастер проводит по шагам в порядке зависимостей данных:")
pdf.bullets([ pdf.bullets([
"**Сервер** — проверить часовой пояс, каталог фото, время сервера.", "**Сервер** — проверить часовой пояс, каталог фото, время сервера.",
"**Графики** — типовые одним нажатием (пятидневка, сутки через трое, 2/2 день-ночь, круглосуточно) или в конструкторе.", "**Графики** — типовые одним нажатием (пятидневка, сутки через трое, 2/2 день-ночь, круглосуточно) или в конструкторе. "
"График потом назначается каждому сотруднику.",
"**Должности → отделы → места → оборудование** — пакетным вводом: одна строка — одна запись, поля через «;». " "**Должности → отделы → места → оборудование** — пакетным вводом: одна строка — одна запись, поля через «;». "
"Для оборудования: «Название; инв. №; место; наработка; отдел; кран» — отдел закрепляет машину за службой, слово «кран» " "Для оборудования: «Название; инв. №; место; наработка; отдел; кран» — отдел закрепляет машину за службой, слово «кран» "
"отправляет её в раздел «Краны».", "отправляет её в раздел «Краны».",
"**Сотрудники** — строки «ФИО; логин; должность; отдел; роль». Логины и временные пароли создаются сами и показываются " "**Сотрудники** — строки «ФИО; логин; должность; отдел; график; роль». Логины и временные пароли создаются сами и показываются "
"таблицей для печати или CSV — **только один раз**.", "таблицей для печати или CSV — **только один раз**.",
"**Карта завода** (и подсказка про отдельную карту кранов), **склады и начальные остатки инструмента**, **чек-листы и расписание**, **внешние обращения**, **резервные копии**.", "**Карта завода** (и подсказка про отдельную карту кранов), **склады и начальные остатки инструмента**, **чек-листы и расписание**, **внешние обращения**, **резервные копии**.",
]) ])
pdf.wide("d_setup", "Мастер первичной настройки") pdf.wide("d_setup", "Мастер первичной настройки")
pdf.note("Шаг считается выполненным, когда в нём есть записи, или если отметить «шаг выполнен». К мастеру можно возвращаться в любое время.", "info") pdf.note("Шаг считается выполненным, когда в нём есть записи, или если отметить «шаг выполнен» — мастер сразу переходит к следующему. "
"К мастеру можно возвращаться в любое время.", "info")
pdf.p("Кнопка **«Завершить и проверить»** на последнем шаге открывает **проверку готовности**: мастер смотрит на введённые данные и "
"показывает, что мешает работе — сотрудники без должности, должности, которые никто не занимает, должности, где сейчас по "
"графикам никого нет на смене, машины без места, нет чек-листов или расписания, выключены автокопии или автоочистка фото. "
"Рядом с каждым пунктом — кнопка, чтобы поправить. После завершения плитка мастера ведёт сразу к этой проверке.")
pdf.h2("AI-помощник (включает администратор)") pdf.h2("AI-помощник (включает администратор)")
pdf.p("В «Настройках сервера» администратор может включить **AI-помощника**: указать провайдера (OpenAI-совместимый — OpenAI, " pdf.p("В «Настройках сервера» администратор может включить **AI-помощника**: указать провайдера (OpenAI-совместимый — OpenAI, "
@@ -855,6 +872,12 @@ def build():
"Ключ API не показывается в настройках и не попадает в резервные копии.", "Ключ API не показывается в настройках и не попадает в резервные копии.",
]) ])
pdf.wide("d_assistant_settings", "Настройка AI-помощника") pdf.wide("d_assistant_settings", "Настройка AI-помощника")
pdf.p("**«Работа с документацией»** — отдельная опция помощника в той же карточке. Включённая, она раз в сутки собирает индекс "
"документации машин (имена файлов и начало текста PDF и документов Office), и помощник отвечает на вопросы вида "
"«где схема двигателя компрессора?» ссылкой на нужный файл и подсказкой, что в нём искать.")
pdf.note("Риски опции: имена файлов и фрагменты документов уходят провайдеру ИИ — с облачным провайдером документация покидает "
"предприятие, для закрытых документов используйте локальную модель. Запросы становятся в разы длиннее, расход токенов "
"растёт. Помощник видит только начало документов — ответ проверяйте в самом файле.", "warn")
pdf.wide("d_assistant", "Помощник: ответ по данным пользователя и подсказка «как сделать»") pdf.wide("d_assistant", "Помощник: ответ по данным пользователя и подсказка «как сделать»")
pdf.phone("a_assistant", "Помощник в приложении Android (круглая кнопка справа внизу или пункт меню «Помощник»)", width=70) pdf.phone("a_assistant", "Помощник в приложении Android (круглая кнопка справа внизу или пункт меню «Помощник»)", width=70)
@@ -883,9 +906,13 @@ def build():
"пользователя. Остальным администраторам мастер первичной настройки и резервные копии доступны как обычно.") "пользователя. Остальным администраторам мастер первичной настройки и резервные копии доступны как обычно.")
pdf.h2("Должности и графики работы") pdf.h2("Должности и графики работы")
pdf.p("**Должность** имеет тип (**техник** выполняет наряды, **инженер** проверяет и планирует) и **график**. По графику " pdf.p("**Должность** имеет тип (**техник** выполняет наряды, **инженер** проверяет и планирует). **График** задаётся не должности, "
"система определяет, кто сейчас «на смене» и кому показывать наряды, назначенные на должность. Должность без графика " "а **сотруднику** («Пользователи» → «Изменить» → «График работы»): должность выполняет тот, кто её занимает и сейчас на смене. "
"считается всегда на смене.") "Например, «Дежурный механик» — четыре сменщика с графиком «сутки через трое», каждый со своим первым днём цикла: наряд или "
"задание на эту должность увидит и получит уведомление только тот, кто сегодня дежурит. Сотрудник без графика считается "
"всегда на смене.")
pdf.note("При обновлении портала графики, которые раньше были заданы должностям, автоматически перенесены их сотрудникам. "
"Для сменщиков поправьте графики (или первый день цикла), чтобы на смене был один человек.", "info")
pdf.p("Графики настраиваются **конструктором**: можно собрать любой цикл — дневные и ночные смены, отсыпные, сутки через трое. " pdf.p("Графики настраиваются **конструктором**: можно собрать любой цикл — дневные и ночные смены, отсыпные, сутки через трое. "
"Каждый шаг цикла — это «Смена» или «Выходной», число дней подряд, время начала и конца и метка (например, «день», " "Каждый шаг цикла — это «Смена» или «Выходной», число дней подряд, время начала и конца и метка (например, «день», "
"«ночь», «отсыпной»). Если конец смены раньше начала (20:00–08:00), смена ночная; одинаковое время (08:00–08:00) — сутки.") "«ночь», «отсыпной»). Если конец смены раньше начала (20:00–08:00), смена ночная; одинаковое время (08:00–08:00) — сутки.")
@@ -901,6 +928,8 @@ def build():
pdf.p("Разделы **«Оборудование»** и **«Места»** — справочники для нарядов и отчётов. Оборудование можно привязать к месту " pdf.p("Разделы **«Оборудование»** и **«Места»** — справочники для нарядов и отчётов. Оборудование можно привязать к месту "
"(например, «Цех №1»), указать инвентарный номер, **отдел** (кто его видит) и отметку **«Кран»**. Неиспользуемое оборудование " "(например, «Цех №1»), указать инвентарный номер, **отдел** (кто его видит) и отметку **«Кран»**. Неиспользуемое оборудование "
"отключается флажком «Активно».") "отключается флажком «Активно».")
pdf.p("Над списком оборудования (и кранов) — фильтр **«Место»**: выберите цех или участок, и в списке останутся машины этого "
"места и всех вложенных в него мест (например, «Цех №1» покажет и машины «Компрессорной», входящей в цех).")
pdf.h2("Обратная связь по порталу") pdf.h2("Обратная связь по порталу")
pdf.p("Внизу каждой страницы сайта — неприметная ссылка **«Предложить улучшение портала или сообщить об ошибке»**; в мобильной версии и " pdf.p("Внизу каждой страницы сайта — неприметная ссылка **«Предложить улучшение портала или сообщить об ошибке»**; в мобильной версии и "
@@ -914,6 +943,15 @@ def build():
pdf.p("Раздел **«Настройки сервера»** позволяет изменить каталог для хранения фотографий и максимальный размер снимка, а также " pdf.p("Раздел **«Настройки сервера»** позволяет изменить каталог для хранения фотографий и максимальный размер снимка, а также "
"ключ для мгновенных уведомлений Android, включить публичную форму заявок и **«Краны — отдельная служба»**. Внизу виден " "ключ для мгновенных уведомлений Android, включить публичную форму заявок и **«Краны — отдельная служба»**. Внизу виден "
"расход места: сколько фото хранится и сколько свободно на диске.") "расход места: сколько фото хранится и сколько свободно на диске.")
pdf.p("**Автоочистка фото**: поле «удалять фото нарядов и заданий старше, дней» (например, 365). Раз в сутки сервер удаляет "
"фото старше этого срока; 0 — не удалять. Снимки с отметкой **«хранить более года»** (в чек-листе у пункта или в задании) "
"с самого начала лежат в отдельном каталоге **keep/** и не удаляются. В блоке «Состояние» видно, сколько фото будет удалено "
"при следующей очистке; кнопка «Очистить сейчас» запускает её сразу.")
pdf.note("Удалённые фото не восстановить, кроме как из резервной копии с фото. По умолчанию автоочистка выключена.", "warn")
pdf.h2("Мониторинг сервера (только администратор портала)")
pdf.p("«Администрирование → **Мониторинг сервера**»: круговые диаграммы загрузки **процессора**, занятой **памяти** и места на "
"**дисках** с данными портала и фото. Значения обновляются каждые 5 секунд, пока страница открыта; от 75 % диаграмма "
"становится жёлтой, от 90 % — красной. В Docker с лимитом памяти показывается память контейнера.")
pdf.wide("d_server", "Настройки сервера", width=150) pdf.wide("d_server", "Настройки сервера", width=150)
pdf.note("При смене каталога фото старые снимки автоматически **не переносятся** — скопируйте файлы вручную.", "warn") pdf.note("При смене каталога фото старые снимки автоматически **не переносятся** — скопируйте файлы вручную.", "warn")
@@ -935,6 +973,19 @@ def build():
pdf.note("Рабочие данные в демо-режиме не изменяются и не удаляются.", "info") pdf.note("Рабочие данные в демо-режиме не изменяются и не удаляются.", "info")
# ================= 7 ================= # ================= 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. Частые вопросы") pdf.h1("14. Частые вопросы")
faq = [ faq = [
("Я не вижу машину (или кран), которая точно есть.", ("Я не вижу машину (или кран), которая точно есть.",
@@ -944,8 +995,8 @@ def build():
"Вы в демонстрационном режиме — это тестовые данные. Нажмите **«Выйти из демо»** в полосе. Если кнопки нет или не помогает, " "Вы в демонстрационном режиме — это тестовые данные. Нажмите **«Выйти из демо»** в полосе. Если кнопки нет или не помогает, "
"попросите администратора выключить демо для вас."), "попросите администратора выключить демо для вас."),
("Я не вижу наряд, который мне должны были назначить.", ("Я не вижу наряд, который мне должны были назначить.",
"Наряд, назначенный на должность, виден только сотрудникам этой должности **на смене**. Проверьте график смены и должность в " "Наряд, назначенный на должность, виден только сотрудникам этой должности **на смене** по их графику. Проверьте свой график "
"настройках; если вы вне смены — наряд появится, когда смена начнётся. Нажмите обновление списка. Если наряд назначен " "и должность в карточке; если вы вне смены — наряд появится, когда смена начнётся. Нажмите обновление списка. Если наряд назначен "
"другому человеку или уже взят коллегой, вы его не увидите."), "другому человеку или уже взят коллегой, вы его не увидите."),
("Пишет «Нет связи» или «не отправлено» и не исчезает.", ("Пишет «Нет связи» или «не отправлено» и не исчезает.",
"Проверьте интернет на телефоне. Данные сохранены и не пропадут: они уйдут сами. Для немедленной попытки нажмите кнопку " "Проверьте интернет на телефоне. Данные сохранены и не пропадут: они уйдут сами. Для немедленной попытки нажмите кнопку "