253 lines
21 KiB
Python
253 lines
21 KiB
Python
"""Собирает docs/presentation/presentation.pptx (редактируемые элементы, 16:9) — копия вёрстки slides.html.
|
|
|
|
Запуск: .venv\\Scripts\\python tools/guide/build_pptx.py (нужен python-pptx)
|
|
"""
|
|
from pathlib import Path
|
|
|
|
from PIL import Image
|
|
from pptx import Presentation
|
|
from pptx.dml.color import RGBColor
|
|
from pptx.enum.shapes import MSO_SHAPE
|
|
from pptx.enum.text import MSO_ANCHOR, PP_ALIGN
|
|
from pptx.util import Emu, Pt
|
|
|
|
ROOT = Path(__file__).resolve().parents[2]
|
|
IMG = ROOT / "docs" / "guide" / "img"
|
|
OUT = ROOT / "docs" / "presentation" / "presentation.pptx"
|
|
PX = 9525
|
|
FONT = "Segoe UI"
|
|
TEXT, MUTED, BLUE = RGBColor(0x1D, 0x23, 0x30), RGBColor(0x5B, 0x64, 0x75), RGBColor(0x25, 0x63, 0xEB)
|
|
WHITE, PALE = RGBColor(255, 255, 255), RGBColor(0xDB, 0xE6, 0xFF)
|
|
|
|
|
|
def px(v):
|
|
return Emu(int(v * PX))
|
|
|
|
|
|
def pt(px_size):
|
|
return Pt(px_size * 0.75)
|
|
|
|
|
|
prs = Presentation()
|
|
prs.slide_width, prs.slide_height = px(1280), px(720)
|
|
BLANK = prs.slide_layouts[6]
|
|
|
|
|
|
def bg(slide, dark):
|
|
f = slide.background.fill
|
|
f.solid()
|
|
f.fore_color.rgb = RGBColor(0x1E, 0x40, 0xAF) if dark else RGBColor(0xF5, 0xF7, 0xFB)
|
|
if dark: # лёгкий градиент как в HTML
|
|
f.gradient()
|
|
f.gradient_angle = 315
|
|
f.gradient_stops[0].color.rgb = RGBColor(0x1E, 0x3A, 0x8A)
|
|
f.gradient_stops[1].color.rgb = RGBColor(0x25, 0x63, 0xEB)
|
|
|
|
|
|
def text(slide, x, y, w, h, runs, size=20, color=TEXT, bold=False, spacing=None, anchor=MSO_ANCHOR.TOP):
|
|
tb = slide.shapes.add_textbox(px(x), px(y), px(w), px(h))
|
|
tf = tb.text_frame
|
|
tf.word_wrap = True
|
|
tf.margin_left = tf.margin_right = tf.margin_top = tf.margin_bottom = 0
|
|
tf.vertical_anchor = anchor
|
|
lines = runs if isinstance(runs, list) else [runs]
|
|
for i, ln in enumerate(lines):
|
|
p = tf.paragraphs[0] if i == 0 else tf.add_paragraph()
|
|
if spacing:
|
|
p.line_spacing = spacing
|
|
r = p.add_run()
|
|
r.text = ln
|
|
r.font.name, r.font.size, r.font.bold = FONT, pt(size), bold
|
|
r.font.color.rgb = color
|
|
return tb
|
|
|
|
|
|
def rect(slide, x, y, w, h, fill, radius=None, line=None, alpha=None):
|
|
s = slide.shapes.add_shape(MSO_SHAPE.ROUNDED_RECTANGLE if radius else MSO_SHAPE.RECTANGLE,
|
|
px(x), px(y), px(w), px(h))
|
|
if radius:
|
|
s.adjustments[0] = min(0.5, radius / min(w, h))
|
|
s.fill.solid()
|
|
s.fill.fore_color.rgb = fill
|
|
if alpha is not None:
|
|
from lxml import etree
|
|
clr = s.fill._xPr.find(".//{http://schemas.openxmlformats.org/drawingml/2006/main}srgbClr")
|
|
a = etree.SubElement(clr, "{http://schemas.openxmlformats.org/drawingml/2006/main}alpha")
|
|
a.set("val", str(int(alpha * 100000)))
|
|
if line:
|
|
s.line.color.rgb = line
|
|
s.line.width = Pt(0.75)
|
|
else:
|
|
s.line.fill.background()
|
|
s.shadow.inherit = False
|
|
return s
|
|
|
|
|
|
def shot(slide, name, x, y, w, h):
|
|
"""Скриншот по ширине блока, лишнее снизу обрезается (как overflow:hidden в HTML)."""
|
|
path = IMG / name
|
|
iw, ih = Image.open(path).size
|
|
full_h = w * ih / iw
|
|
rect(slide, x - 1, y - 1, w + 2, min(h, full_h) + 2, RGBColor(0xD5, 0xDB, 0xE8), radius=12)
|
|
pic = slide.shapes.add_picture(str(path), px(x), px(y), px(w), px(min(h, full_h)))
|
|
if full_h > h:
|
|
pic.crop_bottom = 1 - h / full_h
|
|
return pic
|
|
|
|
|
|
def bullets(slide, x, y, w, items, size=21, small=17):
|
|
cpl = max(10, int(w - 30) / (small * 0.52)) # символов в строке (оценка)
|
|
for head, sub in items:
|
|
hl = 1 if len(head) < (w - 30) / (size * 0.55) else 2
|
|
sl = -(-len(sub) // int(cpl))
|
|
rect(slide, x, y + 8, 14, 14, BLUE, radius=4)
|
|
text(slide, x + 30, y, w - 30, hl * 28, head, size, TEXT, True)
|
|
text(slide, x + 30, y + hl * 28, w - 30, sl * 23, sub, small, MUTED)
|
|
y += hl * 28 + sl * 23 + 16
|
|
return y
|
|
|
|
|
|
def title(slide, h, sub, y=48):
|
|
text(slide, 64, y, 1152, 50, h, 38, TEXT, True)
|
|
text(slide, 64, y + 62, 1152, 30, sub, 20, MUTED)
|
|
|
|
|
|
def cards(slide, y, items, h=150):
|
|
w = (1152 - 40) / 3
|
|
for i, (head, body) in enumerate(items):
|
|
x = 64 + i * (w + 20)
|
|
rect(slide, x, y, w, h, WHITE, radius=16, alpha=0.14)
|
|
text(slide, x + 22, y + 22, w - 44, 60, head, 24, WHITE, True)
|
|
text(slide, x + 22, y + 22 + (60 if len(head) > 20 else 36), w - 44, h - 60, body, 17, PALE)
|
|
|
|
|
|
# 1. Титульный
|
|
s = prs.slides.add_slide(BLANK); bg(s, True)
|
|
text(s, 64, 88, 900, 28, "ДЛЯ РЕМОНТНЫХ СЛУЖБ", 20, PALE)
|
|
text(s, 64, 130, 1100, 130, ["Портал", "технической службы"], 54, WHITE, True, spacing=1.05)
|
|
text(s, 64, 290, 1100, 36, "Задачи, наряды и чек-листы, отказы, карты завода и кранов, инструмент и люди — в одной системе", 24, PALE)
|
|
cards(s, 372, [
|
|
("Отчётность", "Кто, на какой машине, какой чек-лист и когда выполнил — за любой период, в PDF и Excel"),
|
|
("Гибкая настройка", "Любые графики смен, чек-листы с разными типами ответов, автозапуск нарядов по расписанию"),
|
|
("Приложение", "Android и мобильная версия в браузере. Работает без связи, данные уходят на сервер сами")], 160)
|
|
|
|
# Вкладка «Задачи»
|
|
s = prs.slides.add_slide(BLANK); bg(s, False)
|
|
title(s, 'Вкладка «Задачи»: всё в одном месте', 'Сотруднику не нужно ходить по разделам — главное на одном экране')
|
|
shot(s, 'd_work.png', 64, 142, 680, 500)
|
|
bullets(s, 780, 142, 436, [('Наряды', 'Мои, на проверку, просроченные — со статусом и поиском'), ('Задачи и поручения', 'Мои, поставленные мной, на сегодня или все дни'), ('Отказы и неисправности', 'Два блока, списком или на карте завода и кранов'), ('Шапка быстрых действий', 'Задача, авария, неисправность, заявка — в одно касание')], size=20, small=16)
|
|
|
|
# 2. Отчётность
|
|
s = prs.slides.add_slide(BLANK); bg(s, False)
|
|
title(s, "Отчётность: ответы за минуты, а не за часы", "«Выполнение работ» и «Выполненные наряды» — для руководителя, инженера и проверок")
|
|
bullets(s, 64, 142, 470, [
|
|
("% выполнения по каждой машине", "Суточные осмотры, чек-листы, ТО и задания; отклонения и комментарии"),
|
|
("Машина · чек-лист · исполнитель · должность · время", "Каждая выполненная проверка — одной строкой"),
|
|
("Обязательный период с точностью до минуты", "Быстрые кнопки: сегодня, вчера, 7 и 30 дней, месяц"),
|
|
("Отклонения от нормы видны сразу", "Колонка «Откл.» — число пунктов вне допуска"),
|
|
("Экспорт в PDF и CSV (Excel)", "Готовый документ с кириллицей одним нажатием")])
|
|
shot(s, "d_exec_report.png", 578, 142, 638, 490)
|
|
|
|
# Отчёт по сотрудникам
|
|
s = prs.slides.add_slide(BLANK); bg(s, False)
|
|
title(s, 'Отчёт по сотрудникам', 'Количество и качество выполненных задач за период')
|
|
bullets(s, 64, 142, 436, [('Список и календарь', 'Сколько задач выполнено по дням и суммарная трудоёмкость'), ('Качество, а не только количество', 'В срок, просрочки, возвраты с проверки, отклонения от норм'), ('Трудоёмкость', 'Норма часов в задаче или фактическое время работы'), ('Фильтры и выгрузка', 'Период, отдел, должность, сотрудник; PDF и CSV')], size=20, small=16)
|
|
shot(s, 'd_people_cal.png', 536, 142, 680, 500)
|
|
|
|
# Анализ надёжности
|
|
s = prs.slides.add_slide(BLANK); bg(s, False)
|
|
title(s, 'Анализ надёжности', 'Наработка на отказ, восстановление, готовность и прогноз — по реальным отказам и счётчикам')
|
|
shot(s, 'd_reliability.png', 64, 142, 680, 500)
|
|
bullets(s, 780, 142, 436, [('Показатели по машинам и парку', 'θ, λ, время восстановления, коэффициент готовности, простой'), ('Честно при малом числе отказов', 'Консервативная граница с доверием 90 % — даже без отказов'), ('Причины и признаки', 'Что ломается, почему и какие отказы можно было предотвратить'), ('Дрейф параметров и CSV', 'Прогноз выхода за норму по чек-листам; выгрузки для своих расчётов')], size=20, small=16)
|
|
|
|
# 3. Гибкая настройка
|
|
s = prs.slides.add_slide(BLANK); bg(s, False)
|
|
title(s, "Гибкая настройка под ваше производство", "Всё настраивается администратором в интерфейсе, без программиста")
|
|
shot(s, "d_schedule.png", 64, 142, 612, 520)
|
|
y = bullets(s, 712, 142, 504, [
|
|
("Конструктор графиков смен", "Дни, ночи, отсыпной, сутки через трое, любой цикл — с календарём-предпросмотром"),
|
|
("Чек-листы любой структуры", "Ответ: статус, список, число с границами нормы, текст; фото — обязательно или по желанию"),
|
|
("Наряды вручную и автоматически", "Шаблон + периодичность: ежедневно, по дням недели, раз в месяц, каждые N дней"),
|
|
("Исполнитель — человек или должность на смене", "Наряд видит тот, кто сейчас работает"),
|
|
("Роли и проверка", "Техник, инженер, модератор, администратор; приёмка или возврат на доработку")], size=20, small=16)
|
|
x = 712
|
|
for t in ("Оборудование и места", "Каталог фото", "Комментарии", "Уведомления"):
|
|
w = 22 + len(t) * 8.4
|
|
if x + w > 1216:
|
|
x, y = 712, y + 36
|
|
r = rect(s, x, y, w, 28, RGBColor(0xE6, 0xED, 0xFF), radius=14)
|
|
tf = r.text_frame
|
|
tf.margin_left = tf.margin_right = tf.margin_top = tf.margin_bottom = 0
|
|
tf.vertical_anchor = MSO_ANCHOR.MIDDLE
|
|
p = tf.paragraphs[0]; p.alignment = PP_ALIGN.CENTER
|
|
run = p.add_run(); run.text = t
|
|
run.font.name, run.font.size, run.font.color.rgb = FONT, pt(15), RGBColor(0x1D, 0x4E, 0xD8)
|
|
x += w + 8
|
|
|
|
# 4. Приложение
|
|
s = prs.slides.add_slide(BLANK); bg(s, False)
|
|
title(s, "Приложение всегда с техником", "Нативное Android-приложение и мобильная версия в браузере (в том числе для iPhone)")
|
|
shot(s, "a_menu.png", 64, 142, 230, 490)
|
|
shot(s, "a_task.png", 330, 142, 230, 490)
|
|
bullets(s, 600, 142, 616, [
|
|
("Полностью без связи", "Подвал, цех, подстанция: ответы, фото и комментарии копятся и уходят на сервер сами"),
|
|
("Всё для работы в поле", "Крупные кнопки, контроль нормы, фото с камеры, «Взять в работу», «Сдать наряд»"),
|
|
("Боковое меню", "Наряды, задания, отказы, инструмент и заявки — без лишних панелей"),
|
|
("Установка за минуту", "APK скачивается прямо с экрана входа; Google Play не нужен"),
|
|
("iPhone и любой браузер", "Мобильная версия /m ставится на экран «Домой» и тоже работает офлайн")])
|
|
|
|
|
|
s = prs.slides.add_slide(BLANK); bg(s, False)
|
|
title(s, 'Карта завода: состояние машин с одного взгляда', 'План предприятия с иконками оборудования, обновляется сам')
|
|
shot(s, 'd_map.png', 64, 142, 680, 500)
|
|
bullets(s, 780, 142, 436, [('«!» — авария', 'Сотрудник зафиксировал отказ — значок появляется сразу'), ('Жёлтое свечение — пора на ТО', 'Узел машины приближается к пределу по наработке или календарю'), ('Настраивает администратор', 'Загружает план и расставляет иконки перетаскиванием'), ('Карта складов', 'Зоны складов на том же плане; нажатие показывает содержимое склада')], size=20, small=16)
|
|
|
|
s = prs.slides.add_slide(BLANK); bg(s, False)
|
|
title(s, 'Краны и закрепление за отделами', 'Каждая служба видит своё оборудование')
|
|
shot(s, 'd_crane_map.png', 64, 142, 680, 500)
|
|
bullets(s, 780, 142, 436, [('Краны — отдельная служба', 'Свой раздел меню, список, карта кранов и отказы по кранам'), ('Включается одной галочкой', 'В настройках сервера; своя картинка карты или план завода'), ('Оборудование за отделами', 'Крановый электрик не получает списков и аварий по станкам'), ('И в приложении', 'Раздел «Краны» в Android появляется автоматически')], size=20, small=16)
|
|
|
|
s = prs.slides.add_slide(BLANK); bg(s, False)
|
|
title(s, 'Отказы, неисправности и задачи', 'Аварии фиксируются на месте, мелкие поручения — без нарядов')
|
|
bullets(s, 64, 142, 436, [('Отказ в карточке машины', 'Что произошло, затронутые узлы, была ли замена'), ('Сообщение о неисправности', 'Машина работает, но есть проблема: оранжевый «⚠» на карте; при остановке — перевод в отказ'), ('Задачи и поручения', 'Назначен → в работе → приостановлен / выполнен / отменён'), ('Комментарий обязателен', 'При приостановке, выполнении и отмене; можно приложить фото')], size=20, small=16)
|
|
shot(s, 'd_incident.png', 536, 142, 680, 500)
|
|
|
|
s = prs.slides.add_slide(BLANK); bg(s, False)
|
|
title(s, 'Инструмент и склады', 'Учёт инвентаря: что где лежит и что за кем числится')
|
|
shot(s, 'd_inventory.png', 64, 142, 680, 500)
|
|
bullets(s, 780, 142, 436, [('Каталог и склады', 'Поступление, выдача, возврат, перемещение, списание — с журналом'), ('Выдача лично сотруднику', 'Инструмент числится за человеком, а не за должностью'), ('Роль «Администратор склада»', 'Ведёт учёт; сотрудник видит только выданное ему'), ('Заявки на инструмент', 'Что купить, зачем, ссылка на товар; ответ и статус — автору')], size=20, small=16)
|
|
|
|
s = prs.slides.add_slide(BLANK); bg(s, False)
|
|
title(s, 'Карточка сотрудника', 'Кто есть в смене и что умеет')
|
|
bullets(s, 64, 142, 436, [('ФИО, фото, должность', 'И отметка, на смене ли человек сейчас'), ('График', 'Ближайшие смены по графику должности'), ('«Что умею»', 'Навыки и допуски заполняет сам сотрудник'), ('Текущие задачи и инструмент', 'Задания, наряды и выданный инвентарь')], size=20, small=16)
|
|
shot(s, 'd_user_card.png', 536, 142, 680, 500)
|
|
|
|
s = prs.slides.add_slide(BLANK); bg(s, False)
|
|
title(s, 'AI-помощник по порталу', 'Подскажет, где что найти и как сделать, — и только по делу')
|
|
bullets(s, 64, 142, 436, [('Знает портал и права пользователя', 'Отвечает по шагам со ссылками на нужные разделы'), ('Видит только данные пользователя', 'Его задания, наряды, инструмент, состояние машин — и ничего не меняет'), ('Только по работе', 'На посторонние вопросы вежливо отказывает; лимит вопросов в час'), ('Включает администратор', 'YandexGPT, GigaChat, OpenAI-совместимые и локальные модели')], size=20, small=16)
|
|
shot(s, 'd_assistant.png', 536, 142, 680, 500)
|
|
|
|
s = prs.slides.add_slide(BLANK); bg(s, False)
|
|
title(s, 'История машины и обращения извне', 'Всё, что происходило с оборудованием, — в одной ленте')
|
|
shot(s, 'd_history.png', 64, 142, 680, 500)
|
|
bullets(s, 780, 142, 436, [('Хронология по дням', 'Отказы, замены узлов, наработка, наряды и задания'), ('Фильтры', 'По типу события, периоду и тексту'), ('Публичная форма /report', 'Подрядчик сообщает об аварии без входа: место → машина → описание и контакты'), ('API для внешних систем', 'Диспетчерская или SCADA заводят отказы по ключу доступа')], size=20, small=16)
|
|
|
|
s = prs.slides.add_slide(BLANK); bg(s, False)
|
|
title(s, 'Быстрый старт и сохранность данных', 'Мастер первичной настройки и резервные копии')
|
|
shot(s, 'd_setup.png', 64, 142, 680, 500)
|
|
bullets(s, 780, 142, 436, [('Мастер по шагам', 'Графики, должности, отделы, места, оборудование, сотрудники, карта, склады'), ('Пакетный ввод', 'Одна строка — одна запись; можно вставить столбцы из Excel'), ('Логины и пароли — таблицей', 'Для печати и раздачи сотрудникам'), ('Резервные копии', 'База и конфигурация, автокопии по расписанию, восстановление')], size=20, small=16)
|
|
|
|
# Итог
|
|
s = prs.slides.add_slide(BLANK); bg(s, True)
|
|
text(s, 64, 126, 1100, 60, "Готово к запуску", 44, WHITE, True)
|
|
text(s, 64, 196, 1100, 32, "Развёртывание на вашем сервере, данные остаются у вас", 22, PALE)
|
|
cards(s, 270, [
|
|
("Свой сервер", "Docker + PostgreSQL, запуск через Coolify за обратным прокси; резервное копирование стандартными средствами"),
|
|
("Демонстрационный режим", "Личный: включается только у вас, остальные работают с рабочими данными"),
|
|
("Открытый API", "REST /api/v1, ключи доступа для внешних систем, резервные копии и мастер настройки")], 190)
|
|
text(s, 64, 520, 1100, 36, "Руководство пользователя (PDF) и описание API входят в поставку.", 24, WHITE)
|
|
|
|
OUT.parent.mkdir(parents=True, exist_ok=True)
|
|
prs.save(OUT)
|
|
print("OK", OUT)
|