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