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

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