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