1136 lines
114 KiB
Python
1136 lines
114 KiB
Python
"""Собирает «Руководство пользователя» в PDF из текста ниже и размеченных скриншотов (docs/guide/img).
|
||
|
||
Запуск: python tools/guide/build_pdf.py → docs/user-guide.pdf
|
||
"""
|
||
import io
|
||
import json
|
||
import sys
|
||
from datetime import date
|
||
from pathlib import Path
|
||
|
||
from fpdf import FPDF
|
||
from fpdf.enums import Align, TableCellFillMode, XPos, YPos
|
||
from PIL import Image
|
||
|
||
sys.path.insert(0, str(Path(__file__).parent))
|
||
from spec import DESKTOP, MOBILE # noqa: E402
|
||
|
||
ROOT = Path(__file__).resolve().parents[2]
|
||
IMG = ROOT / "docs" / "guide" / "img"
|
||
FONTS = ROOT / "app" / "fonts"
|
||
OUT = ROOT / "docs" / "user-guide.pdf"
|
||
BLUE, RED, GREY, DARK = (37, 99, 235), (225, 29, 72), (107, 115, 133), (29, 35, 48)
|
||
|
||
LABELS = {s.name: [m.label for m in s.marks] for s in DESKTOP + MOBILE}
|
||
_am = IMG / "android_marks.json"
|
||
if _am.exists():
|
||
LABELS.update(json.loads(_am.read_text(encoding="utf-8")))
|
||
|
||
|
||
class Guide(FPDF):
|
||
def __init__(self):
|
||
super().__init__(orientation="P", unit="mm", format="A4")
|
||
self.add_font("D", "", str(FONTS / "DejaVuSans.ttf"))
|
||
self.add_font("D", "B", str(FONTS / "DejaVuSans-Bold.ttf"))
|
||
self.set_margins(18, 18, 18)
|
||
self.set_auto_page_break(True, 20)
|
||
self.fig = 0
|
||
self.cover = True
|
||
self.alias_nb_pages()
|
||
|
||
def mc(self, *a, **k):
|
||
k.setdefault("align", "L")
|
||
return super().multi_cell(*a, **k)
|
||
|
||
# ---- колонтитулы ----
|
||
def header(self):
|
||
if self.cover:
|
||
return
|
||
self.set_font("D", "", 8)
|
||
self.set_text_color(*GREY)
|
||
self.set_y(8)
|
||
self.cell(0, 5, "Портал технической службы — руководство пользователя", align=Align.L)
|
||
self.set_draw_color(220, 224, 232)
|
||
self.line(18, 14, 192, 14)
|
||
self.set_y(18)
|
||
|
||
def footer(self):
|
||
if self.cover:
|
||
return
|
||
self.set_y(-14)
|
||
self.set_font("D", "", 8)
|
||
self.set_text_color(*GREY)
|
||
self.cell(0, 6, f"Стр. {self.page_no()} из {{nb}}", align=Align.R)
|
||
|
||
# ---- текстовые блоки ----
|
||
def h1(self, text):
|
||
self.add_page()
|
||
self.start_section(text, 0)
|
||
self.set_font("D", "B", 20)
|
||
self.set_text_color(*BLUE)
|
||
self.mc(0, 10, text, new_x=XPos.LMARGIN, new_y=YPos.NEXT)
|
||
self.set_draw_color(*BLUE)
|
||
self.set_line_width(0.6)
|
||
self.line(18, self.get_y() + 1, 60, self.get_y() + 1)
|
||
self.set_line_width(0.2)
|
||
self.ln(6)
|
||
|
||
def h2(self, text):
|
||
if self.get_y() > 245:
|
||
self.add_page()
|
||
self.start_section(text, 1)
|
||
self.ln(2)
|
||
self.set_font("D", "B", 13)
|
||
self.set_text_color(*DARK)
|
||
self.mc(0, 7, text, new_x=XPos.LMARGIN, new_y=YPos.NEXT)
|
||
self.ln(1)
|
||
|
||
def q(self, text):
|
||
"""Вопрос FAQ: жирный абзац, не попадает в оглавление."""
|
||
if self.get_y() > 255:
|
||
self.add_page()
|
||
self.ln(1)
|
||
self.set_font("D", "B", 10.5)
|
||
self.set_text_color(*BLUE)
|
||
self.mc(0, 5.8, text, new_x=XPos.LMARGIN, new_y=YPos.NEXT)
|
||
self.ln(0.5)
|
||
|
||
def p(self, text, size=10.5, color=DARK):
|
||
self.set_font("D", "", size)
|
||
self.set_text_color(*color)
|
||
self.mc(0, 5.6, text, markdown=True, new_x=XPos.LMARGIN, new_y=YPos.NEXT)
|
||
self.ln(2)
|
||
|
||
def bullets(self, items, numbered=False, start=1):
|
||
self.set_font("D", "", 10.5)
|
||
self.set_text_color(*DARK)
|
||
for i, t in enumerate(items, start):
|
||
if self.get_y() > 270:
|
||
self.add_page()
|
||
y = self.get_y()
|
||
self.set_x(18)
|
||
self.cell(8, 5.6, f"{i}." if numbered else "•", align=Align.R)
|
||
self.set_xy(28, y)
|
||
self.mc(164, 5.6, t, markdown=True, new_x=XPos.LMARGIN, new_y=YPos.NEXT)
|
||
self.ln(0.8)
|
||
self.ln(2)
|
||
|
||
def note(self, text, kind="info"):
|
||
col = {"info": (37, 99, 235), "warn": (180, 83, 9), "bad": (198, 40, 40), "ok": (22, 128, 60)}[kind]
|
||
title = {"info": "Совет", "warn": "Важно", "bad": "Осторожно", "ok": "Хорошо знать"}[kind]
|
||
self.set_font("D", "", 10)
|
||
lines = self.mc(160, 5.4, text, markdown=True, dry_run=True, output="LINES")
|
||
h = len(lines) * 5.4 + 11
|
||
if self.get_y() + h > 275:
|
||
self.add_page()
|
||
y = self.get_y()
|
||
self.set_fill_color(*[int(c + (255 - c) * 0.9) for c in col])
|
||
self.rect(18, y, 174, h, style="F")
|
||
self.set_fill_color(*col)
|
||
self.rect(18, y, 1.8, h, style="F")
|
||
self.set_xy(24, y + 2)
|
||
self.set_font("D", "B", 9.5)
|
||
self.set_text_color(*col)
|
||
self.cell(0, 5, title)
|
||
self.set_xy(24, y + 7.5)
|
||
self.set_font("D", "", 10)
|
||
self.set_text_color(*DARK)
|
||
self.mc(164, 5.4, text, markdown=True)
|
||
self.set_y(y + h + 3)
|
||
|
||
def code(self, text):
|
||
"""Команды для терминала: серая плашка, строки как есть."""
|
||
lines = text.strip("\n").split("\n")
|
||
h = len(lines) * 4.6 + 4
|
||
if self.get_y() + h > 275:
|
||
self.add_page()
|
||
y = self.get_y()
|
||
self.set_fill_color(241, 244, 249)
|
||
self.rect(18, y, 174, h, style="F")
|
||
self.set_font("D", "", 8.6)
|
||
self.set_text_color(30, 41, 59)
|
||
for i, ln in enumerate(lines):
|
||
self.set_xy(21, y + 2 + i * 4.6)
|
||
self.cell(168, 4.6, ln)
|
||
self.set_y(y + h + 3)
|
||
|
||
def tbl(self, head, rows, widths, size=9.5):
|
||
self.set_font("D", "", size)
|
||
self.set_text_color(*DARK)
|
||
from fpdf import FontFace
|
||
with super().table(col_widths=widths, line_height=5.2, padding=1.6, text_align="LEFT", repeat_headings=1,
|
||
headings_style=FontFace(emphasis="BOLD", fill_color=(232, 236, 245)),
|
||
cell_fill_color=(255, 255, 255), cell_fill_mode=TableCellFillMode.ALL) as t:
|
||
h = t.row()
|
||
for c in head:
|
||
h.cell(c)
|
||
for r in rows:
|
||
row = t.row()
|
||
for c in r:
|
||
row.cell(c)
|
||
self.ln(3)
|
||
|
||
# ---- рисунки ----
|
||
def _jpg(self, name):
|
||
im = Image.open(IMG / f"{name}.png").convert("RGB")
|
||
buf = io.BytesIO()
|
||
im.save(buf, "JPEG", quality=90, optimize=True)
|
||
buf.seek(0)
|
||
return buf, im.size
|
||
|
||
def _badge(self, x, y, n, d=5.6):
|
||
self.set_fill_color(*RED)
|
||
self.ellipse(x, y, d, d, style="F")
|
||
self.set_xy(x, y)
|
||
self.set_font("D", "B", 8)
|
||
self.set_text_color(255, 255, 255)
|
||
self.cell(d, d, str(n), align=Align.C)
|
||
|
||
def legend(self, items, x, y, w):
|
||
for n, t in enumerate(items, 1):
|
||
self.set_font("D", "", 9.5)
|
||
lines = self.mc(w - 8, 4.9, t, dry_run=True, output="LINES")
|
||
h = max(len(lines) * 4.9, 5.6) + 1.8
|
||
if y + h > 282: # не влезает — пункт на следующую страницу (иначе автоперенос даёт пустые страницы)
|
||
self.add_page()
|
||
y = self.get_y()
|
||
self._badge(x, y + 0.2, n)
|
||
self.set_xy(x + 8, y)
|
||
self.set_text_color(*DARK)
|
||
self.set_font("D", "", 9.5)
|
||
self.mc(w - 8, 4.9, t)
|
||
y += h
|
||
return y
|
||
|
||
def wide(self, name, caption, width=174):
|
||
"""Рисунок на всю ширину + легенда под ним."""
|
||
buf, (w, h) = self._jpg(name)
|
||
labels = LABELS.get(name, [])
|
||
max_ih = 150 if labels else 230 # высокий снимок уменьшаем, чтобы с подписями уместился на странице
|
||
if width * h / w > max_ih:
|
||
width = max_ih * w / h
|
||
ih = width * h / w
|
||
x0 = 18 + (174 - width) / 2
|
||
need = ih + 8 + len(labels) * 7
|
||
if self.get_y() + need > 276:
|
||
self.add_page()
|
||
self.fig += 1
|
||
y = self.get_y()
|
||
self.set_draw_color(205, 210, 220)
|
||
self.image(buf, x0, y, w=width)
|
||
self.rect(x0, y, width, ih)
|
||
y += ih + 2
|
||
self.set_xy(18, y)
|
||
self.set_font("D", "B", 9)
|
||
self.set_text_color(*GREY)
|
||
self.mc(0, 5, f"Рис. {self.fig}. {caption}", new_x=XPos.LMARGIN, new_y=YPos.NEXT)
|
||
y = self.get_y() + 1.5
|
||
y = self.legend(labels, 18, y, 174)
|
||
self.set_y(y + 3)
|
||
|
||
def phone(self, name, caption, width=74):
|
||
"""Рисунок телефона слева, легенда справа."""
|
||
buf, (w, h) = self._jpg(name)
|
||
ih = width * h / w
|
||
labels = LABELS.get(name, [])
|
||
if self.get_y() + ih + 12 > 278:
|
||
self.add_page()
|
||
self.fig += 1
|
||
y0 = self.get_y()
|
||
self.image(buf, 18, y0, w=width)
|
||
self.set_xy(18, y0 + ih + 1.5)
|
||
self.set_font("D", "B", 9)
|
||
self.set_text_color(*GREY)
|
||
self.mc(width, 4.6, f"Рис. {self.fig}. {caption}", new_x=XPos.LMARGIN, new_y=YPos.NEXT)
|
||
bottom = self.get_y()
|
||
self.legend(labels, 18 + width + 5, y0 + 4, 174 - width - 5)
|
||
self.set_y(max(bottom, y0 + ih) + 5)
|
||
|
||
def phones2(self, a, ca, b, cb, width=84):
|
||
"""Два телефона рядом (подписи под ними), легенды — списками под рисунками."""
|
||
ba, (wa, ha) = self._jpg(a)
|
||
bb, (wb, hb) = self._jpg(b)
|
||
ih = width * max(ha / wa, hb / wb)
|
||
la, lb = LABELS.get(a, []), LABELS.get(b, [])
|
||
need = ih + 12 + max(len(la), len(lb)) * 11
|
||
if self.get_y() + need > 278:
|
||
self.add_page()
|
||
y0 = self.get_y()
|
||
for k, (buf, cap, name) in enumerate(((ba, ca, a), (bb, cb, b))):
|
||
x = 18 + k * (width + 6)
|
||
self.fig += 1
|
||
self.image(buf, x, y0, w=width)
|
||
self.set_xy(x, y0 + ih + 1.5)
|
||
self.set_font("D", "B", 9)
|
||
self.set_text_color(*GREY)
|
||
self.mc(width, 4.6, f"Рис. {self.fig}. {cap}")
|
||
y = y0 + ih + 12
|
||
ya = self.legend(la, 18, y, width)
|
||
yb = self.legend(lb, 18 + width + 6, y, width)
|
||
self.set_y(max(ya, yb) + 4)
|
||
|
||
|
||
TOC_PAGES = 2 # оглавление не помещается на одну страницу — резервируем две
|
||
|
||
|
||
def toc(pdf: Guide, outline):
|
||
pdf.set_x(18)
|
||
pdf.set_font("D", "B", 16)
|
||
pdf.set_text_color(*BLUE)
|
||
pdf.cell(0, 10, "Содержание", new_x=XPos.LMARGIN, new_y=YPos.NEXT)
|
||
pdf.ln(3)
|
||
for s in outline:
|
||
if pdf.get_y() > 268: # следующая зарезервированная страница (автоперенос здесь отключён)
|
||
if pdf.page >= 1 + TOC_PAGES:
|
||
raise RuntimeError("Оглавление не помещается: увеличьте TOC_PAGES")
|
||
pdf.page += 1
|
||
pdf.set_xy(18, 22)
|
||
pdf.set_text_color(*DARK)
|
||
pdf.set_font("D", "B" if s.level == 0 else "", 10.5 if s.level == 0 else 9.5)
|
||
pdf.set_x(18 + 8 * s.level)
|
||
link = pdf.add_link(page=s.page_number)
|
||
w = 156 - 8 * s.level
|
||
pdf.cell(w, 6, s.name, link=link)
|
||
pdf.cell(0, 6, str(s.page_number), align=Align.R, link=link, new_x=XPos.LMARGIN, new_y=YPos.NEXT)
|
||
|
||
|
||
def cover(pdf: Guide):
|
||
pdf.add_page()
|
||
pdf.set_fill_color(*BLUE)
|
||
pdf.rect(0, 0, 210, 120, style="F")
|
||
pdf.set_xy(18, 38)
|
||
pdf.set_font("D", "B", 30)
|
||
pdf.set_text_color(255, 255, 255)
|
||
pdf.mc(170, 13, "Портал\nтехнической службы")
|
||
pdf.set_x(18)
|
||
pdf.set_font("D", "", 15)
|
||
pdf.ln(4)
|
||
pdf.mc(170, 8, "Руководство пользователя")
|
||
pdf.set_xy(18, 140)
|
||
pdf.set_text_color(*DARK)
|
||
pdf.set_font("D", "", 11)
|
||
pdf.mc(170, 6.5, "Единая вкладка «Задачи», наряды и чек-листы, задачи и поручения, отказы, карты завода и кранов, "
|
||
"учёт инструмента и складов, "
|
||
"карточки сотрудников и отчёты. Работа без связи на телефоне.\n\n"
|
||
"Для техников, инженеров, администраторов склада, модераторов и администраторов.")
|
||
pdf.set_xy(18, 262)
|
||
pdf.set_font("D", "", 9)
|
||
pdf.set_text_color(*GREY)
|
||
pdf.cell(0, 5, f"Версия 1.5 · {date.today():%d.%m.%Y}")
|
||
pdf.cover = False
|
||
|
||
|
||
def build():
|
||
pdf = Guide()
|
||
cover(pdf)
|
||
for _ in range(TOC_PAGES): # страницы под оглавление: заполняются в конце, когда известны номера страниц
|
||
pdf.add_page()
|
||
|
||
# ================= 1 =================
|
||
pdf.h1("1. Что это и как устроено")
|
||
pdf.p("Сервис заменяет бумажные журналы обходов и обслуживания. Руководитель или инженер описывает **оборудование**, "
|
||
"готовит **чек-листы** и настраивает **расписание**; система сама создаёт **наряды** в нужное время и назначает их "
|
||
"сотрудникам. Сотрудник на телефоне или компьютере проходит чек-лист, прикладывает фотографии и сдаёт наряд; "
|
||
"инженер проверяет результат. Все данные и отчёты хранятся на сервере организации.")
|
||
pdf.p("Кроме нарядов портал ведёт **задачи и поручения**, **отказы** (аварии) по оборудованию, **карту завода** с состоянием "
|
||
"машин (и отдельную **карту кранов**), **учёт инструмента** на складах и у сотрудников, **заявки** на покупку инструмента и "
|
||
"**карточки сотрудников**. Всё, что нужно сотруднику каждый день, собрано на одной вкладке **«Задачи»**.")
|
||
pdf.h2("Основные понятия")
|
||
pdf.tbl(["Понятие", "Что это"], [
|
||
["Наряд", "Одно задание: что проверить или обслужить, на каком оборудовании, кому, до какого срока и кто проверит."],
|
||
["Чек-лист", "Список задач внутри наряда. У каждой задачи свой тип ответа: статус, выбор из списка, число или текст; "
|
||
"у некоторых нужно приложить фото."],
|
||
["Должность и график", "Сотрудник занимает должность и работает по своему графику (смены). Одну должность могут занимать "
|
||
"несколько сменщиков. Наряд, назначенный на должность, видят, берут и получают уведомление только те, "
|
||
"кто сейчас на смене, — сменщики дома не беспокоятся."],
|
||
["Расписание нарядов", "Наряд настраивается один раз и затем создаётся автоматически: каждый день, по дням недели, "
|
||
"раз в месяц или каждые N дней."],
|
||
["Проверка", "После сдачи наряд уходит проверяющему: он принимает его или возвращает на доработку с комментарием."],
|
||
], [38, 136])
|
||
pdf.h2("Роли")
|
||
pdf.tbl(["Роль", "Что может"], [
|
||
["Техник", "Видит свои наряды, заполняет чек-листы, прикладывает фото, пишет комментарии, сдаёт наряды."],
|
||
["Инженер", "Всё то же, а также создаёт наряды и чек-листы, настраивает расписание, ведёт оборудование, "
|
||
"проверяет наряды, строит отчёты."],
|
||
["Администратор склада", "Ведёт каталог инструмента и склады, выдаёт инструмент сотрудникам, отмечает зоны складов на "
|
||
"карте, рассматривает заявки на инструмент."],
|
||
["Модератор", "Управляет пользователями, должностями, графиками и рабочими данными. Настройки сервера недоступны, "
|
||
"наряды сам не выполняет."],
|
||
["Администратор", "Полный доступ, включая настройки сервера и демонстрационный режим."],
|
||
], [38, 136])
|
||
pdf.note("Роль определяется должностью пользователя (техник или инженер) и отдельными признаками «Администратор склада», «Модератор» и "
|
||
"«Администратор». Изменить роль может только администратор или модератор (модератор — в пределах своих прав).")
|
||
|
||
pdf.h2("Статусы наряда")
|
||
pdf.tbl(["Статус", "Значение"], [
|
||
["Новый", "Наряд создан, к работе ещё не приступали."],
|
||
["В работе", "Исполнитель начал заполнять чек-лист (или наряд вернули на доработку)."],
|
||
["На проверке", "Исполнитель сдал наряд, ждёт решения проверяющего."],
|
||
["Выполнен", "Наряд принят (или у наряда нет проверяющего и он завершился сразу после сдачи)."],
|
||
["Отменён", "Наряд отменил инженер или администратор."],
|
||
["просрочен", "Не статус, а пометка: срок прошёл, а наряд ещё не завершён."],
|
||
], [38, 136])
|
||
|
||
# ================= 2 =================
|
||
pdf.h1("2. Как начать работу")
|
||
pdf.h2("Три способа открыть сервис")
|
||
pdf.tbl(["Способ", "Для кого", "Особенности"], [
|
||
["Приложение для Android", "Телефоны на Android — рекомендуемый вариант",
|
||
"Работает без связи, снимает фото, уведомляет о новых нарядах. Ставится из APK-файла."],
|
||
["Мобильная версия в браузере (адрес /m)", "iPhone и любые телефоны, если приложение недоступно",
|
||
"Ставится на экран «Домой» как приложение, тоже работает без связи."],
|
||
["Полная версия на компьютере", "Инженеры, модераторы, администраторы",
|
||
"Все возможности: настройки, отчёты, конструктор графиков. Для работы нужна связь."],
|
||
], [48, 54, 72])
|
||
pdf.p("Адрес сервиса, ваш логин и пароль сообщает администратор. Если вы забыли пароль, обратитесь к администратору: "
|
||
"он зададит новый.")
|
||
pdf.h2("Вход на сайте")
|
||
pdf.wide("d_login", "Страница входа", width=120)
|
||
pdf.p("Введите логин и пароль и нажмите «Войти». Если администратор загрузил приложение для Android, под кнопкой будет "
|
||
"ссылка для скачивания, а ниже — ссылки на **инструкцию** и **презентацию** системы. После нескольких неверных попыток "
|
||
"подряд вход на 5 минут блокируется — подождите и повторите.")
|
||
|
||
pdf.h2("Навигация: боковое меню")
|
||
pdf.p("Все разделы собраны в **боковом меню** слева: работа, оборудование, инструмент, люди, отчёты, администрирование. "
|
||
"На компьютере меню видно всегда; на телефоне его открывает кнопка **☰** в левом верхнем углу (в приложении Android меню "
|
||
"также выдвигается свайпом от левого края). Вверху меню — ваша карточка: нажмите её, чтобы заполнить фото и навыки. "
|
||
"Набор пунктов зависит от роли. После входа открывается вкладка **«Задачи»**. Внизу меню — ссылки на инструкцию, "
|
||
"приложение для Android, презентацию и публичную форму для заявки.")
|
||
pdf.wide("d_menu", "Боковое меню (полная версия)", width=100)
|
||
pdf.phones2("m_menu", "Меню мобильной версии", "a_menu", "Меню приложения Android")
|
||
|
||
pdf.h2("Установка приложения для Android")
|
||
pdf.bullets([
|
||
"Откройте на телефоне страницу входа сервиса и нажмите **«Скачать приложение для Android»** (или получите файл APK от администратора).",
|
||
"Откройте скачанный файл. Android попросит **разрешить установку из этого источника** — разрешите.",
|
||
"Запустите «Чек-листы». На первом экране введите **адрес сервера**, логин и пароль и нажмите «Войти».",
|
||
"Когда приложение попросит разрешение на уведомления, нажмите «Разрешить» — иначе вы не узнаете о новых нарядах.",
|
||
], numbered=True)
|
||
pdf.phone("a_login", "Вход в приложение для Android", width=70)
|
||
pdf.note("Адрес сервера вводится один раз и запоминается. Если администратор поменяет адрес, потребуется выйти и войти заново.")
|
||
|
||
pdf.h2("Мобильная версия в браузере: установка на экран «Домой»")
|
||
pdf.p("Откройте в браузере телефона адрес сервиса с добавлением **/m** (например, https://адрес/m). С телефона основной адрес "
|
||
"сам предложит перейти на мобильную версию.")
|
||
pdf.bullets([
|
||
"**Android (Chrome):** меню браузера (три точки) → «Установить приложение» или «Добавить на главный экран».",
|
||
"**iPhone (Safari):** кнопка «Поделиться» → «На экран Домой».",
|
||
])
|
||
pdf.phone("m_login", "Вход в мобильную версию", width=70)
|
||
pdf.note("На iPhone уведомления работают только если сервис установлен на экран «Домой» и открыт по защищённому адресу (https). "
|
||
"Фоновой отправки на iPhone нет: накопленные данные отправляются, пока приложение открыто.", "warn")
|
||
|
||
# ================= Задачи =================
|
||
pdf.h1("3. Вкладка «Задачи»: всё в одном месте")
|
||
pdf.p("Вкладка **«Задачи»** — первый пункт меню и стартовая страница. Чтобы не ходить по разделам, вверху собрана **шапка** с "
|
||
"быстрыми действиями и сводкой, а ниже — четыре блока: **наряды**, **задачи и поручения**, **отказы** и **сообщения о "
|
||
"неисправности**. У каждого блока свои фильтры; они запоминаются в браузере. «Все →» в заголовке блока открывает полный "
|
||
"раздел. Сами разделы работают как раньше.")
|
||
pdf.wide("d_work", "Вкладка «Задачи»: шапка и четыре блока")
|
||
pdf.tbl(["Блок", "Фильтры"], [
|
||
["Наряды", "Мне / на проверку (для инженера) / все доступные; только просроченные; статус; поиск по наряду, машине, номеру."],
|
||
["Задачи и поручения", "Мои / поставленные мной / все (для инженера, с выбором сотрудника); сегодня или все дни; активные или "
|
||
"вместе с завершёнными."],
|
||
["Отказы и неисправности", "Два одинаковых блока. Открытые / все; зафиксированные мной; источник (сотрудник, публичная "
|
||
"форма, внешняя система); оборудование или краны (если краны — отдельная служба); поиск по "
|
||
"машине и описанию. Вид: **список или карта**."],
|
||
["Шапка", "Быстрые действия: новая задача, новый наряд (инженер), зафиксировать аварию, сообщить о неисправности, заявка на "
|
||
"инструмент, мой инструмент. Сводка: мои наряды в работе, просрочено, ждут проверки, мои задачи, открытые аварии и "
|
||
"неисправности."],
|
||
], [38, 136])
|
||
pdf.h2("Отказы и неисправности на карте")
|
||
pdf.p("Кнопка **«На карте»** в блоке отказов (и так же в блоке неисправностей) показывает машины из текущего фильтра на **карте завода**. Если краны выделены в "
|
||
"отдельную службу, рядом появляется переключатель **«Завод / Краны»** с числом машин на каждой карте. Нажатие на машину "
|
||
"открывает её карточку; «открыть большую карту →» ведёт в раздел карты.")
|
||
pdf.wide("d_work_map", "Отказы на карте")
|
||
pdf.p("В приложении для Android вкладка «Задачи» тоже открывается первой: шапка с быстрыми действиями и сводкой, наряды, "
|
||
"задачи, отказы и неисправности (списком или на карте) на одном экране.")
|
||
pdf.phone("a_work", "Вкладка «Задачи» в приложении Android", width=70)
|
||
|
||
# ================= 3 =================
|
||
pdf.h1("4. Работа техника")
|
||
pdf.p("Сценарий один и тот же везде: найти наряд → заполнить чек-лист → приложить фото → сдать. Ниже он показан сначала на "
|
||
"компьютере (экраны полной версии), затем на телефоне.")
|
||
pdf.h2("Список нарядов")
|
||
pdf.wide("d_orders", "Список нарядов (полная версия)")
|
||
pdf.p("Вы видите наряды, назначенные лично вам, и наряды вашей должности, пока вы на смене. Строка с пометкой "
|
||
"**«можно выполнять»** — ваша. Просроченные наряды выделены красным.")
|
||
pdf.phones2("m_list", "Список в мобильной версии", "a_list", "Список в приложении Android")
|
||
pdf.note("В списке есть счётчики по фильтрам. «Мои» — наряды, которые можете выполнять вы; «Завершённые» — за последние дни. "
|
||
"Строка над списком показывает время последней синхронизации или количество неотправленных нарядов.")
|
||
|
||
pdf.h2("Открытие наряда")
|
||
pdf.wide("d_order_top", "Карточка наряда")
|
||
pdf.p("Вверху — оборудование, место, кто назначен и кто проверяет, срок. Если наряд назначен на должность, нажмите "
|
||
"**«Взять в работу»**: тогда он закрепляется за вами, и другие сотрудники этой должности его больше не увидят. "
|
||
"Можно и просто начать отвечать — наряд закрепится автоматически.")
|
||
|
||
pdf.h2("Заполнение чек-листа")
|
||
pdf.p("В чек-листе четыре типа ответов:")
|
||
pdf.bullets([
|
||
"**Статус** — «Не начато», «В работе» или «Выполнено».",
|
||
"**Выбор из списка** — нажмите нужный вариант (в мобильной версии и приложении — кнопки).",
|
||
"**Число** — введите значение. Под полем указана норма; если значение вне нормы, поле подсветится, а инженер увидит отклонение.",
|
||
"**Текст** — свободная заметка (обычно необязательная).",
|
||
])
|
||
pdf.wide("d_order_items", "Заполнение чек-листа (полная версия)")
|
||
pdf.phones2("m_order", "Чек-лист в мобильной версии", "m_submit", "Статусы и кнопки действий")
|
||
pdf.phones2("a_order_top", "Наряд в приложении Android", "a_order_bottom", "Продолжение: выбор, фото, сдача")
|
||
pdf.note("Ответы сохраняются сразу, отдельной кнопки «Сохранить» нет. Обязательные пункты чек-листа без звёздочки; пункты, "
|
||
"помеченные «необязательно», можно пропустить.", "ok")
|
||
|
||
pdf.h2("Фотофиксация")
|
||
pdf.p("У некоторых пунктов чек-листа есть кнопка фото. Фото может быть **обязательным** (без него наряд не сдать — это видно "
|
||
"по красной надписи «Требуется фото») или **по желанию**.")
|
||
pdf.bullets([
|
||
"Нажмите **«Фото»** (в мобильной версии — «Снять») — откроется камера. Сделайте снимок и подтвердите.",
|
||
"В мобильной версии рядом есть кнопка выбора готового снимка из галереи.",
|
||
"Снимок автоматически уменьшается и прикрепляется к пункту. Нажмите на миниатюру, чтобы увеличить; крестик на миниатюре — удалить.",
|
||
"Фото уходит на сервер вместе с остальными данными, при отсутствии связи — позже, автоматически.",
|
||
])
|
||
pdf.wide("d_order_filled", "Заполненный чек-лист с фотографией, готовый к сдаче")
|
||
|
||
pdf.h2("Сдача наряда")
|
||
pdf.p("Когда все обязательные пункты и фото готовы, нажмите **«Сдать наряд»**. Если что-то не заполнено, система покажет, "
|
||
"сколько пунктов осталось. После сдачи наряд получает статус «На проверке» (или сразу «Выполнен», если проверяющего нет), "
|
||
"а поля становятся доступны только для чтения.")
|
||
pdf.wide("d_order_review_wait", "Наряд после сдачи: статус «На проверке»")
|
||
|
||
pdf.h2("Если наряд вернули на доработку")
|
||
pdf.p("Наряд снова получит статус «В работе», а вверху появится красное сообщение с комментарием проверяющего. Исправьте "
|
||
"отмеченное и сдайте наряд ещё раз. Вы получите уведомление о возврате.")
|
||
|
||
pdf.h2("Комментарии")
|
||
pdf.p("Внизу наряда есть блок комментариев: напишите проверяющему или коллегам, например, что обнаружили и что заменили. "
|
||
"Комментарии видят все, кто имеет доступ к наряду; участникам наряда приходит уведомление. В полной версии свой комментарий можно удалить.")
|
||
|
||
pdf.h2("Уведомления")
|
||
pdf.bullets([
|
||
"**Приложение Android:** уведомления о новых нарядах, проверках и возвратах приходят в фоне (проверка примерно раз в 15 минут) "
|
||
"и сразу при открытии приложения. Разрешите уведомления в настройках Android.",
|
||
"**Мобильная версия и компьютер:** включите уведомления кнопкой-колокольчиком внизу бокового меню или в настройках мобильной версии. "
|
||
"Нужен защищённый адрес (https).",
|
||
])
|
||
|
||
# ================= 4 =================
|
||
pdf.h1("5. Работа без связи")
|
||
pdf.p("Приложение для Android и мобильная версия в браузере **работают без интернета**: на цеху, в подвале или на удалённой "
|
||
"площадке связи может не быть. Всё, что вы делаете, сохраняется на телефоне и отправляется на сервер сразу, как только "
|
||
"связь появится.")
|
||
pdf.h2("Что работает без связи")
|
||
pdf.bullets([
|
||
"Открывать уже загруженные наряды и чек-листы, листать и искать по списку.",
|
||
"Отвечать на пункты, делать и прикладывать фото, писать комментарии.",
|
||
"Нажимать «Сдать наряд» — сдача будет отправлена позже.",
|
||
])
|
||
pdf.h2("Как понять, что данные ещё не ушли")
|
||
pdf.bullets([
|
||
"В списке у наряда появляется пометка **«не отправлено»**, а в шапке — «Не отправлено: N».",
|
||
"В наряде видно сообщение «Наряд будет сдан при появлении связи. Данные сохранены на телефоне».",
|
||
"Поля сданного наряда блокируются сразу — даже пока он не отправлен.",
|
||
])
|
||
pdf.phones2("m_offline", "Мобильная версия без связи", "a_offline", "Приложение Android без связи")
|
||
pdf.h2("Что происходит при появлении связи")
|
||
pdf.bullets([
|
||
"Сначала отправляются фото (повторная отправка не создаёт дубликатов), затем ответы одним пакетом, затем комментарии.",
|
||
"Приложение само получает новые наряды и обновления. Принудительно запустить обмен можно кнопкой обновления в шапке.",
|
||
"Если связь пропала посреди отправки, ничего не теряется — отправка продолжится позже.",
|
||
])
|
||
pdf.h2("Правила и ограничения")
|
||
pdf.note("**Заранее загрузите наряды.** Чек-лист нового наряда загружается при наличии связи. Перед выходом без связи откройте "
|
||
"приложение и дождитесь обновления списка.", "warn")
|
||
pdf.note("**Не выходите из аккаунта с неотправленными данными.** При выходе локальные данные удаляются; приложение предупредит, "
|
||
"если что-то не отправлено.", "bad")
|
||
pdf.p("Если пока вы были без связи кто-то другой уже ответил на тот же пункт позже вас, побеждает более свежий ответ: приложение "
|
||
"сообщит об этом в наряде. Если наряд за это время закрыт или передан другому, изменения не принимаются, и приложение "
|
||
"покажет причину.")
|
||
pdf.note("Полная версия на компьютере без связи не работает. Для обходов используйте приложение или мобильную версию.", "info")
|
||
|
||
# ================= 5 =================
|
||
pdf.h1("6. Работа инженера")
|
||
pdf.h2("Проверка выполненных нарядов")
|
||
pdf.p("Наряды, ожидающие вашего решения, помечены **«ждёт вашей проверки»**. В списке выберите фильтр «На проверку» "
|
||
"(в полной версии — «Ждут моей проверки»), откройте наряд и изучите ответы, отклонения от нормы и фото.")
|
||
pdf.bullets([
|
||
"**«Принять»** — наряд получает статус «Выполнен».",
|
||
"**«Вернуть на доработку»** — обязательно напишите комментарий: исполнитель увидит его и получит уведомление.",
|
||
])
|
||
pdf.wide("d_review", "Блок проверки в полной версии")
|
||
pdf.phones2("m_review", "Проверка в мобильной версии", "a_review", "Проверка в приложении Android")
|
||
pdf.phone("a_review_list", "Фильтр «На проверку» в приложении", width=70)
|
||
|
||
pdf.h2("Создание наряда вручную")
|
||
pdf.p("Меню **«+ Наряд»**: укажите название, выберите чек-лист, оборудование и место. Исполнителя можно назначить "
|
||
"**должностью** (наряд увидит любой сотрудник на смене) или **конкретным человеком** (его увидит только он). Так же "
|
||
"задаются проверяющий и срок.")
|
||
pdf.wide("d_neworder", "Создание наряда")
|
||
|
||
pdf.h2("Чек-листы")
|
||
pdf.p("Раздел **«Чек-листы»**. Чек-лист — это набор задач; для каждой задачи выберите тип ответа (статус, список, число, текст):")
|
||
pdf.bullets([
|
||
"для **числа** задайте единицу измерения и границы нормы — отклонения будут подсвечиваться и попадут в отчёт;",
|
||
"для **списка** — варианты ответа, по одному в строке;",
|
||
"для любой задачи — режим фото: без фото, по желанию, обязательно; если фото нужно сохранить надолго (табличка, акт, "
|
||
"дефект), отметьте **«хранить более года»** — такие снимки лежат в отдельном каталоге и не удаляются автоочисткой сервера;",
|
||
"стрелками меняется порядок задач, галочкой «Обязательный» отмечаются пункты, без которых наряд не сдать.",
|
||
])
|
||
pdf.wide("d_checklist", "Редактор чек-листа")
|
||
pdf.note("Изменение чек-листа не меняет уже созданные наряды: у каждого наряда своя копия задач на момент создания.", "ok")
|
||
|
||
pdf.h2("Расписание нарядов (автозапуск)")
|
||
pdf.p("Если проверка повторяется, настройте её один раз: раздел **«Расписание нарядов»**. Укажите чек-лист, оборудование, "
|
||
"исполнителя и проверяющего, срок и периодичность: каждый день, по дням недели, раз в месяц или каждые N дней, а также "
|
||
"время создания. Система сама создаёт наряд в нужный момент; кнопка **«Создать сейчас»** запускает его один раз вне "
|
||
"расписания.")
|
||
pdf.wide("d_templates", "Расписание нарядов", width=160)
|
||
|
||
pdf.h2("Отчёты")
|
||
pdf.p("Раздел **«Отчёты» → «Выполненные наряды»**: список машина, чек-лист, кто выполнил, должность, когда. "
|
||
"**Период (дата и время) обязателен** — без него отчёт не формируется. Дополнительно можно отфильтровать по машине, "
|
||
"должности, исполнителю и включить наряды, ожидающие проверки.")
|
||
pdf.bullets([
|
||
"**«Показать»** — таблица на экране; строка открывает сам наряд.",
|
||
"**«PDF»** — файл для печати и архива. **«CSV»** — для Excel.",
|
||
"Должность исполнителя в отчёте фиксируется на момент выполнения: перевод сотрудника не искажает старые отчёты.",
|
||
])
|
||
pdf.wide("d_reports", "Отчёт «Выполненные наряды»")
|
||
pdf.h2("Отчёт «Выполнение работ»")
|
||
pdf.p("Показывает по каждой машине **процент выполнения** суточных осмотров (наряды из ежедневного расписания), чек-листов, "
|
||
"**ТО** (наряды по чек-листу «Регламентное обслуживание») и задач и поручений за период: выполнено из запланированного. "
|
||
"Отменённые считаются невыполненными. Колонка «Пункты» — доля заполненных пунктов чек-листов.")
|
||
pdf.p("Нажмите строку машины, чтобы раскрыть **отклонения и комментарии**: значения вне нормы, возвраты с проверки, "
|
||
"комментарии к нарядам, отмены, приостановки и отмены заданий с причинами. Отчёт выгружается в PDF и CSV.")
|
||
pdf.wide("d_exec_report", "Отчёт «Выполнение работ»")
|
||
pdf.h2("Отчёт «По сотрудникам»")
|
||
pdf.p("Сколько и **как качественно** каждый сотрудник выполнил работу за период. Фильтры: период (обязательно), отдел (с вложенными), "
|
||
"должность, сотрудник; есть быстрый выбор — этот месяц, прошлый месяц, 7 и 30 дней.")
|
||
pdf.tbl(["Колонка", "Что считается"], [
|
||
["Выполнено", "Наряды, завершённые в периоде (по дате завершения), и выполненные задачи и поручения (по их дню)."],
|
||
["В срок", "Доля выполненного без просрочки."],
|
||
["Просрочено, возвраты", "Сколько сдано позже срока; сколько раз наряд возвращали с проверки на доработку."],
|
||
["Отклонения", "Значения в чек-листах вне допустимой нормы."],
|
||
["Отмены / приост.", "Сколько задач сотрудник отменил или приостановил."],
|
||
["Трудоёмкость, ч", "Для задачи — норма, если её указали при постановке, иначе фактическое время «в работе»; для наряда — время "
|
||
"от начала до сдачи."],
|
||
], [42, 132])
|
||
pdf.wide("d_people_report", "Отчёт «По сотрудникам»: список")
|
||
pdf.p("Вкладка **«Календарь»** показывает то же по дням: в клетке — сколько задач выполнено и **суммарная трудоёмкость**, чем темнее "
|
||
"клетка, тем больше часов; справа — итог за период. Отчёт выгружается в PDF, CSV-список и CSV-календарь.")
|
||
pdf.wide("d_people_cal", "Отчёт «По сотрудникам»: календарь")
|
||
|
||
# ================= 6 =================
|
||
pdf.h1("7. Задачи и поручения")
|
||
pdf.p("Задача (поручение) — простое задание на день, **не наряд**: без чек-листа и проверки. Например, «подтянуть сальник», "
|
||
"«убрать кладовую», «замерить вибрацию». Раздел **«Задачи и поручения»** в боковом меню; свои задачи видны и на "
|
||
"вкладке «Задачи».")
|
||
pdf.tbl(["Статус", "Когда ставится"], [
|
||
["Назначен", "Задание создано и ждёт исполнителя."],
|
||
["В работе", "Исполнитель приступил."],
|
||
["Приостановлен", "Работа остановлена — **обязательно с комментарием** (например, «жду запчасть»)."],
|
||
["Выполнен", "Работа сделана — **обязательно с комментарием**, что сделано."],
|
||
["Отменён", "Задание больше не нужно — **обязательно с комментарием** о причине."],
|
||
], [38, 136])
|
||
pdf.bullets([
|
||
"Задания **другим сотрудникам** ставят инженер, модератор и администратор. Техник может поставить задание **себе**.",
|
||
"К заданию можно привязать **машину** или конкретный **узел** — тогда оно попадёт в отчёт по этой машине.",
|
||
"Исполнитель может приложить **фото**. История статусов с комментариями видна внизу задания.",
|
||
"Вернуть выполненное или отменённое задание в работу может только инженер (тоже с комментарием).",
|
||
"При постановке можно указать **трудоёмкость (норму) в часах** — она идёт в отчёт «По сотрудникам». Если не указать, "
|
||
"считается фактическое время в работе.",
|
||
])
|
||
pdf.wide("d_tasks", "Список задач и поручений")
|
||
pdf.wide("d_task", "Задание: смена статуса с комментарием, фото")
|
||
pdf.h2("Передача задания")
|
||
pdf.p("Если исполнитель не может выполнить задание (занят на аварии, ушёл со смены), его **передают** другому сотруднику. "
|
||
"Это может сделать сам исполнитель, тот, кто поставил задание, или инженер. Сотрудника удобно найти по имени или фамилии; "
|
||
"**причина обязательна**. Начатое задание у нового исполнителя становится «Назначен», ему приходит уведомление, "
|
||
"в истории задания остаётся запись «передано: кто → кому».")
|
||
pdf.wide("d_transfer", "Передача задания")
|
||
pdf.phones2("a_tasks", "Задания в приложении Android", "a_task", "Задание в приложении: статус и фото")
|
||
|
||
# ================= 7 =================
|
||
pdf.h1("8. Оборудование: состояние, отказы, иконки")
|
||
pdf.p("Карточку машины открывает раздел **«Оборудование»** → «Карточка». Вверху — иконка и **состояние машины**:")
|
||
pdf.tbl(["Состояние", "Что значит"], [
|
||
["Авария", "Есть открытый отказ. На карте рядом с иконкой — красный восклицательный знак."],
|
||
["Неисправность", "Есть открытое сообщение о неисправности: проблема замечена, но машина работает. На карте — оранжевый «⚠»."],
|
||
["Требуется ТО", "Хотя бы один обслуживаемый узел выработал ресурс (по наработке или по календарю)."],
|
||
["Скоро ТО", "Узел приближается к пределу (порог «Скоро» задаётся у узла). На карте иконка светится жёлтым."],
|
||
["В норме", "Открытых отказов нет, обслуживание узлов не требуется."],
|
||
], [38, 136])
|
||
pdf.wide("d_equipment", "Карточка машины: иконка, состояние, кнопка отказа", width=160)
|
||
pdf.h2("Отказ: фиксация аварии")
|
||
pdf.p("Отказ — запись об аварии или другой нештатной ситуации. Его заполняет сотрудник, приехавший на заявку: "
|
||
"**что произошло**, когда, **какие узлы затронуты** (из заведённых в карточке машины) и **была ли замена**.")
|
||
pdf.bullets([
|
||
"Отметка «заменён» у узла **сбрасывает его ресурс**, как ручной сброс: остаток считается заново. В истории узла появится "
|
||
"запись «замена при отказе».",
|
||
"Пока отказ открыт, машина в состоянии **«Авария»**; инженерам и администраторам приходит уведомление.",
|
||
"Когда машину починили, автор или инженер нажимает крупную зелёную кнопку **«✔ Закрыть отказ»** и заполняет форму закрытия (ниже).",
|
||
"Все отказы — раздел **«Отказы»** (открытые и вся история).",
|
||
])
|
||
pdf.wide("d_incident", "Фиксация отказа")
|
||
pdf.phones2("a_equipment", "Машина в приложении Android", "a_incident", "Отказ в приложении")
|
||
pdf.h2("Закрытие отказа: данные для анализа надёжности")
|
||
pdf.p("Когда машину починили и запустили, автор отказа или инженер нажимает **«✔ Закрыть отказ»**. Открывается короткая форма: "
|
||
"почти всё в ней уже подставлено, поэтому дежурному обычно достаточно трёх действий.")
|
||
pdf.bullets([
|
||
"**Что сделано** (обязательно): ремонт, замена узла, регулировка или наладка, перезапуск без ремонта (сбой).",
|
||
"**Характер отказа** (обязательно): внезапный или постепенный. И пару слов в **описании устранения**.",
|
||
"Ниже — **время и причина**, подставлены автоматически: начало работ (момент отказа), ввод в эксплуатацию (сейчас), наработка на момент "
|
||
"отказа (снята со счётчика при регистрации), причина «не установлена». Поправьте, если знаете точнее: ожидание запчастей, узел-причина, "
|
||
"причина, были ли признаки заранее, показание счётчика при вводе.",
|
||
"Если при ремонте **заменили узел**, отметьте его — ресурс узла начнёт считаться заново.",
|
||
"Инженер может позже **уточнить данные** закрытого отказа кнопкой «✎ Данные отказа».",
|
||
])
|
||
pdf.wide("d_failure_close", "Закрытие отказа")
|
||
pdf.p("В **приложении для Android** форма та же: что сделано, характер отказа, описание, начало работ и ввод в эксплуатацию "
|
||
"(кнопка «Сейчас» подставляет текущее время), ожидание запчастей, наработка, показание счётчика, узел-причина, причина, признаки "
|
||
"заранее и заменённые узлы. Отказы, закрытые с телефона, попадают в анализ надёжности полностью.")
|
||
pdf.phone("a_failure_close", "Закрытие отказа в приложении Android", width=70)
|
||
pdf.note("Чем точнее заполнены причина и время, тем полезнее раздел «Анализ надёжности». Инженер может уточнить данные закрытого "
|
||
"отказа на сайте кнопкой «✎ Данные отказа».", "info")
|
||
pdf.h2("Сообщение о неисправности")
|
||
pdf.p("Не каждая проблема останавливает оборудование. Если замечен посторонний шум, вибрация, подтекание или нагрев, а машина ещё "
|
||
"работает, нажмите в её карточке **«⚠ Сообщить о неисправности»**: что замечено, подробности, какой узел (если известно). "
|
||
"Машина получает состояние **«Неисправность»** — оранжевый значок на карте и в карточке, — инженерам приходит уведомление, "
|
||
"сообщение попадает в блок «Неисправности» на вкладке «Задачи» и в раздел **«Неисправности»** меню.")
|
||
pdf.bullets([
|
||
"Когда проблема устранена, автор или инженер нажимает **«Неисправность устранена»** и пишет, что сделано.",
|
||
"Если стало хуже и оборудование остановилось — **«Перевести в отказ»**: сообщение становится отказом, машина — «Аварией».",
|
||
"Замены узлов в сообщении нет: ресурс узлов не сбрасывается (замена фиксируется в отказе или при обслуживании).",
|
||
"Публичная форма /report спрашивает, работает ли оборудование: «работает, но есть неисправность» или «остановлено, авария».",
|
||
])
|
||
pdf.wide("d_fault", "Сообщение о неисправности в карточке машины")
|
||
pdf.h2("Закрепление оборудования за отделами")
|
||
pdf.p("В карточке оборудования (Оборудование → «Изменить») можно выбрать **отдел**, за которым закреплена машина. Тогда её видят "
|
||
"сотрудники этого отдела и вложенных в него отделов — в списках, на картах, в отказах, состоянии узлов — и только им "
|
||
"(а также инженерам и руководителям вышестоящих отделов) приходят уведомления об авариях. Например, крановый электрик "
|
||
"не получает информации о станках. Оборудование без отдела видят все.")
|
||
pdf.wide("d_eq_form", "Оборудование: отдел и отметка «Кран»", width=150)
|
||
pdf.note("Администраторы, модераторы и сотрудники без отдела видят всё оборудование. Наряды и задачи, назначенные человеку лично, "
|
||
"видны ему в любом случае.", "info")
|
||
pdf.h2("Иконка оборудования")
|
||
pdf.p("Инженер нажимает **«Иконка»** в карточке машины и выбирает картинку из встроенного набора (насос, компрессор, котёл, "
|
||
"станок и др.) или **загружает своё фото**. Иконка показывается в списке оборудования и на карте завода.")
|
||
pdf.wide("d_icons", "Выбор иконки", width=160)
|
||
pdf.h2("История машины")
|
||
pdf.p("Внизу карточки машины — **хронология всего, что с ней происходило**: отказы и их закрытие, обслуживание и замены узлов, "
|
||
"показания наработки, наряды (создан, выполнен, отменён) и задачи со статусами и передачами. События "
|
||
"сгруппированы по дням. Фильтруйте по типам событий, периоду и тексту (например, по фамилии или названию узла). "
|
||
"В приложении Android история открывается кнопкой «История машины».")
|
||
pdf.wide("d_history", "История машины с фильтрами")
|
||
pdf.phone("a_history", "История машины в приложении Android", width=70)
|
||
pdf.h2("Документация машины (Nextcloud)")
|
||
pdf.p("Если подключён Nextcloud, в карточке машины есть блок **«Документация»**: паспорта, руководства, схемы, хранящиеся "
|
||
"в Nextcloud по машинам. Кнопка **«Открыть»** показывает папку машины: слева файлы и вложенные папки, справа — документ. "
|
||
"**PDF, картинки и текстовые файлы** открываются прямо в портале, документы **Word, Excel, PowerPoint** — тоже, после перевода в PDF "
|
||
"на сервере ONLYOFFICE (первое открытие — несколько секунд). Любой файл можно **скачать**. Заходить в Nextcloud не нужно: "
|
||
"портал сам забирает файлы и показывает только документацию машин, которые видит сотрудник.")
|
||
pdf.p("**Привязывают папку администратор или модератор**: «Привязать папку» в блоке «Документация» открывает проводник по "
|
||
"документации — выберите папку машины и нажмите «Выбрать» или «Привязать эту папку». Рядом с папкой видно, к каким машинам "
|
||
"она уже привязана. «Сменить папку» и «Отвязать» — там же.")
|
||
pdf.h2("Сообщения об отказах извне")
|
||
pdf.p("Об аварии может сообщить и человек без учётной записи — подрядчик, охрана, смежная служба. Для этого есть **публичная "
|
||
"форма** по адресу **/report** (её удобно разместить на QR-коде у оборудования). В форме выбирается **место** — после этого "
|
||
"в списке машин остаётся только то, что стоит в этом месте, — затем машина, **описание** и **контакты** (обязательно). "
|
||
"Отказ появляется в портале с пометкой «через публичную форму» и контактом сообщившего; инженерам приходит уведомление.")
|
||
pdf.wide("d_report_form", "Публичная форма «Сообщить о неисправности»", width=100)
|
||
pdf.p("Внешние системы (диспетчерская, SCADA, сервис заявок) заводят отказы через API по **ключу доступа**, который выпускает "
|
||
"администратор в «Настройках сервера». Описание запросов — в docs/API.md.")
|
||
|
||
# ================= 8 =================
|
||
pdf.h1("9. Карты: завод, краны, склады")
|
||
pdf.p("Раздел **«Карта завода»** показывает план предприятия с иконками оборудования. Состояние видно сразу: "
|
||
"**«!»** — авария, **оранжевый «⚠»** — неисправность (машина работает), **жёлтое свечение** — приближается или наступил срок ТО. Нажмите на иконку — откроется краткая "
|
||
"сводка и ссылка на карточку машины. Состояние обновляется само раз в минуту.")
|
||
pdf.wide("d_map", "Карта завода")
|
||
pdf.p("**Масштаб.** Любую карту можно увеличить: кнопки **«+»** и **«−»** в углу карты, **Ctrl + колесо мыши**, двойной щелчок; "
|
||
"на телефоне — **щипок двумя пальцами**. Увеличенную карту двигают мышью или пальцем, третья кнопка возвращает весь план. "
|
||
"В приложении для Android так же масштабируются карты на вкладке «Задачи».")
|
||
pdf.h2("Как администратор настраивает карту")
|
||
pdf.bullets([
|
||
"Нажмите **«Редактировать карту»** → **«Загрузить картинку карты»**: подойдёт план цеха, схема территории или фото (JPEG/PNG, до 20 МБ).",
|
||
"В списке машин нажмите **«Поставить»** и кликните на карте, где она стоит. Уже размещённые иконки перетаскиваются мышью или пальцем.",
|
||
"Крестик убирает машину с карты (из справочника она не удаляется). Нажмите **«Готово»**, чтобы выйти из редактирования.",
|
||
], numbered=True)
|
||
pdf.wide("d_map_edit", "Редактирование карты завода")
|
||
pdf.h2("Карта складов")
|
||
pdf.p("Вкладка **«Склады»** той же карты показывает зоны складов. Администратор склада в режиме редактирования выбирает склад и "
|
||
"**протягивает прямоугольник** его зоны. Нажатие на зону показывает, что лежит на складе; «Открыть склад» ведёт к учёту.")
|
||
pdf.wide("d_map_wh", "Карта складов: зоны и содержимое склада")
|
||
pdf.h2("Краны — отдельная служба")
|
||
pdf.p("Кранов обычно много, и за них отвечают отдельные люди. Администратор включает в «Настройках сервера» флажок **«Краны — "
|
||
"отдельная служба»**. После этого оборудование с отметкой **«Кран»** уходит в свой раздел меню **«Краны»**: список кранов, "
|
||
"**карта кранов** и **отказы по кранам**. С карты завода и из списка «Оборудование» краны убираются.")
|
||
pdf.bullets([
|
||
"Отметку «Кран» ставит инженер в карточке оборудования (Оборудование → «Изменить»); там же — отдел, например «Крановое хозяйство».",
|
||
"У карты кранов может быть **своя картинка** (Карта кранов → «Редактировать карту» → «Загрузить свою картинку»); пока её нет, "
|
||
"используется план завода. Краны расставляются так же, как машины на карте завода.",
|
||
"Во вкладке «Задачи» в блоке отказов появляется выбор «оборудование / краны» и переключатель карт «Завод / Краны».",
|
||
"В приложении Android раздел «Краны» появляется в меню автоматически.",
|
||
])
|
||
pdf.wide("d_cranes", "Раздел «Краны»")
|
||
pdf.wide("d_crane_map", "Карта кранов: кран с открытым отказом")
|
||
|
||
# ================= надёжность =================
|
||
pdf.h1("10. Анализ надёжности")
|
||
pdf.p("Раздел **«Анализ → Анализ надёжности»** для инженеров и руководителей (включает администратор в «Настройках сервера»). Он считает "
|
||
"показатели надёжности оборудования по отказам, которые фиксируют сотрудники, и по показаниям счётчиков моточасов. Методика — "
|
||
"«Надёжность и безопасность» (ИАТЭ, 2005) и ГОСТ 27.002; вкладка «Как считается» объясняет её коротко.")
|
||
pdf.tbl(["Показатель", "Что значит"], [
|
||
["Наработка, ч", "Моточасы по счётчику за период. Нет показаний — календарное время (оценка в запас)."],
|
||
["θ — наработка на отказ", "Сколько в среднем машина работает между отказами: наработка ÷ число отказов."],
|
||
["θ ≥ (доверие 90 %)", "Консервативная нижняя граница. Работает и при одном-двух отказах, и **без отказов вообще** (наработка ÷ 2,3)."],
|
||
["λ — интенсивность отказов", "Отказов на 1000 ч работы и её верхняя граница."],
|
||
["Tв — восстановление", "От отказа до ввода в эксплуатацию; отдельно — ожидание запчастей."],
|
||
["Kг — готовность", "θ / (θ + Tв): доля времени, когда машина готова работать."],
|
||
["Прогноз", "Вероятность хотя бы одного отказа за неделю, месяц, квартал или год при текущей загрузке (поток Пуассона)."],
|
||
], [48, 126])
|
||
pdf.wide("d_reliability", "Анализ надёжности: показатели по парку и по машинам")
|
||
pdf.p("Ниже — **разбивки**: причины отказов, что сделано, характер, были ли признаки заранее (отказы, о которых было сообщение о неисправности "
|
||
"или замечание на обходе, можно было предотвратить), узлы-причины и отказы по месяцам. Фильтры: период, отдел, оборудование или краны.")
|
||
pdf.wide("d_reliability_bars", "Причины, характер, признаки, узлы-причины")
|
||
pdf.h2("Дрейф параметров")
|
||
pdf.p("Вкладка **«Дрейф параметров»** смотрит на числовые пункты чек-листов с нормой (давление, температура, вибрация): строит тренд, "
|
||
"считает, **через сколько дней значение дойдёт до границы**, и сколько раз уже выходило за неё. Так постепенный отказ видно заранее.")
|
||
pdf.wide("d_drift", "Дрейф параметров")
|
||
pdf.h2("Выгрузки для расчётов")
|
||
pdf.bullets([
|
||
"**«CSV: отказы и неисправности»** — строка на каждую запись: машина, отдел, все времена, наработки, узел-причина, причина, характер, "
|
||
"что сделано, заменённые узлы, признаки заранее, источник.",
|
||
"**«CSV: обслуживание узлов»** — история замен и обслуживания узлов. Плановые замены — «цензурированные» наблюдения (узел заменили, "
|
||
"не дождавшись отказа); без них прогноз ресурса узла получается смещённым.",
|
||
])
|
||
pdf.note("Абсолютные цифры приблизительны: данных мало, допущения сделаны в запас. Главная польза раздела — сравнение машин между собой "
|
||
"и поиск слабых мест на одной методике.", "info")
|
||
|
||
# ================= 9 =================
|
||
pdf.h1("11. Инструмент и склады")
|
||
pdf.p("Учёт инструмента и инвентаря: что есть в **каталоге**, сколько лежит **на каких складах** и что **выдано лично каким "
|
||
"сотрудникам** (не должностям — инструмент числится за человеком).")
|
||
pdf.tbl(["Кто", "Что может"], [
|
||
["Сотрудник", "Видит **«Мой инструмент»** — что выдано ему, и подаёт **заявки** на покупку. Изменять учёт не может."],
|
||
["Администратор склада", "Ведёт каталог и склады, проводит поступление, выдачу, возврат, перемещение и списание, отмечает "
|
||
"зоны складов на карте, рассматривает заявки."],
|
||
["Инженер, модератор", "Видят остатки складов и выданное (для планирования); модератор также рассматривает заявки."],
|
||
], [44, 130])
|
||
pdf.wide("d_inventory", "Склады и учёт")
|
||
pdf.p("Каждая операция сразу меняет остатки и записывается в **журнал** (кто, когда, откуда, куда, комментарий). "
|
||
"Вкладка **«Выдано сотрудникам»** показывает, что за кем числится, и позволяет принять возврат одной кнопкой.")
|
||
pdf.wide("d_issue", "Выдача инструмента сотруднику")
|
||
pdf.h2("Заявка на инструмент")
|
||
pdf.p("Если инструмента не хватает, сотрудник заполняет форму в разделе **«Заявки на инструмент»**: **что купить**, "
|
||
"**зачем** (оба поля обязательны), количество и при желании **ссылку на конкретный товар** в магазине. Заявку увидят "
|
||
"администратор склада и модераторы; им приходит уведомление.")
|
||
pdf.wide("d_request_form", "Заявка на инструмент (вид сотрудника)")
|
||
pdf.p("Ответственный меняет статус — **одобрена, заказано, получено** или **отклонена** (с причиной) — и пишет ответ. "
|
||
"Автор получает уведомление и видит ответ в своей заявке.")
|
||
pdf.wide("d_requests", "Рассмотрение заявок")
|
||
|
||
# ================= 10 =================
|
||
pdf.h1("12. Сотрудники")
|
||
pdf.p("Раздел **«Сотрудники»** — справочник коллег с поиском по ФИО, должности и навыкам. Карточка сотрудника показывает "
|
||
"**ФИО, фото, должность, график** (ближайшие смены), **«что умею»**, **текущие задания и наряды** и выданный инструмент.")
|
||
pdf.wide("d_people", "Сотрудники", width=160)
|
||
pdf.wide("d_user_card", "Карточка сотрудника")
|
||
pdf.h2("Отделы")
|
||
pdf.p("Сотрудников группируют по **отделам** — так же, как по должностям. Отдел может входить в другой отдел (например, "
|
||
"«Механическая служба» в «Службе главного инженера»), у отдела может быть руководитель. Отделы ведут модератор и "
|
||
"администратор в разделе «Администрирование → Отделы»; отдел сотрудника выбирается в его учётной записи. В разделе "
|
||
"«Сотрудники» есть фильтр по отделу (вложенные учитываются) и пункт «без отдела».")
|
||
pdf.wide("d_departments", "Отделы", width=160)
|
||
pdf.note("Фото, телефон и «что умею» заполняет сам сотрудник в своей карточке (нажмите своё имя вверху бокового меню); "
|
||
"модератор и администратор тоже могут их изменить. Выданный инструмент коллеге не показывается — только самому "
|
||
"сотруднику, складу и инженерам.", "info")
|
||
|
||
# ================= 6 =================
|
||
pdf.h1("13. Администратор и модератор")
|
||
pdf.p("Раздел **«Администрирование»** (виден администратору и модератору) собирает все настройки в одном месте.")
|
||
pdf.wide("d_admin", "Панель администратора", width=150)
|
||
|
||
pdf.h2("Мастер первичной настройки")
|
||
pdf.p("Для нового сервера: **«Администрирование → Мастер первичной настройки»** (пока система пустая, на панели администратора "
|
||
"висит приглашение запустить мастер). Мастер проводит по шагам в порядке зависимостей данных:")
|
||
pdf.bullets([
|
||
"**Сервер** — проверить часовой пояс, каталог фото, время сервера.",
|
||
"**Графики** — типовые одним нажатием (пятидневка, сутки через трое, 2/2 день-ночь, круглосуточно) или в конструкторе. "
|
||
"График потом назначается каждому сотруднику.",
|
||
"**Должности → отделы → места → оборудование** — пакетным вводом: одна строка — одна запись, поля через «;». "
|
||
"Для оборудования: «Название; инв. №; место; наработка; отдел; кран» — отдел закрепляет машину за службой, слово «кран» "
|
||
"отправляет её в раздел «Краны».",
|
||
"**Сотрудники** — строки «ФИО; логин; должность; отдел; график; роль». Логины и временные пароли создаются сами и показываются "
|
||
"таблицей для печати или CSV — **только один раз**.",
|
||
"**Карта завода** (и подсказка про отдельную карту кранов), **склады и начальные остатки инструмента**, **чек-листы и расписание**, **внешние обращения**, **резервные копии**.",
|
||
])
|
||
pdf.wide("d_setup", "Мастер первичной настройки")
|
||
pdf.note("Шаг считается выполненным, когда в нём есть записи, или если отметить «шаг выполнен» — мастер сразу переходит к следующему. "
|
||
"К мастеру можно возвращаться в любое время.", "info")
|
||
pdf.p("Кнопка **«Завершить и проверить»** на последнем шаге открывает **проверку готовности**: мастер смотрит на введённые данные и "
|
||
"показывает, что мешает работе — сотрудники без должности, должности, которые никто не занимает, должности, где сейчас по "
|
||
"графикам никого нет на смене, машины без места, нет чек-листов или расписания, выключены автокопии или автоочистка фото. "
|
||
"Рядом с каждым пунктом — кнопка, чтобы поправить. После завершения плитка мастера ведёт сразу к этой проверке.")
|
||
|
||
pdf.h2("AI-помощник (включает администратор)")
|
||
pdf.p("В «Настройках сервера» администратор может включить **AI-помощника**: указать провайдера (OpenAI-совместимый — OpenAI, "
|
||
"YandexGPT, DeepSeek, локальная модель Ollama; Anthropic; GigaChat), адрес API, модель и ключ, нажать «Проверить» и включить. "
|
||
"После этого у всех пользователей в правом нижнем углу появляется круглая кнопка помощника.")
|
||
pdf.bullets([
|
||
"Помощник подсказывает, где что найти и как выполнить действие, и даёт ссылки на разделы.",
|
||
"Он может посмотреть данные пользователя — его задания, наряды, инструмент, состояние оборудования, отказы — "
|
||
"**только с правами этого пользователя** и ничего не меняет.",
|
||
"На вопросы не о работе в портале он вежливо отказывает. Лимит — 30 вопросов в час на человека.",
|
||
"Ключ API не показывается в настройках и не попадает в резервные копии.",
|
||
])
|
||
pdf.wide("d_assistant_settings", "Настройка AI-помощника")
|
||
pdf.p("**«Работа с документацией»** — отдельная опция помощника в той же карточке. Включённая, она раз в сутки собирает индекс "
|
||
"документации машин (имена файлов и начало текста PDF и документов Office), и помощник отвечает на вопросы вида "
|
||
"«где схема двигателя компрессора?» ссылкой на нужный файл и подсказкой, что в нём искать.")
|
||
pdf.note("Риски опции: имена файлов и фрагменты документов уходят провайдеру ИИ — с облачным провайдером документация покидает "
|
||
"предприятие, для закрытых документов используйте локальную модель. Запросы становятся в разы длиннее, расход токенов "
|
||
"растёт. Помощник видит только начало документов — ответ проверяйте в самом файле.", "warn")
|
||
pdf.wide("d_assistant", "Помощник: ответ по данным пользователя и подсказка «как сделать»")
|
||
pdf.phone("a_assistant", "Помощник в приложении Android (круглая кнопка справа внизу или пункт меню «Помощник»)", width=70)
|
||
|
||
pdf.h2("Резервные копии (только администратор)")
|
||
pdf.p("Раздел **«Администрирование → Резервные копии»**. Копия — один zip-файл: вся продуктовая база в переносимом формате и "
|
||
"конфигурация (настройки сервера и несекретные параметры). Пароли, ключ подписи и строка подключения к базе в копию **не входят**. "
|
||
"По желанию в копию добавляются фото и картинки.")
|
||
pdf.bullets([
|
||
"**Автокопии** — раз в N часов (по умолчанию раз в сутки), хранится заданное число последних.",
|
||
"**Скачайте** копию и храните её вне сервера. Файл можно **загрузить** на другой сервер.",
|
||
"**Восстановление** заменяет все данные содержимым копии; перед этим текущее состояние автоматически сохраняется отдельной копией.",
|
||
])
|
||
pdf.wide("d_backups", "Резервные копии")
|
||
pdf.note("В демонстрационном режиме копирование и восстановление недоступны: копировалась бы демо-база, а не рабочая.", "warn")
|
||
|
||
pdf.h2("Пользователи")
|
||
pdf.p("Создайте учётную запись для каждого сотрудника: логин, ФИО, пароль, **должность** и признаки ролей (в том числе "
|
||
"**«Администратор склада»**). Изменяя запись, "
|
||
"оставьте поле пароля пустым, чтобы не менять его; чтобы сбросить забытый пароль, введите новый. Отключите флажок «Активен», "
|
||
"если человек больше не работает: он потеряет доступ, а история сохранится.")
|
||
pdf.wide("d_user_form", "Добавление пользователя")
|
||
pdf.note("Модератор не может изменять администраторов и других модераторов и не выдаёт права администратора или модератора.", "warn")
|
||
pdf.p("**Администратор портала** — отдельное право поверх роли администратора: **настройки сервера** (фото, уведомления, внешние "
|
||
"обращения, ключи API, AI-помощник) и **ответы обратной связи**. У администратора по умолчанию (логин **admin**) оно есть всегда "
|
||
"и не снимается, а другим администраторам его включает и выключает только admin — флажком «Администратор портала» в карточке "
|
||
"пользователя. Остальным администраторам мастер первичной настройки и резервные копии доступны как обычно.")
|
||
|
||
pdf.h2("Должности и графики работы")
|
||
pdf.p("**Должность** имеет тип (**техник** выполняет наряды, **инженер** проверяет и планирует). **График** задаётся не должности, "
|
||
"а **сотруднику** («Пользователи» → «Изменить» → «График работы»): должность выполняет тот, кто её занимает и сейчас на смене. "
|
||
"Например, «Дежурный механик» — четыре сменщика с графиком «сутки через трое», каждый со своим первым днём цикла: наряд или "
|
||
"задание на эту должность увидит и получит уведомление только тот, кто сегодня дежурит. Сотрудник без графика считается "
|
||
"всегда на смене.")
|
||
pdf.note("При обновлении портала графики, которые раньше были заданы должностям, автоматически перенесены их сотрудникам. "
|
||
"Для сменщиков поправьте графики (или первый день цикла), чтобы на смене был один человек.", "info")
|
||
pdf.p("Графики настраиваются **конструктором**: можно собрать любой цикл — дневные и ночные смены, отсыпные, сутки через трое. "
|
||
"Каждый шаг цикла — это «Смена» или «Выходной», число дней подряд, время начала и конца и метка (например, «день», "
|
||
"«ночь», «отсыпной»). Если конец смены раньше начала (20:00–08:00), смена ночная; одинаковое время (08:00–08:00) — сутки.")
|
||
pdf.wide("d_schedule", "Конструктор графика")
|
||
pdf.bullets([
|
||
"Нажмите готовый шаблон («3 дня · отсыпной · 3 ночи · отсыпной · 3 выходных», «Сутки через трое» и др.) и поправьте под себя.",
|
||
"Задайте **первый день цикла** — с этой даты начинается шаг №1.",
|
||
"Ниже календарь на шесть недель показывает результат: синие клетки — дневные смены, фиолетовые — ночные, серые — выходные.",
|
||
])
|
||
pdf.wide("d_schedule_cal", "Календарь графика (предпросмотр)", width=150)
|
||
|
||
pdf.h2("Оборудование и места")
|
||
pdf.p("Разделы **«Оборудование»** и **«Места»** — справочники для нарядов и отчётов. Оборудование можно привязать к месту "
|
||
"(например, «Цех №1»), указать инвентарный номер, **отдел** (кто его видит) и отметку **«Кран»**. Неиспользуемое оборудование "
|
||
"отключается флажком «Активно».")
|
||
pdf.p("Над списком оборудования (и кранов) — фильтр **«Место»**: выберите цех или участок, и в списке останутся машины этого "
|
||
"места и всех вложенных в него мест (например, «Цех №1» покажет и машины «Компрессорной», входящей в цех).")
|
||
|
||
pdf.h2("Обратная связь по порталу")
|
||
pdf.p("Внизу каждой страницы сайта — неприметная ссылка **«Предложить улучшение портала или сообщить об ошибке»**; в мобильной версии и "
|
||
"приложении для Android — в самом низу бокового меню. Сотрудник выбирает «идея», «ошибка» или «другое» и пишет пару строк; раздел, "
|
||
"где он был, подставляется сам. Администраторам портала приходит уведомление.")
|
||
pdf.p("Сообщения видят **только администраторы портала**: «Администрирование → Обратная связь». У каждого — статус (новое, в работе, "
|
||
"сделано, не будем делать) и заметка о решении.")
|
||
pdf.wide("d_feedback", "Обратная связь: сообщения сотрудников", width=160)
|
||
|
||
pdf.h2("Настройки сервера (только администратор портала)")
|
||
pdf.p("Раздел **«Настройки сервера»** позволяет изменить каталог для хранения фотографий и максимальный размер снимка, а также "
|
||
"ключ для мгновенных уведомлений Android, включить публичную форму заявок и **«Краны — отдельная служба»**. Внизу виден "
|
||
"расход места: сколько фото хранится и сколько свободно на диске.")
|
||
pdf.p("**Автоочистка фото**: поле «удалять фото нарядов и заданий старше, дней» (например, 365). Раз в сутки сервер удаляет "
|
||
"фото старше этого срока; 0 — не удалять. Снимки с отметкой **«хранить более года»** (в чек-листе у пункта или в задании) "
|
||
"с самого начала лежат в отдельном каталоге **keep/** и не удаляются. В блоке «Состояние» видно, сколько фото будет удалено "
|
||
"при следующей очистке; кнопка «Очистить сейчас» запускает её сразу.")
|
||
pdf.note("Удалённые фото не восстановить, кроме как из резервной копии с фото. По умолчанию автоочистка выключена.", "warn")
|
||
pdf.h2("Мониторинг сервера (только администратор портала)")
|
||
pdf.p("«Администрирование → **Мониторинг сервера**»: круговые диаграммы загрузки **процессора**, занятой **памяти** и места на "
|
||
"**дисках** с данными портала и фото. Значения обновляются каждые 5 секунд, пока страница открыта; от 75 % диаграмма "
|
||
"становится жёлтой, от 90 % — красной. В Docker с лимитом памяти показывается память контейнера.")
|
||
pdf.wide("d_server", "Настройки сервера", width=150)
|
||
pdf.note("При смене каталога фото старые снимки автоматически **не переносятся** — скопируйте файлы вручную.", "warn")
|
||
|
||
pdf.h2("Демонстрационный режим (только администратор)")
|
||
pdf.p("Для презентаций есть отдельная **тестовая база** с готовыми сотрудниками, оборудованием и кранами, чек-листами, нарядами и "
|
||
"задачами за две недели, картами, складами, заявками и отказами. Демо-режим **личный**: он включается только у того, "
|
||
"кто его включил. **Остальные сотрудники продолжают работать с рабочими данными** и входят под своими учётными записями как обычно.")
|
||
pdf.bullets([
|
||
"«Администрирование → Демонстрационный режим» → **«Войти в демо»**. Пока вы в демо, на всех экранах виден жёлтый баннер "
|
||
"«ДЕМО-РЕЖИМ (только у вас)» с кнопкой **«Выйти из демо»**.",
|
||
"Администратор видит список сотрудников и может **включить или выключить демо любому** — например, если кто-то включил его "
|
||
"случайно и не может выйти. В приложении Android и мобильной версии при переходе в демо и обратно данные на телефоне "
|
||
"очищаются, чтобы тестовые и рабочие наряды не смешались.",
|
||
"Тестовые учётные записи (пароль показан на странице демо-режима) работают параллельно с рабочими. «Войти как» открывает "
|
||
"систему под тестовым пользователем без пароля, чтобы показать роли.",
|
||
"«Пересоздать демо-данные» возвращает тестовую базу к исходному виду, «Удалить демо-данные» выводит из демо всех.",
|
||
])
|
||
pdf.wide("d_demo", "Демонстрационный режим: кто в демо, включение и выключение", width=150)
|
||
pdf.note("Рабочие данные в демо-режиме не изменяются и не удаляются.", "info")
|
||
|
||
# ================= 7 =================
|
||
pdf.h2("Подключение Nextcloud и ONLYOFFICE")
|
||
pdf.p("«Настройки сервера» → **«Документация из Nextcloud»** (только администратор портала).")
|
||
pdf.bullets([
|
||
"В Nextcloud заведите служебного пользователя (например, portal-docs), дайте ему **доступ только на чтение** к папке "
|
||
"с документацией и создайте для него **пароль приложения** (Настройки → Безопасность).",
|
||
"В портале укажите адрес Nextcloud (лучше внутренний, в локальной сети), учётную запись, пароль приложения и корневую папку "
|
||
"документации. Кнопка «Проверить» покажет, сколько папок видно в корне.",
|
||
"Для документов Office укажите адрес **ONLYOFFICE Document Server** в локальной сети, его **секрет JWT** (если включён) и "
|
||
"**внутренний адрес портала** — по нему ONLYOFFICE забирает исходник по одноразовой ссылке. Без ONLYOFFICE документы "
|
||
"Office только скачиваются.",
|
||
"Сотрудникам Nextcloud не нужен: портал сам ходит в него и проверяет, кто какую машину видит. Пароль приложения и секрет "
|
||
"JWT не показываются в настройках и не попадают в резервные копии.",
|
||
])
|
||
pdf.h1("14. Частые вопросы")
|
||
faq = [
|
||
("Я не вижу машину (или кран), которая точно есть.",
|
||
"Оборудование может быть закреплено за другим отделом — тогда его видят только сотрудники того отдела. Если краны выделены "
|
||
"в отдельную службу, они в разделе **«Краны»**, а не в «Оборудовании». Проверьте у инженера или администратора."),
|
||
("Вверху жёлтая полоса «ДЕМО-РЕЖИМ», а моих данных нет.",
|
||
"Вы в демонстрационном режиме — это тестовые данные. Нажмите **«Выйти из демо»** в полосе. Если кнопки нет или не помогает, "
|
||
"попросите администратора выключить демо для вас."),
|
||
("Я не вижу наряд, который мне должны были назначить.",
|
||
"Наряд, назначенный на должность, виден только сотрудникам этой должности **на смене** по их графику. Проверьте свой график "
|
||
"и должность в карточке; если вы вне смены — наряд появится, когда смена начнётся. Нажмите обновление списка. Если наряд назначен "
|
||
"другому человеку или уже взят коллегой, вы его не увидите."),
|
||
("Пишет «Нет связи» или «не отправлено» и не исчезает.",
|
||
"Проверьте интернет на телефоне. Данные сохранены и не пропадут: они уйдут сами. Для немедленной попытки нажмите кнопку "
|
||
"обновления в шапке или «Синхронизировать сейчас» в настройках. Если ошибка остаётся при хорошей связи — сообщите администратору."),
|
||
("Не могу сдать наряд.",
|
||
"Система показывает причину: не заполнены обязательные пункты или не приложены обязательные фото. Откройте чек-лист и "
|
||
"найдите пункты без ответа и надпись «Требуется фото»."),
|
||
("Фото не принимается сервером.",
|
||
"Поддерживаются форматы JPEG, PNG и WebP. Слишком большие файлы отклоняются (по умолчанию лимит 10 МБ; обычно снимок уже "
|
||
"уменьшается автоматически). Попробуйте сделать снимок заново."),
|
||
("Пишет «Сессия истекла».",
|
||
"Войдите заново. Неотправленные данные в приложении и мобильной версии при этом не теряются: после входа под тем же "
|
||
"пользователем они уйдут на сервер. Если войти под другим пользователем, данные прежнего будут удалены."),
|
||
("Не приходят уведомления.",
|
||
"Android: разрешите уведомления для приложения и не запрещайте работу в фоне (энергосбережение иногда отключает проверку). "
|
||
"Мобильная версия: включите уведомления в настройках; на iPhone сервис должен быть установлен на экран «Домой». "
|
||
"Работают только защищённые адреса (https)."),
|
||
("Где посмотреть, какой инструмент за мной числится?",
|
||
"В боковом меню — «Мой инструмент». Если там чего-то не хватает или числится лишнее, обратитесь к администратору склада."),
|
||
("Почему иконка машины на карте светится жёлтым?",
|
||
"У одного из обслуживаемых узлов машины подходит или уже наступил срок обслуживания. Откройте карточку машины: узлы "
|
||
"со статусом «Скоро» и «Требуется» показаны вверху списка."),
|
||
("Я забыл пароль.",
|
||
"Обратитесь к администратору или модератору: в разделе «Пользователи» он задаст новый пароль."),
|
||
("Время в нарядах отличается от времени на телефоне.",
|
||
"Сервер использует часовой пояс организации. Сроки и отчёты показываются в нём; время на телефоне может отличаться, если у вас "
|
||
"другой часовой пояс."),
|
||
("Как отказаться от наряда или передать другому?",
|
||
"Сообщите инженеру: он может отменить наряд или создать новый. Если вы взяли в работу наряд на должность по ошибке, "
|
||
"обратитесь к инженеру или администратору."),
|
||
]
|
||
for q, a in faq:
|
||
pdf.q(q)
|
||
pdf.p(a)
|
||
|
||
pdf.h2("Краткая памятка техника")
|
||
pdf.bullets([
|
||
"Открыть приложение → дождаться обновления списка (перед выходом на цех).",
|
||
"Выбрать наряд с пометкой «можно выполнять» → при необходимости «Взять в работу».",
|
||
"Заполнить все пункты, приложить обязательные фото.",
|
||
"Нажать «Сдать наряд». Значок «не отправлено» исчезнет, когда данные дойдут до сервера.",
|
||
"Если наряд вернули — прочитать комментарий проверяющего, исправить и сдать снова.",
|
||
], numbered=True)
|
||
|
||
# ================= установка =================
|
||
pdf.h1("15. Установка на свой сервер (Docker Compose)")
|
||
pdf.p("Глава для администратора, который разворачивает портал сам. Нужен только **Docker** с плагином **Compose**: портал — это "
|
||
"приложение и база PostgreSQL в двух контейнерах. Панели вроде Coolify удобны, но **не обязательны**. Подробная версия с "
|
||
"примерами для разных прокси, резервным копированием и решением проблем — файл **docs/DEPLOY.md** в репозитории.")
|
||
pdf.h2("Что нужно")
|
||
pdf.bullets([
|
||
"Сервер Linux (Debian 12/13, Ubuntu 22.04/24.04): 2 ядра, 2 ГБ памяти, от 10 ГБ диска плюс место под фото.",
|
||
"Docker Engine 24+ и Compose v2 (**docker compose version**), git.",
|
||
"Домен и обратный прокси с **HTTPS** (nginx, Nginx Proxy Manager, Caddy): без HTTPS не работают уведомления, установка на "
|
||
"iPhone и приложение для Android в релизной сборке.",
|
||
])
|
||
pdf.h2("Установка по шагам")
|
||
pdf.bullets([
|
||
"Скачать проект и перейти в каталог:",
|
||
], numbered=True)
|
||
pdf.code("""
|
||
git clone <адрес репозитория> /opt/portal
|
||
cd /opt/portal && git checkout main
|
||
""")
|
||
pdf.bullets(["Создать файл настроек из шаблона и заполнить его:"], numbered=True, start=2)
|
||
pdf.code("""
|
||
cp .env.example .env
|
||
openssl rand -hex 24 # -> DB_PASSWORD
|
||
openssl rand -hex 32 # -> SECRET_KEY (не короче 32 символов)
|
||
nano .env # ADMIN_PASSWORD (от 10 символов), APP_TZ, APP_PORT
|
||
""")
|
||
pdf.bullets(["Собрать и запустить, проверить состояние:"], numbered=True, start=3)
|
||
pdf.code("""
|
||
docker compose up -d --build
|
||
docker compose ps # app и db: healthy
|
||
curl -s http://127.0.0.1:8080/api/v1/health
|
||
""")
|
||
pdf.bullets([
|
||
"Настроить прокси на **http://127.0.0.1:8080** с HTTPS и ****client_max_body_size 20m**** (иначе фото не загрузятся, ошибка 413).",
|
||
"Войти как **admin** с паролем из **ADMIN_PASSWORD**, сменить пароль (Администрирование → Пользователи) и пройти **мастер "
|
||
"первичной настройки**.",
|
||
"Указать адрес прокси: «Настройки сервера» → «Адрес клиента, каким его видит сервер» — пока прокси не указан, там виден его адрес. "
|
||
"Вписать его в **FORWARDED_ALLOW_IPS** в **.env** и выполнить **docker compose up -d**; после этого там появится ваш собственный адрес.",
|
||
], numbered=True, start=4)
|
||
pdf.tbl(["Переменная в .env", "Что это"], [
|
||
["DB_PASSWORD", "Пароль базы. После первого запуска не менять."],
|
||
["SECRET_KEY", "Ключ подписи входа, от 32 символов. Смена завершит все сессии."],
|
||
["ADMIN_PASSWORD", "Пароль admin при первом запуске, от 10 символов."],
|
||
["APP_TZ", "Часовой пояс предприятия: смены, расписание, отчёты."],
|
||
["APP_PORT", "Порт на сервере для прокси (по умолчанию 8080)."],
|
||
["FORWARDED_ALLOW_IPS", "Адрес прокси — только ему сервер верит, откуда пришёл пользователь."],
|
||
], [48, 126])
|
||
pdf.note("Со слабыми SECRET_KEY или ADMIN_PASSWORD приложение в боевом режиме намеренно не запускается — в логах "
|
||
"(**docker compose logs app**) будет понятное сообщение. Порт приложения закройте файрволом для всех, кроме прокси.", "warn")
|
||
pdf.h2("Обновление, копии и перенос")
|
||
pdf.code("""
|
||
cd /opt/portal && git pull && docker compose up -d --build # обновление
|
||
docker compose cp app:/srv/data/backups ./backups-$(date +%F) # забрать резервные копии
|
||
docker compose exec -T db pg_dump -U checklists -Fc checklists > portal.dump
|
||
docker compose logs -f --tail 200 app # журнал приложения
|
||
""")
|
||
pdf.bullets([
|
||
"Перед обновлением сделайте копию в «Резервных копиях». Структура базы обновляется сама при запуске.",
|
||
"Данные лежат в томах Docker **pgdata**, **photos**, **appdata** и переживают пересборку. Команда **docker compose down -v** "
|
||
"**удаляет их** — на рабочем сервере её не выполнять.",
|
||
"Перенос на другой сервер: на старом — копия с фото, скачать zip; на новом — установка по шагам выше, затем «Резервные "
|
||
"копии → Загрузить файл копии → Восстановить».",
|
||
"Новую версию приложения для Android можно выложить без пересборки: **docker compose cp checklists-X.Y.Z.apk app:/srv/data/apk/**.",
|
||
])
|
||
|
||
outline = list(pdf._outline)
|
||
last = pdf.page
|
||
pdf.page = 2
|
||
pdf.set_xy(18, 18)
|
||
pdf.set_auto_page_break(False)
|
||
toc(pdf, outline)
|
||
pdf.set_auto_page_break(True, 20)
|
||
pdf.page = last
|
||
OUT.parent.mkdir(parents=True, exist_ok=True)
|
||
pdf.output(str(OUT))
|
||
print("saved", OUT, f"{OUT.stat().st_size / 1e6:.1f} МБ,", pdf.page_no(), "стр.")
|
||
|
||
|
||
if __name__ == "__main__":
|
||
build()
|