Как сервисному бизнесу автоматизировать проверку качества обслуживания клиентов

Страницы:  1

Ответить
 

Professor Seleznov


Пока впечатление о полученной услуге свежее, клиент лучше помнит детали и охотнее делится обратной связью. Бизнесу это помогает быстрее находить слабые места в сервисе и исправлять их.
Когда клиентов мало, администратор может быстро их обзвонить: спросить, всё ли понравилось, и зафиксировать ответы. При масштабировании бизнеса этот вариант уже не подходит: звонки отнимают много времени. В итоге часть визитов остаётся без проверки, а бизнес узнаёт о проблеме, когда недовольный клиент опубликовал негативный отзыв в интернете, ухудшив рейтинг компании, и перестал возвращаться.
В этой статье разберём, как автоматизировать исходящие звонки. Клиенту, получившему услугу,  звонит голосовой робот и проводит короткое анкетирование. Результаты опроса сразу попадают в рабочую таблицу, а если клиент остался недоволен, управляющий дополнительно получает СМС и может быстрее разобраться в ситуации.
Стек решения: Python 3.10+, Flask, requests, python-dotenv, SQLite, YCLIENTS API, голосовой робот и SMS API МТС Exolve, MWS Tables.
Общая схема работы
Сценарий начинается, когда YCLIENTS считает визит завершённым. Сервис получает вебхук, забирает полную карточку посещения и проверяет, что по этому визиту ещё не было звонка.
После этого он создаёт локальную запись состояния и запускает голосового робота, который звонит клиенту и задаёт три вопроса: как он оценивает услугу, цену и готов ли вернуться. Такой набор вопросов сохраняет звонок коротким и даёт бизнесу оценку качества работы, восприятие стоимости и риск потери клиента.
После каждого ответа сервис получает колбэк, сохраняет шаг опроса и обновляет статус визита в локальной базе. Если клиент не ответил или звонок завершился с ошибкой, это тоже фиксируется отдельным статусом.
Если оценка услуги низкая, владельцу отправляется СМС, а результат записывается в таблицу. После этого по каждому визиту видно, дошёл ли клиент до конца опроса и какая была обратная связь.
Архитектура решения
Всё работает на одном Flask-сервисе, трёх внешних системах и локальной базе.
YCLIENTS хранит основные данные визита и присылает событие о его завершении. SQLite хранит текущее состояние опроса: статус, идентификатор звонка, ответы по шагам и отметку об отправке алерта. MWS Tables получают уже итог по визиту, когда опрос завершён.
App.py управляет статусами визита: принимает вебхуки, связывает их с нужной записью и запускает внешние вызовы. Основной ключ сценария — visit_id. Идентификатор звонка нужен, чтобы потом привязать колбэк голосового сценария к уже созданной записи.
Внешние вызовы вынесены в отдельные файлы: yclients_api.py читает визит из YCLIENTS, exolve_voice.py запускает звонок, sms_alerts.py отправляет СМС, tables_api.py пишет итог в таблицу.
Пререквизит
Нужен Python 3.10+: в коде используется синтаксис str | None. Токены, идентификатор таблицы и порог низкой оценки лежат в .env.
python -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
Пример переменных окружения:
WEBHOOK_SECRET=change-me
YCLIENTS_BASE_URL=https://api.yclients.com/api/v1
YCLIENTS_PARTNER_TOKEN=***
YCLIENTS_USER_TOKEN=***
MWS_TABLES_BASE_URL=https://tables.mws.ru
MWS_TABLES_API_KEY=***
MWS_TABLE_ID=***
MWS_VIEW_ID=***
EXOLVE_API_KEY=***
EXOLVE_CAMPAIGN_ID=***
EXOLVE_CAMPAIGN_URL=https://api.exolve.ru/campaign/v1/Call
EXOLVE_SENDER=SalonBot
OWNER_ALERT_PHONE=79990001122
LOW_SCORE_THRESHOLD=2
DB_NAME=salon_quality.db
Для финальной записи результатов в MWS Tables заранее создайте таблицу quality_results_table со следующими колонками:
  • visit_id — строка
  • client_name — строка
  • master_name — строка
  • branch_name — строка
  • visit_at — дата/время
  • service_score — число
  • price_score — число
  • return_intent — число
  • survey_status — строка
  • alert_sent — логическое поле
В текущем коде секрет вебхука передаётся как параметр запроса token, поэтому сам URL вебхука тоже нельзя светить. Ключи API приложение читает через python-dotenv.
Шаг 1. Поднимаем конфиг и локальное состояние
Опрос не заканчивается одним событием, поэтому сервису нужна отдельная запись по каждому визиту. Состояние опроса сервис держит в Config и таблице surveys. В ней лежат контекст визита и технические поля опроса: status, call_id, три оценки и alert_sent. Первичный ключ на visit_id защищает от дублей.
# config.py
from dotenv import load_dotenv
import os
load_dotenv()
class Config:
WEBHOOK_SECRET = os.environ.get("WEBHOOK_SECRET", "super-secret-token")
YCLIENTS_BASE_URL = os.environ.get("YCLIENTS_BASE_URL", "https://api.yclients.com/api/v1")
YCLIENTS_PARTNER_TOKEN = os.environ.get("YCLIENTS_PARTNER_TOKEN", "***")
YCLIENTS_USER_TOKEN = os.environ.get("YCLIENTS_USER_TOKEN", "***")
MWS_TABLES_BASE_URL = os.environ.get("MWS_TABLES_BASE_URL", "https://tables.mws.ru")
MWS_TABLES_API_KEY = os.environ.get("MWS_TABLES_API_KEY", "***")
MWS_TABLE_ID = os.environ.get("MWS_TABLE_ID", "***")
MWS_VIEW_ID = os.environ.get("MWS_VIEW_ID", "***")
EXOLVE_API_KEY = os.environ.get("EXOLVE_API_KEY", "your_exolve_key")
EXOLVE_CAMPAIGN_ID = os.environ.get("EXOLVE_CAMPAIGN_ID", "***")
EXOLVE_CAMPAIGN_URL = os.environ.get("EXOLVE_CAMPAIGN_URL", "https://api.exolve.ru/campaign/v1/Call")
EXOLVE_SENDER = os.environ.get("EXOLVE_SENDER", "SalonBot")
OWNER_ALERT_PHONE = os.environ.get("OWNER_ALERT_PHONE", "79990001122")
LOW_SCORE_THRESHOLD = int(os.environ.get("LOW_SCORE_THRESHOLD", 2))
DB_NAME = os.environ.get("DB_NAME", "salon_quality.db")
Ниже минимальная схема таблицы, в которой сервис хранит состояние опроса.
# database.py
def init_db():
with get_conn() as conn:
conn.execute("""
CREATE TABLE IF NOT EXISTS surveys (
visit_id TEXT PRIMARY KEY,
client_name TEXT,
client_phone TEXT,
master_name TEXT,
branch_name TEXT,
visit_at TEXT,
status TEXT NOT NULL,
call_id TEXT,
q1_service_score INTEGER,
q2_price_score INTEGER,
q3_return_intent INTEGER,
alert_sent INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
)
""")
conn.commit()
Эта таблица хранит текущее состояние опроса. Данные визита и технический статус сервис держит в одной записи, без отдельного журнала событий.
Сервис использует такие статусы:
  • NEW — визит создан локально, опрос еще не запущен
  • CALL_STARTED — звонок запущен
  • Q1_ANSWERED — получен ответ на первый вопрос
  • Q2_ANSWERED — на второй
  • Q3_ANSWERED — и на третий
  • COMPLETED — опрос завершен
  • NO_ANSWER — клиент не ответил
  • FAILED — опрос завершился технической ошибкой
Последовательность статусов при успешном звонке выглядит так:
  • NEW -> CALL_STARTED -> Q1_ANSWERED -> Q2_ANSWERED -> Q3_ANSWERED -> COMPLETED
Если клиент не ответил:
  • NEW -> CALL_STARTED -> NO_ANSWER
Если звонок завершился ошибкой:
  • NEW -> CALL_STARTED -> FAILED
Шаг 2. Принимаем закрытый визит из YCLIENTS
Вебхук YCLIENTS сообщает, что визит закрыт. Дальше сервис проверяет token, смотрит на attendance == 1 и отдельно забирает карточку визита через get_visit(). В локальную БД он пишет уже нормализованные данные.
Пример входящего вебхука:
{
"id": "98765",
"company_id": "12345",
"attendance": 1
}
Ниже сам обработчик этого вебхука.
# app.py
@app.route("/webhook/yclients/visit", methods=["POST"])
def yclients_webhook():
if request.args.get("token") != Config.WEBHOOK_SECRET:
return "Forbidden", 403
data = request.get_json(silent=True) or {}
visit_id = str(data.get("id") or "")
company_id = str(data.get("company_id") or "")
if not visit_id or not company_id:
return "Bad Request: visit_id or company_id missing", 400
if data.get("attendance") != 1:
return jsonify({"status": "ignored_status"}), 200
visit_raw = get_visit(company_id, visit_id)
visit = normalize_visit(visit_raw)
if not visit["client_phone"]:
return jsonify({"status": "invalid_phone"}), 200
created = create_survey_if_new(visit)
if not created:
return jsonify({"status": "duplicate"}), 200
После первичной проверки сервис отдельно нормализует ответ YCLIENTS и дальше работает уже с одной и той же структурой визита.
# yclients_api.py
def normalize_phone(phone: str | None) -> str | None:
if not phone:
return None
digits = "".join(ch for ch in str(phone) if ch.isdigit())
if len(digits) == 11 and digits.startswith("8"):
digits = "7" + digits[1:]
return digits if len(digits) == 11 and digits.startswith("7") else None
def normalize_visit(visit: dict) -> dict:
return {
"visit_id": str(visit.get("id")),
"client_name": visit.get("client", {}).get("name"),
"client_phone": normalize_phone(visit.get("client", {}).get("phone")),
"master_name": visit.get("staff", {}).get("name"),
"branch_name": visit.get("company", {}).get("title"),
"visit_at": visit.get("datetime")
}
После нормализации в локальную базу попадает карточка визита с клиентом, мастером, филиалом и временем посещения. Повторный вебхук не создаёт дубль, потому что запись привязана к visit_id. Если телефон не проходит нормализацию, сервис возвращает invalid_phone. Завершённым визит считает только по attendance == 1.
Шаг 3. Запускаем голосовой опрос через МТС Exolve
Когда визит уже записан локально, сервис запускает по этому клиенту голосовую кампанию в МТС Exolve.
pic
Скриншот схемы голосового робота МТС Exolve
В запрос уходит не только телефон, но и initialData.visit_id. Этот ключ нужен, чтобы потом связать колбэк с нужным визитом. В ответе от МТС Exolve сервис дополнительно сохраняет call_id как идентификатор звонка для обратного маршрута вебхука и переводит запись в статус CALL_STARTED.
# exolve_voice.py
def start_quality_campaign(phone: str, visit_id: str) -> dict:
headers = {"Authorization": f"Bearer {Config.EXOLVE_API_KEY}", "Content-Type": "application/json"}
payload = {
"campaign_id": Config.EXOLVE_CAMPAIGN_ID,
"params": {
"destination": phone,
"initialData": {"visit_id": visit_id}
}
}
resp = requests.post(Config.EXOLVE_CAMPAIGN_URL, headers=headers, json=payload, timeout=20)
resp.raise_for_status()
return resp.json()
Дальше сервис сохраняет идентификатор звонка в SQLite и переводит визит в статус CALL_STARTED.
# app.py
response = start_quality_campaign(visit["client_phone"], visit["visit_id"])
call_id = response.get("call_id") or response.get("id")
update_survey_status(visit["visit_id"], "CALL_STARTED", call_id=call_id)
return jsonify({"status": "accepted"}), 200
На этом шаге сервис создаёт звонок и сохраняет два ключа: идентификатор визита и идентификатор звонка. Если вызов к МТС Exolve не проходит из-за сетевой ошибки или не пройденной авторизации, вебхук YCLIENTS не завершается штатно. Ограничение здесь прямое: вызов к МТС Exolve идёт синхронно внутри вебхука.
Шаг 4. Принимаем ответы DTMF и ведём состояние опроса
Во втором вебхуке сервис получает не итог опроса целиком, а отдельные шаги: вопрос, цифру ответа и статус звонка. q1_service_score хранит оценку услуги, q2_price_score — оценку цены, q3_return_intent — готовность вернуться. Отдельные ветки NO_ANSWER и FAILED нужны, чтобы отличать отсутствие ответа от технического сбоя.
Маршрут /webhook/exolve/survey снова проверяет token, приводит тело запроса к внутреннему контракту через normalize_callback, ищет опрос по call_id или visit_id, преобразует dtmf_digit в число и сохраняет ответ в одно из трёх полей состояния.
Пример колбэка, который отправляет голосовой сценарий:
{
"visit_id": "98765",
"call_id": "call_001",
"current_step": "q2",
"dtmf_digit": "4",
"result_status": "COMPLETED"
}
Дальше сервис приводит такой колбэк к короткому внутреннему формату.
# exolve_voice.py
def normalize_callback(payload: dict) -> dict:
return {
"call_id": payload.get("call_id"),
"visit_id": payload.get("visit_id"),
"step": payload.get("current_step"),
"digit": payload.get("dtmf_digit"),
"result_status": payload.get("result_status")
}
После нормализации у обработчика остаются пять полей: шаг, цифра, статус, visit_id и call_id.
# app.py
if cb.get("result_status") == "NO_ANSWER":
update_survey_status(visit_id, "NO_ANSWER")
return jsonify({"status": "processed"}), 200
if cb.get("result_status") == "FAILED":
update_survey_status(visit_id, "FAILED")
return jsonify({"status": "processed"}), 200
if step == "q1":
update_survey_status(visit_id, "Q1_ANSWERED", q1_service_score=digit)
return jsonify({"status": "step_saved"}), 200
if step == "q2":
update_survey_status(visit_id, "Q2_ANSWERED", q2_price_score=digit)
return jsonify({"status": "step_saved"}), 200
if step == "q3":
update_survey_status(visit_id, "Q3_ANSWERED", q3_return_intent=digit)
finalize_survey(visit_id)
return jsonify({"status": "step_saved"}), 200
На этом шаге сервис либо сохраняет ответ, либо переводит опрос в терминальный статус. Прикладные ошибки здесь простые: 404, если визит не найден, и 400, если пришёл неверный шаг или цифра. Код проверяет только int, но не диапазон оценки.
Шаг 5. Финализируем опрос, отправляем СМС и пишем результат в таблицу
Когда опрос заканчивается, сервис читает запись из SQLite, при низкой оценке отправляет СМС владельцу и затем пишет итог по визиту в таблицу.
pic
Скриншот из MWS Tables
Функция finalize_survey читает запись из БД и сначала проверяет первую оценку. Если q1_service_score меньше или равен LOW_SCORE_THRESHOLD, сервис вызывает send_low_score_alert через SMS API МТС Exolve. Затем из локальной записи сервис собирает итог по визиту и отправляет его в таблицу.
# app.py
def finalize_survey(visit_id: str):
survey = get_survey(visit_id)
if not survey:
return
is_alert_sent = False
if survey["q1_service_score"] is not None and survey["q1_service_score"] <= Config.LOW_SCORE_THRESHOLD:
try:
send_low_score_alert(
survey["master_name"],
survey["client_name"],
survey["q1_service_score"]
)
is_alert_sent = True
except Exception:
is_alert_sent = False
record = {
"visit_id": survey["visit_id"],
"client_name": survey["client_name"],
"master_name": survey["master_name"],
"branch_name": survey["branch_name"],
"visit_at": survey["visit_at"],
"service_score": survey["q1_service_score"],
"price_score": survey["q2_price_score"],
"return_intent": survey["q3_return_intent"],
"survey_status": "COMPLETED",
"alert_sent": is_alert_sent
}
На этом месте app.py уже собрал финальный record. Дальше один клиент отправляет СМС владельцу, а второй пишет итог по визиту в таблицу.
# sms_alerts.py
headers = {"Authorization": f"Bearer {Config.EXOLVE_API_KEY}", "Content-Type": "application/json"}
payload = {"number": Config.EXOLVE_SENDER, "destination": Config.OWNER_ALERT_PHONE, "text": text}
resp = requests.post(url, headers=headers, json=payload, timeout=20)
resp.raise_for_status()
В таблицу уходит уже итог по визиту: оценки, статус опроса и отметка об алерте.
# tables_api.py
headers = {"Authorization": f"Bearer {Config.MWS_TABLES_API_KEY}", "Content-Type": "application/json"}
payload = {
"records": [{"fields": record}],
"fieldKey": "name",
}
resp = requests.post(url, params=params, headers=headers, json=payload, timeout=20)
resp.raise_for_status()
На этом шаге сервис отправляет СМС владельцу и пишет строку в таблицу. SQLite остаётся хранилищем состояния, а команда смотрит итог уже в Таблицах. Если любой вызов в этом блоке падает, визит переходит в FAILED. Общей транзакции между СМС, записью в таблицу и локальным статусом здесь нет.
Запуск и проверка
Чтобы прогнать сценарий целиком, понадобятся токены YCLIENTS, МТС Exolve и идентификаторы таблицы. После заполнения .env достаточно поднять Flask-приложение и либо закрыть тестовый визит в YCLIENTS, либо отправить вебхук вручную.
python -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
flask --app app run --port 5000
Если проверяем первый маршрут вручную, отправляем запрос на вебхук YCLIENTS. При валидном секрете и корректных внешних токенах ожидаем {"status":"accepted"}.
curl -X POST "http://127.0.0.1:5000/webhook/yclients/visit?token=super-secret-token" \
-H "Content-Type: application/json" \
-d '{"id":"98765","company_id":"12345","attendance":1}'
Когда запись уже создана, колбэк от голосового сценария можно эмулировать отдельно. Для локальной проверки удобно отправлять visit_id без call_id, тогда маршрут ищет опрос напрямую по визиту. После первого вебхука в SQLite уже должна лежать строка со статусом CALL_STARTED и сохранённым call_id, если внешний вызов кампании отработал штатно.
curl -X POST "http://127.0.0.1:5000/webhook/exolve/survey?token=super-secret-token" \
-H "Content-Type: application/json" \
-d '{"visit_id":"98765","current_step":"q1","dtmf_digit":"2","result_status":"COMPLETED"}'
После колбэка с q1 строка должна перейти в Q1_ANSWERED и получить q1_service_score. После колбэка с q3 сервис либо завершит опрос статусом COMPLETED и создаст запись в таблице, либо пометит визит как FAILED, если финализация не прошла. Проверять в итоге нужно три точки: переходы status в SQLite, СМС на OWNER_ALERT_PHONE при низкой оценке и итоговую запись в таблице после третьего шага.
Если приходят 4xx, смотрим на token, company_id, visit_id и формат цифры. Если получаем 5xx, причина обычно в сетевой ошибке при вызове YCLIENTS, МТС Exolve или Таблиц.
В итоге
Такой сценарий помогает бизнесу быстро получать обратную связь о качестве сервиса и компания узнаёт о качестве обслуживания клиента практически сразу после оказания услуги. Это даёт возможность быстро  решить проблему и не допустить ее повторения, а еще увеличивает шанс на повторный визит клиента, мнение которого услышали и учли.
Решение можно запустить за пару вечеров. Если эффект есть, сценарий можно расширять: встраивать в CRM, добавлять повторные касания, автоматизировать разбор негатива. Такой подход превращает работу с клиентами в постоянный процесс мониторинга и управления качеством сервисного обслуживания.
Возможности для развития
  • Добавить повторный контакт по клиентам, которые не ответили на первый звонок
  • Развести реакцию по уровню негатива: критические случаи отправлять сразу, остальные разбирать в течение дня
  • После низкой оценки не только слать СМС владельцу, но и ставить задачу на обратный звонок или разбор кейса
  • Смотреть результаты отдельно по филиалам, мастерам, услугам и времени визита
  • Сделать разные сценарии для новых и постоянных клиентов
  • Собрать регулярный отчёт по покрытию опроса, доле ответов, низким оценкам и скорости реакции
  • Запускать после низкой оценки отдельный сценарий удержания: быстрый звонок, извинение или повторный визит
  • Интегрироваться с CRM и другими системами, если команда уже работает в ней
Код проекта на гитхабе.-Источник
 
Loading...
Error