Kommo + Productive: проекты и ресурсы из закрытой сделки

Kommo + Productive: проекты и ресурсы из закрытой сделки

При интеграции Kommo и Productive через API выигранная сделка автоматически создаёт проект с правильным бюджетом, назначенной командой и сервисами - без участия PM. Данные переносятся напрямую из карточки сделки: название клиента, сумма, тип работ, ответственный менеджер.

Для digital-агентств это стандартная точка потерь. При закрытии сделки PM вручную открывает Productive, создаёт проект, копирует бюджет из Kommo, назначает команду. При 10-15 новых проектах в месяц это не просто неудобно - это систематические ошибки: неверный бюджет (перепутали net/gross), не та команда (PM не знал что дизайнер в отпуске), неверный тип сервиса. Ниже - архитектура автоматической передачи данных и рабочий код.

Почему нативной интеграции нет и почему Zapier не подходит

Productive - project management и resource planning платформа, популярная среди digital-агентств в EU и US. Её документация доступна на developer.productive.io. Kommo не имеет нативного коннектора к Productive. Zapier предлагает базовый модуль для Productive, но он не поддерживает создание budgets и service assignments - только создание проектов без финансовой части.

Проблема в том, что Productive имеет связанные объекты: Project -> Budget -> Services -> Assignments. Создать проект через Zapier можно, но привязать бюджет с правильной суммой из Kommo и назначить team members на конкретные сервисы через Zapier невозможно без кастомного кода. Именно поэтому агентства либо делают всё вручную, либо пишут интеграцию самостоятельно.

Архитектура интеграции Kommo + Productive

Стек: Python 3.11, Kommo Webhooks, Productive API v2, PostgreSQL для журнала.

Модель данных Productive, которую нужно понять перед разработкой:

  • Project - основной объект, содержит название, клиента (company_id), статус
  • Budget - финансовый план проекта, привязан к Project. Содержит budget_total, currency, billing_type
  • Service - тип работ внутри бюджета (Design, Development, PM)
  • ProjectAssignment - назначение конкретного person_id на проект

Kommo Custom Fields, которые нужно заполнить в карточке сделки до закрытия:

  • deal_value - сумма сделки (переносится в budget_total)
  • service_type - тип проекта (маппится на Service IDs в Productive)
  • assigned_team - список ID сотрудников через запятую (маппится на persons в Productive)
  • billing_type - fixed/hourly (передаётся в Budget)

Auth: Productive API использует Bearer token (Personal Access Token). Генерируется в Productive: Settings -> My Account -> API Access Tokens. Токен передаётся в заголовке Authorization: Bearer {token}.

import logging
import requests
from flask import Flask, request, jsonify

app = Flask(__name__)
logging.basicConfig(level=logging.INFO)

PRODUCTIVE_TOKEN = "your-productive-pat"
PRODUCTIVE_BASE = "https://api.productive.io/api/v2"
PRODUCTIVE_ORG_ID = "your-org-id"  # Из URL аккаунта Productive
KOMMO_BASE = "https://your-domain.kommo.com"
KOMMO_TOKEN = "your-kommo-token"

# Маппинг типов сервисов из Kommo custom field на Productive service_type_ids
SERVICE_MAP = {
    "website": "111",      # Service Type ID в Productive
    "branding": "112",
    "seo": "113",
    "development": "114",
}

# Маппинг team members: email из Kommo -> person_id в Productive
TEAM_MAP = {
    "pm@agency.com": "2001",
    "design@agency.com": "2002",
    "dev@agency.com": "2003",
}

def get_productive_headers() -> dict:
    return {
        "Authorization": f"Bearer {PRODUCTIVE_TOKEN}",
        "Content-Type": "application/vnd.api+json",
        "X-Organization-Id": PRODUCTIVE_ORG_ID,
    }

def get_kommo_lead(lead_id: int) -> dict:
    url = f"{KOMMO_BASE}/api/v4/leads/{lead_id}?with=contacts,custom_fields_values"
    headers = {"Authorization": f"Bearer {KOMMO_TOKEN}"}
    resp = requests.get(url, headers=headers, timeout=10)
    resp.raise_for_status()
    return resp.json()

def find_or_create_company(company_name: str) -> str:
    """Найти или создать Company в Productive для клиента."""
    search_url = f"{PRODUCTIVE_BASE}/companies?filter[name]={company_name}"
    resp = requests.get(search_url, headers=get_productive_headers(), timeout=10)
    resp.raise_for_status()
    companies = resp.json().get("data", [])

    if companies:
        return companies[0]["id"]

    # Создать новую компанию
    payload = {
        "data": {
            "type": "companies",
            "attributes": {"name": company_name}
        }
    }
    create_resp = requests.post(
        f"{PRODUCTIVE_BASE}/companies",
        json=payload,
        headers=get_productive_headers(),
        timeout=10
    )
    create_resp.raise_for_status()
    return create_resp.json()["data"]["id"]

def create_project(name: str, company_id: str) -> str:
    """Создать проект в Productive."""
    payload = {
        "data": {
            "type": "projects",
            "attributes": {
                "name": name,
                "project_color": "#4A90D9",
            },
            "relationships": {
                "company": {
                    "data": {"type": "companies", "id": company_id}
                }
            }
        }
    }
    resp = requests.post(
        f"{PRODUCTIVE_BASE}/projects",
        json=payload,
        headers=get_productive_headers(),
        timeout=10
    )
    resp.raise_for_status()
    project_id = resp.json()["data"]["id"]
    logging.info(f"Created project {project_id}: {name}")
    return project_id

def create_budget(project_id: str, total: float, currency: str, billing_type: str) -> str:
    """
    Создать бюджет для проекта.
    billing_type: 'fixed_price' | 'hourly'
    """
    payload = {
        "data": {
            "type": "budgets",
            "attributes": {
                "name": "Project Budget",
                "budget_total": total,
                "currency_id": currency,  # e.g. 'EUR', 'USD'
                "billing_type": billing_type,
            },
            "relationships": {
                "project": {
                    "data": {"type": "projects", "id": project_id}
                }
            }
        }
    }
    resp = requests.post(
        f"{PRODUCTIVE_BASE}/budgets",
        json=payload,
        headers=get_productive_headers(),
        timeout=10
    )
    resp.raise_for_status()
    budget_id = resp.json()["data"]["id"]
    logging.info(f"Created budget {budget_id} for project {project_id}")
    return budget_id

def assign_people_to_project(project_id: str, person_ids: list[str]):
    """Назначить team members на проект."""
    for person_id in person_ids:
        payload = {
            "data": {
                "type": "project_assignments",
                "relationships": {
                    "project": {"data": {"type": "projects", "id": project_id}},
                    "person": {"data": {"type": "people", "id": person_id}}
                }
            }
        }
        resp = requests.post(
            f"{PRODUCTIVE_BASE}/project_assignments",
            json=payload,
            headers=get_productive_headers(),
            timeout=10
        )
        if resp.status_code == 409:  # Already assigned
            logging.info(f"Person {person_id} already assigned to project {project_id}")
            continue
        resp.raise_for_status()
        logging.info(f"Assigned person {person_id} to project {project_id}")

@app.route("/kommo/webhook", methods=["POST"])
def kommo_webhook():
    """Webhook от Kommo при выигрыше сделки (status = won)."""
    data = request.json
    leads = data.get("leads", {}).get("status", [])

    for lead_event in leads:
        # Kommo status_id 142 для 'Won' может отличаться в вашем аккаунте
        # Проверяйте через API: GET /api/v4/leads/pipelines
        if lead_event.get("status_id") not in [142, 143]:  # Ваши Won status IDs
            continue

        lead_id = lead_event["id"]

        try:
            lead = get_kommo_lead(lead_id)

            # Извлечь custom fields
            cf_values = {}
            for cf in lead.get("custom_fields_values", []):
                cf_values[cf["field_code"]] = cf["values"][0]["value"]

            deal_name = lead.get("name", f"Deal {lead_id}")
            deal_value = float(lead.get("price", 0))
            company_name = cf_values.get("COMPANY", "Unknown Client")
            service_type = cf_values.get("SERVICE_TYPE", "website")
            billing_type = cf_values.get("BILLING_TYPE", "fixed_price")
            team_emails = cf_values.get("ASSIGNED_TEAM", "").split(",")

            # Маппинг team members
            person_ids = [
                TEAM_MAP[email.strip()]
                for email in team_emails
                if email.strip() in TEAM_MAP
            ]

            # Создать объекты в Productive
            company_id = find_or_create_company(company_name)
            project_id = create_project(deal_name, company_id)
            create_budget(project_id, deal_value, "EUR", billing_type)
            assign_people_to_project(project_id, person_ids)

            logging.info(
                f"Lead {lead_id} -> Project {project_id} created in Productive. "
                f"Budget: {deal_value} EUR. Team: {person_ids}"
            )

        except requests.HTTPError as e:
            logging.error(f"HTTP error for lead {lead_id}: {e.response.status_code} {e.response.text}")
        except Exception as e:
            logging.exception(f"Error processing lead {lead_id}: {e}")

    return jsonify({"status": "ok"})

@app.route("/productive/webhook", methods=["POST"])
def productive_webhook():
    """
    Webhook от Productive при завершении задачи (task.completed)
    или превышении бюджета (budget.exceeded).
    """
    data = request.json
    event_type = data.get("data", {}).get("type")

    if event_type == "budget_notifications":
        attrs = data["data"].get("attributes", {})
        project_id = data["data"].get("relationships", {}).get("project", {}).get("data", {}).get("id")
        logging.warning(f"Budget threshold exceeded for project {project_id}: {attrs}")
        # Здесь: отправить уведомление в Slack или обновить заметку в Kommo

    return jsonify({"status": "ok"})

if __name__ == "__main__":
    app.run(host="0.0.0.0", port=8080)

Пошаговая реализация

Шаг 1. Подготовка Custom Fields в Kommo

До интеграции убедитесь что в карточке сделки есть поля: SERVICE_TYPE (select), BILLING_TYPE (select: fixed_price/hourly), ASSIGNED_TEAM (text, emails через запятую). Без этих данных проект будет создан с дефолтными значениями. Настройку воронки и custom fields разбирает статья о настройке воронки Kommo.

Шаг 2. Получение Organization ID в Productive

Organization ID виден в URL вашего Productive-аккаунта: app.productive.io/organizations/{ORG_ID}/.... Он нужен в заголовке каждого запроса как X-Organization-Id.

Шаг 3. Маппинг Service Types

Service Type IDs в Productive получаете через GET /api/v2/service_types. Создайте словарь SERVICE_MAP с маппингом ваших названий из Kommo на IDs Productive. Это однократная настройка.

Шаг 4. Настройка webhook Kommo

Kommo -> Settings -> Webhooks. URL: https://your-server.com/kommo/webhook. Событие: Lead status changed. Проверьте status_id для статуса Won в вашем аккаунте через GET /api/v4/leads/pipelines - он уникален для каждого аккаунта.

Шаг 5. Настройка webhooks Productive

Productive поддерживает webhooks для событий task.completed и бюджетных уведомлений. Настраивается в Settings -> Integrations -> Webhooks. Используйте для обратной нотификации: когда проект завершён, можно обновить статус сделки в Kommo или отправить уведомление.

Реальный кейс с цифрами

Digital-агентство в Берлине, 22 сотрудника, 12-18 новых проектов в месяц. До интеграции: PM тратил 40-60 минут на создание каждого проекта в Productive после закрытия сделки. За месяц - до 18 часов только на ручной перенос данных. Ошибки в бюджете: 2-3 проекта в месяц стартовали с неверной суммой из-за ручного ввода.

После запуска интеграции: проект в Productive создаётся через 10-15 секунд после смены статуса в Kommo. PM получает уведомление в Slack (через отдельный webhook) что проект готов. Ошибки в бюджете - 0 за первые три месяца работы. Экономия: ~15 часов PM в месяц, которые ушли на реальное планирование, а не перепечатку данных.

Дополнительный эффект: поскольку budget_total берётся напрямую из price сделки в Kommo, согласование «сколько мы продали» vs «сколько мы запланировали» стало мгновенным - данные всегда идентичны.

Для кого подходит

Интеграция Kommo + Productive максимально полезна для digital-агентств и сервисных компаний с 10-30 сотрудниками, которые ведут 8-20 новых проектов в месяц. Условие: Productive уже используется как основной инструмент project management и resource planning. Если вы только оцениваете варианты для задач из выигранных сделок - сравните с Kommo + Asana и Kommo + Wrike: каждый инструмент имеет свою модель данных и разный уровень детализации бюджетирования.

Productive отличается тем, что resource planning и financial tracking - его core feature, а не дополнение. Если для вашего агентства бюджет и загрузка команды важнее kanban-досок - Productive + Kommo через API решает эту задачу чище чем любой no-code коннектор. Подробнее о подходе к кастомным интеграциям Kommo.

Часто задаваемые вопросы

Как Productive API аутентифицирует запросы?

Productive API v2 использует Personal Access Token (PAT) в заголовке Authorization: Bearer {token}. Токен генерируется в настройках аккаунта: Settings -> My Account -> API Access Tokens. Дополнительно все запросы должны содержать заголовок X-Organization-Id с ID вашей организации в Productive (виден в URL аккаунта). Документация: developer.productive.io.

Можно ли создать проект из шаблона в Productive через API?

Да. Productive API поддерживает endpoint POST /api/v2/projects/copy с параметром source_project_id. Это позволяет скопировать шаблонный проект со стандартными задачами, milestone’ами и структурой сервисов. При копировании можно передать copy_budgets: true и copy_assignees: false чтобы сохранить финансовую структуру но назначить новую команду под конкретную сделку.

Что делать если person_id из Kommo не совпадает с Productive?

Productive и Kommo - отдельные системы с независимыми ID пользователей. Маппинг нужно создать вручную один раз: получить список людей из Productive через GET /api/v2/people и создать словарь email -> person_id. Обновлять маппинг нужно только при найме новых сотрудников. Храните маппинг в конфигурации или базе данных, не в коде.

Как обрабатывать превышение бюджета?

Productive отправляет webhook-уведомление при достижении порогов бюджета (настраивается в Budget Settings). Ваш сервер получает событие и может: отправить Slack-уведомление PM, добавить заметку в карточку сделки в Kommo, обновить custom field с текущим статусом бюджета. Это позволяет руководителю продаж видеть финансовое состояние проекта прямо в Kommo без переключения в Productive.

Как передать ставки сотрудников в бюджет Productive?

Productive поддерживает Service Assignments с почасовыми ставками. После создания бюджета создайте Services через POST /api/v2/services с указанием service_type_id и hourly_rate. Затем через POST /api/v2/service_assignments привяжите конкретных людей к сервисам. Ставки можно хранить в Kommo custom fields или в конфигурации интеграции - в зависимости от того, меняются ли они от проекта к проекту.

Что дальше

Если ваше агентство теряет 10+ часов в месяц на перенос данных из Kommo в Productive - это типичная задача для нас. Один раз настроенная интеграция работает без участия команды.

Опишите ваш стек команде Exceltic.dev: как устроена воронка в Kommo, какие custom fields заполняются при закрытии сделки и как организована структура проектов в Productive. Оценим объём работ и предложим архитектуру.

Ещё статьи

Все →