Files

642 lines
22 KiB
Python

import asyncio
import logging
import time
from collections.abc import AsyncGenerator
from contextlib import asynccontextmanager
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
from .bitrix import BitrixClient
from .domain import (
DEALS_PER_PAGE,
Binding,
ClientInfo,
DealPage,
DealStage,
DealStageAdvance,
DealStageFilter,
)
logger = logging.getLogger(__name__)
class DealAssignmentConflict(RuntimeError):
"""Ответственный изменился после показа карточки."""
class DealAdvanceForbidden(RuntimeError):
"""Стадию может менять только ответственный за сделку."""
class DealStageConflict(RuntimeError):
"""Стадия изменилась после запроса комментария."""
class DealCommentSaveError(RuntimeError):
"""Стадия изменена, но комментарий не добавлен."""
def __init__(self, advance: DealStageAdvance) -> None:
super().__init__(
"Стадия изменена, но комментарий не удалось сохранить."
)
self.advance = advance
@dataclass
class _DealLockEntry:
lock: asyncio.Lock
users: int = 0
class DealService:
"""Загрузка и изменение сделок Bitrix."""
deal_select = [
"ID",
"TITLE",
"STAGE_ID",
"IS_NEW",
"OPPORTUNITY",
"CURRENCY_ID",
"DATE_CREATE",
"ASSIGNED_BY_ID",
"CONTACT_ID",
"COMPANY_ID",
"SOURCE_ID",
"COMMENTS",
]
def __init__(
self,
bitrix: BitrixClient,
take_to_work_stage_id: str
) -> None:
self.bitrix = bitrix
self.take_to_work_stage_id = take_to_work_stage_id
self._deal_locks: dict[tuple[str, str], _DealLockEntry] = {}
self._deal_locks_guard = asyncio.Lock()
self._stage_cache: dict[
tuple[str, int, int], tuple[float, tuple[DealStage, ...]]
] = {}
async def list_by_stage(
self,
binding: Binding,
stage_key: str = "new",
page: int = 0,
limit: int = DEALS_PER_PAGE
) -> DealPage:
stage_filters = await self.stage_filters(binding)
stage_filter = self._select_stage_filter(stage_filters, stage_key)
bitrix_filter = {}
if stage_filter.stage_id:
bitrix_filter["STAGE_ID"] = stage_filter.stage_id
if stage_filter.assigned_to_viewer:
bitrix_filter["ASSIGNED_BY_ID"] = binding.bitrix_user_id
page = max(page, 0)
start_index = page * limit
end_index = start_index + limit
loaded_deals = []
bitrix_start: int | None = 0
total_deals = 0
# Битрикс и Telegram используют страницы разного размера.
while bitrix_start is not None and len(loaded_deals) < end_index:
# https://apidocs.bitrix24.ru/api-reference/crm/deals/crm-deal-list.html
data = await self.bitrix.call(
binding,
"crm.deal.list",
{
"order": {"DATE_CREATE": "DESC"},
"filter": bitrix_filter,
"select": self.deal_select,
"start": bitrix_start
}
)
loaded_deals.extend(data.get("result", []))
total_deals = int(data.get("total", len(loaded_deals)))
bitrix_start = data.get("next")
deals = loaded_deals[start_index:end_index]
total_pages = max(1, (total_deals + limit - 1) // limit)
return DealPage(
deals,
page,
total_deals,
total_pages,
stage_filter,
stage_filters
)
async def stage_filters(
self,
binding: Binding
) -> tuple[DealStageFilter, ...]:
stages = await self._stages(binding, category_id=0)
filters = [
DealStageFilter(
key="mine",
title="Мои сделки",
stage_id=None,
assigned_to_viewer=True,
)
]
filters.extend(
DealStageFilter(stage.stage_id, stage.title, stage.stage_id)
for stage in stages
)
filters.append(DealStageFilter("all", "Все", None))
return tuple(filters)
def _select_stage_filter(
self,
filters: tuple[DealStageFilter, ...],
stage_key: str
) -> DealStageFilter:
initial = next(
(item for item in filters if item.stage_id is not None),
filters[-1]
)
# Callback `new` означает первую стадию, полученную из Битрикса.
if stage_key == "new":
return initial
selected = next((item for item in filters if item.key == stage_key),
None)
return selected or initial
async def _stages(
self,
binding: Binding,
category_id: int
) -> tuple[DealStage, ...]:
key = (binding.member_id, binding.bitrix_user_id, category_id)
cached = self._stage_cache.get(key)
if cached and cached[0] > time.monotonic():
return cached[1]
entity_id = "DEAL_STAGE" if category_id == 0 else (
f"DEAL_STAGE_{category_id}"
)
items = []
bitrix_start: int | None = 0
# https://apidocs.bitrix24.ru/api-reference/crm/status/crm-status-list.html
while bitrix_start is not None:
data = await self.bitrix.call(
binding,
"crm.status.list",
{
"order": {"SORT": "ASC"},
"filter": {"ENTITY_ID": entity_id},
"start": bitrix_start
}
)
items.extend(data.get("result", []))
bitrix_start = data.get("next")
stages = []
seen_stage_ids = set()
for item in items:
raw_stage_id = str(item.get("STATUS_ID") or "")
if not raw_stage_id:
continue
stage_id = raw_stage_id
prefix = f"C{category_id}:"
if category_id and not stage_id.startswith(prefix):
stage_id = prefix + stage_id
if stage_id in seen_stage_ids:
continue
semantics = self._stage_semantics(item)
stages.append(
DealStage(
stage_id=stage_id,
title=str(item.get("NAME") or raw_stage_id),
semantics=semantics,
)
)
seen_stage_ids.add(stage_id)
result = tuple(stages)
self._stage_cache[key] = (time.monotonic() + 300, result)
return result
async def _stage_map(
self,
binding: Binding,
category_id: int
) -> dict[str, str]:
stages = await self._stages(binding, category_id)
return {stage.stage_id: stage.title for stage in stages}
@staticmethod
def _stage_semantics(item: dict) -> str:
semantics = str(item.get("SEMANTICS") or "").upper()
if semantics in {"P", "S", "F"}:
return semantics
extra_semantics = str(
(item.get("EXTRA") or {}).get("SEMANTICS") or ""
).lower()
return {
"process": "P",
"success": "S",
"failure": "F",
}.get(extra_semantics, "P")
async def stage_name(
self,
binding: Binding,
category_id: int,
stage_id: str
) -> str | None:
stages = await self._stage_map(binding, category_id)
return stages.get(stage_id)
async def get(self, binding: Binding, deal_id: str) -> dict | None:
# https://apidocs.bitrix24.ru/api-reference/crm/deals/crm-deal-get.html
data = await self.bitrix.call(binding, "crm.deal.get", {"id": deal_id})
deal = data.get("result")
if not deal:
return None
client = await self.get_client_info(binding, deal)
deal["CLIENT_NAME"] = client.name
deal["CLIENT_COMPANY"] = client.company
deal["CLIENT_PHONE"] = client.phone
deal["SOURCE_NAME"] = await self.get_source_name(
binding, str(deal.get("SOURCE_ID") or "")
)
deal["STAGE_NAME"] = await self.stage_name(
binding,
int(deal.get("CATEGORY_ID") or 0),
str(deal.get("STAGE_ID") or "")
)
next_stage = await self._next_stage(binding, deal)
if next_stage:
deal["NEXT_STAGE_ID"] = next_stage.stage_id
deal["NEXT_STAGE_NAME"] = next_stage.title
deal["NEXT_STAGE_IS_FINAL"] = next_stage.is_final
return deal
async def prepare_stage_advance(
self,
binding: Binding,
deal_id: str
) -> DealStageAdvance:
deal = await self._get_raw(binding, deal_id)
if not deal:
raise RuntimeError("Сделка не найдена")
self._ensure_responsible(binding, deal)
next_stage = await self._next_stage(binding, deal)
if not next_stage:
raise RuntimeError("Сделка уже находится на финальной стадии")
return DealStageAdvance(
deal_id=deal_id,
current_stage_id=str(deal.get("STAGE_ID") or ""),
target_stage_id=next_stage.stage_id,
target_stage_title=next_stage.title,
is_final=next_stage.is_final,
)
async def advance_stage(
self,
binding: Binding,
deal_id: str,
expected_stage_id: str,
expected_target_stage_id: str,
comment: str | None,
) -> DealStageAdvance:
async with self._deal_lock(binding, deal_id):
current = await self._get_raw(binding, deal_id)
if not current:
raise RuntimeError("Сделка не найдена")
self._ensure_responsible(binding, current)
current_stage_id = str(current.get("STAGE_ID") or "")
if current_stage_id != expected_stage_id:
raise DealStageConflict(
"Стадия уже изменилась. Обновите карточку сделки."
)
next_stage = await self._next_stage(binding, current)
if (
not next_stage
or next_stage.stage_id != expected_target_stage_id
):
raise DealStageConflict(
"Набор стадий изменился. Откройте сделку заново."
)
advance = DealStageAdvance(
deal_id=deal_id,
current_stage_id=current_stage_id,
target_stage_id=next_stage.stage_id,
target_stage_title=next_stage.title,
is_final=next_stage.is_final,
)
# https://apidocs.bitrix24.ru/api-reference/crm/deals/crm-deal-update.html
await self.bitrix.call(
binding,
"crm.deal.update",
{
"id": deal_id,
"fields": {"STAGE_ID": next_stage.stage_id},
"params": {"REGISTER_HISTORY_EVENT": "Y"},
},
)
updated = await self._get_raw(binding, deal_id)
if str((updated or {}).get(
"STAGE_ID") or "") != next_stage.stage_id:
raise DealStageConflict(
"Стадия изменилась одновременно с обновлением."
)
if comment:
try:
# Комментарий добавляется в таймлайн, не затирая COMMENTS.
await self.bitrix.call(
binding,
"crm.timeline.comment.add",
{
"fields": {
"ENTITY_ID": int(deal_id),
"ENTITY_TYPE": "deal",
"COMMENT": comment,
}
},
)
except Exception as error:
raise DealCommentSaveError(advance) from error
return advance
async def _next_stage(
self,
binding: Binding,
deal: dict
) -> DealStage | None:
current_stage_id = str(deal.get("STAGE_ID") or "")
current_semantics = str(
deal.get("STAGE_SEMANTIC_ID") or ""
).upper()
if current_semantics in {"S", "F"}:
return None
stages = await self._stages(
binding,
int(deal.get("CATEGORY_ID") or 0),
)
current_index = next(
(
index
for index, stage in enumerate(stages)
if stage.stage_id == current_stage_id
),
None,
)
if current_index is None or stages[current_index].is_final:
return None
if current_index + 1 >= len(stages):
return None
return stages[current_index + 1]
@staticmethod
def _ensure_responsible(binding: Binding, deal: dict) -> None:
responsible_id = str(deal.get("ASSIGNED_BY_ID") or "")
if responsible_id != str(binding.bitrix_user_id):
raise DealAdvanceForbidden(
"Переводить сделку может только ответственный за нее."
)
async def history(
self,
binding: Binding,
deal_id: str,
limit: int = 5
) -> list[dict]:
# https://apidocs.bitrix24.ru/api-reference/crm/crm-stage-history-list.html
data = await self.bitrix.call(
binding,
"crm.stagehistory.list",
{
"entityTypeId": 2,
"order": {"ID": "DESC"},
"filter": {"OWNER_ID": int(deal_id)},
"select": [
"ID",
"TYPE_ID",
"CATEGORY_ID",
"STAGE_ID",
"CREATED_TIME"
],
"start": 0
}
)
result = data.get("result") or {}
events = list(result.get("items") or [])[:limit]
for event in events:
event["STAGE_NAME"] = await self.stage_name(
binding,
int(event.get("CATEGORY_ID") or 0),
str(event.get("STAGE_ID") or "")
)
return events
async def remind_to_call(
self,
binding: Binding,
deal_id: str
) -> datetime:
deadline = datetime.now(UTC) + timedelta(hours=1)
# https://apidocs.bitrix24.ru/api-reference/crm/timeline/activities/todo/crm-activity-todo-add.html
await self.bitrix.call(
binding,
"crm.activity.todo.add",
{
"ownerTypeId": 2,
"ownerId": int(deal_id),
"deadline": deadline.isoformat(),
"title": "Позвонить клиенту",
"description": f"Отложенный звонок по сделке #{deal_id}",
"responsibleId": binding.bitrix_user_id,
"pingOffsets": [0]
}
)
return deadline
async def take_to_work(
self,
binding: Binding,
deal_id: str,
expected_responsible_id: str
) -> bool:
async with self._deal_lock(binding, deal_id):
current = await self._get_raw(binding, deal_id)
if not current:
raise RuntimeError("Сделка не найдена")
responsible_id = str(current.get("ASSIGNED_BY_ID") or "")
target_id = str(binding.bitrix_user_id)
if responsible_id == target_id:
return False
if responsible_id != expected_responsible_id:
raise DealAssignmentConflict(
"Ответственный уже изменился. Обновите карточку сделки."
)
target_stage_id = await self._take_to_work_stage_id(
binding,
int(current.get("CATEGORY_ID") or 0)
)
# https://apidocs.bitrix24.com/api-reference/crm/deals/crm-deal-update.html
await self.bitrix.call(
binding,
"crm.deal.update",
{
"id": deal_id,
"fields": {
"ASSIGNED_BY_ID": binding.bitrix_user_id,
"STAGE_ID": target_stage_id
},
"params": {"REGISTER_HISTORY_EVENT": "Y"}
},
)
# REST Bitrix не поддерживает условный UPDATE, поэтому проверяем результат.
updated = await self._get_raw(binding, deal_id)
if str((updated or {}).get("ASSIGNED_BY_ID") or "") != target_id:
raise DealAssignmentConflict(
"Ответственный изменился одновременно с назначением."
)
return True
async def _take_to_work_stage_id(
self,
binding: Binding,
category_id: int
) -> str:
stages = await self._stage_map(binding, category_id)
candidates = [self.take_to_work_stage_id]
if category_id and ":" not in self.take_to_work_stage_id:
candidates.append(
f"C{category_id}:{self.take_to_work_stage_id}"
)
target = next((item for item in candidates if item in stages), None)
if target:
return target
raise RuntimeError(
"Стадия для взятия в работу "
f"{self.take_to_work_stage_id} не найдена в Битриксе"
)
async def _get_raw(self, binding: Binding, deal_id: str) -> dict | None:
data = await self.bitrix.call(binding, "crm.deal.get", {"id": deal_id})
return data.get("result") or None
@asynccontextmanager
async def _deal_lock(
self,
binding: Binding,
deal_id: str
) -> AsyncGenerator[None]:
key = (binding.member_id, deal_id)
async with self._deal_locks_guard:
entry = self._deal_locks.get(key)
if entry is None:
entry = _DealLockEntry(asyncio.Lock())
self._deal_locks[key] = entry
entry.users += 1
await entry.lock.acquire()
try:
yield
finally:
entry.lock.release()
async with self._deal_locks_guard:
entry.users -= 1
if entry.users == 0:
self._deal_locks.pop(key, None)
async def get_client_info(self, binding: Binding, deal: dict) -> ClientInfo:
contact = None
company = None
contact_id = str(deal.get("CONTACT_ID") or "")
if contact_id:
# https://apidocs.bitrix24.com/api-reference/crm/contacts/crm-contact-get.html
contact = await self._entity(binding, "crm.contact.get", contact_id)
company_id = str(deal.get("COMPANY_ID") or "")
if company_id:
# https://apidocs.bitrix24.com/api-reference/crm/companies/crm-company-get.html
company = await self._entity(binding, "crm.company.get", company_id)
return ClientInfo(
name=self._contact_name(contact),
company=self._company_name(company),
phone=(
self._phone_from_entity(contact) or self._phone_from_entity(
company)
)
)
async def get_source_name(self, binding: Binding,
source_id: str) -> str | None:
if not source_id:
return None
try:
# https://apidocs.bitrix24.ru/api-reference/crm/status/crm-status-list.html
data = await self.bitrix.call(
binding,
"crm.status.list",
{
"filter": {
"ENTITY_ID": "SOURCE",
"STATUS_ID": source_id
}
}
)
except Exception:
logger.exception("Failed to load Bitrix source name")
return None
sources = data.get("result", [])
return str(sources[0].get("NAME") or "") or None if sources else None
async def _entity(self, binding: Binding, method: str,
entity_id: str) -> dict:
data = await self.bitrix.call(binding, method, {"id": entity_id})
return data.get("result", {}) or {}
@staticmethod
def _phone_from_entity(entity: dict | None) -> str | None:
phones = (entity or {}).get("PHONE") or []
return str(phones[0].get("VALUE") or "") or None if phones else None
@staticmethod
def _contact_name(contact: dict | None) -> str | None:
if not contact:
return None
parts = [
str(contact.get("LAST_NAME") or "").strip(),
str(contact.get("NAME") or "").strip(),
str(contact.get("SECOND_NAME") or "").strip()
]
return " ".join(part for part in parts if part) or None
@staticmethod
def _company_name(company: dict | None) -> str | None:
if not company:
return None
return str(company.get("TITLE") or "").strip() or None