Проект разделен на два репозитория

This commit is contained in:
mi
2026-08-14 15:42:45 +03:00
parent e06a77ee1d
commit bbef7a30c9
521 changed files with 2597 additions and 2302 deletions
@@ -0,0 +1,106 @@
from __future__ import annotations
import hashlib
import random
import re
from dataclasses import dataclass
from datetime import UTC, datetime
from enum import StrEnum
from typing import Any
PHONE_RE = re.compile(r"^\+7[0-9]{10}$")
EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
class WorkflowState(StrEnum):
CREATED = "created"
RUNNING = "running"
WAITING_CRM = "waiting_crm"
WAITING_RETRY = "waiting_retry"
WAITING_MANUAL = "waiting_manual"
SUCCEEDED = "succeeded"
FAILED = "failed"
CANCELLED = "cancelled"
TRANSITIONS: dict[WorkflowState, frozenset[WorkflowState]] = {
WorkflowState.CREATED: frozenset({WorkflowState.RUNNING, WorkflowState.CANCELLED}),
WorkflowState.RUNNING: frozenset(
{
WorkflowState.WAITING_CRM,
WorkflowState.WAITING_RETRY,
WorkflowState.WAITING_MANUAL,
WorkflowState.SUCCEEDED,
WorkflowState.FAILED,
WorkflowState.CANCELLED,
}
),
WorkflowState.WAITING_CRM: frozenset(
{
WorkflowState.RUNNING,
WorkflowState.WAITING_RETRY,
WorkflowState.WAITING_MANUAL,
WorkflowState.FAILED,
}
),
WorkflowState.WAITING_RETRY: frozenset(
{WorkflowState.RUNNING, WorkflowState.WAITING_MANUAL, WorkflowState.FAILED}
),
WorkflowState.WAITING_MANUAL: frozenset(
{WorkflowState.RUNNING, WorkflowState.CANCELLED}
),
WorkflowState.SUCCEEDED: frozenset(),
WorkflowState.FAILED: frozenset(),
WorkflowState.CANCELLED: frozenset(),
}
def assert_transition(current: WorkflowState, target: WorkflowState) -> None:
if target not in TRANSITIONS[current]:
raise ValueError(f"forbidden workflow transition {current} -> {target}")
@dataclass(frozen=True)
class ContactCandidate:
b24_id: str
created_at: datetime
crm_user_id: str | None
def validate_phone(value: str) -> str:
if not PHONE_RE.fullmatch(value):
raise ValueError("phone must be Russian E.164 +7XXXXXXXXXX")
return value
def choose_newest(candidates: list[ContactCandidate]) -> ContactCandidate | None:
if not candidates:
return None
return max(candidates, key=lambda item: (item.created_at, int(item.b24_id)))
def select_email(items: list[dict[str, Any]]) -> str | None:
valid = [
item
for item in items
if isinstance(item.get("VALUE"), str) and EMAIL_RE.fullmatch(item["VALUE"])
]
work = next((item for item in valid if item.get("VALUE_TYPE") == "WORK"), None)
selected = work or (valid[0] if valid else None)
return selected["VALUE"] if selected else None
def safe_hash(value: str | None) -> str | None:
return hashlib.sha256(value.encode()).hexdigest() if value is not None else None
def full_jitter_delay(
attempt: int, base: float, maximum: float, *, rng: random.Random | None = None
) -> float:
ceiling = min(maximum, base * (2 ** max(0, attempt - 1)))
return (rng or random.SystemRandom()).uniform(0, ceiling)
def parse_crm_datetime(value: str) -> datetime:
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
return parsed.astimezone(UTC)