fix(autocall): в презентации готовность приходит по обмену с 1С, а не отметкой менеджера; PDF и HTML собираются из одного файла содержания
This commit is contained in:
parent
d9ded99e81
commit
833d2dcd44
File diff suppressed because one or more lines are too long
Binary file not shown.
|
|
@ -50,13 +50,14 @@ SLIDES = [
|
||||||
"title": "Путь заказа до звонка",
|
"title": "Путь заказа до звонка",
|
||||||
"sub": "Когда именно звучит звонок",
|
"sub": "Когда именно звучит звонок",
|
||||||
"steps": [
|
"steps": [
|
||||||
("1. Заказ готов", "менеджер отмечает\nготовность в системе"),
|
("1. Заказ из 1С", "приходит готовым\nпо обмену"),
|
||||||
("2. SMS со ссылкой", "клиент может выбрать\nдень сам, без звонка"),
|
("2. Ссылка и SMS", "ссылка создаётся\nсама, SMS уходит"),
|
||||||
("3. Пауза 90 минут", "настройка админки:\nстолько ждём ответа"),
|
("3. Пауза 90 минут", "настройка админки:\nстолько ждём ответа"),
|
||||||
("4. Звонок робота", "если клиент не\nподтвердил по ссылке"),
|
("4. Звонок робота", "если клиент не\nподтвердил по ссылке"),
|
||||||
("5. Результат в заказ", "дата и половина дня\nв самом заказе"),
|
("5. Результат в заказ", "дата и половина дня\nв самом заказе"),
|
||||||
],
|
],
|
||||||
"full": {"title": "Важно", "accent": WARN, "items": [
|
"full": {"title": "Важно", "accent": WARN, "items": [
|
||||||
|
"Готовность заказа приходит из 1С по обмену: заказ приходит уже готовым, вручную в системе её никто не отмечает",
|
||||||
"Если клиент подтвердил день по ссылке сам — звонка не будет: заказ уже согласован",
|
"Если клиент подтвердил день по ссылке сам — звонка не будет: заказ уже согласован",
|
||||||
"Звонок идёт только в рабочее время и только в рабочие дни (настройки кампании)",
|
"Звонок идёт только в рабочее время и только в рабочие дни (настройки кампании)",
|
||||||
]},
|
]},
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,11 @@
|
||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""Презентация «Автодозвон SuperSam: как это работает» (тёмная тема, PDF 16:9).
|
"""PDF-версия презентации «Автодозвон: как это работает» (тёмная тема, 16:9).
|
||||||
|
|
||||||
Все факты — из кода: scripts/autocall_scheduler.py, docs/ats/ss-dialog.agi,
|
Содержание — из deck_content.py (то же, что в HTML-версии), здесь только вёрстка.
|
||||||
supabase/functions/autocall-result/index.ts. Ничего «продающего» — только описание.
|
Текст, который не влезает в карточку, считается ошибкой: скрипт падает, а не рисует
|
||||||
|
поверх границ.
|
||||||
|
|
||||||
Запуск: python3 docs/autocall/how-it-works.py
|
Запуск: python3 docs/autocall/how-it-works.py
|
||||||
Проверка: число страниц = числу слайдов, плюс арифметика переносов строк
|
|
||||||
(текст не выходит за карточку — функция wrap_and_check падает с ошибкой, если не влезает).
|
|
||||||
"""
|
"""
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
@ -15,21 +14,26 @@ from reportlab.pdfbase import pdfmetrics
|
||||||
from reportlab.pdfbase.ttfonts import TTFont
|
from reportlab.pdfbase.ttfonts import TTFont
|
||||||
from reportlab.pdfgen import canvas
|
from reportlab.pdfgen import canvas
|
||||||
|
|
||||||
|
import deck_content as content
|
||||||
|
|
||||||
FONT_DIR = Path("/usr/share/fonts/truetype/dejavu")
|
FONT_DIR = Path("/usr/share/fonts/truetype/dejavu")
|
||||||
OUT = Path(__file__).with_name("autocall-how-it-works.pdf")
|
OUT = Path(__file__).with_name("autocall-how-it-works.pdf")
|
||||||
|
|
||||||
W, H = 960, 540
|
W, H = 960, 540
|
||||||
M = 46 # поля
|
M = 46
|
||||||
BG = HexColor("#0B1220")
|
BG = HexColor("#0B1220")
|
||||||
BG2 = HexColor("#101A2C")
|
BG2 = HexColor("#101A2C")
|
||||||
CARD = HexColor("#131E33")
|
CARD = HexColor("#131E33")
|
||||||
BORDER = HexColor("#25344F")
|
BORDER = HexColor("#25344F")
|
||||||
TEXT = HexColor("#E8EEF9")
|
TEXT = HexColor("#E8EEF9")
|
||||||
DIM = HexColor("#9FB0CC")
|
DIM = HexColor("#9FB0CC")
|
||||||
ACCENT = HexColor("#4ADE80")
|
TONES = {
|
||||||
ACCENT2 = HexColor("#60A5FA")
|
"accent": HexColor("#4ADE80"),
|
||||||
WARN = HexColor("#FBBF24")
|
"accent2": HexColor("#60A5FA"),
|
||||||
DANGER = HexColor("#F87171")
|
"warn": HexColor("#FBBF24"),
|
||||||
|
"danger": HexColor("#F87171"),
|
||||||
|
}
|
||||||
|
ACCENT = TONES["accent"]
|
||||||
|
|
||||||
pdfmetrics.registerFont(TTFont("DV", str(FONT_DIR / "DejaVuSans.ttf")))
|
pdfmetrics.registerFont(TTFont("DV", str(FONT_DIR / "DejaVuSans.ttf")))
|
||||||
pdfmetrics.registerFont(TTFont("DV-B", str(FONT_DIR / "DejaVuSans-Bold.ttf")))
|
pdfmetrics.registerFont(TTFont("DV-B", str(FONT_DIR / "DejaVuSans-Bold.ttf")))
|
||||||
|
|
@ -39,6 +43,26 @@ c.setTitle("Автодозвон SuperSam — как это работает")
|
||||||
c.setAuthor("SuperSam")
|
c.setAuthor("SuperSam")
|
||||||
|
|
||||||
slide_no = 0
|
slide_no = 0
|
||||||
|
CONTENT_W = W - 2 * M
|
||||||
|
|
||||||
|
|
||||||
|
def tone(name):
|
||||||
|
return TONES.get(name, ACCENT)
|
||||||
|
|
||||||
|
|
||||||
|
def wrap(text, font, size, max_w):
|
||||||
|
words, lines, cur = text.split(), [], ""
|
||||||
|
for word in words:
|
||||||
|
probe = f"{cur} {word}".strip()
|
||||||
|
if pdfmetrics.stringWidth(probe, font, size) <= max_w:
|
||||||
|
cur = probe
|
||||||
|
else:
|
||||||
|
if cur:
|
||||||
|
lines.append(cur)
|
||||||
|
cur = word
|
||||||
|
if cur:
|
||||||
|
lines.append(cur)
|
||||||
|
return lines
|
||||||
|
|
||||||
|
|
||||||
def bg():
|
def bg():
|
||||||
|
|
@ -84,35 +108,23 @@ def card(x, y, w, h, accent=None, fill=CARD):
|
||||||
c.roundRect(x, y, w, h, 12, stroke=1, fill=1)
|
c.roundRect(x, y, w, h, 12, stroke=1, fill=1)
|
||||||
|
|
||||||
|
|
||||||
def wrap(text, font, size, max_w):
|
def block_size(items, w, size=12, lead=18, title_size=14):
|
||||||
words, lines, cur = text.split(), [], ""
|
"""Сколько места займёт карточка: список строк и высота."""
|
||||||
for word in words:
|
wrapped = [wrap(item, "DV", size, w - 44) for item in items]
|
||||||
probe = f"{cur} {word}".strip()
|
lines = sum(len(w_) for w_ in wrapped)
|
||||||
if pdfmetrics.stringWidth(probe, font, size) <= max_w:
|
height = 50 + lines * lead + len(items) * 4
|
||||||
cur = probe
|
return wrapped, height
|
||||||
else:
|
|
||||||
if cur:
|
|
||||||
lines.append(cur)
|
|
||||||
cur = word
|
|
||||||
if cur:
|
|
||||||
lines.append(cur)
|
|
||||||
return lines
|
|
||||||
|
|
||||||
|
|
||||||
def block(x, y, w, title, items, accent=ACCENT, size=12, lead=18, title_size=14):
|
def draw_block(x, y, w, h, title, items, accent=ACCENT, size=12, lead=18, title_size=14):
|
||||||
"""Карточка с заголовком и пунктами. Падает, если текст не влезает."""
|
wrapped, _ = block_size(items, w, size, lead, title_size)
|
||||||
lines = []
|
card(x, y - h, w, h, accent=accent)
|
||||||
for item in items:
|
|
||||||
wrapped = wrap(item, "DV", size, w - 44)
|
|
||||||
lines.append(("bullet", wrapped))
|
|
||||||
height = 46 + len(lines) * (lead * 1.0 + 4) + sum((len(l[1]) - 1) * lead for l in lines) + 10
|
|
||||||
card(x, y - height, w, height, accent=accent)
|
|
||||||
c.setFont("DV-B", title_size)
|
c.setFont("DV-B", title_size)
|
||||||
c.setFillColor(accent)
|
c.setFillColor(accent)
|
||||||
c.drawString(x + 16, y - 26, title)
|
c.drawString(x + 16, y - 26, title)
|
||||||
cursor = y - 48
|
cursor = y - 48
|
||||||
for _, wrapped in lines:
|
for wrapped_item in wrapped:
|
||||||
for i, line in enumerate(wrapped):
|
for i, line in enumerate(wrapped_item):
|
||||||
if i == 0:
|
if i == 0:
|
||||||
c.setFillColor(accent)
|
c.setFillColor(accent)
|
||||||
c.circle(x + 20, cursor + 4, 2.4, stroke=0, fill=1)
|
c.circle(x + 20, cursor + 4, 2.4, stroke=0, fill=1)
|
||||||
|
|
@ -121,380 +133,151 @@ def block(x, y, w, title, items, accent=ACCENT, size=12, lead=18, title_size=14)
|
||||||
c.drawString(x + 32, cursor, line)
|
c.drawString(x + 32, cursor, line)
|
||||||
cursor -= lead
|
cursor -= lead
|
||||||
cursor -= 4
|
cursor -= 4
|
||||||
bottom = y - height
|
if cursor < (y - h) - 2:
|
||||||
if cursor < bottom - 2:
|
raise AssertionError(f"текст вышел за карточку «{title}»")
|
||||||
raise AssertionError(f"текст вышел за карточку «{title}»: {cursor:.0f} < {bottom:.0f}")
|
return y - h
|
||||||
return height
|
|
||||||
|
|
||||||
|
|
||||||
def table(x, y, w, rows, widths, head=True, size=11, lead=19):
|
def table_measure(rows, widths, size=11, lead=19):
|
||||||
"""Таблица; возвращает высоту. Строки: список списков строк."""
|
"""Высота таблицы — чтобы можно было отцентровать блок по слайду."""
|
||||||
|
h = 0
|
||||||
|
for row in rows:
|
||||||
|
lines = max(len(wrap(str(cell), "DV", size, cw - 16)) for cell, cw in zip(row, widths))
|
||||||
|
h += lead + 8 + (lines - 1) * (lead - 4)
|
||||||
|
return h
|
||||||
|
|
||||||
|
|
||||||
|
def cards_row(cards, y, total_w=CONTENT_W, gap=14):
|
||||||
|
"""Ряд карточек одинаковой высоты; возвращает нижнюю границу ряда."""
|
||||||
|
n = len(cards)
|
||||||
|
w = (total_w - gap * (n - 1)) / n
|
||||||
|
heights = [block_size(card_["items"], w)[1] for card_ in cards]
|
||||||
|
h = max(heights)
|
||||||
|
x = M
|
||||||
|
for card_ in cards:
|
||||||
|
draw_block(x, y, w, h, card_["title"], card_["items"], accent=tone(card_.get("accent")))
|
||||||
|
x += w + gap
|
||||||
|
return y - h
|
||||||
|
|
||||||
|
|
||||||
|
def table(rows, widths, y, size=11, lead=19, head=True):
|
||||||
|
"""Таблица; возвращает нижнюю границу."""
|
||||||
for r_i, row in enumerate(rows):
|
for r_i, row in enumerate(rows):
|
||||||
cx = x
|
lines_per_cell = [wrap(str(cell), "DV", size, cw - 16) for cell, cw in zip(row, widths)]
|
||||||
h = lead + 8
|
h = lead + 8 + (max(len(l) for l in lines_per_cell) - 1) * (lead - 4)
|
||||||
if head and r_i == 0:
|
if head and r_i == 0:
|
||||||
card(x, y - h, w, h, accent=ACCENT2, fill=HexColor("#182640"))
|
card(M, y - h, CONTENT_W, h, accent=TONES["accent2"], fill=HexColor("#182640"))
|
||||||
for cell, cw in zip(row, widths):
|
cx = M
|
||||||
cell_lines = wrap(str(cell), "DV-B" if (head and r_i == 0) else "DV", size, cw - 16)
|
for (cell, cw), cell_lines in zip(zip(row, widths), lines_per_cell):
|
||||||
|
if head and r_i == 0:
|
||||||
|
cx += cw
|
||||||
|
continue
|
||||||
for i, line in enumerate(cell_lines):
|
for i, line in enumerate(cell_lines):
|
||||||
c.setFont("DV-B" if (head and r_i == 0) else "DV", size)
|
c.setFont("DV", size)
|
||||||
c.setFillColor(TEXT if (head and r_i == 0) else DIM if i else TEXT)
|
c.setFillColor(DIM if (i == 0 and cx == M) else TEXT)
|
||||||
c.drawString(cx + 8, y - 22 - i * (lead - 4), line)
|
c.drawString(cx + 8, y - 21 - i * (lead - 4), line)
|
||||||
cx += cw
|
cx += cw
|
||||||
if len(max([wrap(str(cell), "DV", size, cw - 16) for cell, cw in zip(row, widths)], key=len)) > 1:
|
if head and r_i == 0:
|
||||||
h += lead
|
cx = M
|
||||||
|
for cell, cw in zip(row, widths):
|
||||||
|
c.setFont("DV-B", size)
|
||||||
|
c.setFillColor(TEXT)
|
||||||
|
c.drawString(cx + 8, y - 21, str(cell))
|
||||||
|
cx += cw
|
||||||
y -= h
|
y -= h
|
||||||
return y
|
return y
|
||||||
|
|
||||||
|
|
||||||
# ─────────────────────────── 1. Титул ───────────────────────────
|
def steps_row(steps, y):
|
||||||
bg()
|
n = len(steps)
|
||||||
c.setFont("DV-B", 11)
|
gap = 14
|
||||||
c.setFillColor(ACCENT)
|
w = (CONTENT_W - gap * (n - 1)) / n
|
||||||
c.drawString(M, H - 150, "СУПЕР САМ · ДОСТАВКА И САМОВЫВОЗ")
|
h = 170
|
||||||
c.setFont("DV-B", 40)
|
x = M
|
||||||
c.setFillColor(TEXT)
|
for i, (title, desc) in enumerate(steps):
|
||||||
c.drawString(M, H - 210, "Автодозвон: как это работает")
|
accent = ACCENT if i < n - 1 else TONES["accent2"]
|
||||||
c.setFont("DV", 15)
|
card(x, y - h, w, h, accent=accent)
|
||||||
c.setFillColor(DIM)
|
c.setFont("DV-B", 12)
|
||||||
c.drawString(M, H - 244, "Робот звонит клиенту, согласует день и половину дня — без участия оператора")
|
c.setFillColor(accent)
|
||||||
card(M, 120, W - 2 * M, 150, accent=ACCENT)
|
c.drawString(x + 14, y - 34, title)
|
||||||
c.setFont("DV", 13)
|
c.setFont("DV", 11)
|
||||||
lines = [
|
c.setFillColor(TEXT)
|
||||||
"Что описано: правила звонка, паузы, поведение в нештатных случаях (не дозвонились,",
|
line_y = y - 58
|
||||||
"сбросили трубку, не нажали цифру), где что настраивается и что видит менеджер.",
|
for raw_line in desc.split("\n"):
|
||||||
"",
|
for line in wrap(raw_line, "DV", 11, w - 28):
|
||||||
"Основано на текущем коде и настройках боевой системы, без обещаний и продажи.",
|
c.drawString(x + 14, line_y, line)
|
||||||
]
|
line_y -= 17
|
||||||
for i, line in enumerate(lines):
|
if i < n - 1:
|
||||||
c.setFillColor(TEXT if line else DIM)
|
c.setFillColor(DIM)
|
||||||
c.drawString(M + 18, 240 - i * 20, line)
|
c.setFont("DV-B", 16)
|
||||||
footer()
|
c.drawString(x + w + 1, y - 72, "›")
|
||||||
c.showPage()
|
x += w + gap
|
||||||
|
return y - h
|
||||||
|
|
||||||
# ─────────────────────────── 2. Что делает робот ───────────────────────────
|
|
||||||
bg()
|
|
||||||
header("Что делает робот", "Коротко: он не продаёт и не консультирует — только согласует день и половину дня")
|
|
||||||
block(M, 430, 430, "Три задачи",
|
|
||||||
["Дозвониться, когда заказ готов и клиент не подтвердил день по ссылке из SMS",
|
|
||||||
"Предложить конкретные дни голосом и получить выбор цифрой",
|
|
||||||
"Записать выбор в заказ — дату, половину дня и признак «согласовано роботом»"],
|
|
||||||
accent=ACCENT)
|
|
||||||
block(M + 440, 430, 430, "Чего он не делает",
|
|
||||||
["Не соединяет с живым менеджером — такой цифры в диалоге нет",
|
|
||||||
"Не спорит и не уговаривает: нет ответа — повторит по расписанию",
|
|
||||||
"Не предлагает нерабочие дни и не звонит вне рабочего времени"],
|
|
||||||
accent=WARN)
|
|
||||||
block(M, 236, W - 2 * M, "Как это выглядит для клиента",
|
|
||||||
["Звонок с одного номера, голос один и тот же: «Ваш заказ готов. Выберите день и время»",
|
|
||||||
"Клиент нажимает цифру дня и цифру половины дня — робот подтверждает выбор вслух",
|
|
||||||
"После подтверждения даёт возможность изменить выбор (нажатием цифры 2)"],
|
|
||||||
accent=ACCENT2)
|
|
||||||
footer()
|
|
||||||
c.showPage()
|
|
||||||
|
|
||||||
# ─────────────────────────── 3. Путь заказа ───────────────────────────
|
def render(slide):
|
||||||
bg()
|
bg()
|
||||||
header("Путь заказа до звонка", "Когда именно звучит звонок")
|
if slide["kind"] == "title":
|
||||||
steps = [
|
c.setFont("DV-B", 11)
|
||||||
("1. Заказ готов", "менеджер отмечает\nготовность в системе"),
|
c.setFillColor(ACCENT)
|
||||||
("2. SMS со ссылкой", "клиент может выбрать\nдень сам, без звонка"),
|
c.drawString(M, H - 150, slide["kicker"])
|
||||||
("3. Пауза 90 минут", "настройка админки:\nстолько ждём ответа"),
|
c.setFont("DV-B", 40)
|
||||||
("4. Звонок робота", "если клиент не\nподтвердил по ссылке"),
|
c.setFillColor(TEXT)
|
||||||
("5. Результат в заказ", "дата и половина дня\nв самом заказе"),
|
c.drawString(M, H - 210, slide["title"])
|
||||||
]
|
c.setFont("DV", 15)
|
||||||
bw, gap = 162, 14
|
|
||||||
bx = M
|
|
||||||
for i, (title, desc) in enumerate(steps):
|
|
||||||
accent = ACCENT if i < 4 else ACCENT2
|
|
||||||
card(bx, 300, bw, 130, accent=accent)
|
|
||||||
c.setFont("DV-B", 12)
|
|
||||||
c.setFillColor(accent)
|
|
||||||
c.drawString(bx + 14, 396, title)
|
|
||||||
c.setFont("DV", 11)
|
|
||||||
c.setFillColor(TEXT)
|
|
||||||
line_y = 372
|
|
||||||
for raw_line in desc.split("\n"):
|
|
||||||
for line in wrap(raw_line, "DV", 11, bw - 28):
|
|
||||||
c.drawString(bx + 14, line_y, line)
|
|
||||||
line_y -= 17
|
|
||||||
if i < len(steps) - 1:
|
|
||||||
c.setFillColor(DIM)
|
c.setFillColor(DIM)
|
||||||
c.setFont("DV-B", 16)
|
c.drawString(M, H - 244, slide["sub"])
|
||||||
c.drawString(bx + bw + 1, 358, "›")
|
card(M, 120, CONTENT_W, 150, accent=ACCENT)
|
||||||
bx += bw + gap
|
for i, line in enumerate(slide["lines"]):
|
||||||
block(M, 262, W - 2 * M, "Важно",
|
c.setFont("DV", 13)
|
||||||
["Если клиент подтвердил день по ссылке сам — звонка не будет: заказ уже согласован",
|
c.setFillColor(TEXT if line else DIM)
|
||||||
"Звонок идёт только в рабочее время и только в рабочие дни (настройки кампании)"],
|
c.drawString(M + 18, 240 - i * 20, line)
|
||||||
accent=WARN)
|
footer()
|
||||||
footer()
|
c.showPage()
|
||||||
c.showPage()
|
return
|
||||||
|
|
||||||
# ─────────────────────────── 4. Откуда дни и время ───────────────────────────
|
header(slide["title"], slide.get("sub"))
|
||||||
bg()
|
kind = slide["kind"]
|
||||||
header("Откуда берутся дни и время", "Админка → Расписание → Рабочие дни: один источник правды")
|
full = slide.get("full")
|
||||||
block(M, 430, 430, "Что настраивается",
|
|
||||||
["Рабочие дни склада (самовывоз) и рабочие дни доставки",
|
|
||||||
"Часы: открытие и закрытие склада, часы доставки",
|
|
||||||
"«Первая половина — до» — граница между половинами дня",
|
|
||||||
"«Сегодня предлагаем до» — до какого времени предлагать сегодняшний день"],
|
|
||||||
accent=ACCENT, size=11)
|
|
||||||
block(M + 440, 430, 430, "Как это работает в коде",
|
|
||||||
["Окно звонка считается рабочими днями: выходные не сокращают окно, а пропускаются",
|
|
||||||
"Робот называет реальные дни голосом: «сегодня», «в понедельник», «во вторник»",
|
|
||||||
"Клиентская ссылка использует те же дни — расхождений между звонком и ссылкой нет"],
|
|
||||||
accent=ACCENT2, size=11)
|
|
||||||
block(M, 250, W - 2 * M, "Текущие значения в системе",
|
|
||||||
["Склад: пн–пт, 09:00–17:30, половина дня до 13:00, «сегодня» предлагаем до 15:30",
|
|
||||||
"Доставка: пн–пт (суббота — выходной) 09:00–21:00, половина дня до 15:00",
|
|
||||||
"Утро и вечер: до 13:00 робот спросит, какая половина дня удобнее; после 13:00 — сразу зафиксирует вторую"],
|
|
||||||
accent=WARN)
|
|
||||||
footer()
|
|
||||||
c.showPage()
|
|
||||||
|
|
||||||
# ─────────────────────────── 5. Самовывоз ───────────────────────────
|
# Измеряем содержимое и центруем блок по вертикали: иначе внизу слайда
|
||||||
bg()
|
# остаётся пустая полоса, а на другом слайде текст поджимает футер.
|
||||||
header("Самовывоз: какие дни предлагает", "Окно — «день готовности + два рабочих дня», выходные пропускаются")
|
if kind == "steps":
|
||||||
rows = [
|
main_h = 170
|
||||||
["Заказ готов", "Что предложит робот", "Почему так"],
|
elif kind in ("cards2full", "cards3full"):
|
||||||
["в понедельник", "пн, вт, ср", "три рабочих дня подряд"],
|
n = len(slide["cards"])
|
||||||
["в пятницу", "пт, пн, вт", "суббота и воскресенье — выходные склада"],
|
w = (CONTENT_W - 14 * (n - 1)) / n
|
||||||
["в субботу", "пн, вт, ср", "сам выходной не предлагаем"],
|
main_h = max(block_size(card_["items"], w)[1] for card_ in slide["cards"])
|
||||||
["в пятницу после 15:30", "пн, вт", "«сегодня» больше не предлагаем (настройка)"],
|
elif kind == "tablecard":
|
||||||
["в понедельник после 13:00", "пн, вт, ср", "половину дня не спрашиваем — сразу вторая"],
|
main_h = table_measure(slide["table"]["rows"], slide["table"]["widths"])
|
||||||
["неделю назад", "звонка нет", "окно прошло — звонить не о чем"],
|
else:
|
||||||
]
|
raise AssertionError(f"неизвестный тип слайда: {kind}")
|
||||||
table(M, 420, W - 2 * M, rows, [200, 240, W - 2 * M - 440])
|
|
||||||
block(M, 190, W - 2 * M, "Две настройки, которые это двигают",
|
|
||||||
["«Сегодня предлагаем до» (сейчас 15:30) — позже этого времени сегодняшний день исчезает из звонка и из ссылки",
|
|
||||||
"«Первая половина — до» (сейчас 13:00) — после неё робот не спрашивает половину дня, сразу пишет вторую"],
|
|
||||||
accent=ACCENT, size=11)
|
|
||||||
footer()
|
|
||||||
c.showPage()
|
|
||||||
|
|
||||||
# ─────────────────────────── 6. Доставка ───────────────────────────
|
full_h = block_size(full["items"], CONTENT_W)[1] if full else 0
|
||||||
bg()
|
gap = 22 if full else 0
|
||||||
header("Доставка: не в день готовности", "Нужен запас времени, чтобы собрать маршрут — поэтому начинаем со следующего дня")
|
top, bottom_limit = H - 96, 56
|
||||||
rows = [
|
total = main_h + (gap + full_h if full else 0)
|
||||||
["Заказ готов", "Что предложит робот"],
|
y0 = top - max(0.0, (top - bottom_limit - total) / 2)
|
||||||
["в понедельник", "вторник и среда"],
|
|
||||||
["в пятницу", "понедельник и вторник (в субботу не возим)"],
|
|
||||||
["в субботу", "понедельник и вторник"],
|
|
||||||
["в четверг, звонок в пятницу", "пятница и понедельник"],
|
|
||||||
["в пятницу, звонок в понедельник", "понедельник и вторник"],
|
|
||||||
]
|
|
||||||
table(M, 420, W - 2 * M, rows, [300, W - 2 * M - 300])
|
|
||||||
block(M, 200, W - 2 * M, "Ключевое отличие от самовывоза",
|
|
||||||
["В день готовности доставку не предлагаем никогда — только следующие рабочие дни",
|
|
||||||
"Суббота и воскресенье в графике доставки — выходные, их робот не назовёт",
|
|
||||||
"Время доставки — половина дня: до 15:00 или после (границу задаёт админка)"],
|
|
||||||
accent=ACCENT2, size=11)
|
|
||||||
footer()
|
|
||||||
c.showPage()
|
|
||||||
|
|
||||||
# ─────────────────────────── 7. Как проходит разговор ───────────────────────────
|
if kind == "steps":
|
||||||
bg()
|
bottom = steps_row(slide["steps"], y0)
|
||||||
header("Как проходит разговор", "Робот задаёт вопрос и ждёт цифру; молчание не считается согласием")
|
elif kind in ("cards2full", "cards3full"):
|
||||||
rows = [
|
bottom = cards_row(slide["cards"], y0)
|
||||||
["Шаг", "Что говорит робот", "Что делает клиент", "Сколько ждёт"],
|
else:
|
||||||
["Приветствие", "«Ваш заказ готов. Выберите день и время доставки»", "слушает", "—"],
|
bottom = table(slide["table"]["rows"], slide["table"]["widths"], y0)
|
||||||
["День", "«Нажмите один, чтобы доставить. В понедельник. Нажмите два…»", "нажимает 1 или 2", "9 секунд"],
|
|
||||||
["Половина дня", "«Первая половина дня или вторая?»", "нажимает 1 или 2", "9 секунд"],
|
|
||||||
["Подтверждение", "«Первая половина дня» — озвучивает выбор", "слушает", "—"],
|
|
||||||
["Изменить?", "подсказка про кнопку 2 — один раз", "нажимает 2, чтобы переделать", "6 секунд"],
|
|
||||||
]
|
|
||||||
table(M, 420, W - 2 * M, rows, [130, 360, 200, 110], size=10)
|
|
||||||
block(M, 170, W - 2 * M, "Что делает при ошибке ввода",
|
|
||||||
["Не понял цифру — повторяет тот же вопрос один раз",
|
|
||||||
"Снова не понял или молчит — завершает: «Вы не выбрали время. С вами свяжется менеджер»",
|
|
||||||
"Клиент нажимает 2 — диалог начинается заново («день и время»), прежний слот при этом не теряется"],
|
|
||||||
accent=WARN, size=11)
|
|
||||||
footer()
|
|
||||||
c.showPage()
|
|
||||||
|
|
||||||
# ─────────────────────────── 8. Цифры в диалоге ───────────────────────────
|
if full:
|
||||||
bg()
|
y = bottom - gap
|
||||||
header("Что можно нажать", "Всего несколько цифр — и одна из них «больше не звоните»")
|
if y - full_h < 44:
|
||||||
rows = [
|
raise AssertionError(f"нижняя карточка не влезает на слайде «{slide['title']}»")
|
||||||
["Цифра", "Значение", "Что происходит"],
|
draw_block(M, y, CONTENT_W, full_h, full["title"], full["items"], accent=tone(full.get("accent")))
|
||||||
["1", "первый предложенный день", "робот переходит к выбору половины дня"],
|
footer()
|
||||||
["2", "второй предложенный день", "робот переходит к выбору половины дня"],
|
c.showPage()
|
||||||
["3", "третий день (только самовывоз)", "робот переходит к выбору половины дня"],
|
|
||||||
["1 / 2", "половина дня", "первая половина / вторая половина"],
|
|
||||||
["9", "отказ от звонков", "робот больше не позвонит; в карточке — «отказ от звонков»"],
|
|
||||||
["0 и прочие", "не принимаются", "вопрос повторяется; соединения с оператором нет"],
|
|
||||||
]
|
|
||||||
table(M, 430, W - 2 * M, rows, [110, 240, W - 2 * M - 350], size=10)
|
|
||||||
block(M, 150, W - 2 * M, "Отдельно про «0»",
|
|
||||||
["Раньше в диалоге была попытка «позвать менеджера», но она никогда не срабатывала (нажатие не отличалось от тишины)",
|
|
||||||
"Эту ветку убрали: живого соединения с оператором у робота нет, менеджер подключается только задачей из системы"],
|
|
||||||
accent=DANGER, size=11)
|
|
||||||
footer()
|
|
||||||
c.showPage()
|
|
||||||
|
|
||||||
# ─────────────────────────── 9. Не дозвонились ───────────────────────────
|
|
||||||
bg()
|
|
||||||
header("Если не дозвонились", "Робот различает «не взял трубку» и «вместо человека ответил автоответчик/оператор»")
|
|
||||||
block(M, 430, 440, "Как понимает, что трубку не взяли",
|
|
||||||
["Никто не ответил за 45 секунд — попытка считается неудачной",
|
|
||||||
"Трубку подняли, но гудки шли больше 20 секунд и ни одной цифры не нажали — это объявление оператора, а не человек: пишем «не взял трубку»",
|
|
||||||
"Живой человек почти всегда берёт трубку раньше, поэтому правило надёжно отделяет автоответчик"],
|
|
||||||
accent=WARN, size=11)
|
|
||||||
block(M + 450, 430, 420, "Что происходит дальше",
|
|
||||||
["Повторный звонок — не раньше чем через 120 минут (настройка)",
|
|
||||||
"Всего не больше 5 попыток на заказ (настройка)",
|
|
||||||
"Звонит только в рабочие часы кампании: 09:00–20:00, дни — по настройке",
|
|
||||||
"Исчерпали попытки — заказ уходит менеджеру, автозвонок по нему останавливается"],
|
|
||||||
accent=ACCENT, size=11)
|
|
||||||
block(M, 214, W - 2 * M, "Для менеджера это видно в карточке",
|
|
||||||
["Каждая попытка — отдельная строка истории: время, результат, длительность разговора",
|
|
||||||
"Статус заказа меняется на «нужен менеджер» — с комментарием «не дозвонились / не нажал»",
|
|
||||||
"Отдельно красным предупреждаем, если заявка «Позвонить сейчас» висит больше 15 минут"],
|
|
||||||
accent=ACCENT2, size=11)
|
|
||||||
footer()
|
|
||||||
c.showPage()
|
|
||||||
|
|
||||||
# ─────────────────────────── 10. Сбросили трубку ───────────────────────────
|
for slide_ in content.ALL_SLIDES:
|
||||||
bg()
|
render(slide_)
|
||||||
header("Если сбросили трубку в середине разговора", "Робот запоминает то, что успел услышать, и не портит уже согласованный слот")
|
|
||||||
rows = [
|
|
||||||
["Когда положили трубку", "Что запишет робот", "Что увидит менеджер"],
|
|
||||||
["до выбора дня", "ответил, ничего не нажал", "«взял, не нажал (день)» — попытка зачтена"],
|
|
||||||
["день выбрал, время не успел", "день выбран, время нет", "«взял, не нажал (время)»"],
|
|
||||||
["выбор подтвердил и сбросил", "слот согласован", "дата и половина дня в заказе, статус «согласовано»"],
|
|
||||||
["начал менять выбор и сбросил", "остаётся прежний слот", "«правка не доведена» — прежнее согласование в силе"],
|
|
||||||
]
|
|
||||||
table(M, 420, W - 2 * M, rows, [250, 250, W - 2 * M - 500], size=10)
|
|
||||||
block(M, 190, W - 2 * M, "Почему так",
|
|
||||||
["Слот становится согласованным только после двух ответов: день и половина дня — это защита от «услышал половину фразы»",
|
|
||||||
"Обрыв не считается согласием: если клиент не подтвердил, робот перезвонит по расписанию или отдаст заказ менеджеру",
|
|
||||||
"Если клиент уже согласовал слот, а потом начал менять и бросил трубку — прежний выбор сохраняется, ничего не теряется"],
|
|
||||||
accent=ACCENT2, size=11)
|
|
||||||
footer()
|
|
||||||
c.showPage()
|
|
||||||
|
|
||||||
# ─────────────────────────── 11. Когда зовёт менеджера ───────────────────────────
|
|
||||||
bg()
|
|
||||||
header("Когда подключается менеджер", "Робот доводит дело до конца сам, но в трёх случаях передаёт человеку")
|
|
||||||
block(M, 420, 280, "Не нажал цифру 2 раза",
|
|
||||||
["Робот писал «взял, но не нажал» дважды",
|
|
||||||
"Заказ помечается «нужен менеджер», комментарий «не дозвонились / не нажал»",
|
|
||||||
"Порог «не нажал × N» настраивается в админке автодозвона"], accent=WARN, size=11)
|
|
||||||
block(M + 290, 420, 280, "Исчерпаны 5 попыток",
|
|
||||||
["Каждый звонок — отдельная попытка",
|
|
||||||
"Когда попытки закончились, автозвонок прекращается",
|
|
||||||
"Заказ остаётся в списке у менеджера"], accent=WARN, size=11)
|
|
||||||
block(M + 580, 420, 290, "Отказ или неверный номер",
|
|
||||||
["Цифра 9 — клиент просит больше не звонить: ставим запрет автоматически",
|
|
||||||
"Неверный номер — задача менеджеру",
|
|
||||||
"Отказ клиента действует и на другие заказы: он в списке «не звонить»"], accent=DANGER, size=11)
|
|
||||||
block(M, 236, W - 2 * M, "Что видит менеджер в карточке заказа",
|
|
||||||
["Единый статус автодозвона и история всех попыток с результатами и слотами",
|
|
||||||
"Кнопка «Позвонить сейчас» — ручной звонок клиенту (не тестовый номер) и живое состояние: «в очереди», «звоним», «звонок сделан» с результатом",
|
|
||||||
"Кнопка «Не звонить» — если робот не должен беспокоить этого клиента"],
|
|
||||||
accent=ACCENT, size=11)
|
|
||||||
footer()
|
|
||||||
c.showPage()
|
|
||||||
|
|
||||||
# ─────────────────────────── 12. Когда не звонит ───────────────────────────
|
|
||||||
bg()
|
|
||||||
header("Когда робот не звонит вообще", "Список условий, при которых звонка не будет — проверяются до набора номера")
|
|
||||||
block(M, 430, 440, "По клиенту и заказу",
|
|
||||||
["Клиент сам подтвердил день по ссылке — звонок не нужен",
|
|
||||||
"Клиент просил больше не звонить (нажал 9) или менеджер поставил «Не звонить»",
|
|
||||||
"Номер в списке исключений — например, на МТС-диапазон звонки не идут",
|
|
||||||
"Самовывоз: автообзвон по самовывозу включается отдельной галочкой (сейчас выключен)"],
|
|
||||||
accent=ACCENT, size=11)
|
|
||||||
block(M + 450, 430, 420, "По времени и географии",
|
|
||||||
["Вне рабочих часов кампании (09:00–20:00) и вне рабочих дней",
|
|
||||||
"Город исключён целиком или для города выключен канал",
|
|
||||||
"В админке автодозвона выключены звонки по городу — например, Ялта: SMS идут, звонков нет",
|
|
||||||
"Меньше 90 минут прошло после SMS со ссылкой"],
|
|
||||||
accent=WARN, size=11)
|
|
||||||
block(M, 200, W - 2 * M, "Админка автодозвона: где это переключается",
|
|
||||||
["Панель «Автодозвон»: тестовый режим, часы и дни звонков, паузы, лимит попыток, порог «не нажал × N»",
|
|
||||||
"Панель «Локации»: для каждого города — звонки включены или выключены (SMS не затрагивается)",
|
|
||||||
"Панель исключений: номера и префиксы, на которые не звоним, с причиной"],
|
|
||||||
accent=ACCENT2, size=11)
|
|
||||||
footer()
|
|
||||||
c.showPage()
|
|
||||||
|
|
||||||
# ─────────────────────────── 13. Что попадает в заказ ───────────────────────────
|
|
||||||
bg()
|
|
||||||
header("Что записывается в заказ", "Результат звонка виден там же, где всё остальное")
|
|
||||||
rows = [
|
|
||||||
["Поле", "Значение"],
|
|
||||||
["Дата", "конкретная дата, которую назвал робот (например, понедельник 21.09)"],
|
|
||||||
["Время", "«первая половина дня» или «вторая половина дня»"],
|
|
||||||
["Источник", "«согласовано роботом» — видно, что выбор сделал клиент в звонке"],
|
|
||||||
["Статус", "«согласовано» / «нужен менеджер» / «отказ от звонков»"],
|
|
||||||
["Комментарий", "«Согласовано роботом: завтра, первая половина дня»"],
|
|
||||||
]
|
|
||||||
table(M, 420, W - 2 * M, rows, [200, W - 2 * M - 200])
|
|
||||||
block(M, 190, W - 2 * M, "Зачем это нужно",
|
|
||||||
["Менеджер видит причину и источник решения, а не просто «дата стоит»",
|
|
||||||
"История звонков хранится отдельно: видно, сколько было попыток и чем они закончились",
|
|
||||||
"Дата, названная роботом, используется дальше в работе склада и доставки"],
|
|
||||||
accent=ACCENT, size=11)
|
|
||||||
footer()
|
|
||||||
c.showPage()
|
|
||||||
|
|
||||||
# ─────────────────────────── 14. Тестовый режим и запуск ───────────────────────────
|
|
||||||
bg()
|
|
||||||
header("Тестовый режим и текущее состояние", "Сейчас обзвон клиентов выключен — система в тестовом режиме")
|
|
||||||
rows = [
|
|
||||||
["Параметр", "Значение сейчас", "Что будет при запуске"],
|
|
||||||
["Обзвон клиентов", "выключен", "включается отдельной командой"],
|
|
||||||
["Тестовый номер", "звонит только на него", "массовый обзвон идёт клиентам"],
|
|
||||||
["Кнопка «Позвонить сейчас»", "звонит клиенту сразу", "так же — звонок клиенту"],
|
|
||||||
["Звонки по самовывозу", "выключены галочкой", "включаются отдельно от доставки"],
|
|
||||||
]
|
|
||||||
table(M, 420, W - 2 * M, rows, [230, 280, W - 2 * M - 510], size=10)
|
|
||||||
block(M, 190, W - 2 * M, "Как проверяется перед запуском",
|
|
||||||
["Тестовый звонок на свой номер: диалог, дни, половина дня, запись результата",
|
|
||||||
"Прогон диалога без звонка: проверка всех веток (согласие, обрыв, молчание, отказ)",
|
|
||||||
"Проверка окон: робот предлагает именно те дни, которые заданы в админке"],
|
|
||||||
accent=ACCENT2, size=11)
|
|
||||||
footer()
|
|
||||||
c.showPage()
|
|
||||||
|
|
||||||
# ─────────────────────────── 15. Что пока не учитывается ───────────────────────────
|
|
||||||
bg()
|
|
||||||
header("Что пока не учитывается", "Честный список: это важно знать до запуска")
|
|
||||||
block(M, 430, 440, "График",
|
|
||||||
["Праздники: календаря нерабочих дат нет — если праздник попадёт в окно, робот его предложит",
|
|
||||||
"Часы доставки используются частично: границу половины дня робот проговаривает словами, без конкретного времени"],
|
|
||||||
accent=WARN, size=11)
|
|
||||||
block(M + 450, 430, 420, "Настройки, которые пока не влияют",
|
|
||||||
["«Попыток в день» — поле есть, но расписание повторов задаётся интервалом между попытками",
|
|
||||||
"«Попытки следующего дня (10:00, 14:00)» — пока не используются",
|
|
||||||
"«Таймаут звонка, 30 секунд» — звонок ждёт ответа 45 секунд (значение в самом звонке)"],
|
|
||||||
accent=DANGER, size=11)
|
|
||||||
block(M, 190, W - 2 * M, "Что предлагаем уточнить до боевого запуска",
|
|
||||||
["Список праздников (или решение не учитывать их)",
|
|
||||||
"Нужен ли жёсткий график повторов по времени, кроме интервала 120 минут",
|
|
||||||
"Оставить ли «0» без реакции — сейчас это просто неверная цифра"],
|
|
||||||
accent=ACCENT2, size=11)
|
|
||||||
footer()
|
|
||||||
c.showPage()
|
|
||||||
|
|
||||||
# ─────────────────────────── 16. Итог ───────────────────────────
|
|
||||||
bg()
|
|
||||||
header("Коротко", "Одна страница вместо всей презентации")
|
|
||||||
block(M, 420, W - 2 * M, "Что делает система",
|
|
||||||
["Ждёт 90 минут после SMS и звонит, если клиент не подтвердил день сам",
|
|
||||||
"Предлагает только рабочие дни по графику: самовывоз — включая сегодня, доставка — со следующего дня",
|
|
||||||
"Спрашивает день и половину дня, подтверждает вслух, даёт один раз переделать выбор",
|
|
||||||
"Повторяет попытки не чаще чем раз в 2 часа, максимум 5 раз, только 09:00–20:00 в рабочие дни",
|
|
||||||
"После двух «взял, но не нажал» или исчерпания попыток передаёт заказ менеджеру",
|
|
||||||
"Отказ (цифра 9) останавливает обзвон клиента навсегда",
|
|
||||||
"Каждая попытка и результат видны в карточке заказа; ручной звонок идёт клиенту и показывает своё состояние"],
|
|
||||||
accent=ACCENT, size=11)
|
|
||||||
block(M, 140, W - 2 * M, "Что осталось решить",
|
|
||||||
["Праздники и точный график повторов — по вашим правилам",
|
|
||||||
"Момент включения боевого обзвона клиентов и по самовывозу"],
|
|
||||||
accent=WARN, size=11)
|
|
||||||
footer()
|
|
||||||
|
|
||||||
c.save()
|
c.save()
|
||||||
print(f"готово: {OUT}")
|
print(f"готово: {OUT} — слайдов {slide_no}")
|
||||||
|
print(f"вёрстка проверена: все карточки по размеру текста (проверено {slide_no} слайдов)")
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue