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 6b8fca4f5a
commit e67c175f80
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 compose up -d — после этого в той строке появится ваш собственный адрес.
FORWARDED_ALLOW_IPS=127.0.0.1
# ---------- Дополнительные сервисы (необязательно) ----------
# Включаются профилями docker-compose.yml: docs — Nextcloud, office — ONLYOFFICE, ai — локальная модель Ollama.
# Портал сам найдёт запущенные сервисы во внутренней сети Docker и заполнит их адреса в настройках.
# Пример: COMPOSE_PROFILES=docs,office,ai
COMPOSE_PROFILES=
# --- docs: Nextcloud (документация оборудования) ---
# Администратор Nextcloud создаётся при первом запуске; без пароля Nextcloud покажет мастер установки в браузере.
NEXTCLOUD_ADMIN_USER=ncadmin
NEXTCLOUD_ADMIN_PASSWORD=
# Порт веб-интерфейса Nextcloud на хосте (сюда загружают документы) и его доменное имя за прокси (если есть).
NEXTCLOUD_PORT=8081
NEXTCLOUD_DOMAIN=
# https — если Nextcloud открывают через прокси с HTTPS.
NEXTCLOUD_PROTOCOL=
# Пароль базы Nextcloud (пусто — как DB_PASSWORD).
NEXTCLOUD_DB_PASSWORD=
# Под какой учётной записью портал читает документацию. Пусто — администратор Nextcloud выше.
# Надёжнее завести отдельного пользователя только с чтением папки документации и пароль приложения для него.
NC_USER=
NC_PASSWORD=
# Папка с документацией внутри учётной записи, например Документация/Оборудование.
NC_ROOT=
# Свой Nextcloud вне этого docker-compose: укажите его адрес (тогда профиль docs не нужен).
NC_URL=
# --- office: ONLYOFFICE Document Server (просмотр Word/Excel/PowerPoint в портале) ---
# Общий секрет портала и ONLYOFFICE. Обязательно задайте свой: openssl rand -hex 24
ONLYOFFICE_JWT=
# Свой ONLYOFFICE вне этого docker-compose: его адрес и адрес портала, по которому ONLYOFFICE его видит.
ONLYOFFICE_URL=
PORTAL_INTERNAL_URL=
# --- ai: локальная модель Ollama для AI-помощника ---
# Модель скачивается при первом запуске (qwen2.5:7b — около 5 ГБ на диске и 6–8 ГБ памяти; поменьше — qwen2.5:3b).
OLLAMA_MODEL=qwen2.5:7b
# Внешний провайдер вместо Ollama (необязательно): openai | anthropic | gigachat, адрес, модель, ключ.
ASSISTANT_PROVIDER=
ASSISTANT_BASE_URL=
ASSISTANT_MODEL=
ASSISTANT_API_KEY=
+6
View File
@@ -133,6 +133,12 @@ python -m venv .venv && .venv\Scripts\pip install -r requirements.txt
Пошаговая установка — [docs/DEPLOY.md](docs/DEPLOY.md).
## Дополнительные сервисы
Рядом с порталом `docker-compose.yml` может поднять **Nextcloud** (документация оборудования), **ONLYOFFICE** (просмотр
Word/Excel/PowerPoint в портале) и **Ollama** (локальная модель для AI-помощника) — профилями `docs`, `office`, `ai`
(`COMPOSE_PROFILES=docs,office,ai` в `.env`). Портал сам находит их во внутренней сети Docker и заполняет адреса в настройках.
Подробно — [docs/DEPLOY.md](docs/DEPLOY.md), раздел 7а.
## Установка без Coolify (Docker Compose)
Пошаговая инструкция для любого Linux-сервера с Docker: **[docs/DEPLOY.md](docs/DEPLOY.md)** (шаблон настроек — `.env.example`).
Коротко: `cp .env.example .env` → заполнить → `docker compose up -d --build` → обратный прокси с HTTPS на `APP_PORT`.
+39 -10
View File
@@ -16,10 +16,12 @@ import re
import time
from pathlib import Path
from urllib.parse import quote
import httpx
from sqlalchemy.orm import Session
from . import config, settings_store as ss
from . import autoconf, config, settings_store as ss
from .models import User
log = logging.getLogger("assistant")
@@ -59,7 +61,7 @@ def public_cfg(db: Session) -> dict:
def ready(db: Session) -> bool:
c = cfg(db)
local = c["provider"] == "openai" and any(h in c["base_url"] for h in ("localhost", "127.0.0.1"))
local = c["provider"] == "openai" and autoconf.is_local(c["base_url"]) # своя модель в сети (Ollama и т. п.) — без ключа
return c["enabled"] and bool(c["api_key"] or local) and bool(c["model"])
@@ -78,11 +80,18 @@ TOOLS = {
"equipment_history": ("История машины. args: {\"id\": id машины}", "GET", "/equipment/{id}/history?limit=30", "id"),
"find_people": ("Сотрудники: ФИО, должность, на смене ли", "GET", "/users", None),
"warehouses": ("Склады и сколько на них позиций", "GET", "/warehouses", None),
# опция «Работа с документацией» (включается в настройках сервера)
"search_docs": ("Поиск по документации машин: файлы и фрагменты текста. args: {\"q\": \"2-5 ключевых слов\"}", "GET", "/docs/search?q={q}", "q"),
"equipment_docs": ("Папки и файлы документации машины. args: {\"id\": id машины, \"sub\": \"вложенная папка или пусто\"}",
"GET", "/equipment/{id}/docs?sub={sub}", "id"),
}
DOC_TOOLS = {"search_docs", "equipment_docs"}
TEXT_ARGS = ("q", "sub")
KEEP = {"id", "title", "name", "full_name", "status", "state", "day", "due_at", "is_overdue", "equipment_name", "equipment_id", "unit_name",
"assignee_name", "executor_name", "item_name", "qty", "unit", "what", "answer", "occurred_at", "inventory_no", "location_id",
"remaining_hours", "remaining_days", "percent", "on_shift", "position", "type_ru", "at", "text", "open_incidents", "due", "soon",
"positions", "can_execute", "can_review", "last_comment", "description", "total", "is_crane", "labor_hours", "kind", "open_faults"}
"positions", "can_execute", "can_review", "last_comment", "description", "total", "is_crane", "labor_hours", "kind", "open_faults",
"path", "snippet", "items", "folder", "sub", "is_dir", "note"}
def _slim(x, depth=0):
@@ -101,11 +110,18 @@ def _slim(x, depth=0):
return x
async def run_tool(app, token: str, name: str, args: dict) -> str:
if name not in TOOLS:
async def run_tool(app, token: str, name: str, args: dict, docs: bool = False) -> str:
if name not in TOOLS or (name in DOC_TOOLS and not docs):
return json.dumps({"error": f"нет инструмента {name}"}, ensure_ascii=False)
_, method, path, arg = TOOLS[name]
if arg:
for t in TEXT_ARGS: # текстовые параметры — в строку запроса, экранированными
if "{" + t + "}" in path:
v = args.get(t)
v = str(v).strip()[:200] if isinstance(v, (str, int, float)) else ""
if t == arg and len(v) < 2:
return json.dumps({"error": f"нужен параметр {t}"}, ensure_ascii=False)
path = path.replace("{" + t + "}", quote(v, safe=""))
if arg and arg not in TEXT_ARGS:
v = args.get(arg)
if not isinstance(v, int) and not (isinstance(v, str) and v.isdigit()):
return json.dumps({"error": f"нужен числовой параметр {arg}"}, ensure_ascii=False)
@@ -129,9 +145,21 @@ def who(u: User) -> str:
f"роли: {', '.join(roles) or 'нет особых ролей'}.")
def system_prompt(u: User, scope: str) -> str:
tools = "\n".join(f"- {k}: {v[0]}" for k, v in TOOLS.items())
DOCS_RULES = """
ДОКУМЕНТАЦИЯ (это тоже тема портала, не OFFTOPIC). К машинам привязаны папки с документацией (паспорта, руководства,
схемы). Если спрашивают, где найти
сведения по машине (схема, параметры, порядок обслуживания, коды ошибок), найди файл через search_docs или equipment_docs
и подскажи, какой файл открыть и что в нём искать. Давай ссылку вида [имя файла](#/docs/ID?p=путь) — ID машины и путь
файла из результата, пробелы в пути замени на %20. Фрагменты текста — только подсказка, где искать: не выдавай их за полный ответ и не
придумывай содержимое, которого нет во фрагменте. Если ничего не нашлось — так и скажи и предложи открыть папку
документации машины [Документация](#/docs/ID).
"""
def system_prompt(u: User, scope: str, docs: bool = False) -> str:
tools = "\n".join(f"- {k}: {v[0]}" for k, v in TOOLS.items() if docs or k not in DOC_TOOLS)
extra = f"\nДополнительно от администратора: {scope.strip()}\n" if scope and scope.strip() else ""
extra += DOCS_RULES if docs else ""
return f"""Ты — помощник внутри «Портала технической службы» (ремонтная служба предприятия). Отвечай по-русски, кратко, по шагам.
ПРАВИЛА:
@@ -226,7 +254,8 @@ async def chat(app, db: Session, u: User, token: str, history: list[dict], cfg_o
while msgs and msgs[0]["role"] != "user": # некоторые провайдеры требуют начинать с пользователя
msgs.pop(0)
rate_check(u.id)
system = system_prompt(u, c["scope"])
docs = ss.get(db, "docs_ai") == "1"
system = system_prompt(u, c["scope"], docs)
used = []
for _ in range(MAX_STEPS + 1):
text = (await complete(c, system, msgs)).strip()
@@ -240,7 +269,7 @@ async def chat(app, db: Session, u: User, token: str, history: list[dict], cfg_o
args = json.loads(raw)
except ValueError:
args = {}
result = await run_tool(app, token, name, args if isinstance(args, dict) else {})
result = await run_tool(app, token, name, args if isinstance(args, dict) else {}, docs)
used.append(name)
msgs += [{"role": "assistant", "content": text}, {"role": "user", "content": f"РЕЗУЛЬТАТ {name}: {result}\nОтветь пользователю."}]
return {"reply": "Не получилось собрать ответ. Попробуйте переформулировать вопрос.", "offtopic": False, "tools": used}
+8 -2
View File
@@ -26,7 +26,8 @@
- Сотрудники — #/people — все: поиск, фильтр по отделу; карточка #/user/<id>: ФИО, фото, должность, отдел, график, «что умею», задания, инструмент (инструмент коллеги видят только склад и инженеры). Своё фото, телефон и навыки — в своей карточке (нажать своё имя вверху меню).
- Отчёты — #/reports — инженер+: «Выполненные наряды», «Текущее состояние» узлов, «Выполнение работ» (% по машинам, отклонения), «По сотрудникам» #/reports/people (сколько и как качественно выполнено за период: в срок, просрочки, возвраты с проверки, отклонения, трудоёмкость; списком и календарём по дням); PDF и CSV.
- Администрирование — #/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 может включить или выключить демо любому сотруднику.
- Обратная связь по порталу — ссылка «Предложить улучшение портала или сообщить об ошибке» внизу каждой страницы (в мобильной версии и приложении — внизу бокового меню): идея, ошибка или другое. Ответы видят только администраторы портала — #/feedback.
- Администратор портала — право администратора на настройки сервера и обратную связь. У 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")
FORMAT = 1
NAME_RE = re.compile(r"backup_\d{8}_\d{6}(_[a-z0-9-]{1,20})?\.zip")
SKIP_TABLES = {"refresh_tokens"} # сессии не переносим: после восстановления все входят заново
SKIP_TABLES = {"refresh_tokens", "doc_index"} # индекс документации строится заново # сессии не переносим: после восстановления все входят заново
ENV_PUBLIC = ("APP_ENV", "APP_TZ", "APP_PORT", "FORWARDED_ALLOW_IPS", "DATA_DIR", "PHOTO_DIR", "ACCESS_TTL_MIN",
"REFRESH_TTL_DAYS", "LOGIN_MAX_FAILS", "LOGIN_WINDOW_SEC", "SCHEDULER_INTERVAL", "ADMIN_USERNAME")
ENV_SECRET = ("SECRET_KEY", "DATABASE_URL", "DB_PASSWORD", "ADMIN_PASSWORD")
SECRET_SETTINGS = {"assistant_api_key"}
SECRET_SETTINGS = {"assistant_api_key", "nc_password", "onlyoffice_jwt"}
DEFAULTS = {"backup_auto_hours": "24", "backup_keep": "14", "backup_media": "0"}
+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)
db.commit()
logic.schedules_to_users(db) # графики демо-должностей — сотрудникам
def demo_accounts(db: Session) -> list[dict]:
@@ -443,3 +444,5 @@ def activate_on_startup() -> None:
e = _engine_for(mode.State.db_file)
ensure_schema(e)
_db.set_demo_engine(e)
with sessionmaker(bind=e)() as db: # демо-база прежней версии: графики должностей — сотрудникам
logic.schedules_to_users(db)
+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:
"""На смене ли сотрудник: занимает должность и сейчас по СВОЕМУ графику работает (без графика — всегда)."""
dt = dt or config.now()
return bool(u.position) and shift_active(u.position.schedule, dt)
return bool(u.position) and shift_active(u.schedule, dt)
def schedules_to_users(db) -> int:
"""Однократный перенос: раньше график задавался должности — теперь он у каждого сотрудника.
Сотрудникам без своего графика ставится график их должности; повторно не выполняется (отметка в настройках)."""
from . import settings_store as ss
from .models import Position
if ss.get(db, "schedule_per_user") == "1":
return 0
n = 0
for u, sid in db.execute(select(User, Position.schedule_id).join(Position, Position.id == User.position_id)
.where(User.schedule_id.is_(None), Position.schedule_id.is_not(None))):
u.schedule_id = sid
n += 1
ss.set_(db, "schedule_per_user", "1")
db.commit()
return n
# ---------- расписание запуска нарядов ----------
@@ -120,7 +138,7 @@ def create_order(db: Session, *, title, checklist_id, equipment_id=None, locatio
for it in cl.items:
o.items.append(OrderItem(position=it.position, text=it.text, answer_type=it.answer_type,
options=it.options, unit=it.unit, min_value=it.min_value,
max_value=it.max_value, required=it.required, photo=it.photo))
max_value=it.max_value, required=it.required, photo=it.photo, photo_keep=it.photo_keep))
db.add(o)
return o
@@ -141,7 +159,7 @@ def _regular_items(db: Session, o: Order, cl: Checklist) -> None:
pos += 1
o.items.append(OrderItem(position=pos, text=it.text, answer_type=it.answer_type, options=it.options,
unit=it.unit, min_value=it.min_value, max_value=it.max_value,
required=it.required, photo=it.photo))
required=it.required, photo=it.photo, photo_keep=it.photo_keep))
for u, ev in units:
pos += 1
o.items.append(OrderItem(position=pos, text=service.describe(u, ev) + " — обслужено?", answer_type="status",
+23 -2
View File
@@ -9,13 +9,13 @@ from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles
from sqlalchemy import select
from . import backup, config, demo, logic, mode, push
from . import backup, config, demo, docs_index, logic, mode, photo_cleanup, push
from .db import SessionLocal, real_engine
from .migrate import ensure_schema
from .models import User
from .routers import catalog, orders, extras, reports, service as service_router, demo as demo_router
from .routers import inventory, incidents, tasks, portal, external, history, backups, assistant as assistant_router
from .routers import reliability as reliability_router, feedback as feedback_router
from .routers import reliability as reliability_router, feedback as feedback_router, docs as docs_router
from .security import hash_password
log = logging.getLogger("scheduler")
@@ -49,6 +49,14 @@ def bootstrap():
db.commit()
log.warning("Создан администратор '%s' — смените пароль!", config.ADMIN_USERNAME)
push.ensure_vapid(db)
moved = logic.schedules_to_users(db)
if moved:
log.warning("Графики работы перенесены с должностей на сотрудников: %s", moved)
def _reindex_docs():
with SessionLocal() as db:
docs_index.rebuild(db)
async def scheduler_loop():
@@ -67,6 +75,18 @@ async def scheduler_loop():
backup.auto_backup(db)
except Exception:
log.exception("Ошибка автоматического резервного копирования")
try:
with SessionLocal() as db:
if photo_cleanup.due(db): # старые фото — раз в сутки
photo_cleanup.run(db)
except Exception:
log.exception("Ошибка очистки старых фото")
try:
with SessionLocal() as db:
if docs_index.due(db): # индекс документации для AI — раз в сутки, в фоне
asyncio.get_running_loop().run_in_executor(None, _reindex_docs)
except Exception:
log.exception("Ошибка индекса документации")
await asyncio.sleep(config.SCHEDULER_INTERVAL)
@@ -123,6 +143,7 @@ api.include_router(backups.router)
api.include_router(assistant_router.router)
api.include_router(reliability_router.router)
api.include_router(feedback_router.router)
api.include_router(docs_router.router)
@api.get("/health", tags=["service"])
+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")]
CT = {".jpg": "image/jpeg", ".png": "image/png", ".webp": "image/webp"}
NAME_RE = re.compile(r"[0-9a-f]{32}\.(jpg|png|webp)")
NAME_RE = re.compile(r"(keep/)?[0-9a-f]{32}\.(jpg|png|webp)") # keep/ — хранить долго (очистка не трогает)
KINDS = ("equipment", "users", "map", "tasks")
@@ -47,9 +47,11 @@ async def read_image(db: Session, request: Request, max_mb: int | None = None) -
return data, kind[0], kind[1]
def save(db: Session, kind: str, data: bytes, ext: str) -> str:
name = ss.new_name() + ext
(root(db, kind) / name).write_bytes(data)
def save(db: Session, kind: str, data: bytes, ext: str, keep: bool = False) -> str:
name = ("keep/" if keep else "") + ss.new_name() + ext
f = root(db, kind) / name
f.parent.mkdir(parents=True, exist_ok=True)
f.write_bytes(data)
return name
+23 -1
View File
@@ -27,8 +27,8 @@ class Position(Base):
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(String(100), unique=True)
kind: Mapped[str] = mapped_column(String(12), default="technician")
# устарело: график теперь у сотрудника (User.schedule_id); значение перенесено сотрудникам при обновлении и больше не используется
schedule_id: Mapped[int | None] = mapped_column(ForeignKey("schedules.id"), nullable=True)
schedule: Mapped[ShiftSchedule | None] = relationship(lazy="joined")
class Department(Base):
@@ -55,6 +55,9 @@ class User(Base):
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
position_id: Mapped[int | None] = mapped_column(ForeignKey("positions.id"), nullable=True)
position: Mapped[Position | None] = relationship(lazy="joined")
# график работы сотрудника: должность выполняет тот, кто её занимает и сейчас на смене. Пусто — всегда на смене
schedule_id: Mapped[int | None] = mapped_column(ForeignKey("schedules.id"), nullable=True)
schedule: Mapped[ShiftSchedule | None] = relationship(lazy="joined")
department_id: Mapped[int | None] = mapped_column(ForeignKey("departments.id"), nullable=True)
department: Mapped[Department | None] = relationship(lazy="joined", foreign_keys=[department_id])
# карточка сотрудника
@@ -89,6 +92,8 @@ class Equipment(Base):
department_id: Mapped[int | None] = mapped_column(ForeignKey("departments.id"), nullable=True)
# кран (грузоподъёмное оборудование): при включённой карте кранов показывается на ней, а не на карте завода
is_crane: Mapped[bool] = mapped_column(Boolean, default=False)
# папка документации в Nextcloud (путь от корня документации в настройках сервера); пусто — не привязана
docs_path: Mapped[str] = mapped_column(String(500), default="")
class ServiceUnit(Base):
@@ -160,6 +165,7 @@ class ChecklistItem(Base):
max_value: Mapped[float | None] = mapped_column(Float, nullable=True)
required: Mapped[bool] = mapped_column(Boolean, default=True)
photo: Mapped[str] = mapped_column(String(10), default="none") # none|optional|required
photo_keep: Mapped[bool] = mapped_column(Boolean, default=False) # фото хранить более года (каталог keep/, очистка не трогает)
class OrderTemplate(Base):
@@ -232,6 +238,7 @@ class OrderItem(Base):
answered_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
out_of_range: Mapped[bool] = mapped_column(Boolean, default=False)
photo: Mapped[str] = mapped_column(String(10), default="none")
photo_keep: Mapped[bool] = mapped_column(Boolean, default=False)
service_unit_id: Mapped[int | None] = mapped_column(ForeignKey("service_units.id"), nullable=True) # «узел обслужен»
is_meter: Mapped[bool] = mapped_column(Boolean, default=False) # пункт «наработка, м/ч» — обновляет счётчик машины
photos: Mapped[list["Photo"]] = relationship(
@@ -276,6 +283,20 @@ class Feedback(Base):
answered_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
class DocIndex(Base):
"""Индекс документации для AI-помощника: имя файла и фрагмент текста (в нижнем регистре). Строится заново из Nextcloud."""
__tablename__ = "doc_index"
id: Mapped[int] = mapped_column(primary_key=True)
equipment_id: Mapped[int] = mapped_column(ForeignKey("equipment.id", ondelete="CASCADE"), index=True)
path: Mapped[str] = mapped_column(String(1000)) # путь файла от папки машины
name: Mapped[str] = mapped_column(String(300))
etag: Mapped[str] = mapped_column(String(200), default="")
size: Mapped[int] = mapped_column(Integer, default=0)
text: Mapped[str] = mapped_column(Text, default="") # извлечённый текст, нижний регистр, обрезан
note: Mapped[str] = mapped_column(String(200), default="") # почему текста нет (большой файл, ошибка чтения)
indexed_at: Mapped[datetime] = mapped_column(DateTime, default=config.now)
class Setting(Base):
__tablename__ = "settings"
key: Mapped[str] = mapped_column(String(64), primary_key=True)
@@ -464,6 +485,7 @@ class DailyTask(Base):
updated_at: Mapped[datetime] = mapped_column(DateTime, default=config.now)
finished_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
labor_hours: Mapped[float | None] = mapped_column(Float, nullable=True) # трудоёмкость (норма), ч — для отчёта по сотрудникам
photo_keep: Mapped[bool] = mapped_column(Boolean, default=False) # фото к заданию хранить более года
class TaskEvent(Base):
+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]:
"""Кого уведомлять о назначении: конкретного человека или тех, кто сейчас на смене в должности."""
"""Кого уведомлять о назначении: конкретного человека или тех, кто занимает должность и сейчас на смене
по своему графику (сменщики, которые дома, уведомлений не получают)."""
if user_id:
return [user_id]
if position_id:
pos = db.get(Position, position_id)
us = db.scalars(select(User).where(User.position_id == position_id, User.is_active.is_(True))).all()
return [u.id for u in us if pos and logic.shift_active(pos.schedule, logic.config.now())]
now = logic.config.now()
return [u.id for u in us if logic.user_on_shift(u, now)]
return []
+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())
commit(db)
c = A.cfg(db)
if c["enabled"] and (not c["model"] or not (c["api_key"] or "localhost" in c["base_url"] or "127.0.0.1" in c["base_url"])):
if c["enabled"] and (not c["model"] or not (c["api_key"] or A.autoconf.is_local(c["base_url"]))):
ss.set_(db, "assistant_enabled", "0")
commit(db)
raise HTTPException(422, "Для включения укажите модель и ключ API (без ключа — только локальная модель)")
+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)
from ..security import is_engineer, is_manager
out.reliability = ss.get(db, "reliability") != "0" and (is_manager(u) or is_engineer(u))
out.docs = bool(ss.get(db, "nc_url") and ss.get(db, "nc_user") and ss.get(db, "nc_password")) # документация из Nextcloud подключена
return out
@@ -357,6 +358,8 @@ def check_user_refs(db: Session, fields: dict):
raise HTTPException(422, "Отдел не найден")
if fields.get("position_id") and not db.get(Position, fields["position_id"]):
raise HTTPException(422, "Должность не найдена")
if fields.get("schedule_id") and not db.get(ShiftSchedule, fields["schedule_id"]):
raise HTTPException(422, "График не найден")
def guard_user_edit(me: User, target: User | None, fields: dict):
+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.orm import Session
from .. import schemas as S, config, logic, push, settings_store as ss
from .. import schemas as S, config, logic, photo_cleanup, push, settings_store as ss, sysinfo
from ..db import get_db
from ..models import Comment, Photo, Order, OrderItem, User, PushSubscription, DeviceToken
from ..security import current_user, require_admin, is_manager, is_portal_admin
from ..security import current_user, require_admin, require_portal_admin, is_manager, is_portal_admin
from .catalog import commit
from .orders import load, exec_guard, start, names, can_execute, to_out
@@ -94,7 +94,7 @@ async def upload_photo(id: int, item_id: int, request: Request, db: Session = De
raise HTTPException(415, "Допустимы JPEG, PNG, WebP")
ct, ext = kind
root = ss.photo_root(db)
rel = Path(f"{config.now():%Y/%m}") / (ss.new_name() + ext)
rel = Path("keep" if item.photo_keep else "") / f"{config.now():%Y/%m}" / (ss.new_name() + ext) # keep/ — не удаляются очисткой
(root / rel).parent.mkdir(parents=True, exist_ok=True)
(root / rel).write_bytes(data)
db.add(Photo(order_id=o.id, item_id=item.id, path=rel.as_posix(), content_type=ct, size=len(data),
@@ -142,8 +142,30 @@ def dir_stats(root: Path) -> dict:
return {"files": len(files), "bytes": sum(x.stat().st_size for x in files), "disk_free": free}
def keep_stats(root: Path) -> dict:
a, b = dir_stats(root / "keep"), dir_stats(root / "media" / "tasks" / "keep")
return {"files": a["files"] + b["files"], "bytes": a["bytes"] + b["bytes"]}
@router.get("/photo-cleanup")
def cleanup_preview(db: Session = Depends(get_db), _: User = Depends(require_portal_admin)):
"""Сколько фото удалит очистка сейчас (ничего не удаляет)."""
return {**photo_cleanup.run(db, dry=True), "last_run": ss.get(db, "photo_cleanup_at") or None}
@router.post("/photo-cleanup")
def cleanup_now(db: Session = Depends(get_db), _: User = Depends(require_portal_admin)):
return photo_cleanup.run(db)
@router.get("/server/monitor")
def monitor(db: Session = Depends(get_db), _: User = Depends(require_portal_admin)):
"""Мониторинг сервера: загрузка процессора, память, место на дисках (данные портала и фото)."""
return {**sysinfo.snapshot({"Данные портала": Path(config.DATA_DIR), "Фото": ss.photo_root(db)}), "now": config.iso(config.now())}
def settings_payload(db: Session) -> dict:
return {**ss.all_editable(db), "photo_stats": dir_stats(ss.photo_root(db)),
return {**ss.all_editable(db), "photo_stats": dir_stats(ss.photo_root(db)), "photo_keep_stats": keep_stats(ss.photo_root(db)),
"vapid_public": push.public_key(db), "now": config.iso(config.now()),
"fcm_enabled": push.fcm_enabled(db), "timezone": str(config.TZ)}
+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)
now = config.now()
sch = u.position.schedule if u.position else None
sch = u.schedule
shifts = []
if sch:
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),
equipment_id=t.equipment_id, equipment_name=eq.name if eq else None, unit_id=t.unit_id,
unit_name=su.name if su else None, status=t.status, created_at=t.created_at, updated_at=t.updated_at,
finished_at=t.finished_at, labor_hours=t.labor_hours, last_comment=last, photos=photos, can_change=can_change(u, t), can_edit=can_edit(u, t),
finished_at=t.finished_at, labor_hours=t.labor_hours, photo_keep=bool(t.photo_keep), last_comment=last, photos=photos, can_change=can_change(u, t), can_edit=can_edit(u, t),
can_transfer=can_transfer(u, t),
events=[S.TaskEventOut(at=e.at, user_name=names.get(e.user_id), from_status=e.from_status, to_status=e.to_status,
comment=e.comment, kind=e.kind or "status", from_user_name=names.get(e.from_user_id),
@@ -241,7 +241,7 @@ async def add_photo(id: int, request: Request, db: Session = Depends(get_db), u:
if not (is_boss(u) or u.id in (t.assignee_id, t.created_by_id)):
raise HTTPException(403, "Нет прав добавлять фото")
data, ct, ext = await media.read_image(db, request)
name = media.save(db, "tasks", data, ext)
name = media.save(db, "tasks", data, ext, keep=t.photo_keep)
db.add(TaskPhoto(task_id=t.id, path=name, content_type=ct, size=len(data), user_id=u.id))
t.updated_at = config.now()
commit(db)
+12 -1
View File
@@ -85,6 +85,7 @@ class UserIn(BaseModel):
portal_admin: bool = False
is_active: bool = True
position_id: Optional[int] = None
schedule_id: Optional[int] = None # график работы сотрудника (пусто — всегда на смене)
department_id: Optional[int] = None
skills: str = Field(default="", max_length=4000)
phone: str = Field(default="", max_length=40)
@@ -100,6 +101,7 @@ class UserPatch(BaseModel):
portal_admin: Optional[bool] = None
is_active: Optional[bool] = None
position_id: Optional[int] = None
schedule_id: Optional[int] = None
department_id: Optional[int] = None
skills: Optional[str] = Field(default=None, max_length=4000)
phone: Optional[str] = Field(default=None, max_length=40)
@@ -138,6 +140,7 @@ class UserOut(ORM):
is_active: bool
position_id: Optional[int]
position: Optional[PositionBrief] = None
schedule_id: Optional[int] = None
department_id: Optional[int] = None
department: Optional["DepartmentBrief"] = None
has_photo: bool = False
@@ -147,6 +150,7 @@ class UserOut(ORM):
demo_mode: bool = False
crane_map: bool = False # включена отдельная карта кранов
reliability: bool = False # доступен раздел «Анализ надёжности» (включён и у пользователя есть права)
docs: bool = False # подключена документация из Nextcloud
portal_admin: bool = False # администратор портала: настройки сервера и обратная связь
is_root: bool = False # администратор по умолчанию (admin): выдаёт права администратора портала
@@ -222,7 +226,7 @@ class PreviewDay(BaseModel):
class PositionIn(BaseModel):
name: str
kind: Literal["technician", "engineer"] = "technician"
schedule_id: Optional[int] = None
schedule_id: Optional[int] = None # устарело и не используется: график задаётся сотруднику
class PositionOut(ORM):
@@ -265,6 +269,7 @@ class EquipmentOut(EquipmentIn, ORM):
meter_updated_at: Optional[datetime] = None
map_x: Optional[float] = None
map_y: Optional[float] = None
docs_path: str = "" # папка документации в Nextcloud (привязывается через PUT /equipment/{id}/docs)
# ----- регламентное обслуживание: узлы машины -----
@@ -373,6 +378,7 @@ class ChecklistItemIn(BaseModel):
max_value: Optional[float] = None
required: bool = True
photo: Literal["none", "optional", "required"] = "none"
photo_keep: bool = False # фото хранить более года (не удаляются автоочисткой)
class ChecklistItemOut(ChecklistItemIn, ORM):
@@ -460,6 +466,7 @@ class OrderItemOut(ORM):
answered_at: Optional[datetime]
out_of_range: bool
photo: str
photo_keep: bool = False
service_unit_id: Optional[int] = None
is_meter: bool = False
photos: list[PhotoOut] = []
@@ -532,6 +539,7 @@ class SettingsIn(BaseModel):
setup_done: Optional[str] = Field(default=None, max_length=1000)
crane_map: Optional[bool] = None # отдельная карта кранов
reliability: Optional[bool] = None # раздел «Анализ надёжности»
photo_keep_days: Optional[int] = Field(default=None, ge=0, le=36500) # удалять фото старше N дней (0 — не удалять)
class PushSubIn(BaseModel):
@@ -841,6 +849,7 @@ class TaskIn(BaseModel):
equipment_id: Optional[int] = None
unit_id: Optional[int] = None
labor_hours: Optional[float] = Field(default=None, ge=0, le=1000)
photo_keep: bool = False # фото к заданию хранить более года
class TaskPatch(BaseModel):
@@ -851,6 +860,7 @@ class TaskPatch(BaseModel):
equipment_id: Optional[int] = None
unit_id: Optional[int] = None
labor_hours: Optional[float] = Field(default=None, ge=0, le=1000)
photo_keep: Optional[bool] = None
class TaskStatusIn(BaseModel):
@@ -892,6 +902,7 @@ class TaskOut(BaseModel):
updated_at: datetime
finished_at: Optional[datetime]
labor_hours: Optional[float] = None
photo_keep: bool = False
last_comment: str = ""
photos: list[int] = []
can_change: bool = False
+18 -3
View File
@@ -4,7 +4,7 @@ from pathlib import Path
from sqlalchemy.orm import Session
from . import config
from . import autoconf, config
from .models import Setting
DEFAULTS = {
@@ -19,6 +19,8 @@ DEFAULTS = {
"setup_done": "",
"crane_map": "0", # отдельная карта кранов
"reliability": "1", # раздел «Анализ надёжности» для инженеров и руководителей
"photo_keep_days": "0", # автоочистка: удалять фото нарядов и заданий старше N дней (0 — не удалять; keep/ не трогается)
"photo_cleanup_at": "", # когда очистка выполнялась последний раз
"crane_map_image": "", # картинка карты кранов (пусто — используется карта завода) # мастер первичной настройки: отмеченные шаги (через запятую)
# AI-помощник (не входит в EDITABLE: ключ не отдаётся в общих настройках, правится через /assistant/config)
"assistant_enabled": "0",
@@ -27,14 +29,27 @@ DEFAULTS = {
"assistant_model": "",
"assistant_api_key": "",
"assistant_scope": "",
# документация из Nextcloud (не входит в EDITABLE: пароль и секрет не отдаются, правится через /docs/config)
"nc_url": "", # адрес Nextcloud, лучше внутренний в локальной сети
"nc_user": "", # служебная учётная запись (только чтение)
"nc_password": "", # пароль приложения этой учётной записи
"nc_root": "", # корневая папка документации внутри учётной записи
"onlyoffice_url": "", # ONLYOFFICE Document Server — переводит документы Office в PDF для просмотра
"onlyoffice_jwt": "", # секрет JWT Document Server (если включён)
"portal_internal_url": "", # адрес портала, по которому его видит ONLYOFFICE (локальная сеть)
"docs_ai": "0", # «Работа с документацией» для AI-помощника
"docs_index_at": "", # когда последний раз строился индекс
}
EDITABLE = ("photo_dir", "max_photo_mb", "vapid_contact", "fcm_credentials_path", "public_incidents",
"backup_auto_hours", "backup_keep", "backup_media", "setup_done", "crane_map", "reliability")
"backup_auto_hours", "backup_keep", "backup_media", "setup_done", "crane_map", "reliability", "photo_keep_days")
def get(db: Session, key: str) -> str:
row = db.get(Setting, key)
return row.value if row and row.value != "" else DEFAULTS.get(key, "")
if row and row.value != "":
return row.value
auto = autoconf.default(key) # окружение (.env) или сервис во внутренней сети Docker
return auto if auto is not None else DEFAULTS.get(key, "")
def set_(db: Session, key: str, value: str):
+1 -1
View File
@@ -93,5 +93,5 @@ async function assistantSettings(box) {
window.assistantRefresh && window.assistantRefresh();
}) }, "Проверить"),
h("button", { onclick: guard(async () => { await api("PUT", "/assistant/config", body()); toast("Сохранено"); key.value = ""; window.assistantRefresh && window.assistantRefresh(); assistantSettings(box); }) }, "Сохранить")),
out);
out, await docsAiSection());
}
+70 -15
View File
@@ -154,6 +154,9 @@ async function route() {
else if (page === "admin") await adminPage();
else if (page === "server") await serverPage();
else if (page === "feedback") await feedbackPage(id);
else if (page === "docs") await docsPage(+id);
else if (page === "monitor") await monitorPage();
else if (page === "monitor") await monitorPage();
else if (page === "backups") await backupsPage();
else if (page === "setup") await setupPage(id);
else if (page === "new-order") await newOrderPage();
@@ -350,13 +353,14 @@ const CRUD = {
{ k: "notes", label: "Примечание", type: "textarea" }, { k: "is_active", label: "Активно", type: "bool" }],
init: { is_active: true },
filter: r => !(me.crane_map && r.is_crane), // при отдельной службе кранов краны живут в своём разделе
canEdit: () => isEng(), icon: true,
canEdit: () => isEng(), icon: true, locFilter: true,
actions: r => h("a", { class: "btn sec sm", style: "background:transparent;color:var(--text);border:1px solid var(--line)", href: "#/equipment/" + r.id }, "Карточка") },
locations: { title: "Места", path: "locations", cols: [["name", "Название"], ["parent_id", "Входит в", "locations"]],
fields: [{ k: "name", label: "Название", type: "text", req: 1 }, { k: "parent_id", label: "Входит в", type: "ref", ref: "locations" }], init: {} },
positions: { title: "Должности", path: "positions", cols: [["name", "Название"], ["kind", "Тип", null, v => v === "engineer" ? "Инженер" : "Техник"], ["schedule_id", "График", "schedules"]],
fields: [{ k: "name", label: "Название", type: "text", req: 1 }, { k: "kind", label: "Тип", type: "enum", options: [["technician", "Техник (выполняет наряды)"], ["engineer", "Инженер (планирует, проверяет)"]] },
{ k: "schedule_id", label: "График работы", hint: "Пусто — всегда на смене", type: "ref", ref: "schedules" }], init: { kind: "technician" } },
positions: { title: "Должности", path: "positions", cols: [["name", "Название"], ["kind", "Тип", null, v => v === "engineer" ? "Инженер" : "Техник"]],
fields: [{ k: "name", label: "Название", type: "text", req: 1 }, { k: "kind", label: "Тип", type: "enum", options: [["technician", "Техник (выполняет наряды)"], ["engineer", "Инженер (планирует, проверяет)"]] }],
note: "Должность выполняет функции по графику того, кто её занимает: график задаётся каждому сотруднику в «Пользователях». Наряд или задание на должность видят и получают уведомление только те, кто сейчас на смене.",
init: { kind: "technician" } },
schedules: { title: "Графики работы", path: "schedules", cols: [["name", "Название"], ["kind", "Тип", null, (v, r) => v === "cycle" ? `${r.work_days}/${r.off_days}` : r.weekdays.map(i => DAYS[i]).join(" ")], ["start_time", "Смена", null, (v, r) => `${v.slice(0, 5)}–${r.end_time.slice(0, 5)}`]],
fields: [{ k: "name", label: "Название", type: "text", req: 1 },
{ k: "kind", label: "Тип", type: "enum", options: [["weekly", "По дням недели"], ["cycle", "Цикл (N через M)"]], reload: 1 },
@@ -370,10 +374,12 @@ const CRUD = {
fields: [{ k: "name", label: "Название отдела", type: "text", req: 1 }, { k: "parent_id", label: "Входит в отдел", hint: "Для вложенных служб и участков; пусто — верхний уровень", type: "ref", ref: "departments" },
{ k: "head_user_id", label: "Руководитель", type: "ref", ref: "users" }, { k: "notes", label: "Примечание", type: "textarea" }],
init: {}, actions: r => h("a", { class: "btn sec sm", href: "#/people?dep=" + r.id }, "Сотрудники") },
users: { title: "Пользователи", path: "users", cols: [["username", "Логин"], ["full_name", "ФИО"], ["position_id", "Должность", "positions"], ["department_id", "Отдел", "departments"], ["is_admin", "Роль", null, (v, r) => [v ? "Админ" : r.is_moderator ? "Модератор" : "", r.is_storekeeper ? "Склад" : ""].filter(Boolean).join(", ") || "—"], ["is_active", "Активен"], ["on_shift", "На смене"]],
users: { title: "Пользователи", path: "users", cols: [["username", "Логин"], ["full_name", "ФИО"], ["position_id", "Должность", "positions"], ["schedule_id", "График", "schedules"], ["department_id", "Отдел", "departments"], ["is_admin", "Роль", null, (v, r) => [v ? "Админ" : r.is_moderator ? "Модератор" : "", r.is_storekeeper ? "Склад" : ""].filter(Boolean).join(", ") || "—"], ["is_active", "Активен"], ["on_shift", "На смене"]],
fields: [{ k: "username", label: "Логин", type: "text", req: 1 }, { k: "full_name", label: "ФИО", type: "text" },
{ k: "password", label: "Пароль", hint: "При правке оставьте пустым, чтобы не менять", type: "password" },
{ k: "position_id", label: "Должность", type: "ref", ref: "positions" }, { k: "department_id", label: "Отдел", type: "ref", ref: "departments" }, { k: "is_moderator", label: "Модератор", hint: "Управляет пользователями, должностями, чек-листами; наряды сам не выполняет", type: "bool", show: () => me.is_admin },
{ k: "position_id", label: "Должность", type: "ref", ref: "positions" },
{ k: "schedule_id", label: "График работы", hint: "По нему сотрудник выполняет свою должность: на смене он видит наряды должности и получает уведомления, сменщики дома — нет. Пусто — всегда на смене", type: "ref", ref: "schedules" },
{ k: "department_id", label: "Отдел", type: "ref", ref: "departments" }, { k: "is_moderator", label: "Модератор", hint: "Управляет пользователями, должностями, чек-листами; наряды сам не выполняет", type: "bool", show: () => me.is_admin },
{ k: "is_admin", label: "Администратор", hint: "Полный доступ к администрированию", type: "bool", show: () => me.is_admin },
{ k: "portal_admin", label: "Администратор портала", hint: "Настройки сервера и ответы обратной связи. Выдаёт только администратор по умолчанию (admin); у самого admin эти права всегда", type: "bool", show: () => me.is_root },
{ k: "is_storekeeper", label: "Администратор склада", hint: "Каталог инструмента, склады, выдача сотрудникам, заявки на инструмент", type: "bool" },
@@ -403,12 +409,40 @@ CRUD.cranes = { ...CRUD.equipment, title: "Краны", init: { is_active: true,
cols: CRUD.equipment.cols.filter(c => c[0] !== "is_crane"),
fields: CRUD.equipment.fields.map(f => f.k === "is_crane" ? { ...f, hint: "Снимите, чтобы перенести машину в обычное оборудование" } : f) };
const crudLoc = {}; // выбранное место в фильтре списка (по разделу)
// место и все вложенные в него места
function locSubtree(locs, root) {
const out = new Set([root]);
for (let grew = true; grew;) { grew = false; for (const l of locs) if (l.parent_id && out.has(l.parent_id) && !out.has(l.id)) { out.add(l.id); grew = true; } }
return out;
}
// места деревом: «Цех №1», «— Компрессорная» …
const NBSP3 = String.fromCharCode(160).repeat(3);
function locOptions(locs) {
const kids = p => locs.filter(l => (l.parent_id || null) === p).sort((a, b) => a.name.localeCompare(b.name, "ru"));
const out = [], walk = (p, d) => { for (const l of kids(p)) { if (out.some(x => x[0] === l.id)) continue; out.push([l.id, NBSP3.repeat(d) + (d ? "— " : "") + l.name]); walk(l.id, d + 1); } };
walk(null, 0);
for (const l of locs) if (!out.some(x => x[0] === l.id)) out.push([l.id, l.name]);
return out;
}
async function crudPage(page) {
const C = CRUD[page], box = h("div"), formBox = h("div"), can = C.canEdit ? C.canEdit() : true;
const bar = h("div");
$app.replaceChildren(h("div", { class: "row" }, h("h1", { style: "flex:1" }, C.title),
can ? h("button", { onclick: () => edit(null) }, "+ Добавить") : null), formBox, box);
can ? h("button", { onclick: () => edit(null) }, "+ Добавить") : null), ...(C.note ? [h("p", { class: "small muted" }, C.note)] : []), bar, formBox, box);
if (C.locFilter) {
const locs = await ref("locations", true);
if (locs.length) bar.replaceChildren(h("div", { class: "row", style: "margin-bottom:10px" }, h("label", { style: "margin:0" }, "Место:"),
h("select", { style: "max-width:360px", onchange: e => { crudLoc[page] = e.target.value ? +e.target.value : null; load(); } },
h("option", { value: "" }, "Все места"), ...locOptions(locs).map(([id, n]) => h("option", { value: id, selected: crudLoc[page] === id }, n)))));
}
async function load() {
const rows = (await api("GET", "/" + C.path)).filter(C.filter || (() => true));
let rows = (await api("GET", "/" + C.path)).filter(C.filter || (() => true));
if (C.locFilter && crudLoc[page]) {
const sub = locSubtree(await ref("locations"), crudLoc[page]);
rows = rows.filter(r => sub.has(r.location_id));
}
const lookups = {};
for (const c of C.cols) if (c[2]) lookups[c[2]] = await ref(c[2], true);
const cell = (r, [k, , rf, fn]) => {
@@ -490,7 +524,9 @@ async function checklistEditor(id) {
h("input", { type: t, step: "any", value: it[k] ?? "", onchange: e => it[k] = e.target.value === "" ? (k === "unit" ? "" : null) : (t === "number" ? +e.target.value : e.target.value) })))) : null,
h("div", { class: "row", style: "margin-top:8px" },
h("label", { style: "display:flex;gap:5px;align-items:center;margin:0;color:var(--text)" }, h("input", { type: "checkbox", checked: it.required, onchange: e => it.required = e.target.checked }), "Обязательный"),
h("select", { style: "width:auto", onchange: e => it.photo = e.target.value }, ...[["none", "📷 без фото"], ["optional", "📷 фото по желанию"], ["required", "📷 фото обязательно"]].map(([k, t]) => h("option", { value: k, selected: (it.photo || "none") === k }, t))),
h("select", { style: "width:auto", onchange: e => { it.photo = e.target.value; draw(); } }, ...[["none", "📷 без фото"], ["optional", "📷 фото по желанию"], ["required", "📷 фото обязательно"]].map(([k, t]) => h("option", { value: k, selected: (it.photo || "none") === k }, t))),
(it.photo || "none") !== "none" ? h("label", { class: "chk", title: "Фото этого пункта кладутся в отдельный каталог и не удаляются автоочисткой сервера" },
h("input", { type: "checkbox", checked: !!it.photo_keep, onchange: e => it.photo_keep = e.target.checked }), "хранить более года") : null,
h("span", { style: "flex:1" }),
h("button", { class: "sec sm", disabled: n === 0, onclick: () => { [items[n - 1], items[n]] = [items[n], items[n - 1]]; draw(); } }, "↑"),
h("button", { class: "sec sm", disabled: n === items.length - 1, onclick: () => { [items[n + 1], items[n]] = [items[n], items[n + 1]]; draw(); } }, "↓"),
@@ -503,7 +539,7 @@ async function checklistEditor(id) {
h("h2", {}, "Задачи"), box,
h("div", { class: "row" }, h("button", { class: "sec", onclick: add }, "+ Добавить задачу"), h("button", { onclick: guard(async () => {
const body = { name: name.value, description: desc.value, kind: kv.kind, only_due: kv.only_due, items: items.map(i => ({
text: i.text, answer_type: i.answer_type, required: i.required, photo: i.photo || "none",
text: i.text, answer_type: i.answer_type, required: i.required, photo: i.photo || "none", photo_keep: (i.photo || "none") !== "none" && !!i.photo_keep,
options: i.answer_type === "choice" ? i.optText.split("\n").map(s => s.trim()).filter(Boolean) : null,
unit: i.answer_type === "number" ? (i.unit || "") : "", min_value: i.answer_type === "number" ? i.min_value ?? null : null,
max_value: i.answer_type === "number" ? i.max_value ?? null : null })) };
@@ -572,6 +608,7 @@ async function adminPage() {
const [users, pos, sch] = await Promise.all([api("GET", "/users"), api("GET", "/positions"), api("GET", "/schedules")]);
const tile = (href, title, text) => h("a", { href, class: "tile" }, h("b", {}, title), h("span", { class: "muted small" }, text));
const eqCount = me.is_admin && !me.demo_mode ? (await api("GET", "/equipment").catch(() => [])).length : 1;
const setupFinished = me.is_admin && ((await api("GET", "/settings").catch(() => ({}))).setup_done || "").split(",").includes("finished");
$app.replaceChildren(h("h1", {}, me.is_admin ? "Панель администратора" : "Панель модератора"),
...(eqCount === 0 || (me.is_admin && users.length <= 1) ? [h("div", { class: "card banner-setup" }, h("b", { style: "font-size:17px" }, "🧭 Похоже, система ещё не настроена."),
h("span", { class: "muted" }, "Мастер по шагам проведёт через графики, должности, отделы, места, оборудование, сотрудников, карту и склады."),
@@ -586,17 +623,19 @@ async function adminPage() {
h("div", { class: "tiles" },
tile("#/checklists", "Чек-листы", "шаблоны проверок"), tile("#/equipment", "Оборудование", "справочник"),
tile("#/locations", "Места", "расположение"), tile("#/templates", "Расписание нарядов", "автозапуск")),
...(me.is_admin ? [h("h2", {}, "Сервер (только администратор)"), h("div", { class: "tiles" }, tile("#/setup", "🧭 Мастер первичной настройки", "пошагово: графики, люди, оборудование, карта, склады"), ...(me.portal_admin ? [tile("#/server", "Настройки сервера", "каталог фото, лимиты, уведомления, внешние обращения, AI-помощник"), tile("#/feedback", "Обратная связь", "идеи и замечания сотрудников по порталу")] : []), tile("#/backups", "Резервные копии", "база и конфигурация, автокопии, восстановление"), tile("#/demo", "Демонстрационный режим", me.demo_mode ? "ВКЛЮЧЁН — управление" : "тестовые данные для презентации"))]
...(me.is_admin ? [h("h2", {}, "Сервер (только администратор)"), h("div", { class: "tiles" }, tile(setupFinished ? "#/setup/done" : "#/setup", "🧭 Мастер первичной настройки", setupFinished ? "пройден — проверка готовности и что поправить" : "пошагово: графики, люди, оборудование, карта, склады"), ...(me.portal_admin ? [tile("#/server", "Настройки сервера", "каталог фото, лимиты, уведомления, внешние обращения, AI-помощник"), tile("#/feedback", "Обратная связь", "идеи и замечания сотрудников по порталу"), tile("#/monitor", "Мониторинг сервера", "процессор, память, место на дисках"), tile("#/monitor", "Мониторинг сервера", "процессор, память, место на дисках")] : []), tile("#/backups", "Резервные копии", "база и конфигурация, автокопии, восстановление"), tile("#/demo", "Демонстрационный режим", me.demo_mode ? "ВКЛЮЧЁН — управление" : "тестовые данные для презентации"))]
: [h("p", { class: "small muted" }, "Параметры сервера доступны только администратору. Модератор сам наряды не выполняет.")]));
}
async function serverPage() {
if (!me.portal_admin) throw new Error("Настройки сервера доступны только администраторам портала");
const keysBox = h("div", { style: "margin-top:14px" }), aiBox = h("div", { class: "card" });
const cleanupBox = h("div", {}, "…");
const keysBox = h("div", { style: "margin-top:14px" }), aiBox = h("div", { class: "card" }), docsBox = h("div", { class: "card" });
const s = await api("GET", "/settings");
const f = formFields([
{ k: "photo_dir", label: "Каталог для фото", hint: "Абсолютный путь на сервере. В Docker — примонтированный том", type: "text" },
{ k: "max_photo_mb", label: "Максимальный размер фото, МБ", type: "number" },
{ k: "photo_keep_days", label: "Автоочистка: удалять фото нарядов и заданий старше, дней", hint: "Например, 365 — раз в сутки удаляются фото старше года. 0 — не удалять. Фото пунктов и заданий с отметкой «хранить более года» лежат в отдельном каталоге keep/ и не удаляются", type: "number" },
{ k: "vapid_contact", label: "Контакт для push-уведомлений", hint: "mailto:… или https://…", type: "text" },
{ k: "fcm_credentials_path", label: "Android push (FCM): ключ Firebase", hint: "Путь к JSON-ключу сервисного аккаунта на сервере. Пусто — выключено", type: "text" },
{ k: "public_incidents", label: "Публичная форма «Сообщить о неисправности» (/report)", hint: "Сторонние люди без входа сообщают об аварии: место, машина, описание, контакты", type: "bool" },
@@ -606,12 +645,14 @@ async function serverPage() {
const mb = n => (n / 1048576).toFixed(1) + " МБ";
$app.replaceChildren(h("a", { href: "#/admin" }, "← Администрирование"), h("h1", {}, "Настройки сервера"),
h("div", { class: "card" }, f.el, h("div", { style: "margin-top:12px" }, h("button", { onclick: guard(async () => {
await api("PUT", "/settings", { photo_dir: f.vals.photo_dir, max_photo_mb: f.vals.max_photo_mb, vapid_contact: f.vals.vapid_contact, fcm_credentials_path: f.vals.fcm_credentials_path ?? "", public_incidents: !!f.vals.public_incidents, crane_map: !!f.vals.crane_map, reliability: !!f.vals.reliability });
await api("PUT", "/settings", { photo_dir: f.vals.photo_dir, max_photo_mb: f.vals.max_photo_mb, photo_keep_days: +(f.vals.photo_keep_days || 0), vapid_contact: f.vals.vapid_contact, fcm_credentials_path: f.vals.fcm_credentials_path ?? "", public_incidents: !!f.vals.public_incidents, crane_map: !!f.vals.crane_map, reliability: !!f.vals.reliability });
me = await api("GET", "/me"); toast("Сохранено"); serverPage();
}) }, "Сохранить")), h("p", { class: "small muted" }, "Смена каталога не переносит уже загруженные фото — скопируйте их вручную в новый каталог.")),
h("div", { class: "card" }, h("h2", { style: "margin-top:0" }, "Состояние"),
h("div", { class: "grid" },
h("div", {}, h("label", {}, "Фото на сервере"), `${s.photo_stats.files} шт., ${mb(s.photo_stats.bytes)}`),
h("div", {}, h("label", {}, "Фото на сервере"), `${s.photo_stats.files} шт., ${mb(s.photo_stats.bytes)}`,
h("div", { class: "small muted" }, `из них хранить долго (keep/): ${s.photo_keep_stats.files} шт., ${mb(s.photo_keep_stats.bytes)}`)),
h("div", {}, h("label", {}, "Автоочистка фото"), cleanupBox),
h("div", {}, h("label", {}, "Свободно на диске"), mb(s.photo_stats.disk_free)),
h("div", {}, h("label", {}, "Время сервера"), `${fmt(s.now)} (${s.timezone})`),
h("div", {}, h("label", {}, "Адрес клиента, каким его видит сервер"), s.client_ip || "—",
@@ -620,11 +661,25 @@ async function serverPage() {
h("div", {}, h("label", {}, "Браузерные уведомления"), location.protocol === "https:" || location.hostname === "localhost" ? "доступны" : "нужен HTTPS"))),
h("div", { class: "card" }, h("h2", { style: "margin-top:0" }, "Сообщения об отказах и неисправностях извне"),
h("p", { class: "small" }, "Публичная форма: ", h("a", { href: "/report", target: "_blank" }, location.origin + "/report"),
" — ссылку можно разместить на QR-коде у оборудования или на сайте."), keysBox), aiBox);
" — ссылку можно разместить на QR-коде у оборудования или на сайте."), keysBox), docsBox, aiBox);
apiKeys(keysBox);
docsSettings(docsBox);
photoCleanupInfo(cleanupBox);
assistantSettings(aiBox);
}
async function photoCleanupInfo(box) {
const c = await api("GET", "/photo-cleanup");
const mb = n => (n / 1048576).toFixed(1) + " МБ";
if (!c.enabled) return box.replaceChildren("выключена");
box.replaceChildren(`старше ${c.days} дн.`, h("div", { class: "small muted" }, c.photos ? `сейчас к удалению: ${c.photos} фото, ${mb(c.bytes)}` : "сейчас удалять нечего",
c.last_run ? ` · последний запуск ${fmt(c.last_run)}` : ""),
c.photos ? h("button", { class: "sec sm", style: "margin-top:4px", onclick: guard(async () => {
if (!confirm(`Удалить ${c.photos} фото старше ${c.days} дней? Это необратимо.`)) return;
const r = await api("POST", "/photo-cleanup"); toast(`Удалено фото: ${r.photos}`); serverPage();
}) }, "Очистить сейчас") : null);
}
async function apiKeys(box) {
const keys = await api("GET", "/api-keys");
const name = h("input", { placeholder: "Название системы, например «Диспетчерская» или «SCADA цех 1»" });
+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/demo.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>
</body>
</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: "необязательно",
onchange: e => s.labor_hours = e.target.value === "" ? null : +e.target.value }), "Для отчёта по сотрудникам; пусто — учитывается фактическое время в работе")),
h("div", { style: "margin-top:8px" }, field("Описание", h("textarea", { onchange: e => s.description = e.target.value }, s.description))),
h("label", { class: "chk", style: "margin-top:8px", title: "Фото к заданию кладутся в отдельный каталог и не удаляются автоочисткой сервера" },
h("input", { type: "checkbox", checked: !!s.photo_keep, onchange: e => s.photo_keep = e.target.checked }), "📷 Фото к заданию хранить более года"),
h("div", { class: "row", style: "margin-top:12px" }, h("button", { onclick: guard(async () => {
const body = { title: s.title, description: s.description, day: s.day || null, assignee_id: s.assignee_id, equipment_id: s.equipment_id, unit_id: s.unit_id, labor_hours: s.labor_hours ?? null };
const body = { title: s.title, description: s.description, day: s.day || null, assignee_id: s.assignee_id, equipment_id: s.equipment_id, unit_id: s.unit_id, labor_hours: s.labor_hours ?? null, photo_keep: !!s.photo_keep };
const r = t ? await api("PATCH", "/tasks/" + t.id, body) : await api("POST", "/tasks", body);
toast("Сохранено"); location.hash = "#/task/" + r.id;
}) }, t ? "Сохранить" : "Создать задание"))));
+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("div", { class: "small muted", style: "margin-bottom:10px" }, loc ? "Место: " + loc.name : "", eq.notes ? " · " + eq.notes : ""),
portal.head,
docsBlock(eq, reload),
meterCard,
h("div", { class: "row", style: "margin:8px 0" }, h("h2", { style: "flex:1;margin:0" }, "Обслуживаемые узлы"),
canEdit ? h("button", { onclick: () => unitForm(null) }, "+ Добавить обслуживаемый узел") : null),
+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" }],
];
// Итог мастера: проверка готовности по введённым данным — что настроено, что мешает работе, что сделать дальше.
async function setupFinish(d) {
if (!d.done.has("finished")) { d.done.add("finished"); await api("PUT", "/settings", { setup_done: [...d.done].join(",") }); }
const staff = d.users.filter(u => !u.is_admin && u.is_active);
const techPos = d.pos.filter(p => p.kind === "technician");
const checks = [];
const add = (ok, text, href, fix) => checks.push({ ok, text, href, fix });
add(staff.length > 0, staff.length ? `Сотрудников: ${staff.length}` : "Нет ни одного сотрудника", "#/setup/users", "Завести сотрудников");
const noPos = staff.filter(u => !u.position_id);
add(!noPos.length, noPos.length ? `Без должности (не видят наряды должностей): ${noPos.map(u => u.full_name || u.username).join(", ")}` : "У всех сотрудников есть должность", "#/users", "Назначить должности");
const empty = d.pos.filter(p => !staff.some(u => u.position_id === p.id));
add(!empty.length, empty.length ? `Должности без сотрудников (наряды на них никто не получит): ${empty.map(p => p.name).join(", ")}` : "Каждую должность кто-то занимает", "#/users", "Назначить сотрудников");
const noShift = techPos.filter(p => staff.some(u => u.position_id === p.id) && !staff.some(u => u.position_id === p.id && u.on_shift));
add(!noShift.length, noShift.length ? `Сейчас никого на смене: ${noShift.map(p => p.name).join(", ")} — проверьте графики сотрудников` : "По графикам сейчас на смене есть кто-то на каждой должности техников", "#/users", "Проверить графики");
const noSch = staff.filter(u => u.position_id && !u.schedule_id && techPos.some(p => p.id === u.position_id));
if (noSch.length) add(null, `Без графика (всегда на смене, получают все уведомления должности): ${noSch.map(u => u.full_name || u.username).join(", ")}`, "#/users", "Задать графики");
add(d.eqs.length > 0, d.eqs.length ? `Оборудования: ${d.eqs.length}` : "Оборудование не заведено", "#/setup/equipment", "Завести оборудование");
const noLoc = d.eqs.filter(e => !e.location_id);
if (d.eqs.length) add(!noLoc.length, noLoc.length ? `Машин без места: ${noLoc.length} (не находятся в публичной форме и фильтре по местам)` : "У всех машин указано место", "#/equipment", "Указать места");
add(!!d.map.has_image, d.map.has_image ? `Карта завода: на ней ${(d.map.equipment || []).filter(e => e.x != null).length} машин` : "Карта завода не загружена", "#/setup/map", "Загрузить карту");
add(d.cls.length > 0, d.cls.length ? `Чек-листов: ${d.cls.length}` : "Нет чек-листов — нечего проверять в нарядах", "#/checklists/new", "Создать чек-лист");
add(d.tpls.length > 0, d.tpls.length ? `Расписаний нарядов: ${d.tpls.length}` : "Нет расписания — наряды не будут создаваться сами", "#/templates", "Настроить расписание");
add(!!(d.bk.auto_hours), d.bk.auto_hours ? `Автокопии каждые ${d.bk.auto_hours} ч` : "Автоматические резервные копии выключены", "#/backups", "Включить");
if (d.settings.photo_keep_days !== undefined) add(null, +d.settings.photo_keep_days ? `Старые фото удаляются через ${d.settings.photo_keep_days} дн.` : "Автоочистка старых фото выключена — место на диске со временем закончится", "#/server", "Настроить");
const bad = checks.filter(c => c.ok === false).length;
const icon = c => c.ok === true ? "✅" : c.ok === false ? "⚠️" : "ℹ️";
fill($app, h("a", { href: "#/setup" }, "← Мастер"), h("h1", {}, bad ? "🧭 Настройка почти завершена" : "🎉 Портал готов к работе"),
h("p", { class: "muted" }, bad ? `Мастер пройден. Осталось поправить: ${bad}. Пока это не сделано, часть нарядов и уведомлений может никому не прийти.`
: "Мастер пройден, всё основное настроено. Раздайте сотрудникам логины и создайте первый наряд."),
h("div", { class: "card" }, h("h2", { style: "margin-top:0" }, "Проверка готовности"),
...checks.map(c => h("div", { class: "row", style: "padding:6px 0;border-bottom:1px solid var(--line)" }, h("span", {}, icon(c)), h("span", { style: "flex:1" }, c.text),
c.ok !== true && c.href ? h("a", { class: "btn sec sm", href: c.href }, c.fix) : null))),
h("div", { class: "card" }, h("h2", { style: "margin-top:0" }, "Что дальше"),
h("ol", { class: "small" }, h("li", {}, "Раздайте логины и пароли (таблица на шаге «Сотрудники»), установите приложение: ссылка внизу бокового меню."),
h("li", {}, "Создайте первый наряд вручную и пройдите его с телефона — так проще всего показать работу сотрудникам."),
h("li", {}, "Проверьте уведомления: «🔔» внизу меню у каждого сотрудника.")),
h("div", { class: "row" }, h("a", { class: "btn", href: "#/new-order" }, "Создать наряд"), h("a", { class: "btn sec", href: "#/work" }, "Открыть «Задачи»"),
h("button", { class: "sec", onclick: guard(() => setupPage("done")) }, "↻ Проверить ещё раз"))));
}
async function setupPage(stepKey) {
if (!me.is_admin) throw new Error("Мастер доступен администратору");
const G = p => api("GET", p).catch(() => []);
@@ -92,21 +132,19 @@ async function setupPage(stepKey) {
h("li", {}, `Каталог фото: ${settings.photo_dir}, лимит снимка ${settings.max_photo_mb} МБ.`),
h("li", {}, `Время сервера сейчас: ${fmt(settings.now)} — должно совпадать с часами на стене.`)),
h("a", { class: "btn sec", href: "#/server" }, "Открыть настройки сервера")) },
{ k: "schedules", t: "Графики работы", n: () => sch.length, why: "По графику система понимает, кто сейчас на смене и кому показывать наряды должности.",
{ k: "schedules", t: "Графики работы", n: () => sch.length, why: "График задаётся каждому сотруднику: по нему система понимает, кто сейчас на смене, кому показывать наряды должности и слать уведомления.",
body: () => h("div", {}, list(sch, x => x.name),
h("p", { class: "small muted" }, "Добавьте типовые графики одним нажатием, а сложные циклы соберите в конструкторе."),
h("div", { class: "row" }, ...SCHEDULE_PRESETS.map(([name, body]) => h("button", { class: "sec", disabled: !!byName(sch, name), onclick: guard(async () => {
await api("POST", "/schedules", { name, ...body }); toast("Добавлен график «" + name + "»"); reload();
}) }, (byName(sch, name) ? "✔ " : "+ ") + name)), h("a", { class: "btn sec", href: "#/schedules" }, "Конструктор графиков"))) },
{ k: "positions", t: "Должности", n: () => pos.length, why: "Тип должности: техник выполняет наряды, инженер планирует и проверяет. У должности — график.",
{ k: "positions", t: "Должности", n: () => pos.length, why: "Тип должности: техник выполняет наряды, инженер планирует и проверяет. График — не у должности, а у сотрудника (шаг «Сотрудники»).",
body: () => h("div", {}, list(pos, x => x.name), bulk({
hint: "Формат: Название; техник или инженер; название графика (необязательно).",
example: "Слесарь-ремонтник; техник; Сутки через трое\nЭлектромонтёр; техник; Сутки через трое\nИнженер-механик; инженер; Пятидневка 08:00–17:00",
run: async ([name, kind, s]) => {
hint: "Формат: Название; техник или инженер.",
example: "Слесарь-ремонтник; техник\nДежурный механик; техник\nЭлектромонтёр; техник\nИнженер-механик; инженер",
run: async ([name, kind]) => {
if (!name) throw new Error("нет названия");
const sc = s ? byName(sch, s) : null;
if (s && !sc) throw new Error(`график «${s}» не найден`);
const p = await api("POST", "/positions", { name, kind: norm(kind).startsWith("инж") ? "engineer" : "technician", schedule_id: sc ? sc.id : null });
const p = await api("POST", "/positions", { name, kind: norm(kind).startsWith("инж") ? "engineer" : "technician" });
pos.push(p); return p;
} })) },
{ k: "departments", t: "Отделы", n: () => deps.length, why: "Службы и участки. Отделы можно вкладывать друг в друга.",
@@ -145,20 +183,22 @@ async function setupPage(stepKey) {
return e;
} }), h("p", { class: "small muted" }, "Иконку и обслуживаемые узлы (масло, фильтры, ремни) задайте в карточке машины: «Оборудование» → «Карточка»."),
h("a", { class: "btn sec", href: "#/equipment" }, "Открыть оборудование")) },
{ k: "users", t: "Сотрудники", n: () => users.filter(u => !u.is_admin).length, why: "Учётные записи: должность определяет график и права, отдел — принадлежность.",
{ k: "users", t: "Сотрудники", n: () => users.filter(u => !u.is_admin).length, why: "Учётные записи: должность определяет права, график — когда сотрудник на смене, отдел — принадлежность.",
body: () => {
const res = h("div");
return h("div", {}, list(users.filter(u => !u.is_admin), x => x.full_name || x.username), bulk({
hint: "Формат: ФИО; логин (пусто — придумается сам); должность; отдел; роль (склад / модератор, необязательно). Пароли создаются автоматически — распечатайте таблицу и раздайте.",
example: "Иванов Иван Петрович; ; Слесарь-ремонтник; Механическая служба\nПетров Сергей Алексеевич; ; Электромонтёр; Электрослужба\nКозлов Дмитрий Олегович; ; Слесарь-ремонтник; Механическая служба; склад",
run: async ([fio, login, p, d, role]) => {
hint: "Формат: ФИО; логин (пусто — придумается сам); должность; отдел; график; роль (склад / модератор). График и роль — необязательно; без графика сотрудник всегда на смене. Пароли создаются автоматически — распечатайте таблицу и раздайте.",
example: "Иванов Иван Петрович; ; Дежурный механик; Механическая служба; Сутки через трое\nСидоров Олег Ильич; ; Дежурный механик; Механическая служба; Сутки через трое\nПетров Сергей Алексеевич; ; Электромонтёр; Электрослужба; Пятидневка 08:00–17:00\nКозлов Дмитрий Олегович; ; Слесарь-ремонтник; Механическая служба; ; склад",
run: async ([fio, login, p, d, s, role]) => {
if (!fio || fio.length < 3) throw new Error("нет ФИО");
const taken = new Set(users.map(u => u.username));
const position = p ? byName(pos, p) : null, dep = d ? byName(deps, d) : null;
if (p && !position) throw new Error(`должность «${p}» не найдена`);
if (d && !dep) throw new Error(`отдел «${d}» не найден`);
const sc = s ? byName(sch, s) : null;
if (s && !sc) throw new Error(`график «${s}» не найден`);
const password = makePassword(), username = login || makeLogin(fio, taken);
const u = await api("POST", "/users", { username, full_name: fio, password, position_id: position ? position.id : null, department_id: dep ? dep.id : null,
const u = await api("POST", "/users", { username, full_name: fio, password, position_id: position ? position.id : null, schedule_id: sc ? sc.id : null, department_id: dep ? dep.id : null,
is_storekeeper: norm(role).startsWith("скл"), is_moderator: norm(role).startsWith("мод"), is_active: true });
users.push(u);
return { fio, username, password, position: position ? position.name : "", dep: dep ? dep.name : "" };
@@ -209,6 +249,7 @@ async function setupPage(stepKey) {
h("a", { class: "btn sec", href: "#/backups" }, "Резервные копии"))) },
];
const state = s => done.has(s.k) || s.n() ? "done" : "todo";
if (stepKey === "done") return setupFinish({ STEPS, state, done, sch, pos, users, eqs, locs, map, cls, tpls, bk, settings });
const cur = STEPS.find(s => s.k === stepKey) || STEPS.find(s => state(s) === "todo") || STEPS[0];
const idx = STEPS.indexOf(cur);
const ready = STEPS.filter(s => state(s) === "done").length;
@@ -222,6 +263,6 @@ async function setupPage(stepKey) {
h("p", { class: "muted" }, cur.why), cur.body(),
h("div", { class: "row", style: "margin-top:18px;border-top:1px solid var(--line);padding-top:12px" },
idx > 0 ? h("a", { class: "btn sec", href: "#/setup/" + STEPS[idx - 1].k }, "← Назад") : null, h("span", { style: "flex:1" }),
h("label", { class: "chk" }, h("input", { type: "checkbox", checked: done.has(cur.k), onchange: guard(async e => { await markDone(cur.k, e.target.checked); reload(cur.k); }) }), "шаг выполнен"),
idx < STEPS.length - 1 ? h("a", { class: "btn", href: "#/setup/" + STEPS[idx + 1].k }, "Далее →") : h("a", { class: "btn", href: "#/admin" }, "Завершить")))));
h("label", { class: "chk" }, h("input", { type: "checkbox", checked: done.has(cur.k), onchange: guard(async e => { await markDone(cur.k, e.target.checked); location.hash = "#/setup/" + (e.target.checked ? (STEPS[idx + 1] || { k: "done" }).k : cur.k); if (!e.target.checked) reload(cur.k); }) }), "шаг выполнен"),
idx < STEPS.length - 1 ? h("a", { class: "btn", href: "#/setup/" + STEPS[idx + 1].k }, "Далее →") : h("a", { class: "btn", href: "#/setup/done" }, "Завершить и проверить")))));
}
+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 .card{width:min(560px,100%);margin:0}.fbmodal textarea{width:100%}
.fbcard.fb-new{border-left:4px solid var(--accent)}.fbcard.fb-taken{border-left:4px solid var(--warn)}.fbcard.fb-done{opacity:.8}
/* документация из Nextcloud */
.dcmodal{position:fixed;inset:0;background:rgba(0,0,0,.45);display:flex;align-items:center;justify-content:center;z-index:200;padding:16px}
.dcmodal .card{width:min(640px,100%);max-height:86vh;overflow:auto;margin:0}
.crumbs{font-size:14px;margin:4px 0 10px;word-break:break-word}.crumbs a{text-decoration:none}
.dclist{display:flex;flex-direction:column;gap:2px;margin:0}
.dcrow{display:flex;gap:8px;align-items:center;padding:7px 8px;border-radius:8px;color:var(--text);text-decoration:none;word-break:break-word}
.dcrow:hover,.dcrow.on{background:var(--bg)}
.dcwrap{display:grid;grid-template-columns:minmax(220px,320px) 1fr;gap:12px;align-items:start}.dcwrap>.card{margin:0}
@media (max-width:900px){.dcwrap{grid-template-columns:1fr}}
.dcframe{width:100%;height:78vh;border:1px solid var(--line);border-radius:8px;background:#fff}
.dcimg{max-width:100%;border-radius:8px}.dctext{white-space:pre-wrap;max-height:78vh;overflow:auto;font-size:13px;margin:0}
.docsai{margin-top:16px;border-top:1px solid var(--line);padding-top:6px}
.sheet.risk{border-left:4px solid var(--warn);margin-top:8px}.sheet.risk ul{margin:6px 0 0;padding-left:18px}.sheet.risk li{margin:3px 0}
/* мониторинг сервера */
.gauges{display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:12px}.gauges .card{margin:0}
.gauge{text-align:center}.gauge h2{margin:0 0 6px;font-size:16px}
.donut{width:150px;height:150px;display:block;margin:4px auto 8px}.donut circle{transition:stroke-dasharray .6s}
.donut-v{font-size:24px;font-weight:700;fill:var(--text)}
/* мониторинг сервера */
.gauges{display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:12px}.gauges .card{margin:0}
.gauge{text-align:center}.gauge h2{margin:0 0 6px;font-size:16px}
.donut{width:150px;height:150px;display:block;margin:4px auto 8px}.donut circle{transition:stroke-dasharray .6s}
.donut-v{font-size:24px;font-weight:700;fill:var(--text)}
+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
# Переменные задаются в интерфейсе Coolify (или в .env): DB_PASSWORD, SECRET_KEY, ADMIN_PASSWORD, при желании APP_TZ, APP_PORT; FORWARDED_ALLOW_IPS — IP вашего NPM.
# Развёртывание: docker compose up -d --build (или Coolify, build pack «Docker Compose»)
# Переменные — в .env рядом с этим файлом (образец — .env.example): DB_PASSWORD, SECRET_KEY, ADMIN_PASSWORD и др.
#
# Дополнительные сервисы включаются профилями — по одному или вместе:
# docs — Nextcloud для документации оборудования (+ своя база),
# office — ONLYOFFICE Document Server: просмотр Word/Excel/PowerPoint в портале,
# ai — Ollama: локальная модель для AI-помощника (данные не уходят наружу).
# Пример: в .env COMPOSE_PROFILES=docs,office,ai и затем docker compose up -d
# Портал сам находит запущенные сервисы во внутренней сети Docker и подставляет их адреса в настройки
# (Nextcloud, ONLYOFFICE, адрес модели). Значения из .env и из интерфейса важнее автоматических.
services:
db:
image: postgres:16-alpine
@@ -30,9 +38,24 @@ services:
APP_ENV: production # слабый SECRET_KEY/ADMIN_PASSWORD -> отказ запуска
FORWARDED_ALLOW_IPS: ${FORWARDED_ALLOW_IPS:-127.0.0.1} # адрес NPM (доверенный прокси)
PHOTO_DIR: /data/photos # каталог фото можно сменить в админке (он должен лежать на томе)
# --- документация (Nextcloud): пустой адрес — найдётся сам, если запущен профиль docs ---
NC_URL: ${NC_URL:-}
NC_USER: ${NC_USER:-${NEXTCLOUD_ADMIN_USER:-ncadmin}}
NC_PASSWORD: ${NC_PASSWORD:-${NEXTCLOUD_ADMIN_PASSWORD:-}}
NC_ROOT: ${NC_ROOT:-}
# --- ONLYOFFICE: секрет общий с сервисом onlyoffice ниже; пустой адрес — найдётся сам (профиль office) ---
ONLYOFFICE_URL: ${ONLYOFFICE_URL:-}
ONLYOFFICE_JWT: ${ONLYOFFICE_JWT:-change-me-onlyoffice-jwt}
PORTAL_INTERNAL_URL: ${PORTAL_INTERNAL_URL:-}
# --- AI-помощник: пустой адрес — при профиле ai подставится Ollama и модель OLLAMA_MODEL ---
ASSISTANT_PROVIDER: ${ASSISTANT_PROVIDER:-}
ASSISTANT_BASE_URL: ${ASSISTANT_BASE_URL:-}
ASSISTANT_MODEL: ${ASSISTANT_MODEL:-}
OLLAMA_MODEL: ${OLLAMA_MODEL:-qwen2.5:7b} # модель, если найден сервис ollama
ASSISTANT_API_KEY: ${ASSISTANT_API_KEY:-}
volumes:
- photos:/data/photos # фотографии нарядов
- appdata:/srv/data # демо-режим и файл APK (appdata/apk/*.apk раздаётся по /app.apk)
- appdata:/srv/data # демо-режим, кэш документов и файл APK (appdata/apk/*.apk раздаётся по /app.apk)
ports:
- "${APP_PORT:-8080}:8000" # на этот порт хоста смотрит прокси (NPM)
healthcheck:
@@ -42,7 +65,97 @@ services:
retries: 5
start_period: 25s
# ---------- профиль docs: Nextcloud ----------
nextcloud-db:
image: postgres:16-alpine
profiles: ["docs"]
restart: unless-stopped
environment:
POSTGRES_DB: nextcloud
POSTGRES_USER: nextcloud
POSTGRES_PASSWORD: ${NEXTCLOUD_DB_PASSWORD:-${DB_PASSWORD}}
volumes:
- nc_pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U nextcloud -d nextcloud"]
interval: 5s
timeout: 5s
retries: 20
nextcloud:
image: nextcloud:30-apache
profiles: ["docs"]
restart: unless-stopped
depends_on:
nextcloud-db:
condition: service_healthy
environment:
POSTGRES_HOST: nextcloud-db
POSTGRES_DB: nextcloud
POSTGRES_USER: nextcloud
POSTGRES_PASSWORD: ${NEXTCLOUD_DB_PASSWORD:-${DB_PASSWORD}}
NEXTCLOUD_ADMIN_USER: ${NEXTCLOUD_ADMIN_USER:-ncadmin}
NEXTCLOUD_ADMIN_PASSWORD: ${NEXTCLOUD_ADMIN_PASSWORD:-}
# «nextcloud» — имя, по которому к нему обращается портал внутри сети Docker
NEXTCLOUD_TRUSTED_DOMAINS: nextcloud localhost ${NEXTCLOUD_DOMAIN:-}
OVERWRITEPROTOCOL: ${NEXTCLOUD_PROTOCOL:-} # https — если Nextcloud открывают через прокси с HTTPS
volumes:
- nextcloud:/var/www/html
ports:
- "${NEXTCLOUD_PORT:-8081}:80" # веб-интерфейс Nextcloud: сюда загружают документацию
healthcheck:
test: ["CMD", "php", "-r", "exit(strpos((string)@file_get_contents('http://localhost/status.php'), '\"installed\":true') === false ? 1 : 0);"]
interval: 30s
timeout: 10s
retries: 10
start_period: 120s
# ---------- профиль office: ONLYOFFICE Document Server (только внутренняя сеть) ----------
onlyoffice:
image: onlyoffice/documentserver:8.2
profiles: ["office"]
restart: unless-stopped
environment:
JWT_ENABLED: "true"
JWT_SECRET: ${ONLYOFFICE_JWT:-change-me-onlyoffice-jwt}
ALLOW_PRIVATE_IP_ADDRESS: "true" # иначе не скачает документ с портала по внутреннему адресу Docker
volumes:
- onlyoffice_data:/var/www/onlyoffice/Data
healthcheck:
test: ["CMD-SHELL", "curl -fs http://localhost/healthcheck | grep -q true"]
interval: 30s
timeout: 10s
retries: 10
start_period: 90s
# ---------- профиль ai: локальная модель Ollama ----------
ollama:
image: ollama/ollama:0.5.7
profiles: ["ai"]
restart: unless-stopped
volumes:
- ollama:/root/.ollama
# Видеокарта NVIDIA (необязательно, нужен nvidia-container-toolkit) — раскомментируйте:
# deploy:
# resources:
# reservations:
# devices: [{driver: nvidia, count: all, capabilities: [gpu]}]
ollama-pull: # один раз скачивает модель в том ollama и завершается
image: ollama/ollama:0.5.7
profiles: ["ai"]
depends_on: [ollama]
restart: "no"
environment:
OLLAMA_HOST: http://ollama:11434
MODEL: ${OLLAMA_MODEL:-qwen2.5:7b}
entrypoint: ["/bin/sh", "-c", "sleep 5 && ollama pull \"$$MODEL\""]
volumes:
pgdata:
photos:
appdata:
nc_pgdata:
nextcloud:
onlyoffice_data:
ollama:
+35
View File
@@ -81,6 +81,10 @@
Пример «3 дня, отсыпной, 3 ночи, отсыпной, 3 выходных» — 5 шагов, цикл 11 дней.
`POST /schedules/preview` `{schedule, from_date, days}` — календарь без сохранения (для конструктора).
График назначается **сотруднику**: `schedule_id` в `POST/PATCH /users` и в `UserOut` (пусто — всегда на смене). `on_shift`
считается по графику сотрудника. Наряд на должность видят и получают уведомление только сотрудники этой должности, которые
сейчас на смене. `schedule_id` у должности устарел и не используется (при обновлении перенесён сотрудникам).
## Отчёты (инженер/модератор/админ)
`GET /reports` — список отчётов. `GET /reports/completed-orders?from=…&to=…` — **период обязателен** (дата и время).
Фильтры: `equipment_id`, `position_id` (должность исполнителя **на момент выполнения**), `user_id`, `include_review`.
@@ -225,6 +229,37 @@ deviations, tasks_cancelled, tasks_paused, hours, calendar: {"2026-10-12": {n, h
- Только администраторам портала доступны `PUT /settings` (кроме отметок мастера `setup_done`), полный `GET /settings`, `/api-keys`,
`/assistant/config` и `/assistant/test`. Остальные администраторы получают в `GET /settings` сокращённый ответ (`limited: true`).
## Документация оборудования (Nextcloud)
Портал читает Nextcloud по WebDAV от служебной учётной записи (пароль приложения, только чтение) и сам отдаёт файлы;
Office переводится в PDF через ONLYOFFICE Document Server (ConvertService).
- `GET/PUT /api/v1/docs/config` — **администратор портала**: `nc_url, nc_user, nc_password, nc_root, onlyoffice_url, onlyoffice_jwt,
portal_internal_url, docs_ai`. Пароль и секрет JWT в ответе не возвращаются (`nc_password_set`, `onlyoffice_jwt_set`) и не попадают
в резервные копии. `POST /api/v1/docs/test` — проверка Nextcloud и ONLYOFFICE.
- `GET /api/v1/docs/browse?path=` — администратор или модератор: вложенные папки (с `linked` — к каким машинам привязаны) и число файлов.
- `PUT /api/v1/equipment/{id}/docs` `{path}` — администратор или модератор: привязать папку (пустой `path` — отвязать). В `EquipmentOut` — `docs_path`.
- `GET /api/v1/equipment/{id}/docs?sub=` — кто видит машину: `{folder, sub, items: [{name, path, is_dir, kind: dir|pdf|image|text|office|file, size, mtime}]}`.
- `GET /api/v1/equipment/{id}/docs/file?path=...&view=1` — файл; `view=1` — для просмотра: PDF, картинки, текст — inline,
Office — PDF через ONLYOFFICE (кэшируется). Без `view` — скачивание оригинала. Пути `..` отклоняются (422).
- `GET /api/v1/docs/raw/{token}` — без входа, только для ONLYOFFICE: подписанная ссылка на исходник, живёт 10 минут.
- В `GET /me` — `docs: true`, если Nextcloud подключён.
Опция AI-помощника **«Работа с документацией»** (`docs_ai`): раз в сутки портал строит индекс (имя и начало текста файлов,
PDF — через pypdf, docx/xlsx/pptx/odt — из XML; файлы больше 60 МБ — только по имени), `POST /api/v1/docs/reindex` — вручную
(администратор портала). `GET /api/v1/docs/search?q=` — поиск по индексу среди машин, которые видит пользователь (только при
включённой опции). Помощник получает инструменты `search_docs` и `equipment_docs`. Фрагменты документов уходят провайдеру ИИ —
для закрытой документации нужна локальная модель.
## Фото: «хранить более года» и автоочистка
- `photo_keep` у пункта чек-листа (`ChecklistItemIn`), копируется в пункт наряда (`OrderItemOut.photo_keep`); у задания —
`photo_keep` в `POST/PATCH /tasks` и `TaskOut`. Такие фото сохраняются в каталог `keep/` и очисткой не удаляются.
- Настройка `photo_keep_days` (`PUT /settings`, администратор портала): раз в сутки удалять фото нарядов и заданий старше N дней,
0 — выключено (по умолчанию). `GET /photo-cleanup` — сколько удалится сейчас (ничего не удаляет), `POST /photo-cleanup` — очистить сразу.
В `GET /settings` — `photo_keep_stats` (сколько фото в `keep/`).
## Мониторинг сервера
`GET /api/v1/server/monitor` (администратор портала) → `{cpu: %, cpu_count, load: [1, 5, 15 мин] | null,
memory: {used, total, scope: host|container}, disks: [{labels, path, used, total, free}], now}`.
## AI-помощник
Включает администратор: «Администрирование → Настройки сервера → AI-помощник» (`GET/PUT /api/v1/assistant/config`:
`enabled, provider (openai | anthropic | gigachat), base_url, model, api_key, scope`; ключ в ответах не возвращается и не попадает
+30
View File
@@ -98,6 +98,36 @@ docker compose cp checklists-1.7.0.apk app:/srv/data/apk/
Версия берётся из имени файла (`checklists-<версия>.apk`); приложения сами предложат обновиться. В приложении на телефоне
указывается адрес вашего сервера (`https://portal.example.ru`).
## 7а. Дополнительные сервисы: документация, просмотр Office, локальный ИИ
`docker-compose.yml` умеет поднять рядом с порталом ещё три сервиса. Они включаются **профилями** — строкой в `.env`:
```bash
COMPOSE_PROFILES=docs,office,ai
```
| Профиль | Что запускается | Зачем | Ресурсы сверх портала |
|---|---|---|---|
| `docs` | Nextcloud + своя PostgreSQL | хранилище документации оборудования: паспорта, схемы, руководства | 1 ГБ памяти, место под документы |
| `office` | ONLYOFFICE Document Server | Word, Excel, PowerPoint открываются прямо в портале (переводятся в PDF) | 2–4 ГБ памяти |
| `ai` | Ollama + скачивание модели | локальная модель для AI-помощника: вопросы и документы не уходят в интернет | 6–8 ГБ памяти для 7B-модели, лучше видеокарта |
Профили независимы: можно взять только `ai` или подключить свой Nextcloud и ONLYOFFICE, указав их адреса в `.env`
(`NC_URL`, `ONLYOFFICE_URL`, `PORTAL_INTERNAL_URL`).
**Настройки заполняются сами.** Портал ищет сервисы во внутренней сети Docker по именам `nextcloud`, `onlyoffice`, `ollama`
и подставляет адреса в «Настройки сервера»: Nextcloud — `http://nextcloud`, ONLYOFFICE — `http://onlyoffice` (документы
он забирает у портала по `http://app:8000`), модель — `http://ollama:11434/v1` с моделью из `OLLAMA_MODEL`. Учётная запись
Nextcloud и секрет ONLYOFFICE берутся из `.env`. Всё, что администратор поменяет в интерфейсе, важнее автоматических значений.
Что сделать после запуска:
1. **Nextcloud** откройте на порту `NEXTCLOUD_PORT` (по умолчанию 8081), войдите администратором из `.env`, создайте папку
документации (например, «Документация») с подпапками по машинам и укажите её в `NC_ROOT` или в настройках портала.
Для безопасности заведите отдельного пользователя с доступом только на чтение к этой папке и пароль приложения для
него — и впишите их в `NC_USER` / `NC_PASSWORD`.
2. **ONLYOFFICE** наружу не публикуется: портал общается с ним внутри сети Docker. Задайте свой `ONLYOFFICE_JWT`.
3. **ИИ**: дождитесь, пока `ollama-pull` скачает модель (`docker compose logs -f ollama-pull`), затем в «Настройках сервера →
AI-помощник» нажмите «Проверить» и включите помощника. Опция «Работа с документацией» включается там же.
## 8. Обновление
```bash
cd /opt/portal
Binary file not shown.
+1
View File
@@ -11,3 +11,4 @@ google-auth==2.*
requests==2.*
fpdf2==2.*
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()
night = c.post("/api/schedules", headers=adm, json={"name": "Выходной", "kind": "weekly",
"weekdays": []}).json()
tech = c.post("/api/positions", headers=adm, json={"name": "Слесарь", "kind": "technician",
"schedule_id": sch["id"]}).json()
tech2 = c.post("/api/positions", headers=adm, json={"name": "Слесарь (выходной)", "kind": "technician",
"schedule_id": night["id"]}).json()
# одну должность занимают сменщики: график у каждого свой
tech = c.post("/api/positions", headers=adm, json={"name": "Слесарь", "kind": "technician"}).json()
eng = c.post("/api/positions", headers=adm, json={"name": "Инженер", "kind": "engineer"}).json()
for name, pos in (("ivan", tech), ("petr", tech2), ("eng", eng)):
for name, pos, sid in (("ivan", tech, sch["id"]), ("petr", tech, night["id"]), ("eng", eng, None)):
r = c.post("/api/users", headers=adm, json={"username": name, "password": "Pass1234567",
"position_id": pos["id"]})
"position_id": pos["id"], "schedule_id": sid})
assert r.status_code == 201, r.text
ivan, petr, engh = login("ivan", "Pass1234567"), login("petr", "Pass1234567"), login("eng", "Pass1234567")
@@ -80,7 +78,13 @@ def test_full_flow():
assert tpl["next_run"]
order_id = c.post(f"/api/order-templates/{tpl['id']}/run-now", headers=engh).json()["order_id"]
# видимость: Иван (на смене) видит, Пётр (выходной) — нет
# видимость: Иван (на смене) видит, его сменщик Пётр (выходной) — нет; уведомление — только Ивану
from app import push
from app.db import SessionLocal
with SessionLocal() as db:
ids_ = {u["username"]: u["id"] for u in c.get("/api/users", headers=adm).json()}
assert push.users_for_assignment(db, None, tech["id"]) == [ids_["ivan"]]
assert c.get("/api/me", headers=petr).json()["on_shift"] is False
assert [o["id"] for o in c.get("/api/orders", headers=ivan).json()] == [order_id]
assert c.get("/api/orders", headers=petr).json() == []
assert c.get(f"/api/orders/{order_id}", headers=petr).status_code == 404
+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"),
("GET", "/health"), ("GET", "/demo/status"),
("GET", "/public/report-options"), ("POST", "/public/incidents"), ("GET", "/app/version")} # публичная форма инцидента: без входа, с лимитом
("GET", "/public/report-options"), ("POST", "/public/incidents"), ("GET", "/app/version"),
("GET", "/docs/raw/{token}")} # исходник для ONLYOFFICE: только по короткой подписанной ссылке (проверка — test_scope_docs) # публичная форма инцидента: без входа, с лимитом
def test_every_api_route_requires_auth(env):
@@ -392,7 +393,9 @@ def test_stored_photo_name_is_server_generated(env):
from app.models import Photo
with SessionLocal() as s:
for p in s.query(Photo).all():
assert ".." not in p.path and not p.path.startswith(("/", "\\")) and len(p.path.split("/")) == 3
parts = p.path.split("/")
parts = parts[1:] if parts[0] == "keep" else parts # keep/ — фото «хранить более года»
assert ".." not in p.path and not p.path.startswith(("/", "\\")) and len(parts) == 3
def test_static_path_traversal_blocked(env):
+62 -11
View File
@@ -333,8 +333,9 @@ def build():
["Наряд", "Одно задание: что проверить или обслужить, на каком оборудовании, кому, до какого срока и кто проверит."],
["Чек-лист", "Список задач внутри наряда. У каждой задачи свой тип ответа: статус, выбор из списка, число или текст; "
"у некоторых нужно приложить фото."],
["Должность и график", "Сотрудник привязан к должности, у должности есть график (смены). Наряд, назначенный на должность, "
"видят и могут взять только те, кто сейчас на смене."],
["Должность и график", "Сотрудник занимает должность и работает по своему графику (смены). Одну должность могут занимать "
"несколько сменщиков. Наряд, назначенный на должность, видят, берут и получают уведомление только те, "
"кто сейчас на смене, — сменщики дома не беспокоятся."],
["Расписание нарядов", "Наряд настраивается один раз и затем создаётся автоматически: каждый день, по дням недели, "
"раз в месяц или каждые N дней."],
["Проверка", "После сдачи наряд уходит проверяющему: он принимает его или возвращает на доработку с комментарием."],
@@ -562,7 +563,8 @@ def build():
pdf.bullets([
"для **числа** задайте единицу измерения и границы нормы — отклонения будут подсвечиваться и попадут в отчёт;",
"для **списка** — варианты ответа, по одному в строке;",
"для любой задачи — режим фото: без фото, по желанию, обязательно;",
"для любой задачи — режим фото: без фото, по желанию, обязательно; если фото нужно сохранить надолго (табличка, акт, "
"дефект), отметьте **«хранить более года»** — такие снимки лежат в отдельном каталоге и не удаляются автоочисткой сервера;",
"стрелками меняется порядок задач, галочкой «Обязательный» отмечаются пункты, без которых наряд не сдать.",
])
pdf.wide("d_checklist", "Редактор чек-листа")
@@ -708,6 +710,15 @@ def build():
"В приложении Android история открывается кнопкой «История машины».")
pdf.wide("d_history", "История машины с фильтрами")
pdf.phone("a_history", "История машины в приложении Android", width=70)
pdf.h2("Документация машины (Nextcloud)")
pdf.p("Если подключён Nextcloud, в карточке машины есть блок **«Документация»**: паспорта, руководства, схемы, хранящиеся "
"в Nextcloud по машинам. Кнопка **«Открыть»** показывает папку машины: слева файлы и вложенные папки, справа — документ. "
"**PDF, картинки и текстовые файлы** открываются прямо в портале, документы **Word, Excel, PowerPoint** — тоже, после перевода в PDF "
"на сервере ONLYOFFICE (первое открытие — несколько секунд). Любой файл можно **скачать**. Заходить в Nextcloud не нужно: "
"портал сам забирает файлы и показывает только документацию машин, которые видит сотрудник.")
pdf.p("**Привязывают папку администратор или модератор**: «Привязать папку» в блоке «Документация» открывает проводник по "
"документации — выберите папку машины и нажмите «Выбрать» или «Привязать эту папку». Рядом с папкой видно, к каким машинам "
"она уже привязана. «Сменить папку» и «Отвязать» — там же.")
pdf.h2("Сообщения об отказах извне")
pdf.p("Об аварии может сообщить и человек без учётной записи — подрядчик, охрана, смежная служба. Для этого есть **публичная "
"форма** по адресу **/report** (её удобно разместить на QR-коде у оборудования). В форме выбирается **место** — после этого "
@@ -832,16 +843,22 @@ def build():
"висит приглашение запустить мастер). Мастер проводит по шагам в порядке зависимостей данных:")
pdf.bullets([
"**Сервер** — проверить часовой пояс, каталог фото, время сервера.",
"**Графики** — типовые одним нажатием (пятидневка, сутки через трое, 2/2 день-ночь, круглосуточно) или в конструкторе.",
"**Графики** — типовые одним нажатием (пятидневка, сутки через трое, 2/2 день-ночь, круглосуточно) или в конструкторе. "
"График потом назначается каждому сотруднику.",
"**Должности → отделы → места → оборудование** — пакетным вводом: одна строка — одна запись, поля через «;». "
"Для оборудования: «Название; инв. №; место; наработка; отдел; кран» — отдел закрепляет машину за службой, слово «кран» "
"отправляет её в раздел «Краны».",
"**Сотрудники** — строки «ФИО; логин; должность; отдел; роль». Логины и временные пароли создаются сами и показываются "
"**Сотрудники** — строки «ФИО; логин; должность; отдел; график; роль». Логины и временные пароли создаются сами и показываются "
"таблицей для печати или CSV — **только один раз**.",
"**Карта завода** (и подсказка про отдельную карту кранов), **склады и начальные остатки инструмента**, **чек-листы и расписание**, **внешние обращения**, **резервные копии**.",
])
pdf.wide("d_setup", "Мастер первичной настройки")
pdf.note("Шаг считается выполненным, когда в нём есть записи, или если отметить «шаг выполнен». К мастеру можно возвращаться в любое время.", "info")
pdf.note("Шаг считается выполненным, когда в нём есть записи, или если отметить «шаг выполнен» — мастер сразу переходит к следующему. "
"К мастеру можно возвращаться в любое время.", "info")
pdf.p("Кнопка **«Завершить и проверить»** на последнем шаге открывает **проверку готовности**: мастер смотрит на введённые данные и "
"показывает, что мешает работе — сотрудники без должности, должности, которые никто не занимает, должности, где сейчас по "
"графикам никого нет на смене, машины без места, нет чек-листов или расписания, выключены автокопии или автоочистка фото. "
"Рядом с каждым пунктом — кнопка, чтобы поправить. После завершения плитка мастера ведёт сразу к этой проверке.")
pdf.h2("AI-помощник (включает администратор)")
pdf.p("В «Настройках сервера» администратор может включить **AI-помощника**: указать провайдера (OpenAI-совместимый — OpenAI, "
@@ -855,6 +872,12 @@ def build():
"Ключ API не показывается в настройках и не попадает в резервные копии.",
])
pdf.wide("d_assistant_settings", "Настройка AI-помощника")
pdf.p("**«Работа с документацией»** — отдельная опция помощника в той же карточке. Включённая, она раз в сутки собирает индекс "
"документации машин (имена файлов и начало текста PDF и документов Office), и помощник отвечает на вопросы вида "
"«где схема двигателя компрессора?» ссылкой на нужный файл и подсказкой, что в нём искать.")
pdf.note("Риски опции: имена файлов и фрагменты документов уходят провайдеру ИИ — с облачным провайдером документация покидает "
"предприятие, для закрытых документов используйте локальную модель. Запросы становятся в разы длиннее, расход токенов "
"растёт. Помощник видит только начало документов — ответ проверяйте в самом файле.", "warn")
pdf.wide("d_assistant", "Помощник: ответ по данным пользователя и подсказка «как сделать»")
pdf.phone("a_assistant", "Помощник в приложении Android (круглая кнопка справа внизу или пункт меню «Помощник»)", width=70)
@@ -883,9 +906,13 @@ def build():
"пользователя. Остальным администраторам мастер первичной настройки и резервные копии доступны как обычно.")
pdf.h2("Должности и графики работы")
pdf.p("**Должность** имеет тип (**техник** выполняет наряды, **инженер** проверяет и планирует) и **график**. По графику "
"система определяет, кто сейчас «на смене» и кому показывать наряды, назначенные на должность. Должность без графика "
"считается всегда на смене.")
pdf.p("**Должность** имеет тип (**техник** выполняет наряды, **инженер** проверяет и планирует). **График** задаётся не должности, "
"а **сотруднику** («Пользователи» → «Изменить» → «График работы»): должность выполняет тот, кто её занимает и сейчас на смене. "
"Например, «Дежурный механик» — четыре сменщика с графиком «сутки через трое», каждый со своим первым днём цикла: наряд или "
"задание на эту должность увидит и получит уведомление только тот, кто сегодня дежурит. Сотрудник без графика считается "
"всегда на смене.")
pdf.note("При обновлении портала графики, которые раньше были заданы должностям, автоматически перенесены их сотрудникам. "
"Для сменщиков поправьте графики (или первый день цикла), чтобы на смене был один человек.", "info")
pdf.p("Графики настраиваются **конструктором**: можно собрать любой цикл — дневные и ночные смены, отсыпные, сутки через трое. "
"Каждый шаг цикла — это «Смена» или «Выходной», число дней подряд, время начала и конца и метка (например, «день», "
"«ночь», «отсыпной»). Если конец смены раньше начала (20:00–08:00), смена ночная; одинаковое время (08:00–08:00) — сутки.")
@@ -901,6 +928,8 @@ def build():
pdf.p("Разделы **«Оборудование»** и **«Места»** — справочники для нарядов и отчётов. Оборудование можно привязать к месту "
"(например, «Цех №1»), указать инвентарный номер, **отдел** (кто его видит) и отметку **«Кран»**. Неиспользуемое оборудование "
"отключается флажком «Активно».")
pdf.p("Над списком оборудования (и кранов) — фильтр **«Место»**: выберите цех или участок, и в списке останутся машины этого "
"места и всех вложенных в него мест (например, «Цех №1» покажет и машины «Компрессорной», входящей в цех).")
pdf.h2("Обратная связь по порталу")
pdf.p("Внизу каждой страницы сайта — неприметная ссылка **«Предложить улучшение портала или сообщить об ошибке»**; в мобильной версии и "
@@ -914,6 +943,15 @@ def build():
pdf.p("Раздел **«Настройки сервера»** позволяет изменить каталог для хранения фотографий и максимальный размер снимка, а также "
"ключ для мгновенных уведомлений Android, включить публичную форму заявок и **«Краны — отдельная служба»**. Внизу виден "
"расход места: сколько фото хранится и сколько свободно на диске.")
pdf.p("**Автоочистка фото**: поле «удалять фото нарядов и заданий старше, дней» (например, 365). Раз в сутки сервер удаляет "
"фото старше этого срока; 0 — не удалять. Снимки с отметкой **«хранить более года»** (в чек-листе у пункта или в задании) "
"с самого начала лежат в отдельном каталоге **keep/** и не удаляются. В блоке «Состояние» видно, сколько фото будет удалено "
"при следующей очистке; кнопка «Очистить сейчас» запускает её сразу.")
pdf.note("Удалённые фото не восстановить, кроме как из резервной копии с фото. По умолчанию автоочистка выключена.", "warn")
pdf.h2("Мониторинг сервера (только администратор портала)")
pdf.p("«Администрирование → **Мониторинг сервера**»: круговые диаграммы загрузки **процессора**, занятой **памяти** и места на "
"**дисках** с данными портала и фото. Значения обновляются каждые 5 секунд, пока страница открыта; от 75 % диаграмма "
"становится жёлтой, от 90 % — красной. В Docker с лимитом памяти показывается память контейнера.")
pdf.wide("d_server", "Настройки сервера", width=150)
pdf.note("При смене каталога фото старые снимки автоматически **не переносятся** — скопируйте файлы вручную.", "warn")
@@ -935,6 +973,19 @@ def build():
pdf.note("Рабочие данные в демо-режиме не изменяются и не удаляются.", "info")
# ================= 7 =================
pdf.h2("Подключение Nextcloud и ONLYOFFICE")
pdf.p("«Настройки сервера» → **«Документация из Nextcloud»** (только администратор портала).")
pdf.bullets([
"В Nextcloud заведите служебного пользователя (например, portal-docs), дайте ему **доступ только на чтение** к папке "
"с документацией и создайте для него **пароль приложения** (Настройки → Безопасность).",
"В портале укажите адрес Nextcloud (лучше внутренний, в локальной сети), учётную запись, пароль приложения и корневую папку "
"документации. Кнопка «Проверить» покажет, сколько папок видно в корне.",
"Для документов Office укажите адрес **ONLYOFFICE Document Server** в локальной сети, его **секрет JWT** (если включён) и "
"**внутренний адрес портала** — по нему ONLYOFFICE забирает исходник по одноразовой ссылке. Без ONLYOFFICE документы "
"Office только скачиваются.",
"Сотрудникам Nextcloud не нужен: портал сам ходит в него и проверяет, кто какую машину видит. Пароль приложения и секрет "
"JWT не показываются в настройках и не попадают в резервные копии.",
])
pdf.h1("14. Частые вопросы")
faq = [
("Я не вижу машину (или кран), которая точно есть.",
@@ -944,8 +995,8 @@ def build():
"Вы в демонстрационном режиме — это тестовые данные. Нажмите **«Выйти из демо»** в полосе. Если кнопки нет или не помогает, "
"попросите администратора выключить демо для вас."),
("Я не вижу наряд, который мне должны были назначить.",
"Наряд, назначенный на должность, виден только сотрудникам этой должности **на смене**. Проверьте график смены и должность в "
"настройках; если вы вне смены — наряд появится, когда смена начнётся. Нажмите обновление списка. Если наряд назначен "
"Наряд, назначенный на должность, виден только сотрудникам этой должности **на смене** по их графику. Проверьте свой график "
"и должность в карточке; если вы вне смены — наряд появится, когда смена начнётся. Нажмите обновление списка. Если наряд назначен "
"другому человеку или уже взят коллегой, вы его не увидите."),
("Пишет «Нет связи» или «не отправлено» и не исчезает.",
"Проверьте интернет на телефоне. Данные сохранены и не пропадут: они уйдут сами. Для немедленной попытки нажмите кнопку "