Финальная стабильная реализация
This commit is contained in:
@@ -0,0 +1,5 @@
|
|||||||
|
.env
|
||||||
|
*.log
|
||||||
|
node_modules/
|
||||||
|
__pycache__/
|
||||||
|
for_bugs_exchange/
|
||||||
@@ -30,6 +30,7 @@ BITRIX_SYNC_MODE=disabled
|
|||||||
BITRIX_SYNC_CONTACT_USER_ID_FIELD=UF_CRM_<latin_letters_or_digits>
|
BITRIX_SYNC_CONTACT_USER_ID_FIELD=UF_CRM_<latin_letters_or_digits>
|
||||||
BITRIX_SYNC_CONTACT_REGISTERED_FIELD=UF_CRM_<latin_letters_or_digits>
|
BITRIX_SYNC_CONTACT_REGISTERED_FIELD=UF_CRM_<latin_letters_or_digits>
|
||||||
BITRIX_SYNC_CONTACT_CITIZENSHIP_FIELD=UF_CRM_<latin_letters_or_digits>
|
BITRIX_SYNC_CONTACT_CITIZENSHIP_FIELD=UF_CRM_<latin_letters_or_digits>
|
||||||
|
BITRIX_SYNC_CONTACT_SOURCE=<bitrix-contact-source-id>
|
||||||
BITRIX_SYNC_PORTAL_HOST=<approved-portal>.bitrix24.ru
|
BITRIX_SYNC_PORTAL_HOST=<approved-portal>.bitrix24.ru
|
||||||
BITRIX_SYNC_PORTAL_MEMBER_ID=<approved-member-id>
|
BITRIX_SYNC_PORTAL_MEMBER_ID=<approved-member-id>
|
||||||
BITRIX_SYNC_PUBLIC_BASE_URL=https://<processing-public-host>
|
BITRIX_SYNC_PUBLIC_BASE_URL=https://<processing-public-host>
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ BITRIX_SYNC_PUBLIC_BASE_URL=https://sync.example.ru
|
|||||||
BITRIX_SYNC_CONTACT_USER_ID_FIELD=UF_CRM_100
|
BITRIX_SYNC_CONTACT_USER_ID_FIELD=UF_CRM_100
|
||||||
BITRIX_SYNC_CONTACT_REGISTERED_FIELD=UF_CRM_101
|
BITRIX_SYNC_CONTACT_REGISTERED_FIELD=UF_CRM_101
|
||||||
BITRIX_SYNC_CONTACT_CITIZENSHIP_FIELD=UF_CRM_102
|
BITRIX_SYNC_CONTACT_CITIZENSHIP_FIELD=UF_CRM_102
|
||||||
|
BITRIX_SYNC_CONTACT_SOURCE=WEB
|
||||||
BITRIX_SYNC_WEBHOOK_ALLOWED_CIDRS=203.0.113.0/24
|
BITRIX_SYNC_WEBHOOK_ALLOWED_CIDRS=203.0.113.0/24
|
||||||
BITRIX_SYNC_HTTP_TIMEOUT_SEC=10
|
BITRIX_SYNC_HTTP_TIMEOUT_SEC=10
|
||||||
BITRIX_SYNC_DB_POOL_SIZE=5
|
BITRIX_SYNC_DB_POOL_SIZE=5
|
||||||
|
|||||||
@@ -54,6 +54,15 @@ gates: локальный managed PostgreSQL не поднимается Compose
|
|||||||
4. Заполнить и активировать валидную `business_alerts` settings version:
|
4. Заполнить и активировать валидную `business_alerts` settings version:
|
||||||
entity/category/stage/field IDs и responsible party. Placeholder `null`
|
entity/category/stage/field IDs и responsible party. Placeholder `null`
|
||||||
запрещает alert receiver.
|
запрещает alert receiver.
|
||||||
|
|
||||||
|
`value_json` настройки `business_alerts` использует ключи `entity_type_id`,
|
||||||
|
`category_id`, `stage_new`, опциональный `responsible_id` и объект `field_ids`.
|
||||||
|
В `field_ids` REST-имена пользовательских полей сопоставляются ключам
|
||||||
|
`alert_number`, `fingerprint`, `alert_type`, `severity`, `app_user_id`,
|
||||||
|
`selected_external_id`, `occurrence_count`,
|
||||||
|
`first_occurred_at`, `last_occurred_at`, `workflow_id`.
|
||||||
|
Конфликтующие Contact передаются в стандартное поле `contactIds`, а значение
|
||||||
|
`BITRIX_SYNC_CONTACT_SOURCE` — в стандартное поле `sourceId`.
|
||||||
5. Проверить least-privilege credential negative tests; credential администратора
|
5. Проверить least-privilege credential negative tests; credential администратора
|
||||||
запрещён.
|
запрещён.
|
||||||
6. Валидировать nginx exact routes, no-redirect HTTP policy, body/rate limits,
|
6. Валидировать nginx exact routes, no-redirect HTTP policy, body/rate limits,
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ class Settings(BaseSettings):
|
|||||||
contact_user_id_field: str | None = None
|
contact_user_id_field: str | None = None
|
||||||
contact_registered_field: str | None = None
|
contact_registered_field: str | None = None
|
||||||
contact_citizenship_field: str | None = None
|
contact_citizenship_field: str | None = None
|
||||||
|
contact_source: str | None = Field(default=None, min_length=1, max_length=128)
|
||||||
webhook_allowed_cidrs: str = ""
|
webhook_allowed_cidrs: str = ""
|
||||||
|
|
||||||
http_timeout_sec: float = Field(default=10, ge=1, le=60)
|
http_timeout_sec: float = Field(default=10, ge=1, le=60)
|
||||||
@@ -97,6 +98,7 @@ class Settings(BaseSettings):
|
|||||||
"contact_user_id_field": self.contact_user_id_field,
|
"contact_user_id_field": self.contact_user_id_field,
|
||||||
"contact_registered_field": self.contact_registered_field,
|
"contact_registered_field": self.contact_registered_field,
|
||||||
"contact_citizenship_field": self.contact_citizenship_field,
|
"contact_citizenship_field": self.contact_citizenship_field,
|
||||||
|
"contact_source": self.contact_source,
|
||||||
}
|
}
|
||||||
missing = [name for name, value in required.items() if not value]
|
missing = [name for name, value in required.items() if not value]
|
||||||
if missing:
|
if missing:
|
||||||
@@ -110,6 +112,10 @@ class Settings(BaseSettings):
|
|||||||
raise ValueError(f"{name} must match UF_CRM_<latin letters or digits>")
|
raise ValueError(f"{name} must match UF_CRM_<latin letters or digits>")
|
||||||
if not MEMBER_RE.fullmatch(str(self.portal_member_id)):
|
if not MEMBER_RE.fullmatch(str(self.portal_member_id)):
|
||||||
raise ValueError("portal_member_id has invalid format")
|
raise ValueError("portal_member_id has invalid format")
|
||||||
|
assert self.contact_source
|
||||||
|
self.contact_source = self.contact_source.strip()
|
||||||
|
if not self.contact_source:
|
||||||
|
raise ValueError("contact_source cannot be blank")
|
||||||
|
|
||||||
crm = urlsplit(self.crm_rest_webhook_url.get_secret_value())
|
crm = urlsplit(self.crm_rest_webhook_url.get_secret_value())
|
||||||
public = urlsplit(str(self.public_base_url))
|
public = urlsplit(str(self.public_base_url))
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ from app.domain import (
|
|||||||
choose_newest,
|
choose_newest,
|
||||||
full_jitter_delay,
|
full_jitter_delay,
|
||||||
parse_crm_datetime,
|
parse_crm_datetime,
|
||||||
|
safe_hash,
|
||||||
select_email,
|
select_email,
|
||||||
validate_phone,
|
validate_phone,
|
||||||
)
|
)
|
||||||
@@ -199,6 +200,14 @@ class WorkflowEngine:
|
|||||||
alert_code: str | None = None
|
alert_code: str | None = None
|
||||||
if same_user:
|
if same_user:
|
||||||
selected_id = str(same_user["ID"])
|
selected_id = str(same_user["ID"])
|
||||||
|
if len(contacts) > 1:
|
||||||
|
await self._alert(
|
||||||
|
workflow_id,
|
||||||
|
profile.user_id,
|
||||||
|
"duplicate_contacts",
|
||||||
|
[str(item["ID"]) for item in contacts],
|
||||||
|
selected_external_id=selected_id,
|
||||||
|
)
|
||||||
elif not contacts:
|
elif not contacts:
|
||||||
selected_id = await self._create_contact(workflow_id, profile)
|
selected_id = await self._create_contact(workflow_id, profile)
|
||||||
else:
|
else:
|
||||||
@@ -214,15 +223,27 @@ class WorkflowEngine:
|
|||||||
assert selected is not None
|
assert selected is not None
|
||||||
all_ids = [item.b24_id for item in candidates]
|
all_ids = [item.b24_id for item in candidates]
|
||||||
if selected.crm_user_id and selected.crm_user_id != str(profile.user_id):
|
if selected.crm_user_id and selected.crm_user_id != str(profile.user_id):
|
||||||
selected_id = await self._create_contact(workflow_id, profile)
|
|
||||||
alert_code = "contact_owned_by_other_user"
|
alert_code = "contact_owned_by_other_user"
|
||||||
|
selected_id = await self._create_contact(workflow_id, profile)
|
||||||
|
await self._alert(
|
||||||
|
workflow_id,
|
||||||
|
profile.user_id,
|
||||||
|
alert_code,
|
||||||
|
[*all_ids, selected_id],
|
||||||
|
selected_external_id=selected_id,
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
selected_id = selected.b24_id
|
selected_id = selected.b24_id
|
||||||
await self._write_identity(workflow_id, selected_id, profile.user_id, active=True)
|
|
||||||
if len(candidates) > 1:
|
if len(candidates) > 1:
|
||||||
alert_code = "duplicate_contacts"
|
alert_code = "duplicate_contacts"
|
||||||
if alert_code:
|
await self._alert(
|
||||||
await self._alert(workflow_id, profile.user_id, alert_code, all_ids)
|
workflow_id,
|
||||||
|
profile.user_id,
|
||||||
|
alert_code,
|
||||||
|
all_ids,
|
||||||
|
selected_external_id=selected_id,
|
||||||
|
)
|
||||||
|
await self._write_identity(workflow_id, selected_id, profile.user_id, active=True)
|
||||||
await self._activate_mapping(workflow_id, profile.user_id, selected_id)
|
await self._activate_mapping(workflow_id, profile.user_id, selected_id)
|
||||||
|
|
||||||
async def _create_contact(self, workflow_id: uuid.UUID, profile: Profile) -> str:
|
async def _create_contact(self, workflow_id: uuid.UUID, profile: Profile) -> str:
|
||||||
@@ -551,33 +572,146 @@ class WorkflowEngine:
|
|||||||
)
|
)
|
||||||
|
|
||||||
async def _alert(
|
async def _alert(
|
||||||
self, workflow_id: uuid.UUID, user_id: uuid.UUID, alert_type: str, candidates: list[str]
|
self,
|
||||||
|
workflow_id: uuid.UUID,
|
||||||
|
user_id: uuid.UUID,
|
||||||
|
alert_type: str,
|
||||||
|
candidates: list[str],
|
||||||
|
*,
|
||||||
|
selected_external_id: str | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
fingerprint = f"{alert_type}:{user_id}"
|
fingerprint = safe_hash(f"{alert_type}:{user_id}")
|
||||||
|
assert fingerprint is not None
|
||||||
async with self.repository.transaction() as connection:
|
async with self.repository.transaction() as connection:
|
||||||
|
alert = (
|
||||||
await connection.execute(
|
await connection.execute(
|
||||||
text(
|
text(
|
||||||
"""
|
"""
|
||||||
INSERT INTO bitrix_sync.business_alerts
|
INSERT INTO bitrix_sync.business_alerts
|
||||||
(id,fingerprint,alert_type,severity,app_user_id,candidate_external_ids,
|
(id,fingerprint,alert_type,severity,app_user_id,
|
||||||
workflow_id,status,occurrence_count,first_occurred_at,last_occurred_at,
|
selected_external_id,candidate_external_ids,workflow_id,status,
|
||||||
created_at,updated_at)
|
occurrence_count,first_occurred_at,last_occurred_at,created_at,updated_at)
|
||||||
VALUES (gen_random_uuid(),encode(digest(:fingerprint,'sha256'),'hex'),
|
VALUES (gen_random_uuid(),:fingerprint,
|
||||||
:type,'warning',:user_id,:candidates,:workflow_id,'open',1,
|
:type,'warning',:user_id,:selected_external_id,:candidates,
|
||||||
now(),now(),now(),now())
|
:workflow_id,'open',1,now(),now(),now(),now())
|
||||||
ON CONFLICT (alert_type,fingerprint) WHERE status='open'
|
ON CONFLICT (alert_type,fingerprint) WHERE status='open'
|
||||||
DO UPDATE SET occurrence_count=business_alerts.occurrence_count+1,
|
DO UPDATE SET
|
||||||
|
selected_external_id=coalesce(
|
||||||
|
excluded.selected_external_id,
|
||||||
|
business_alerts.selected_external_id
|
||||||
|
),
|
||||||
|
candidate_external_ids=excluded.candidate_external_ids,
|
||||||
|
workflow_id=excluded.workflow_id,
|
||||||
|
occurrence_count=business_alerts.occurrence_count+1,
|
||||||
last_occurred_at=now(),updated_at=now()
|
last_occurred_at=now(),updated_at=now()
|
||||||
|
RETURNING id,alert_number,fingerprint,alert_type,severity,app_user_id,
|
||||||
|
selected_external_id,candidate_external_ids,remote_item_id,
|
||||||
|
occurrence_count,first_occurred_at,last_occurred_at
|
||||||
"""
|
"""
|
||||||
),
|
),
|
||||||
{
|
{
|
||||||
"fingerprint": fingerprint,
|
"fingerprint": fingerprint,
|
||||||
"type": alert_type,
|
"type": alert_type,
|
||||||
"user_id": user_id,
|
"user_id": user_id,
|
||||||
|
"selected_external_id": selected_external_id,
|
||||||
"candidates": candidates,
|
"candidates": candidates,
|
||||||
"workflow_id": workflow_id,
|
"workflow_id": workflow_id,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
).mappings().one()
|
||||||
|
alert_config = (
|
||||||
|
await connection.execute(
|
||||||
|
text(
|
||||||
|
"""
|
||||||
|
SELECT value_json FROM bitrix_sync.settings
|
||||||
|
WHERE key='business_alerts' AND active=true
|
||||||
|
AND validation_status='valid'
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
|
||||||
|
if not isinstance(alert_config, dict):
|
||||||
|
raise RetryableWorkflow("business_alerts_not_configured")
|
||||||
|
entity_type_id = alert_config.get("entity_type_id")
|
||||||
|
stage_new = alert_config.get("stage_new")
|
||||||
|
if not entity_type_id or not stage_new:
|
||||||
|
raise RetryableWorkflow("business_alerts_not_configured")
|
||||||
|
|
||||||
|
title = (
|
||||||
|
f"Конфликт синхронизации #{alert['alert_number']}: {alert_type}; "
|
||||||
|
f"user={user_id}; contacts={','.join(candidates)}"
|
||||||
|
)
|
||||||
|
fields: dict[str, Any] = {
|
||||||
|
"title": title[:255],
|
||||||
|
"stageId": stage_new,
|
||||||
|
"contactIds": [int(candidate) for candidate in candidates if candidate.isdigit()],
|
||||||
|
"sourceId": self.settings.contact_source,
|
||||||
|
}
|
||||||
|
if category_id := alert_config.get("category_id"):
|
||||||
|
fields["categoryId"] = category_id
|
||||||
|
if responsible_id := alert_config.get("responsible_id"):
|
||||||
|
fields["assignedById"] = responsible_id
|
||||||
|
alert_values = {
|
||||||
|
"alert_number": str(alert["alert_number"]),
|
||||||
|
"fingerprint": alert["fingerprint"],
|
||||||
|
"alert_type": alert["alert_type"],
|
||||||
|
"severity": alert["severity"],
|
||||||
|
"app_user_id": str(alert["app_user_id"]),
|
||||||
|
"selected_external_id": alert["selected_external_id"] or "",
|
||||||
|
"occurrence_count": alert["occurrence_count"],
|
||||||
|
"first_occurred_at": alert["first_occurred_at"].isoformat(),
|
||||||
|
"last_occurred_at": alert["last_occurred_at"].isoformat(),
|
||||||
|
"workflow_id": str(workflow_id),
|
||||||
|
}
|
||||||
|
field_ids = alert_config.get("field_ids")
|
||||||
|
if isinstance(field_ids, dict):
|
||||||
|
for value_name, field_id in field_ids.items():
|
||||||
|
if value_name in alert_values and isinstance(field_id, str) and field_id:
|
||||||
|
fields[field_id] = alert_values[value_name]
|
||||||
|
|
||||||
|
remote_item_id = alert["remote_item_id"]
|
||||||
|
if remote_item_id:
|
||||||
|
await self._command(
|
||||||
|
workflow_id,
|
||||||
|
"alert_update",
|
||||||
|
"crm.item.update",
|
||||||
|
{
|
||||||
|
"entityTypeId": int(entity_type_id),
|
||||||
|
"id": remote_item_id,
|
||||||
|
"fields": fields,
|
||||||
|
},
|
||||||
|
mutating=True,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
result = await self._command(
|
||||||
|
workflow_id,
|
||||||
|
"alert_add",
|
||||||
|
"crm.item.add",
|
||||||
|
{"entityTypeId": int(entity_type_id), "fields": fields},
|
||||||
|
mutating=True,
|
||||||
|
)
|
||||||
|
payload = result.result if isinstance(result.result, dict) else {}
|
||||||
|
item = payload.get("item", payload)
|
||||||
|
remote_item_id = item.get("id") if isinstance(item, dict) else None
|
||||||
|
if remote_item_id is None:
|
||||||
|
raise RetryableWorkflow("alert_item_id_missing")
|
||||||
|
async with self.repository.transaction() as connection:
|
||||||
|
await connection.execute(
|
||||||
|
text(
|
||||||
|
"""
|
||||||
|
UPDATE bitrix_sync.business_alerts
|
||||||
|
SET remote_item_id=:remote_item_id,remote_stage_id=:stage_id,updated_at=now()
|
||||||
|
WHERE id=:id
|
||||||
|
"""
|
||||||
|
),
|
||||||
|
{
|
||||||
|
"id": alert["id"],
|
||||||
|
"remote_item_id": str(remote_item_id),
|
||||||
|
"stage_id": str(stage_new),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
async def _manual(self, workflow_id: uuid.UUID, code: str, user_id: uuid.UUID) -> None:
|
async def _manual(self, workflow_id: uuid.UUID, code: str, user_id: uuid.UUID) -> None:
|
||||||
async with self.repository.transaction() as connection:
|
async with self.repository.transaction() as connection:
|
||||||
|
|||||||
@@ -45,13 +45,13 @@ class IncrementalReconciler:
|
|||||||
started_at = datetime.now(UTC)
|
started_at = datetime.now(UTC)
|
||||||
since = (cursor or datetime(1970, 1, 1, tzinfo=UTC)) - timedelta(seconds=overlap_seconds)
|
since = (cursor or datetime(1970, 1, 1, tzinfo=UTC)) - timedelta(seconds=overlap_seconds)
|
||||||
start = 0
|
start = 0
|
||||||
scanned: list[str] = []
|
scanned: list[tuple[str, str]] = []
|
||||||
while True:
|
while True:
|
||||||
result = await self.crm.call(
|
result = await self.crm.call(
|
||||||
"crm.item.list",
|
"crm.item.list",
|
||||||
{
|
{
|
||||||
"entityTypeId": 3,
|
"entityTypeId": 3,
|
||||||
"select": ["id"],
|
"select": ["id", "updatedTime"],
|
||||||
"filter": {
|
"filter": {
|
||||||
">=updatedTime": since.replace(tzinfo=None).isoformat(timespec="seconds"),
|
">=updatedTime": since.replace(tzinfo=None).isoformat(timespec="seconds"),
|
||||||
"opened": 1,
|
"opened": 1,
|
||||||
@@ -63,10 +63,15 @@ class IncrementalReconciler:
|
|||||||
)
|
)
|
||||||
if result.outcome != CrmOutcome.SUCCEEDED:
|
if result.outcome != CrmOutcome.SUCCEEDED:
|
||||||
raise RuntimeError(result.error_code or "reconciliation_failed")
|
raise RuntimeError(result.error_code or "reconciliation_failed")
|
||||||
payload: dict[str, Any] = result.result or {}
|
payload: Any = result.result or {}
|
||||||
items = payload.get("items", payload if isinstance(payload, list) else [])
|
items = payload.get("items", []) if isinstance(payload, dict) else payload
|
||||||
scanned.extend(str(item["id"]) for item in items if "id" in item)
|
if not isinstance(items, list):
|
||||||
next_start = payload.get("next")
|
raise RuntimeError("reconciliation_items_invalid")
|
||||||
|
for item in items:
|
||||||
|
if not isinstance(item, dict) or "id" not in item or "updatedTime" not in item:
|
||||||
|
raise RuntimeError("reconciliation_item_missing_identity")
|
||||||
|
scanned.append((str(item["id"]), str(item["updatedTime"])))
|
||||||
|
next_start = payload.get("next") if isinstance(payload, dict) else None
|
||||||
if next_start is None:
|
if next_start is None:
|
||||||
break
|
break
|
||||||
start = int(next_start)
|
start = int(next_start)
|
||||||
@@ -89,27 +94,35 @@ class IncrementalReconciler:
|
|||||||
)
|
)
|
||||||
return len(scanned)
|
return len(scanned)
|
||||||
|
|
||||||
async def _enqueue_changed(self, external_ids: list[str]) -> None:
|
async def _enqueue_changed(self, changed_contacts: list[tuple[str, str]]) -> None:
|
||||||
if not external_ids:
|
if not changed_contacts:
|
||||||
return
|
return
|
||||||
|
external_ids = [external_id for external_id, _ in changed_contacts]
|
||||||
|
event_ids = [
|
||||||
|
f"contact.reconciliation:{external_id}:{updated_time}"
|
||||||
|
for external_id, updated_time in changed_contacts
|
||||||
|
]
|
||||||
async with self.repository.transaction() as connection:
|
async with self.repository.transaction() as connection:
|
||||||
await connection.execute(
|
await connection.execute(
|
||||||
text(
|
text(
|
||||||
"""
|
"""
|
||||||
INSERT INTO bitrix_sync.webhook_inbox
|
INSERT INTO bitrix_sync.webhook_inbox
|
||||||
(id,receiver_type,event_type,external_entity_id,status,coalesced_count,
|
(id,receiver_type,event_type,event_id,external_entity_id,status,
|
||||||
received_at,last_received_at)
|
coalesced_count,received_at,last_received_at)
|
||||||
SELECT gen_random_uuid(),'contact','contact.reconciliation',id,'received',1,
|
SELECT gen_random_uuid(),'contact','contact.reconciliation',candidate.event_id,
|
||||||
now(),now()
|
candidate.external_id,'received',1,now(),now()
|
||||||
FROM unnest(CAST(:ids AS text[])) id
|
FROM unnest(CAST(:ids AS text[]),CAST(:event_ids AS text[]))
|
||||||
|
AS candidate(external_id,event_id)
|
||||||
WHERE NOT EXISTS (
|
WHERE NOT EXISTS (
|
||||||
SELECT 1 FROM bitrix_sync.webhook_inbox w
|
SELECT 1 FROM bitrix_sync.webhook_inbox w
|
||||||
WHERE w.receiver_type='contact' AND w.external_entity_id=id
|
WHERE w.receiver_type='contact'
|
||||||
|
AND w.external_entity_id=candidate.external_id
|
||||||
AND w.status IN ('received','processing','retry_wait')
|
AND w.status IN ('received','processing','retry_wait')
|
||||||
)
|
)
|
||||||
|
ON CONFLICT (receiver_type,event_id) WHERE event_id IS NOT NULL DO NOTHING
|
||||||
"""
|
"""
|
||||||
),
|
),
|
||||||
{"ids": external_ids},
|
{"ids": external_ids, "event_ids": event_ids},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ from typing import Any
|
|||||||
from sqlalchemy import text
|
from sqlalchemy import text
|
||||||
from sqlalchemy.ext.asyncio import AsyncConnection, AsyncEngine, create_async_engine
|
from sqlalchemy.ext.asyncio import AsyncConnection, AsyncEngine, create_async_engine
|
||||||
|
|
||||||
|
from app.domain import safe_hash
|
||||||
|
|
||||||
|
|
||||||
def postgres_ssl_context() -> ssl.SSLContext:
|
def postgres_ssl_context() -> ssl.SSLContext:
|
||||||
ca_file = os.environ.get("PG_CA_FILE")
|
ca_file = os.environ.get("PG_CA_FILE")
|
||||||
@@ -83,9 +85,12 @@ class Repository:
|
|||||||
WITH candidates AS (
|
WITH candidates AS (
|
||||||
SELECT id
|
SELECT id
|
||||||
FROM han_app.sync_queue
|
FROM han_app.sync_queue
|
||||||
WHERE status IN ('pending','retry_wait')
|
WHERE (
|
||||||
AND next_attempt_at <= now()
|
status IN ('pending','retry_wait') AND next_attempt_at <= now()
|
||||||
AND (locked_until IS NULL OR locked_until < now())
|
)
|
||||||
|
OR (
|
||||||
|
status='leased' AND locked_until < now()
|
||||||
|
)
|
||||||
ORDER BY next_attempt_at, created_at
|
ORDER BY next_attempt_at, created_at
|
||||||
FOR UPDATE SKIP LOCKED
|
FOR UPDATE SKIP LOCKED
|
||||||
LIMIT :limit
|
LIMIT :limit
|
||||||
@@ -156,7 +161,7 @@ class Repository:
|
|||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
async with self.engine.begin() as connection:
|
async with self.engine.begin() as connection:
|
||||||
return (
|
workflow_id = (
|
||||||
await connection.execute(
|
await connection.execute(
|
||||||
sql,
|
sql,
|
||||||
{
|
{
|
||||||
@@ -167,6 +172,17 @@ class Repository:
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
).scalar_one()
|
).scalar_one()
|
||||||
|
await connection.execute(
|
||||||
|
text(
|
||||||
|
"""
|
||||||
|
UPDATE bitrix_sync.workflow_instances
|
||||||
|
SET state='running',current_step='load_profile',updated_at=now()
|
||||||
|
WHERE id=:id AND state IN ('created','running')
|
||||||
|
"""
|
||||||
|
),
|
||||||
|
{"id": workflow_id},
|
||||||
|
)
|
||||||
|
return workflow_id
|
||||||
|
|
||||||
async def complete_task(self, task: LeasedTask, workflow_id: uuid.UUID) -> bool:
|
async def complete_task(self, task: LeasedTask, workflow_id: uuid.UUID) -> bool:
|
||||||
async with self.engine.begin() as connection:
|
async with self.engine.begin() as connection:
|
||||||
@@ -280,8 +296,12 @@ class Repository:
|
|||||||
"""
|
"""
|
||||||
WITH candidates AS (
|
WITH candidates AS (
|
||||||
SELECT id FROM bitrix_sync.webhook_inbox
|
SELECT id FROM bitrix_sync.webhook_inbox
|
||||||
WHERE status IN ('received','retry_wait') AND next_attempt_at<=now()
|
WHERE (
|
||||||
AND (locked_until IS NULL OR locked_until<now())
|
status IN ('received','retry_wait') AND next_attempt_at<=now()
|
||||||
|
)
|
||||||
|
OR (
|
||||||
|
status='processing' AND locked_until<now()
|
||||||
|
)
|
||||||
ORDER BY next_attempt_at,received_at
|
ORDER BY next_attempt_at,received_at
|
||||||
FOR UPDATE SKIP LOCKED LIMIT :limit
|
FOR UPDATE SKIP LOCKED LIMIT :limit
|
||||||
)
|
)
|
||||||
@@ -345,7 +365,8 @@ class Repository:
|
|||||||
text(
|
text(
|
||||||
"""
|
"""
|
||||||
UPDATE han_app.client_profiles
|
UPDATE han_app.client_profiles
|
||||||
SET full_name=:full_name,citizenship=:citizenship,email=:email,updated_at=now()
|
SET full_name=:full_name,citizenship=:citizenship,email=:email,
|
||||||
|
source_updated_at=:source_updated_at,updated_at=now()
|
||||||
WHERE user_id=:user_id AND record_status='A'
|
WHERE user_id=:user_id AND record_status='A'
|
||||||
"""
|
"""
|
||||||
),
|
),
|
||||||
@@ -354,6 +375,7 @@ class Repository:
|
|||||||
"full_name": full_name,
|
"full_name": full_name,
|
||||||
"citizenship": citizenship,
|
"citizenship": citizenship,
|
||||||
"email": email,
|
"email": email,
|
||||||
|
"source_updated_at": source_updated_at,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
await connection.execute(
|
await connection.execute(
|
||||||
@@ -363,14 +385,20 @@ class Repository:
|
|||||||
(id,mapping_id,user_id,external_id,full_name_hash,email_hash,
|
(id,mapping_id,user_id,external_id,full_name_hash,email_hash,
|
||||||
citizenship_hash,source_updated_at,last_applied_source,
|
citizenship_hash,source_updated_at,last_applied_source,
|
||||||
last_webhook_received_at,created_at,updated_at)
|
last_webhook_received_at,created_at,updated_at)
|
||||||
SELECT gen_random_uuid(),m.id,:user_id,:external_id,
|
SELECT gen_random_uuid(),m.id,:user_id,
|
||||||
encode(digest(coalesce(:full_name,''),'sha256'),'hex'),
|
CAST(:external_id AS varchar(128)),
|
||||||
encode(digest(coalesce(:email,''),'sha256'),'hex'),
|
CAST(:full_name_hash AS varchar(64)),
|
||||||
encode(digest(coalesce(:citizenship,''),'sha256'),'hex'),
|
CAST(:email_hash AS varchar(64)),
|
||||||
:source_updated_at,:source,
|
CAST(:citizenship_hash AS varchar(64)),
|
||||||
CASE WHEN :source='webhook' THEN now() END,now(),now()
|
CAST(:source_updated_at AS timestamptz),
|
||||||
|
CAST(:source AS varchar(24)),
|
||||||
|
CASE WHEN CAST(:source AS varchar(24))='webhook'
|
||||||
|
THEN now() END,
|
||||||
|
now(),now()
|
||||||
FROM bitrix_sync.entity_external_mapping m
|
FROM bitrix_sync.entity_external_mapping m
|
||||||
WHERE m.entity_id=:user_id AND m.external_id=:external_id AND m.status='active'
|
WHERE m.entity_id=:user_id
|
||||||
|
AND m.external_id=CAST(:external_id AS varchar(128))
|
||||||
|
AND m.status='active'
|
||||||
ON CONFLICT (mapping_id) DO UPDATE
|
ON CONFLICT (mapping_id) DO UPDATE
|
||||||
SET full_name_hash=excluded.full_name_hash,email_hash=excluded.email_hash,
|
SET full_name_hash=excluded.full_name_hash,email_hash=excluded.email_hash,
|
||||||
citizenship_hash=excluded.citizenship_hash,
|
citizenship_hash=excluded.citizenship_hash,
|
||||||
@@ -385,9 +413,9 @@ class Repository:
|
|||||||
{
|
{
|
||||||
"user_id": user_id,
|
"user_id": user_id,
|
||||||
"external_id": external_id,
|
"external_id": external_id,
|
||||||
"full_name": full_name,
|
"full_name_hash": safe_hash(full_name or ""),
|
||||||
"citizenship": citizenship,
|
"citizenship_hash": safe_hash(citizenship or ""),
|
||||||
"email": email,
|
"email_hash": safe_hash(email or ""),
|
||||||
"source_updated_at": source_updated_at,
|
"source_updated_at": source_updated_at,
|
||||||
"source": source,
|
"source": source,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ services:
|
|||||||
BITRIX_SYNC_CONTACT_USER_ID_FIELD: ${BITRIX_SYNC_CONTACT_USER_ID_FIELD:-}
|
BITRIX_SYNC_CONTACT_USER_ID_FIELD: ${BITRIX_SYNC_CONTACT_USER_ID_FIELD:-}
|
||||||
BITRIX_SYNC_CONTACT_REGISTERED_FIELD: ${BITRIX_SYNC_CONTACT_REGISTERED_FIELD:-}
|
BITRIX_SYNC_CONTACT_REGISTERED_FIELD: ${BITRIX_SYNC_CONTACT_REGISTERED_FIELD:-}
|
||||||
BITRIX_SYNC_CONTACT_CITIZENSHIP_FIELD: ${BITRIX_SYNC_CONTACT_CITIZENSHIP_FIELD:-}
|
BITRIX_SYNC_CONTACT_CITIZENSHIP_FIELD: ${BITRIX_SYNC_CONTACT_CITIZENSHIP_FIELD:-}
|
||||||
|
BITRIX_SYNC_CONTACT_SOURCE: ${BITRIX_SYNC_CONTACT_SOURCE:-}
|
||||||
BITRIX_SYNC_WEBHOOK_ALLOWED_CIDRS: ${BITRIX_WEBHOOK_ALLOWED_CIDRS:-}
|
BITRIX_SYNC_WEBHOOK_ALLOWED_CIDRS: ${BITRIX_WEBHOOK_ALLOWED_CIDRS:-}
|
||||||
volumes: &sync_secrets
|
volumes: &sync_secrets
|
||||||
- /run/han-chat/secrets/bitrix-sync/database-url:/run/secrets/bitrix_sync_database_url:ro
|
- /run/han-chat/secrets/bitrix-sync/database-url:/run/secrets/bitrix_sync_database_url:ro
|
||||||
|
|||||||
@@ -21,5 +21,6 @@ def full_settings() -> Settings:
|
|||||||
contact_user_id_field="UF_CRM_100",
|
contact_user_id_field="UF_CRM_100",
|
||||||
contact_registered_field="UF_CRM_101",
|
contact_registered_field="UF_CRM_101",
|
||||||
contact_citizenship_field="UF_CRM_102",
|
contact_citizenship_field="UF_CRM_102",
|
||||||
|
contact_source="WEB",
|
||||||
webhook_allowed_cidrs="203.0.113.0/24",
|
webhook_allowed_cidrs="203.0.113.0/24",
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ def test_full_mode_rejects_portal_host_mismatch() -> None:
|
|||||||
contact_user_id_field="UF_CRM_1",
|
contact_user_id_field="UF_CRM_1",
|
||||||
contact_registered_field="UF_CRM_2",
|
contact_registered_field="UF_CRM_2",
|
||||||
contact_citizenship_field="UF_CRM_3",
|
contact_citizenship_field="UF_CRM_3",
|
||||||
|
contact_source="WEB",
|
||||||
webhook_allowed_cidrs="203.0.113.0/24",
|
webhook_allowed_cidrs="203.0.113.0/24",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -56,6 +57,10 @@ def test_image_default_allows_compose_process_role_override() -> None:
|
|||||||
for source in (compose, production_compose):
|
for source in (compose, production_compose):
|
||||||
assert 'command: ["han-bitrix-sync-worker"]' in source
|
assert 'command: ["han-bitrix-sync-worker"]' in source
|
||||||
assert 'command: ["han-bitrix-sync-reconciliation"]' in source
|
assert 'command: ["han-bitrix-sync-reconciliation"]' in source
|
||||||
|
reconciliation_service = source.split(" bitrix-sync-reconciliation:", 1)[1].split(
|
||||||
|
"\n bitrix-sync-", 1
|
||||||
|
)[0]
|
||||||
|
assert 'restart: "no"' in reconciliation_service
|
||||||
|
|
||||||
|
|
||||||
def test_alembic_chain_preserves_legacy_baseline() -> None:
|
def test_alembic_chain_preserves_legacy_baseline() -> None:
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import uuid
|
import uuid
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from sqlalchemy.dialects.postgresql import JSONB
|
from sqlalchemy.dialects.postgresql import JSONB
|
||||||
@@ -14,9 +15,54 @@ from app.repository import Profile
|
|||||||
class Result:
|
class Result:
|
||||||
rowcount = 1
|
rowcount = 1
|
||||||
|
|
||||||
|
def __init__(self, *, row=None, scalar=None) -> None:
|
||||||
|
self.row = row
|
||||||
|
self.scalar = scalar
|
||||||
|
|
||||||
|
def mappings(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
def one(self):
|
||||||
|
return self.row
|
||||||
|
|
||||||
|
def scalar_one_or_none(self):
|
||||||
|
return self.scalar
|
||||||
|
|
||||||
|
|
||||||
class Connection:
|
class Connection:
|
||||||
|
def __init__(self, statements: list[str]) -> None:
|
||||||
|
self.statements = statements
|
||||||
|
|
||||||
async def execute(self, statement, params=None):
|
async def execute(self, statement, params=None):
|
||||||
|
sql = str(statement)
|
||||||
|
self.statements.append(sql)
|
||||||
|
if "INSERT INTO bitrix_sync.business_alerts" in sql:
|
||||||
|
now = datetime.now(UTC)
|
||||||
|
return Result(
|
||||||
|
row={
|
||||||
|
"id": uuid.uuid4(),
|
||||||
|
"alert_number": 1,
|
||||||
|
"fingerprint": "fingerprint",
|
||||||
|
"alert_type": params["type"],
|
||||||
|
"severity": "warning",
|
||||||
|
"app_user_id": params["user_id"],
|
||||||
|
"selected_external_id": params["selected_external_id"],
|
||||||
|
"candidate_external_ids": params["candidates"],
|
||||||
|
"remote_item_id": None,
|
||||||
|
"occurrence_count": 1,
|
||||||
|
"first_occurred_at": now,
|
||||||
|
"last_occurred_at": now,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if "SELECT value_json FROM bitrix_sync.settings" in sql:
|
||||||
|
return Result(
|
||||||
|
scalar={
|
||||||
|
"entity_type_id": 178,
|
||||||
|
"category_id": 5,
|
||||||
|
"stage_new": "DT178_5:NEW",
|
||||||
|
"field_ids": {"candidate_external_ids": "ufCrm_999"},
|
||||||
|
}
|
||||||
|
)
|
||||||
return Result()
|
return Result()
|
||||||
|
|
||||||
|
|
||||||
@@ -27,7 +73,7 @@ class FakeRepository:
|
|||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def transaction(self):
|
async def transaction(self):
|
||||||
yield Connection()
|
yield Connection(self.statements)
|
||||||
|
|
||||||
async def active_mapping(self, user_id):
|
async def active_mapping(self, user_id):
|
||||||
return self.mapping
|
return self.mapping
|
||||||
@@ -37,8 +83,16 @@ class FakeRepository:
|
|||||||
|
|
||||||
|
|
||||||
class FakeCrm:
|
class FakeCrm:
|
||||||
def __init__(self, user_id: uuid.UUID) -> None:
|
def __init__(
|
||||||
|
self,
|
||||||
|
user_id: uuid.UUID,
|
||||||
|
*,
|
||||||
|
existing_identity: bool = False,
|
||||||
|
foreign_owner: bool = False,
|
||||||
|
) -> None:
|
||||||
self.user_id = user_id
|
self.user_id = user_id
|
||||||
|
self.existing_identity = existing_identity
|
||||||
|
self.foreign_owner = foreign_owner
|
||||||
self.calls: list[tuple[str, dict]] = []
|
self.calls: list[tuple[str, dict]] = []
|
||||||
|
|
||||||
async def call(self, method, params, *, mutating):
|
async def call(self, method, params, *, mutating):
|
||||||
@@ -52,9 +106,19 @@ class FakeCrm:
|
|||||||
{
|
{
|
||||||
"ID": contact_id,
|
"ID": contact_id,
|
||||||
"CREATED_TIME": "2026-08-06T10:00:00Z",
|
"CREATED_TIME": "2026-08-06T10:00:00Z",
|
||||||
"UF_CRM_100": None,
|
"UF_CRM_100": (
|
||||||
|
str(self.user_id)
|
||||||
|
if self.existing_identity and contact_id == "10"
|
||||||
|
else str(uuid.UUID(int=1))
|
||||||
|
if self.foreign_owner and contact_id == "10"
|
||||||
|
else None
|
||||||
|
),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
if method == "crm.contact.add":
|
||||||
|
return CrmResult(CrmOutcome.SUCCEEDED, "11")
|
||||||
|
if method == "crm.item.add":
|
||||||
|
return CrmResult(CrmOutcome.SUCCEEDED, {"item": {"id": "500"}})
|
||||||
return CrmResult(CrmOutcome.SUCCEEDED, True)
|
return CrmResult(CrmOutcome.SUCCEEDED, True)
|
||||||
|
|
||||||
|
|
||||||
@@ -76,3 +140,46 @@ async def test_multiple_contacts_choose_numeric_newest(full_settings) -> None:
|
|||||||
updates = [params for method, params in crm.calls if method == "crm.contact.update"]
|
updates = [params for method, params in crm.calls if method == "crm.contact.update"]
|
||||||
assert updates[0]["id"] == "10"
|
assert updates[0]["id"] == "10"
|
||||||
assert not any(method == "crm.contact.add" for method, _ in crm.calls)
|
assert not any(method == "crm.contact.add" for method, _ in crm.calls)
|
||||||
|
assert any(method == "crm.item.add" for method, _ in crm.calls)
|
||||||
|
alert_add = next(params for method, params in crm.calls if method == "crm.item.add")
|
||||||
|
assert alert_add["fields"]["contactIds"] == [9, 10]
|
||||||
|
assert alert_add["fields"]["sourceId"] == "WEB"
|
||||||
|
assert "ufCrm_999" not in alert_add["fields"]
|
||||||
|
assert any("entity_external_mapping" in statement for statement in repository.statements)
|
||||||
|
assert not any("digest(" in statement for statement in repository.statements)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_recovered_duplicate_creates_alert_before_mapping(full_settings) -> None:
|
||||||
|
user_id = uuid.uuid4()
|
||||||
|
repository = FakeRepository()
|
||||||
|
crm = FakeCrm(user_id, existing_identity=True)
|
||||||
|
engine = WorkflowEngine(repository, crm, full_settings)
|
||||||
|
|
||||||
|
await engine._map_or_create(
|
||||||
|
uuid.uuid4(),
|
||||||
|
Profile(user_id=user_id, phone="+79001234567", identity_status="A", profile_status="A"),
|
||||||
|
)
|
||||||
|
|
||||||
|
methods = [method for method, _ in crm.calls]
|
||||||
|
assert "crm.item.add" in methods
|
||||||
|
assert "crm.contact.update" not in methods
|
||||||
|
assert any("entity_external_mapping" in statement for statement in repository.statements)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_foreign_owned_contact_alert_includes_new_contact(full_settings) -> None:
|
||||||
|
user_id = uuid.uuid4()
|
||||||
|
repository = FakeRepository()
|
||||||
|
crm = FakeCrm(user_id, foreign_owner=True)
|
||||||
|
engine = WorkflowEngine(repository, crm, full_settings)
|
||||||
|
|
||||||
|
await engine._map_or_create(
|
||||||
|
uuid.uuid4(),
|
||||||
|
Profile(user_id=user_id, phone="+79001234567", identity_status="A", profile_status="A"),
|
||||||
|
)
|
||||||
|
|
||||||
|
methods = [method for method, _ in crm.calls]
|
||||||
|
alert_add = next(params for method, params in crm.calls if method == "crm.item.add")
|
||||||
|
assert methods.index("crm.contact.add") < methods.index("crm.item.add")
|
||||||
|
assert alert_add["fields"]["contactIds"] == [9, 10, 11]
|
||||||
|
|||||||
@@ -1,11 +1,15 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
|
from datetime import UTC, datetime
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from app.repository import Repository
|
from app.domain import safe_hash
|
||||||
|
from app.reconciliation import IncrementalReconciler
|
||||||
|
from app.repository import LeasedTask, Repository
|
||||||
|
|
||||||
|
|
||||||
class FakeResult:
|
class FakeResult:
|
||||||
@@ -16,9 +20,15 @@ class FakeResult:
|
|||||||
def __iter__(self):
|
def __iter__(self):
|
||||||
return iter(self.rows)
|
return iter(self.rows)
|
||||||
|
|
||||||
|
def mappings(self):
|
||||||
|
return self
|
||||||
|
|
||||||
def scalar_one_or_none(self):
|
def scalar_one_or_none(self):
|
||||||
return self.scalar
|
return self.scalar
|
||||||
|
|
||||||
|
def scalar_one(self):
|
||||||
|
return self.scalar
|
||||||
|
|
||||||
|
|
||||||
class FakeConnection:
|
class FakeConnection:
|
||||||
async def execute(self, statement):
|
async def execute(self, statement):
|
||||||
@@ -38,10 +48,29 @@ class FakeConnection:
|
|||||||
|
|
||||||
|
|
||||||
class FakeEngine:
|
class FakeEngine:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.queries: list[str] = []
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def connect(self):
|
async def connect(self):
|
||||||
yield FakeConnection()
|
yield FakeConnection()
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def begin(self):
|
||||||
|
yield ClaimConnection(self.queries)
|
||||||
|
|
||||||
|
|
||||||
|
class ClaimConnection:
|
||||||
|
def __init__(self, queries: list[str]) -> None:
|
||||||
|
self.queries = queries
|
||||||
|
|
||||||
|
async def execute(self, statement, params):
|
||||||
|
sql = str(statement)
|
||||||
|
self.queries.append(sql)
|
||||||
|
if "INSERT INTO bitrix_sync.workflow_instances" in sql:
|
||||||
|
return FakeResult(scalar=params["id"])
|
||||||
|
return FakeResult()
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_status_uses_common_status_alias_for_workflows() -> None:
|
async def test_status_uses_common_status_alias_for_workflows() -> None:
|
||||||
@@ -55,3 +84,95 @@ async def test_status_uses_common_status_alias_for_workflows() -> None:
|
|||||||
assert result["commands"] == {"succeeded": 4}
|
assert result["commands"] == {"succeeded": 4}
|
||||||
assert result["webhook_lag_seconds"] == 1.5
|
assert result["webhook_lag_seconds"] == 1.5
|
||||||
assert result["settings_version"] == 1
|
assert result["settings_version"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_claims_recover_expired_leases() -> None:
|
||||||
|
repository = object.__new__(Repository)
|
||||||
|
engine = FakeEngine()
|
||||||
|
repository.engine = engine
|
||||||
|
|
||||||
|
assert await repository.claim_tasks("worker", 10, 60) == []
|
||||||
|
assert await repository.claim_webhooks("worker", 10, 60) == []
|
||||||
|
|
||||||
|
assert "status='leased' AND locked_until < now()" in engine.queries[0]
|
||||||
|
assert "status='processing' AND locked_until<now()" in engine.queries[1]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_create_workflow_marks_recovered_workflow_running() -> None:
|
||||||
|
repository = object.__new__(Repository)
|
||||||
|
engine = FakeEngine()
|
||||||
|
repository.engine = engine
|
||||||
|
task = LeasedTask(uuid.uuid4(), "contact.map_or_create", uuid.uuid4(), uuid.uuid4(), 0)
|
||||||
|
|
||||||
|
workflow_id = await repository.create_workflow(task)
|
||||||
|
|
||||||
|
assert isinstance(workflow_id, uuid.UUID)
|
||||||
|
assert "SET state='running'" in engine.queries[1]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_reconciliation_uses_unambiguous_external_id_alias() -> None:
|
||||||
|
queries: list[str] = []
|
||||||
|
|
||||||
|
class Connection:
|
||||||
|
async def execute(self, statement, params):
|
||||||
|
queries.append(str(statement))
|
||||||
|
return FakeResult()
|
||||||
|
|
||||||
|
class ReconciliationRepository:
|
||||||
|
@asynccontextmanager
|
||||||
|
async def transaction(self):
|
||||||
|
yield Connection()
|
||||||
|
|
||||||
|
reconciler = IncrementalReconciler(ReconciliationRepository(), None, "ufCrm_1")
|
||||||
|
await reconciler._enqueue_changed(
|
||||||
|
[
|
||||||
|
("29406", "2026-08-20T13:21:00+00:00"),
|
||||||
|
("29502", "2026-08-20T13:22:00+00:00"),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
assert "AS candidate(external_id,event_id)" in queries[0]
|
||||||
|
assert "w.external_entity_id=candidate.external_id" in queries[0]
|
||||||
|
assert "ON CONFLICT (receiver_type,event_id)" in queries[0]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_apply_crm_profile_hashes_without_database_digest() -> None:
|
||||||
|
calls: list[tuple[str, dict | None]] = []
|
||||||
|
|
||||||
|
class Connection:
|
||||||
|
async def execute(self, statement, params=None):
|
||||||
|
calls.append((str(statement), params))
|
||||||
|
return FakeResult()
|
||||||
|
|
||||||
|
class Engine:
|
||||||
|
@asynccontextmanager
|
||||||
|
async def begin(self):
|
||||||
|
yield Connection()
|
||||||
|
|
||||||
|
repository = object.__new__(Repository)
|
||||||
|
repository.engine = Engine()
|
||||||
|
source_updated_at = datetime.now(UTC)
|
||||||
|
|
||||||
|
await repository.apply_crm_profile(
|
||||||
|
uuid.uuid4(),
|
||||||
|
"29406",
|
||||||
|
full_name="Тестовый пользователь",
|
||||||
|
citizenship=None,
|
||||||
|
email=None,
|
||||||
|
source_updated_at=source_updated_at,
|
||||||
|
source="webhook",
|
||||||
|
)
|
||||||
|
|
||||||
|
profile_sql, profile_params = calls[1]
|
||||||
|
snapshot_sql, snapshot_params = calls[2]
|
||||||
|
assert "source_updated_at=:source_updated_at" in profile_sql
|
||||||
|
assert profile_params["source_updated_at"] == source_updated_at
|
||||||
|
assert "digest(" not in snapshot_sql
|
||||||
|
assert "CAST(:external_id AS varchar(128))" in snapshot_sql
|
||||||
|
assert "CAST(:source AS varchar(24))" in snapshot_sql
|
||||||
|
assert snapshot_params["full_name_hash"] == safe_hash("Тестовый пользователь")
|
||||||
|
assert snapshot_params["email_hash"] == safe_hash("")
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ x-bitrix-sync-environment: &bitrix-sync-environment
|
|||||||
BITRIX_SYNC_CONTACT_USER_ID_FIELD: ${BITRIX_SYNC_CONTACT_USER_ID_FIELD:?set contact field}
|
BITRIX_SYNC_CONTACT_USER_ID_FIELD: ${BITRIX_SYNC_CONTACT_USER_ID_FIELD:?set contact field}
|
||||||
BITRIX_SYNC_CONTACT_REGISTERED_FIELD: ${BITRIX_SYNC_CONTACT_REGISTERED_FIELD:?set registration field}
|
BITRIX_SYNC_CONTACT_REGISTERED_FIELD: ${BITRIX_SYNC_CONTACT_REGISTERED_FIELD:?set registration field}
|
||||||
BITRIX_SYNC_CONTACT_CITIZENSHIP_FIELD: ${BITRIX_SYNC_CONTACT_CITIZENSHIP_FIELD:?set citizenship field}
|
BITRIX_SYNC_CONTACT_CITIZENSHIP_FIELD: ${BITRIX_SYNC_CONTACT_CITIZENSHIP_FIELD:?set citizenship field}
|
||||||
|
BITRIX_SYNC_CONTACT_SOURCE: ${BITRIX_SYNC_CONTACT_SOURCE:?set contact source}
|
||||||
BITRIX_SYNC_WEBHOOK_ALLOWED_CIDRS: ${BITRIX_WEBHOOK_ALLOWED_CIDRS:-}
|
BITRIX_SYNC_WEBHOOK_ALLOWED_CIDRS: ${BITRIX_WEBHOOK_ALLOWED_CIDRS:-}
|
||||||
BITRIX_SYNC_HTTP_TIMEOUT_SEC: ${BITRIX_SYNC_HTTP_TIMEOUT_SEC:-10}
|
BITRIX_SYNC_HTTP_TIMEOUT_SEC: ${BITRIX_SYNC_HTTP_TIMEOUT_SEC:-10}
|
||||||
BITRIX_SYNC_DB_POOL_SIZE: ${BITRIX_SYNC_DB_POOL_SIZE:-5}
|
BITRIX_SYNC_DB_POOL_SIZE: ${BITRIX_SYNC_DB_POOL_SIZE:-5}
|
||||||
@@ -372,6 +373,7 @@ services:
|
|||||||
image: ${BITRIX_SYNC_IMAGE:?set immutable bitrix-sync image digest}
|
image: ${BITRIX_SYNC_IMAGE:?set immutable bitrix-sync image digest}
|
||||||
command: ["han-bitrix-sync-reconciliation"]
|
command: ["han-bitrix-sync-reconciliation"]
|
||||||
user: "10001:10001"
|
user: "10001:10001"
|
||||||
|
restart: "no"
|
||||||
environment: *bitrix-sync-environment
|
environment: *bitrix-sync-environment
|
||||||
volumes:
|
volumes:
|
||||||
- *postgres-ca-volume
|
- *postgres-ca-volume
|
||||||
|
|||||||
@@ -582,6 +582,7 @@ BITRIX_SYNC_MODE=full
|
|||||||
BITRIX_SYNC_CONTACT_USER_ID_FIELD=UF_CRM_...
|
BITRIX_SYNC_CONTACT_USER_ID_FIELD=UF_CRM_...
|
||||||
BITRIX_SYNC_CONTACT_REGISTERED_FIELD=UF_CRM_1778692456
|
BITRIX_SYNC_CONTACT_REGISTERED_FIELD=UF_CRM_1778692456
|
||||||
BITRIX_SYNC_CONTACT_CITIZENSHIP_FIELD=UF_CRM_...
|
BITRIX_SYNC_CONTACT_CITIZENSHIP_FIELD=UF_CRM_...
|
||||||
|
BITRIX_SYNC_CONTACT_SOURCE=<Bitrix source ID>
|
||||||
BITRIX_SYNC_PORTAL_HOST=<approved-host>
|
BITRIX_SYNC_PORTAL_HOST=<approved-host>
|
||||||
BITRIX_SYNC_PORTAL_MEMBER_ID=<approved-member-id>
|
BITRIX_SYNC_PORTAL_MEMBER_ID=<approved-member-id>
|
||||||
BITRIX_SYNC_PUBLIC_BASE_URL=https://<processing-public-host>
|
BITRIX_SYNC_PUBLIC_BASE_URL=https://<processing-public-host>
|
||||||
|
|||||||
Binary file not shown.
@@ -77,6 +77,9 @@
|
|||||||
30. #INFRASTRUCTURE перевести взаимодействие с signoz на TLS (сейчас OTEL_REMOTE_TLS_INSECURE=true)
|
30. #INFRASTRUCTURE перевести взаимодействие с signoz на TLS (сейчас OTEL_REMOTE_TLS_INSECURE=true)
|
||||||
31. #BACK_BUSINESS VM1 -> VM2: `curl -sS --cacert /etc/han/ca/vm2-internal-ca.crt "https://processing.internal:8443/internal/safety/status" | python3 -m json.tool` В коде захардкожен Redis в components = "degraded". Надо реализовать реальную проверку вместо костыля.
|
31. #BACK_BUSINESS VM1 -> VM2: `curl -sS --cacert /etc/han/ca/vm2-internal-ca.crt "https://processing.internal:8443/internal/safety/status" | python3 -m json.tool` В коде захардкожен Redis в components = "degraded". Надо реализовать реальную проверку вместо костыля.
|
||||||
32. #BACK_BUSINESS Решить проблему с обновлением сигнатур CLAMAV.
|
32. #BACK_BUSINESS Решить проблему с обновлением сигнатур CLAMAV.
|
||||||
|
33. #BACK_BUSINESS Создать поле "Номер телефона в приложении". Подробности реализации ниже (на подумать)
|
||||||
|
34. #BACK_BUSINESS реализация смены номера телефона. Подробности реализации ниже (на подумать)
|
||||||
|
35. #BACK_BUSINESS схлапывание контактов. Подробности реализации ниже (на подумать)
|
||||||
|
|
||||||
# Критично для релиза:
|
# Критично для релиза:
|
||||||
~~1. Разработка message-safety~~
|
~~1. Разработка message-safety~~
|
||||||
@@ -85,3 +88,170 @@
|
|||||||
~~4. Подключить OTLP-провайдер~~
|
~~4. Подключить OTLP-провайдер~~
|
||||||
~~5. Починить UI баги~~
|
~~5. Починить UI баги~~
|
||||||
6. Второй контур для продакшн
|
6. Второй контур для продакшн
|
||||||
|
|
||||||
|
|
||||||
|
33. details
|
||||||
|
Комплексный алгоритм:
|
||||||
|
|
||||||
|
1. Нормализовать номер приложения `P`.
|
||||||
|
2. Если active mapping существует:
|
||||||
|
- Contact принадлежит текущему `user_id` — обновить служебные поля и app-телефон;
|
||||||
|
- `user_id` пуст — восстановить служебные поля;
|
||||||
|
- указан чужой `user_id` — ничего не перезаписывать, создать alert `mapping_identity_mismatch`;
|
||||||
|
- Contact отсутствует — пометить mapping broken и создать alert.
|
||||||
|
3. Если mapping отсутствует, собрать объединённый список Contact:
|
||||||
|
- найденные по текущему `user_id`;
|
||||||
|
- найденные по новому полю app-телефона `P`;
|
||||||
|
- найденные через `duplicate.findbycomm` по стандартному `PHONE`.
|
||||||
|
4. Загрузить все карточки и классифицировать:
|
||||||
|
- принадлежат текущему пользователю;
|
||||||
|
- свободны — `user_id` пуст;
|
||||||
|
- принадлежат другому пользователю;
|
||||||
|
- app-телефон равен `P`;
|
||||||
|
- app-телефон пуст — legacy-контакт;
|
||||||
|
- app-телефон отличается от `P`.
|
||||||
|
|
||||||
|
Дальнейшие ветки:
|
||||||
|
|
||||||
|
- Найден Contact с тем же `user_id`:
|
||||||
|
- выбрать самый новый среди таких Contact;
|
||||||
|
- восстановить mapping;
|
||||||
|
- записать `P` в app-телефон;
|
||||||
|
- если таких Contact несколько или есть другие совпадения — создать duplicate alert со всеми ID.
|
||||||
|
|
||||||
|
- Контактов с тем же `user_id` нет:
|
||||||
|
- кандидатами считаются Contact с app-телефоном `P`;
|
||||||
|
- также допускаются legacy-контакты с пустым app-телефоном, найденные по стандартному `PHONE`;
|
||||||
|
- Contact с другим непустым app-телефоном нельзя автоматически привязывать.
|
||||||
|
|
||||||
|
- Один кандидат свободен:
|
||||||
|
- записать `user_id`, registration flag и app-телефон;
|
||||||
|
- создать mapping.
|
||||||
|
|
||||||
|
- Несколько кандидатов:
|
||||||
|
- выбрать самый новый по `CREATED_TIME`, затем по числовому ID;
|
||||||
|
- если выбранный свободен — привязать его и создать `duplicate_contacts`;
|
||||||
|
- если выбранный принадлежит другому пользователю — создать новый Contact и alert `contact_owned_by_other_user`;
|
||||||
|
- в `contactIds` alert передать все старые ID и ID нового Contact.
|
||||||
|
|
||||||
|
- Совпадения есть только по стандартному `PHONE`, но у них указан другой app-телефон:
|
||||||
|
- не считать их Contact текущего пользователя;
|
||||||
|
- создать новый Contact;
|
||||||
|
- создать alert о совпадении общего номера.
|
||||||
|
|
||||||
|
- Совпадений нет:
|
||||||
|
- создать новый Contact без alert.
|
||||||
|
|
||||||
|
Дополнительные правила:
|
||||||
|
|
||||||
|
- app-телефон — App-master: изменения этого поля в Б24 не меняют номер авторизации.
|
||||||
|
- При смене номера приложение обновляет app-телефон и гарантирует наличие нового номера в стандартном `PHONE`, но не удаляет остальные номера Contact.
|
||||||
|
- Перед повторным `contact.add` после timeout необходимо повторно искать по `user_id` и app-телефону, чтобы не создать дубль.
|
||||||
|
- Mapping создаётся только после успешной записи служебных полей.
|
||||||
|
- Все конфликтные ветки должны иметь отдельные регрессионные тесты до реализации.
|
||||||
|
|
||||||
|
|
||||||
|
34. details
|
||||||
|
Изменение стандартного `PHONE` в Битрикс24 не должно автоматически менять номер авторизации в приложении. Иначе сотрудник с доступом к CRM сможет случайно или намеренно передать чужой аккаунт другому номеру.
|
||||||
|
|
||||||
|
Рекомендуемый процесс:
|
||||||
|
|
||||||
|
1. Сотрудник меняет стандартный номер в карточке.
|
||||||
|
2. Webhook фиксирует расхождение с полем «Номер телефона в приложении».
|
||||||
|
3. Система создаёт отдельный запрос/смарт-процесс «Смена номера приложения».
|
||||||
|
4. Сотрудник явно выбирает, какой из номеров Contact является новым номером приложения.
|
||||||
|
5. Выполняются проверки:
|
||||||
|
- нормализация E.164;
|
||||||
|
- новый номер не занят другим пользователем;
|
||||||
|
- Contact действительно связан с нужным `user_id`;
|
||||||
|
- нет другого активного запроса.
|
||||||
|
6. Клиент подтверждает смену:
|
||||||
|
- OTP на новый номер;
|
||||||
|
- подтверждение в активной сессии приложения;
|
||||||
|
- желательно OTP на старый номер, если он доступен.
|
||||||
|
7. Если старый номер недоступен — отдельный усиленный сценарий идентификации сотрудником с обязательным аудитом и, желательно, дополнительным согласованием.
|
||||||
|
8. После подтверждения App меняет номер в своём источнике истины — БД приложения/Keycloak.
|
||||||
|
9. Через outbox создаётся задача синхронизации.
|
||||||
|
10. Bitrix Sync обновляет:
|
||||||
|
- поле «Номер телефона в приложении»;
|
||||||
|
- служебные поля связи;
|
||||||
|
- при необходимости добавляет новый номер в стандартный `PHONE`;
|
||||||
|
- закрывает запрос на смену номера.
|
||||||
|
|
||||||
|
Важные правила:
|
||||||
|
|
||||||
|
- Обычное редактирование `PHONE` только создаёт предложение на смену, но не меняет логин.
|
||||||
|
- Поле «Номер телефона в приложении» остаётся read-only для сотрудников.
|
||||||
|
- Старые дополнительные номера Contact автоматически не удаляются.
|
||||||
|
- Нужны срок действия запроса, ограничение попыток OTP, журнал сотрудника и времени операции.
|
||||||
|
- Повторный запрос должен быть идемпотентным.
|
||||||
|
|
||||||
|
Так сохраняется удобство работы через Б24, но CRM не становится небезопасным источником истины для номера авторизации.
|
||||||
|
|
||||||
|
35. details
|
||||||
|
Здесь нужно объединять не только карточки Битрикс24, но прежде всего два аккаунта приложения. Простое слияние Contact оставит данные личного кабинета привязанными к старому `user_id`.
|
||||||
|
|
||||||
|
Рекомендуемый сценарий account merge.
|
||||||
|
|
||||||
|
1. Создать заявку на объединение:
|
||||||
|
- старый `user_id` и старый Contact;
|
||||||
|
- новый `user_id` и новый Contact;
|
||||||
|
- результат комплайенса;
|
||||||
|
- оператор и подтверждающий сотрудник;
|
||||||
|
- причина и audit trail.
|
||||||
|
|
||||||
|
2. Выбрать канонический аккаунт. Обычно это старый аккаунт, содержащий историю и данные. Новый пустой аккаунт становится поглощаемым.
|
||||||
|
|
||||||
|
3. На время операции заблокировать изменения обоих аккаунтов и повторные merge-заявки.
|
||||||
|
|
||||||
|
4. Проверить конфликты:
|
||||||
|
- заказы, документы, согласия;
|
||||||
|
- бонусы, баланс и другие финансовые данные;
|
||||||
|
- активные процессы;
|
||||||
|
- наличие данных в новом аккаунте;
|
||||||
|
- отсутствие третьего аккаунта с новым номером.
|
||||||
|
|
||||||
|
5. На стороне приложения:
|
||||||
|
- привязать новый номер к каноническому старому аккаунту;
|
||||||
|
- старый номер сделать неактивным либо историческим;
|
||||||
|
- перенести допустимые данные нового аккаунта;
|
||||||
|
- отметить новый `user_id` как `merged_into=<canonical_user_id>`;
|
||||||
|
- запретить дальнейшее использование поглощённого аккаунта;
|
||||||
|
- обновить Keycloak/механизм авторизации;
|
||||||
|
- отозвать старые сессии и потребовать повторный вход.
|
||||||
|
|
||||||
|
6. Только после успешного App merge обработать Битрикс:
|
||||||
|
- старый Contact выбрать каноническим;
|
||||||
|
- записать в него канонический `user_id`;
|
||||||
|
- поле «Номер телефона в приложении» установить в новый номер;
|
||||||
|
- объединить стандартные номера обоих Contact;
|
||||||
|
- создать active mapping канонического пользователя на канонический Contact;
|
||||||
|
- у второго Contact очистить app-телефон, `user_id` и registration flag;
|
||||||
|
- затем объединить его с каноническим Contact штатным механизмом Б24 либо архивировать как поглощённый.
|
||||||
|
|
||||||
|
7. Закрыть старые mapping со специальной причиной `account_merge`, но сохранить историю.
|
||||||
|
|
||||||
|
8. Завершить заявку только после проверок:
|
||||||
|
- вход по новому номеру открывает старый личный кабинет;
|
||||||
|
- существует один active App user;
|
||||||
|
- существует один active mapping;
|
||||||
|
- только один Contact содержит app-телефон и registration flag;
|
||||||
|
- поглощённый аккаунт больше не создаёт задачи синхронизации.
|
||||||
|
|
||||||
|
Ключевой принцип: сначала объединение аккаунтов приложения, затем CRM. Если сначала слить карточки Б24, это не вернёт пользователю данные.
|
||||||
|
|
||||||
|
Для устойчивой реализации потребуется отдельная audited-процедура наподобие:
|
||||||
|
|
||||||
|
```text
|
||||||
|
request_account_merge(
|
||||||
|
canonical_user_id,
|
||||||
|
absorbed_user_id,
|
||||||
|
canonical_contact_id,
|
||||||
|
absorbed_contact_id,
|
||||||
|
reason,
|
||||||
|
operator_id,
|
||||||
|
compliance_case_id
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
Операция должна быть идемпотентной, поэтапной и восстанавливаемой после сбоя. Для финансовых или юридически значимых данных автоматический перенос без отдельных правил недопустим.
|
||||||
Reference in New Issue
Block a user