"""Собирает «Руководство пользователя» в 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()