357 lines
15 KiB
Python
357 lines
15 KiB
Python
"""Create durable bitrix_sync schema and contracts.
|
|
|
|
Revision ID: 0001_bitrix_sync_full
|
|
Revises: 0001_sync_baseline
|
|
Create Date: 2026-08-06
|
|
"""
|
|
|
|
from collections.abc import Sequence
|
|
|
|
from alembic import op
|
|
|
|
revision: str = "0001_bitrix_sync_full"
|
|
down_revision: str | None = "0001_sync_baseline"
|
|
branch_labels: str | Sequence[str] | None = None
|
|
depends_on: str | Sequence[str] | None = None
|
|
|
|
|
|
def _execute_script(script: str) -> None:
|
|
"""Execute simple DDL statements separately for asyncpg compatibility."""
|
|
for statement in script.split(";"):
|
|
if statement.strip():
|
|
op.execute(statement)
|
|
|
|
|
|
def upgrade() -> None:
|
|
op.execute("CREATE EXTENSION IF NOT EXISTS pgcrypto")
|
|
op.execute("CREATE SCHEMA IF NOT EXISTS bitrix_sync")
|
|
_execute_script(
|
|
"""
|
|
CREATE TABLE bitrix_sync.workflow_instances (
|
|
id uuid PRIMARY KEY,
|
|
workflow_type varchar(64) NOT NULL CHECK (workflow_type IN
|
|
('contact.map_or_create','contact.update','contact.deactivate','contact.rebind',
|
|
'contact.webhook','contact.reconciliation','alert.reconciliation')),
|
|
user_id uuid,
|
|
external_id varchar(128),
|
|
state varchar(24) NOT NULL CHECK (state IN
|
|
('created','running','waiting_crm','waiting_retry','waiting_manual',
|
|
'succeeded','failed','cancelled')),
|
|
current_step varchar(64) NOT NULL,
|
|
source_task_id uuid UNIQUE,
|
|
deadline_at timestamptz NOT NULL,
|
|
outcome varchar(64),
|
|
completed_at timestamptz,
|
|
created_at timestamptz NOT NULL DEFAULT now(),
|
|
updated_at timestamptz NOT NULL DEFAULT now()
|
|
);
|
|
CREATE INDEX ix_workflow_claim
|
|
ON bitrix_sync.workflow_instances(state,updated_at)
|
|
WHERE state IN ('created','running','waiting_crm','waiting_retry');
|
|
|
|
CREATE TABLE bitrix_sync.entity_external_mapping (
|
|
id uuid PRIMARY KEY,
|
|
entity_type varchar(64) NOT NULL,
|
|
entity_id uuid NOT NULL,
|
|
external_system varchar(32) NOT NULL DEFAULT 'bitrix24'
|
|
CHECK (external_system='bitrix24'),
|
|
external_entity_type varchar(32) NOT NULL DEFAULT 'contact'
|
|
CHECK (external_entity_type='contact'),
|
|
external_id varchar(128) NOT NULL,
|
|
status varchar(16) NOT NULL CHECK (status IN ('active','closed','broken')),
|
|
opened_at timestamptz NOT NULL,
|
|
closed_at timestamptz,
|
|
close_reason varchar(64),
|
|
workflow_id uuid REFERENCES bitrix_sync.workflow_instances(id),
|
|
created_at timestamptz NOT NULL DEFAULT now(),
|
|
updated_at timestamptz NOT NULL DEFAULT now(),
|
|
CHECK ((status='active' AND closed_at IS NULL) OR
|
|
(status IN ('closed','broken')
|
|
AND (closed_at IS NOT NULL OR close_reason IS NOT NULL)))
|
|
);
|
|
CREATE UNIQUE INDEX uq_mapping_active_entity
|
|
ON bitrix_sync.entity_external_mapping(external_system,entity_type,entity_id)
|
|
WHERE status='active';
|
|
CREATE UNIQUE INDEX uq_mapping_active_external
|
|
ON bitrix_sync.entity_external_mapping
|
|
(external_system,external_entity_type,external_id)
|
|
WHERE status='active';
|
|
CREATE INDEX ix_mapping_history
|
|
ON bitrix_sync.entity_external_mapping(entity_id,opened_at DESC);
|
|
|
|
CREATE TABLE bitrix_sync.crm_commands (
|
|
id uuid PRIMARY KEY,
|
|
workflow_id uuid NOT NULL REFERENCES bitrix_sync.workflow_instances(id),
|
|
command_type varchar(40) NOT NULL CHECK (command_type IN
|
|
('duplicate_find','contact_get','contact_add','contact_update',
|
|
'citizenship_fields_get','contact_incremental_list',
|
|
'alert_get','alert_add','alert_update',
|
|
'rebind_target_get','rebind_old_get')),
|
|
safe_request jsonb NOT NULL DEFAULT '{}',
|
|
status varchar(24) NOT NULL CHECK (status IN
|
|
('pending','leased','in_flight','succeeded','retry','retry_wait',
|
|
'uncertain','reconcile','dead_letter','permanent','rate_limited')),
|
|
attempt_count integer NOT NULL DEFAULT 0 CHECK (attempt_count>=0),
|
|
next_attempt_at timestamptz NOT NULL DEFAULT now(),
|
|
locked_by varchar(128),
|
|
locked_until timestamptz,
|
|
lease_token uuid,
|
|
batch_id uuid,
|
|
correlation_id uuid,
|
|
safe_response jsonb,
|
|
safe_error_code varchar(64),
|
|
http_status integer,
|
|
completed_at timestamptz,
|
|
created_at timestamptz NOT NULL DEFAULT now(),
|
|
updated_at timestamptz NOT NULL DEFAULT now()
|
|
);
|
|
CREATE INDEX ix_crm_command_claim
|
|
ON bitrix_sync.crm_commands(status,next_attempt_at,created_at);
|
|
CREATE INDEX ix_crm_command_workflow
|
|
ON bitrix_sync.crm_commands(workflow_id,created_at);
|
|
|
|
CREATE TABLE bitrix_sync.webhook_inbox (
|
|
id uuid PRIMARY KEY,
|
|
receiver_type varchar(16) NOT NULL CHECK (receiver_type IN ('contact','alert')),
|
|
event_type varchar(64) NOT NULL,
|
|
event_id varchar(255),
|
|
source_timestamp timestamptz,
|
|
external_entity_id varchar(128) NOT NULL,
|
|
dedup_fingerprint varchar(64),
|
|
source_ip inet,
|
|
status varchar(16) NOT NULL CHECK (status IN
|
|
('received','coalesced','processing','processed','retry_wait','dead_letter')),
|
|
coalesced_count integer NOT NULL DEFAULT 1,
|
|
attempt_count integer NOT NULL DEFAULT 0,
|
|
next_attempt_at timestamptz NOT NULL DEFAULT now(),
|
|
locked_by varchar(128),
|
|
locked_until timestamptz,
|
|
lease_token uuid,
|
|
received_at timestamptz NOT NULL,
|
|
last_received_at timestamptz NOT NULL,
|
|
processed_at timestamptz,
|
|
safe_error_code varchar(64)
|
|
);
|
|
CREATE UNIQUE INDEX uq_webhook_event_id
|
|
ON bitrix_sync.webhook_inbox(receiver_type,event_id) WHERE event_id IS NOT NULL;
|
|
CREATE INDEX ix_webhook_claim
|
|
ON bitrix_sync.webhook_inbox(status,next_attempt_at,received_at);
|
|
CREATE UNIQUE INDEX uq_webhook_active_entity
|
|
ON bitrix_sync.webhook_inbox(receiver_type,external_entity_id)
|
|
WHERE status IN ('received','processing','retry_wait');
|
|
"""
|
|
)
|
|
_execute_script(
|
|
"""
|
|
CREATE SEQUENCE bitrix_sync.business_alert_number_seq;
|
|
CREATE TABLE bitrix_sync.business_alerts (
|
|
id uuid PRIMARY KEY,
|
|
alert_number bigint NOT NULL DEFAULT nextval('bitrix_sync.business_alert_number_seq'),
|
|
fingerprint varchar(64) NOT NULL,
|
|
alert_type varchar(64) NOT NULL,
|
|
severity varchar(16) NOT NULL CHECK (severity IN ('info','warning','critical')),
|
|
app_user_id uuid,
|
|
current_external_id varchar(128),
|
|
selected_external_id varchar(128),
|
|
candidate_external_ids text[] NOT NULL DEFAULT '{}',
|
|
remote_item_id varchar(128),
|
|
remote_stage_id varchar(128),
|
|
previous_alert_id uuid REFERENCES bitrix_sync.business_alerts(id),
|
|
workflow_id uuid REFERENCES bitrix_sync.workflow_instances(id),
|
|
status varchar(24) NOT NULL CHECK (status IN
|
|
('open','in_progress','resolved','closed_without_resolution','remote_missing')),
|
|
occurrence_count integer NOT NULL DEFAULT 1,
|
|
first_occurred_at timestamptz NOT NULL,
|
|
last_occurred_at timestamptz NOT NULL,
|
|
created_at timestamptz NOT NULL DEFAULT now(),
|
|
updated_at timestamptz NOT NULL DEFAULT now()
|
|
);
|
|
CREATE UNIQUE INDEX uq_business_alert_open
|
|
ON bitrix_sync.business_alerts(alert_type,fingerprint) WHERE status='open';
|
|
CREATE INDEX ix_business_alert_remote
|
|
ON bitrix_sync.business_alerts(remote_item_id) WHERE remote_item_id IS NOT NULL;
|
|
|
|
CREATE TABLE bitrix_sync.rebind_requests (
|
|
id uuid PRIMARY KEY,
|
|
user_id uuid NOT NULL,
|
|
old_external_id varchar(128),
|
|
target_external_id varchar(128) NOT NULL,
|
|
reason varchar(500) NOT NULL,
|
|
operator_id varchar(128) NOT NULL,
|
|
workflow_id uuid NOT NULL UNIQUE REFERENCES bitrix_sync.workflow_instances(id),
|
|
status varchar(24) NOT NULL CHECK (status IN
|
|
('pending','processing','retry_wait','succeeded','failed','cancelled')),
|
|
safe_error_code varchar(64),
|
|
requested_at timestamptz NOT NULL DEFAULT now(),
|
|
completed_at timestamptz,
|
|
created_at timestamptz NOT NULL DEFAULT now(),
|
|
updated_at timestamptz NOT NULL DEFAULT now()
|
|
);
|
|
CREATE INDEX ix_rebind_claim
|
|
ON bitrix_sync.rebind_requests(status,requested_at)
|
|
WHERE status IN ('pending','retry_wait');
|
|
|
|
CREATE TABLE bitrix_sync.contact_snapshots (
|
|
id uuid PRIMARY KEY,
|
|
mapping_id uuid NOT NULL REFERENCES bitrix_sync.entity_external_mapping(id),
|
|
user_id uuid NOT NULL,
|
|
external_id varchar(128) NOT NULL,
|
|
full_name_hash varchar(64),
|
|
email_hash varchar(64),
|
|
phone_hash varchar(64),
|
|
citizenship_hash varchar(64),
|
|
citizenship_enum_id varchar(128),
|
|
citizenship_dictionary_loaded_at timestamptz,
|
|
source_updated_at timestamptz,
|
|
app_version varchar(128),
|
|
last_applied_source varchar(24) NOT NULL CHECK (last_applied_source IN
|
|
('webhook','reconciliation','app_create','app_update')),
|
|
last_webhook_received_at timestamptz,
|
|
last_webhook_source_at timestamptz,
|
|
profile_stale boolean NOT NULL DEFAULT false,
|
|
created_at timestamptz NOT NULL DEFAULT now(),
|
|
updated_at timestamptz NOT NULL DEFAULT now(),
|
|
UNIQUE(mapping_id)
|
|
);
|
|
CREATE INDEX ix_contact_snapshot_source
|
|
ON bitrix_sync.contact_snapshots(source_updated_at);
|
|
|
|
CREATE TABLE bitrix_sync.settings_versions (
|
|
id uuid PRIMARY KEY,
|
|
version bigint NOT NULL UNIQUE,
|
|
validation_status varchar(16) NOT NULL
|
|
CHECK (validation_status IN ('pending','valid','invalid')),
|
|
validation_errors jsonb NOT NULL DEFAULT '[]',
|
|
active boolean NOT NULL DEFAULT false,
|
|
created_by varchar(128) NOT NULL,
|
|
created_at timestamptz NOT NULL DEFAULT now(),
|
|
activated_at timestamptz
|
|
);
|
|
CREATE UNIQUE INDEX uq_settings_version_active
|
|
ON bitrix_sync.settings_versions(active) WHERE active=true;
|
|
|
|
CREATE TABLE bitrix_sync.settings (
|
|
id uuid PRIMARY KEY,
|
|
version_id uuid NOT NULL REFERENCES bitrix_sync.settings_versions(id),
|
|
key varchar(128) NOT NULL,
|
|
value_type varchar(16) NOT NULL
|
|
CHECK (value_type IN ('integer','number','boolean','object')),
|
|
value_json jsonb NOT NULL,
|
|
validation_status varchar(16) NOT NULL CHECK (validation_status IN ('valid','invalid')),
|
|
active boolean NOT NULL DEFAULT false,
|
|
created_at timestamptz NOT NULL DEFAULT now(),
|
|
updated_at timestamptz NOT NULL DEFAULT now(),
|
|
UNIQUE(version_id,key)
|
|
);
|
|
CREATE INDEX ix_settings_active ON bitrix_sync.settings(key) WHERE active=true;
|
|
|
|
CREATE TABLE bitrix_sync.technical_dead_letters (
|
|
id uuid PRIMARY KEY,
|
|
workflow_id uuid REFERENCES bitrix_sync.workflow_instances(id),
|
|
command_id uuid REFERENCES bitrix_sync.crm_commands(id),
|
|
operation varchar(64) NOT NULL,
|
|
safe_error_code varchar(64) NOT NULL,
|
|
attempt_count integer NOT NULL DEFAULT 0,
|
|
deadline_at timestamptz,
|
|
correlation_id uuid,
|
|
failed_at timestamptz NOT NULL,
|
|
created_at timestamptz NOT NULL DEFAULT now()
|
|
);
|
|
CREATE INDEX ix_technical_dlq_failed
|
|
ON bitrix_sync.technical_dead_letters(failed_at DESC);
|
|
|
|
CREATE TABLE bitrix_sync.reconciliation_cursors (
|
|
job_type varchar(64) PRIMARY KEY,
|
|
watermark timestamptz NOT NULL,
|
|
overlap_seconds integer NOT NULL CHECK (overlap_seconds>=0),
|
|
last_success_at timestamptz,
|
|
last_scanned_count integer NOT NULL DEFAULT 0,
|
|
last_updated_count integer NOT NULL DEFAULT 0,
|
|
recovered_without_webhook_count integer NOT NULL DEFAULT 0,
|
|
created_at timestamptz NOT NULL DEFAULT now(),
|
|
updated_at timestamptz NOT NULL DEFAULT now()
|
|
);
|
|
|
|
CREATE TABLE bitrix_sync.limiter_coordination (
|
|
limiter_key varchar(128) PRIMARY KEY,
|
|
tokens numeric(12,6) NOT NULL,
|
|
capacity numeric(12,6) NOT NULL CHECK (capacity>0),
|
|
refill_per_second numeric(12,6) NOT NULL CHECK (refill_per_second>0),
|
|
updated_at timestamptz NOT NULL,
|
|
blocked_until timestamptz,
|
|
method_class_blocks jsonb NOT NULL DEFAULT '{}',
|
|
fencing_token bigint NOT NULL DEFAULT 0
|
|
);
|
|
|
|
CREATE TABLE bitrix_sync.citizenship_dictionary (
|
|
enum_id varchar(128) PRIMARY KEY,
|
|
display_value varchar(255) NOT NULL,
|
|
loaded_at timestamptz NOT NULL,
|
|
expires_at timestamptz NOT NULL
|
|
);
|
|
"""
|
|
)
|
|
op.execute(
|
|
"""
|
|
CREATE OR REPLACE FUNCTION bitrix_sync.request_bitrix_contact_rebind(
|
|
p_user_id uuid,
|
|
p_target_b24_id varchar,
|
|
p_reason varchar,
|
|
p_operator_id varchar
|
|
) RETURNS uuid
|
|
LANGUAGE plpgsql
|
|
SECURITY DEFINER
|
|
SET search_path=bitrix_sync,pg_temp
|
|
AS $$
|
|
DECLARE
|
|
v_old_id varchar(128);
|
|
v_request_id uuid := gen_random_uuid();
|
|
v_workflow_id uuid := gen_random_uuid();
|
|
BEGIN
|
|
IF p_target_b24_id !~ '^[1-9][0-9]*$'
|
|
OR length(trim(p_reason)) < 5
|
|
OR length(trim(p_operator_id)) < 1 THEN
|
|
RAISE EXCEPTION 'invalid rebind request' USING ERRCODE='22023';
|
|
END IF;
|
|
SELECT external_id INTO v_old_id
|
|
FROM bitrix_sync.entity_external_mapping
|
|
WHERE entity_id=p_user_id AND entity_type='contact'
|
|
AND external_system='bitrix24' AND status='active'
|
|
FOR UPDATE;
|
|
IF EXISTS (
|
|
SELECT 1 FROM bitrix_sync.entity_external_mapping
|
|
WHERE external_system='bitrix24' AND external_entity_type='contact'
|
|
AND external_id=p_target_b24_id AND status='active'
|
|
AND entity_id<>p_user_id
|
|
) THEN
|
|
RAISE EXCEPTION 'target contact has another active mapping'
|
|
USING ERRCODE='23505';
|
|
END IF;
|
|
INSERT INTO bitrix_sync.workflow_instances
|
|
(id,workflow_type,user_id,external_id,state,current_step,deadline_at,created_at,updated_at)
|
|
VALUES
|
|
(v_workflow_id,'contact.rebind',p_user_id,p_target_b24_id,'created',
|
|
'validate_contacts',now()+interval '24 hours',now(),now());
|
|
INSERT INTO bitrix_sync.rebind_requests
|
|
(id,user_id,old_external_id,target_external_id,reason,operator_id,
|
|
workflow_id,status,requested_at,created_at,updated_at)
|
|
VALUES
|
|
(v_request_id,p_user_id,v_old_id,p_target_b24_id,trim(p_reason),
|
|
trim(p_operator_id),v_workflow_id,'pending',now(),now(),now());
|
|
RETURN v_request_id;
|
|
END;
|
|
$$;
|
|
"""
|
|
)
|
|
op.execute(
|
|
"""
|
|
REVOKE ALL ON FUNCTION
|
|
bitrix_sync.request_bitrix_contact_rebind(uuid,varchar,varchar,varchar)
|
|
FROM PUBLIC
|
|
"""
|
|
)
|
|
|
|
|
|
def downgrade() -> None:
|
|
raise RuntimeError("bitrix_sync production migration is forward-only")
|