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

от автора

Пока впечатление о полученной услуге свежее, клиент лучше помнит детали и охотнее делится обратной связью. Бизнесу это помогает быстрее находить слабые места в сервисе и исправлять их.

Когда клиентов мало, администратор может быстро их обзвонить: спросить, всё ли понравилось, и зафиксировать ответы. При масштабировании бизнеса этот вариант уже не подходит: звонки отнимают много времени. В итоге часть визитов остаётся без проверки, а бизнес узнаёт о проблеме, когда недовольный клиент опубликовал негативный отзыв в интернете, ухудшив рейтинг компании, и перестал возвращаться.

В этой статье разберём, как автоматизировать исходящие звонки. Клиенту, получившему услугу,  звонит голосовой робот и проводит короткое анкетирование. Результаты опроса сразу попадают в рабочую таблицу, а если клиент остался недоволен, управляющий дополнительно получает СМС и может быстрее разобраться в ситуации.

Стек решения: 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 .venvsource .venv/bin/activatepip install -r requirements.txt

Пример переменных окружения:

WEBHOOK_SECRET=change-meYCLIENTS_BASE_URL=https://api.yclients.com/api/v1YCLIENTS_PARTNER_TOKEN=***YCLIENTS_USER_TOKEN=***MWS_TABLES_BASE_URL=https://tables.mws.ruMWS_TABLES_API_KEY=***MWS_TABLE_ID=***MWS_VIEW_ID=***EXOLVE_API_KEY=***EXOLVE_CAMPAIGN_ID=***EXOLVE_CAMPAIGN_URL=https://api.exolve.ru/campaign/v1/CallEXOLVE_SENDER=SalonBotOWNER_ALERT_PHONE=79990001122LOW_SCORE_THRESHOLD=2DB_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.pyfrom dotenv import load_dotenvimport osload_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.pydef 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.pydef 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 Nonedef 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.

Скриншот схемы голосового робота МТС Exolve

Скриншот схемы голосового робота МТС Exolve

В запрос уходит не только телефон, но и initialData.visit_id. Этот ключ нужен, чтобы потом связать колбэк с нужным визитом. В ответе от МТС Exolve сервис дополнительно сохраняет call_id как идентификатор звонка для обратного маршрута вебхука и переводит запись в статус CALL_STARTED.

# exolve_voice.pydef 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.pyresponse = 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.pydef 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.pyif cb.get("result_status") == "NO_ANSWER":   update_survey_status(visit_id, "NO_ANSWER")   return jsonify({"status": "processed"}), 200if 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"}), 200if 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, при низкой оценке отправляет СМС владельцу и затем пишет итог по визиту в таблицу.

Скриншот из MWS Tables

Скриншот из MWS Tables

Функция finalize_survey читает запись из БД и сначала проверяет первую оценку. Если q1_service_score меньше или равен LOW_SCORE_THRESHOLD, сервис вызывает send_low_score_alert через SMS API МТС Exolve. Затем из локальной записи сервис собирает итог по визиту и отправляет его в таблицу.

# app.pydef 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.pyheaders = {"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.pyheaders = {"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 .venvsource .venv/bin/activatepip install -r requirements.txtflask --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 и другими системами, если команда уже работает в ней

Код проекта на гитхабе.

ссылка на оригинал статьи https://habr.com/ru/articles/1037412/