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:
@@ -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=
|
||||
|
||||
@@ -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
@@ -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}
|
||||
|
||||
@@ -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 — что мешает работе и где поправить.
|
||||
- Не могу сдать наряд: не заполнены обязательные пункты или нет обязательного фото.
|
||||
- Нет связи: в мобильной версии и приложении ответы сохраняются на телефоне и уйдут сами.
|
||||
- Не вижу машину или кран: оборудование закреплено за другим отделом, или краны вынесены в раздел «Краны».
|
||||
|
||||
@@ -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
@@ -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"}
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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):
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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 []
|
||||
|
||||
|
||||
|
||||
@@ -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 (без ключа — только локальная модель)")
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
@@ -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)}
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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»" });
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 ? "Сохранить" : "Создать задание"))));
|
||||
|
||||
@@ -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
@@ -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" }, "Завершить и проверить")))));
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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`; ключ в ответах не возвращается и не попадает
|
||||
|
||||
@@ -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.
@@ -11,3 +11,4 @@ google-auth==2.*
|
||||
requests==2.*
|
||||
fpdf2==2.*
|
||||
tzdata
|
||||
pypdf==5.*
|
||||
|
||||
+11
-7
@@ -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
|
||||
|
||||
@@ -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("")
|
||||
@@ -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
|
||||
@@ -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"])
|
||||
@@ -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
@@ -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():
|
||||
"Вы в демонстрационном режиме — это тестовые данные. Нажмите **«Выйти из демо»** в полосе. Если кнопки нет или не помогает, "
|
||||
"попросите администратора выключить демо для вас."),
|
||||
("Я не вижу наряд, который мне должны были назначить.",
|
||||
"Наряд, назначенный на должность, виден только сотрудникам этой должности **на смене**. Проверьте график смены и должность в "
|
||||
"настройках; если вы вне смены — наряд появится, когда смена начнётся. Нажмите обновление списка. Если наряд назначен "
|
||||
"Наряд, назначенный на должность, виден только сотрудникам этой должности **на смене** по их графику. Проверьте свой график "
|
||||
"и должность в карточке; если вы вне смены — наряд появится, когда смена начнётся. Нажмите обновление списка. Если наряд назначен "
|
||||
"другому человеку или уже взят коллегой, вы его не увидите."),
|
||||
("Пишет «Нет связи» или «не отправлено» и не исчезает.",
|
||||
"Проверьте интернет на телефоне. Данные сохранены и не пропадут: они уйдут сами. Для немедленной попытки нажмите кнопку "
|
||||
|
||||
Reference in New Issue
Block a user