Перейти до змісту

DOP ↔ BAF — План синхронізації

Розхідні накладні + довідники | Python + React → BAF (HTTP-сервіс на BSL-подібному рушії)

Статус: ⚠️ Частково реалізовано — MVP-інфраструктура (2026-04-22)

Реалізовано (Phase F-3): - Окремий backend app baf_sync/ з 3 моделями: BAFSyncSettings, BAFEntityMapping, BAFSyncLog. - Pluggable transport: BAFTransport Protocol + FakeTransport (in-memory, deterministic UUID за hash payload). Реальний httpx-transport — наступна ітерація. - Dry-run за замовчуванням: BAFSyncSettings.dry_run = True, sync лише записує payload в BAFSyncLog без HTTP. - Idempotent push: BAFEntityMapping зберігає last_payload_hash — sync пропускає записи, що не змінились (актуально для dry-run; live-режим завжди викликає transport). - Push для clients і posted-invoices: push_clients(tenant), push_invoices(tenant, only_posted=True). - REST: - GET/PATCH /api/v1/baf-sync/settings/ — singleton settings (auth_token write-only). - POST /api/v1/baf-sync/run/ — body {entity_type: "client" | "invoice"}. - GET /api/v1/baf-sync/mappings/?entity_type=... — read-only. - GET /api/v1/baf-sync/logs/?... — read-only history. - 7 backend-тестів покривають live-mode FakeTransport, partial-failure, dry-run, only_posted, settings round-trip, endpoint smoke. - Frontend BAFSyncPage.tsx — settings form (URL/token/enabled/dry-run) + manual triggers + log viewer з status badges. Зареєстровано як PROCESS-item у essentials.ts + ProcessPanel case 'bafSync'. appBAFSync → status installed.

📋 Planned-розширення (далі по документу — повний план): - Реальний httpx-transport з timeout / retry / connection pool. - Pull-flow (отримання змін з BAF → апдейт DOP) — зараз лише push. - Pull для довідників (Контрагенти, Номенклатура — для часткового збагачення DOP-довідників). - Pull для платіжних документів (HS-сервіс на BAF може повернути нові надходження готівки/перерахування). - Конфлікт-резолюція при двосторонньому обміні (last-write-wins / manual queue). - Рекурсивна синхронізація залежностей (Invoice → автоматично push/pull Client + Item, якщо їх ще немає). - Журналювання payload в окремий blob storage (для diff/compliance). - Webhook від BAF при змінах (push notification замість pull-pull-pull). - Async batches (Celery worker для періодичних або великих push). - Schema validator (Pydantic) для payload-структур.

BAF (Business Automation Framework) — українська платформа автоматизації бізнесу на вбудованій мові BSL, архітектурно схожа на 1С:Підприємство (довідники, документи, HTTP-сервіси, регістри). Далі наводяться BSL-приклади для ілюстрації обробників на стороні BAF — конкретні імена об'єктів (Довідник.Контрагенти, Документ.РеалізаціяТоварівПослуг) слід адаптувати під конкретну конфігурацію BAF клієнта.


Реалізована інфраструктура — детально

Models (baf_sync/models/)

Модель Поля
BAFSyncSettings base_url, auth_token (write-only через API), enabled, dry_run (default True), last_clients_sync_at, last_invoices_sync_at. Singleton per tenant.
BAFEntityMapping (tenant, entity_type, local_id) UNIQUE. Поля: baf_uuid, last_synced_at (auto), last_payload_hash. Choices: client/item/invoice/purchase_invoice/payment_in/payment_out.
BAFSyncLog Журнал синхронізацій: started_at, finished_at, direction (push/pull), entity_type, status (queued/running/success/partial/failed/dry_run), request_count, success_count, error_count, detail.

Service push_records()

1. Створює BAFSyncLog зі статусом 'running'.
2. Для кожного record:
   payload = payload_fn(record)            # _client_payload / _invoice_payload
   h = sha1(payload)                       # для idempotency
   if dry_run or transport is None:
       upsert mapping.last_payload_hash = h
       success += 1
   else:
       resp = transport.send(entity_type, payload, base_url, auth_token)
       if not resp.success: error_count += 1
       else: upsert mapping(baf_uuid=resp.baf_uuid, last_payload_hash=h)
3. Закриває log: status = success / partial / failed / dry_run, finished_at = now.

Transport protocol

class BAFTransport(Protocol):
    def send(self, entity_type: str, payload: dict,
             base_url: str, auth_token: str) -> BAFResponse: ...

@dataclass
class BAFResponse:
    success: bool
    baf_uuid: str = ''
    detail: str = ''

FakeTransport (для тестів і dry-run-проксі) генерує deterministic UUID baf-{entity_type}-{sha1(payload)[:16]} і має fail_predicate для simulation помилок.

Frontend

Sidebar → Essentials → Integrations → BAF Sync. Сторінка містить: - Settings card (URL / token / enabled / dry-run switches). - Trigger buttons: «Push clients», «Push invoices», з показом останніх sync-timestamps. - Sync history table зі status badges, success/error counters, detail-tooltip.


Очікуваний повний план (для production-deploy)


Зміст

  1. Налаштування середовища розробки
  2. Вибір способу обміну
  3. Структура проєкту
  4. Маппінг об'єктів
  5. HTTP-сервіс у BAF
  6. Python-клієнт синхронізації
  7. Порядок синхронізації
  8. Обробка помилок та ідемпотентність
  9. Моніторинг та логування
  10. CI/CD і версіонування BAF-конфігурації

1. Налаштування середовища розробки

1.1 Інструменти для BAF

Інструмент Призначення
BAF IDE / Конфігуратор Редагування конфігурації BAF (аналог EDT), підтримка Git
VS Code + BSL Language Server Редагування BSL-коду у VS Code (bsl-marketplace — сумісний синтаксис)
gitsync Вивантаження конфігурації BAF у XML → Git
OneScript (oscript) Запуск BSL-скриптів поза платформою BAF

1.2 Ініціалізація gitsync

# Встановлення (потребує OneScript)
opm install gitsync

# Ініціалізація репозиторію
gitsync init --storage ./src/baf --infobase "Srvr=localhost;Ref=BAF_UA"

# Перша синхронізація (вивантаження конфігурації у файли)
gitsync sync

# Далі — звичайний git workflow
git add .
git commit -m "feat: initial BAF config export"

1.3 VS Code — розширення

// .vscode/extensions.json
{
  "recommendations": [
    "1c-syntax.language-1c-bsl",   // BSL підсвічування + автодоповнення
    "ms-python.python",             // Python
    "ms-python.vscode-pylance",     // Python type hints
    "esbenp.prettier-vscode",       // Форматування
    "eamodio.gitlens"               // Git history
  ]
}

1.4 Структура монорепозиторію

project-root/
├── src/
│   ├── baf/                    # gitsync вивантаження конфігурації BAF
│   │   ├── CommonModules/
│   │   ├── Documents/
│   │   ├── Catalogs/
│   │   └── HTTPServices/
│   ├── sync/                   # Python сервіс синхронізації
│   │   ├── baf_client.py
│   │   ├── mapper.py
│   │   ├── queue_worker.py
│   │   └── models.py
│   └── erp/                    # Існуючий Python/React код
├── tests/
│   ├── test_mapper.py
│   └── test_baf_client.py
├── docker-compose.yml
├── .gitsync                    # Конфіг gitsync
└── README.md

2. Вибір способу обміну

Порівняння варіантів

Варіант Надійність Складність Підходить якщо
BAF HTTP-сервіс (рекомендовано) ★★★★ ★★ Сервер BAF доступний по мережі
Python → BAF REST ★★★★ ★★ Те саме
Черга (RabbitMQ / Redis Streams) ★★★★★ ★★★★ Великий обсяг, потрібна надійність
XML-файли через FTP/папку ★★★ BAF на ізольованому ПК
COM-з'єднання ★★★ ★★★ Тільки Windows, сервер поруч

Рекомендована архітектура

DOP (Python/React)
        │  HTTP POST /api/v1/...
┌──────────────────┐
│  Sync Service    │  ← окремий Python мікросервіс
│  (mapper.py)     │
└──────────────────┘
        │  HTTP (Basic Auth або OAuth)
┌──────────────────┐
│  BAF HTTP-сервіс │  ← публікується через веб-сервер (Apache/nginx)
│  /hs/erp/v1/     │
└──────────────────┘
      BAF

3. Структура проєкту

3.1 Python sync-сервіс

src/sync/
├── __init__.py
├── baf_client.py       # HTTP клієнт до BAF
├── mapper.py           # Маппінг DOP ↔ BAF
├── models.py           # Pydantic моделі
├── queue_worker.py     # Воркер черги (опціонально)
├── id_registry.py      # Зберігання external_id ↔ ref_baf
└── config.py           # Налаштування з env

3.2 BAF — нові об'єкти конфігурації

Довідники:
  └── ЗовнішніІдентифікатори     # таблиця маппінгу UID

Реквізити (додаються до існуючих об'єктів):
  ├── Контрагенти.ЗовнішнійІД    # Тип: Рядок(36), UUID з DOP
  ├── Номенклатура.ЗовнішнійІД
  └── РеалізаціяТоварівПослуг.ЗовнішнійІД

HTTP-сервіси:
  └── DOP_API                    # Корінь: /hs/erp/v1
        ├── POST /contractors    # Контрагенти
        ├── POST /products       # Номенклатура
        └── POST /invoices       # Розхідні накладні

4. Маппінг об'єктів

4.1 Таблиця маппінгу

DOP (Python модель)              →   BAF
─────────────────────────────────────────────────────
Supplier / Customer                   Довідник.Контрагенти
  .external_id (UUID)           →       .ЗовнішнійІД
  .name                         →       .Найменування
  .tax_code (ЄДРПОУ/ІПН)       →       .КодЗаЄДРПОУ / .ІПН
  .vat_code (ПДВ)               →       .НомерСвідоцтваПДВ
  .type (legal/individual)      →       .ЮридичнаФізичнаОсоба

Product / SKU                         Довідник.Номенклатура
  .external_id                  →       .ЗовнішнійІД
  .name                         →       .Найменування
  .sku_code                     →       .Артикул
  .unit_id                      →       .БазоваОдиницяВимірювання
  .vat_rate (0/7/20)            →       .СтавкаПДВ

SaleInvoice                           Документ.РеалізаціяТоварівПослуг
  .external_id                  →       .ЗовнішнійІД
  .date                         →       .Дата
  .number                       →       .Номер
  .contractor_id                →       .Контрагент (посилання)
  .warehouse_id                 →       .Склад
  .contract_id                  →       .ДоговірКонтрагента
  .lines[].product_id           →       .Товари[].Номенклатура
  .lines[].quantity             →       .Товари[].Кількість
  .lines[].price                →       .Товари[].Ціна
  .lines[].vat_rate             →       .Товари[].СтавкаПДВ
  .lines[].amount               →       .Товари[].Сума
  .lines[].vat_amount           →       .Товари[].СумаПДВ

4.2 Pydantic моделі (Python)

# src/sync/models.py
from pydantic import BaseModel, Field
from datetime import datetime
from decimal import Decimal
from uuid import UUID
from typing import Literal

class Contractor(BaseModel):
    external_id: UUID
    name: str
    tax_code: str                              # ЄДРПОУ або ІПН
    vat_code: str | None = None
    type: Literal["legal", "individual"] = "legal"

class Product(BaseModel):
    external_id: UUID
    name: str
    sku_code: str | None = None
    unit_code: str = "шт"                     # код одиниці виміру
    vat_rate: Literal[0, 7, 20] = 20

class InvoiceLine(BaseModel):
    product_id: UUID
    quantity: Decimal
    price: Decimal
    vat_rate: Literal[0, 7, 20] = 20
    amount: Decimal
    vat_amount: Decimal

class SaleInvoice(BaseModel):
    external_id: UUID
    date: datetime
    number: str
    contractor_id: UUID
    warehouse_code: str = "Основний"
    lines: list[InvoiceLine]

5. HTTP-сервіс у BAF

5.1 Оголошення HTTP-сервісу

Назва: DOP_API
Корінь URL: erp/v1
Шаблони URL:
  /contractors   — метод POST
  /products      — метод POST
  /invoices      — метод POST

5.2 Загальний модуль — ЗовнішнійОбмін

// CommonModules/ЗовнішнійОбмін
// Функції для роботи з HTTP-сервісом

Функція JSONВОбєкт(Рядок) Експорт
    Читач = Новий ЧитачJSON;
    Читач.УстановитиРядок(Рядок);
    Повернути ПрочитатиJSON(Читач);
КінецьФункції

Функція ОбєктВJSON(Значення) Експорт
    Запис = Новий ЗаписувачJSON;
    Запис.УстановитиРядок(Новий ПараметриЗаписуJSON);
    ЗаписатиJSON(Запис, Значення);
    Повернути Запис.Закрити();
КінецьФункції

Функція ВідповідьУспіх(RefId) Експорт
    Результат = Новий Структура;
    Результат.Вставити("success", Істина);
    Результат.Вставити("ref_id", Рядок(RefId));
    Повернути ЗовнішнійОбмін.ОбєктВJSON(Результат);
КінецьФункції

Функція ВідповідьПомилка(Повідомлення) Експорт
    Результат = Новий Структура;
    Результат.Вставити("success", Хибність);
    Результат.Вставити("error", Повідомлення);
    Повернути ЗовнішнійОбмін.ОбєктВJSON(Результат);
КінецьФункції

// Пошук об'єкту за зовнішнім ID
Функція НайтиЗаЗовнішнімІД(ІмяОбєкту, ЗовнішнійІД) Експорт
    Запит = Новий Запит;
    Запит.Текст =
        "ВИБРАТИ Перші 1
        |   Довідник.Посилання
        |ІЗ
        |   " + ІмяОбєкту + " ЯК Довідник
        |ДЕ
        |   Довідник.ЗовнішнійІД = &ЗовнішнійІД";
    Запит.УстановитиПараметр("ЗовнішнійІД", ЗовнішнійІД);
    Вибірка = Запит.Виконати().Вибрати();
    Якщо Вибірка.Наступний() Тоді
        Повернути Вибірка.Посилання;
    КінецьЯкщо;
    Повернути Невизначено;
КінецьФункції

5.3 Обробник — Контрагенти

// HTTPServices/DOP_API/contractors/POST
Процедура ОбробитиЗапит(Запит, Відповідь)
    Спроба
        Дані = ЗовнішнійОбмін.JSONВОбєкт(Запит.ОтриматиТілоЯкРядок());

        // Ідемпотентність — шукаємо існуючий запис
        Контрагент = ЗовнішнійОбмін.НайтиЗаЗовнішнімІД(
            "Довідник.Контрагенти", Дані.external_id);

        Якщо Контрагент = Невизначено Тоді
            КонтрагентОб = Довідники.Контрагенти.СтворитиЕлемент();
        Інакше
            КонтрагентОб = Контрагент.ОтриматиОбєкт();
        КінецьЯкщо;

        КонтрагентОб.Найменування       = Дані.name;
        КонтрагентОб.ЗовнішнійІД        = Дані.external_id;
        КонтрагентОб.КодЗаЄДРПОУ        = Дані.tax_code;

        Якщо ЗначенняЗаповнено(Дані.vat_code) Тоді
            КонтрагентОб.НомерСвідоцтваПДВ = Дані.vat_code;
            КонтрагентОб.ПлатникПДВ        = Істина;
        КінецьЯкщо;

        КонтрагентОб.Записати();

        Відповідь.КодСтану = 200;
        Відповідь.УстановитиТілоЗРядка(
            ЗовнішнійОбмін.ВідповідьУспіх(КонтрагентОб.Посилання));

    Виключення
        Відповідь.КодСтану = 500;
        Відповідь.УстановитиТілоЗРядка(
            ЗовнішнійОбмін.ВідповідьПомилка(ОписПомилки()));
    КінецьСпроби;
КінецьПроцедури

5.4 Обробник — Розхідні накладні

// HTTPServices/DOP_API/invoices/POST
Процедура ОбробитиЗапит(Запит, Відповідь)
    Спроба
        Дані = ЗовнішнійОбмін.JSONВОбєкт(Запит.ОтриматиТілоЯкРядок());

        // Ідемпотентність
        НакладнаПос = ЗовнішнійОбмін.НайтиЗаЗовнішнімІД(
            "Документ.РеалізаціяТоварівПослуг", Дані.external_id);

        Якщо НакладнаПос = Невизначено Тоді
            Накладна = Документи.РеалізаціяТоварівПослуг.СтворитиДокумент();
        Інакше
            Накладна = НакладнаПос.ОтриматиОбєкт();
        КінецьЯкщо;

        // Заголовок
        Накладна.ЗовнішнійІД = Дані.external_id;
        Накладна.Дата         = XMLЗначення(Тип("Дата"), Дані.date);
        Накладна.Номер        = Дані.number;
        Накладна.Контрагент   = ЗовнішнійОбмін.НайтиЗаЗовнішнімІД(
            "Довідник.Контрагенти", Дані.contractor_id);
        Накладна.Склад        = Довідники.Склади.НайтиЗаНайменуванням(
            Дані.warehouse_code);

        // Табличная частина
        Накладна.Товари.Очистити();
        Для Кожного Рядок Із Дані.lines Цикл
            НовийРядок = Накладна.Товари.Додати();
            НовийРядок.Номенклатура = ЗовнішнійОбмін.НайтиЗаЗовнішнімІД(
                "Довідник.Номенклатура", Рядок.product_id);
            НовийРядок.Кількість   = Рядок.quantity;
            НовийРядок.Ціна        = Рядок.price;
            НовийРядок.Сума        = Рядок.amount;
            НовийРядок.СумаПДВ     = Рядок.vat_amount;
            НовийРядок.СтавкаПДВ   = НайтиСтавкуПДВ(Рядок.vat_rate);
        КінецьЦиклу;

        Накладна.Записати(РежимЗаписуДокументу.Проведення);

        Відповідь.КодСтану = 200;
        Відповідь.УстановитиТілоЗРядка(
            ЗовнішнійОбмін.ВідповідьУспіх(Накладна.Посилання));

    Виключення
        Відповідь.КодСтану = 500;
        Відповідь.УстановитиТілоЗРядка(
            ЗовнішнійОбмін.ВідповідьПомилка(ОписПомилки()));
    КінецьСпроби;
КінецьПроцедури

// Допоміжна функція
Функція НайтиСтавкуПДВ(Відсоток)
    Повернути Перелічення.СтавкиПДВ["ПДВ" + Формат(Відсоток, "ЧН=")];
КінецьФункції

6. Python-клієнт синхронізації

6.1 Конфігурація

# src/sync/config.py
from pydantic_settings import BaseSettings

class Settings(BaseSettings):
    baf_base_url: str        # http://192.168.1.10/baf/hs/erp/v1
    baf_login: str           # Логін користувача BAF
    baf_password: str        # Пароль
    baf_timeout: int = 30

    class Config:
        env_file = ".env"

settings = Settings()
# .env (не комітити в git!)
BAF_BASE_URL=http://192.168.1.10/baf/hs/erp/v1
BAF_LOGIN=sync_user
BAF_PASSWORD=secret

6.2 HTTP-клієнт

# src/sync/baf_client.py
import httpx
from .models import Contractor, Product, SaleInvoice
from .config import settings
import logging

logger = logging.getLogger(__name__)

class BafClient:
    def __init__(self):
        self.client = httpx.Client(
            base_url=settings.baf_base_url,
            auth=(settings.baf_login, settings.baf_password),
            timeout=settings.baf_timeout,
            headers={"Content-Type": "application/json; charset=utf-8"},
        )

    def upsert_contractor(self, contractor: Contractor) -> str:
        """Повертає ref_id (посилання BAF) контрагента."""
        resp = self.client.post("/contractors", content=contractor.model_dump_json())
        resp.raise_for_status()
        data = resp.json()
        if not data.get("success"):
            raise RuntimeError(f"BAF error: {data.get('error')}")
        logger.info("Contractor synced: %s%s", contractor.external_id, data["ref_id"])
        return data["ref_id"]

    def upsert_product(self, product: Product) -> str:
        resp = self.client.post("/products", content=product.model_dump_json())
        resp.raise_for_status()
        data = resp.json()
        if not data.get("success"):
            raise RuntimeError(f"BAF error: {data.get('error')}")
        return data["ref_id"]

    def push_invoice(self, invoice: SaleInvoice) -> str:
        resp = self.client.post("/invoices", content=invoice.model_dump_json())
        resp.raise_for_status()
        data = resp.json()
        if not data.get("success"):
            raise RuntimeError(f"BAF error: {data.get('error')}")
        logger.info("Invoice synced: %s%s", invoice.external_id, data["ref_id"])
        return data["ref_id"]

    def __enter__(self): return self
    def __exit__(self, *_): self.client.close()

6.3 Маппер

# src/sync/mapper.py
from .models import Contractor, Product, SaleInvoice, InvoiceLine
from decimal import Decimal

def dop_to_contractor(row: dict) -> Contractor:
    return Contractor(
        external_id=row["id"],
        name=row["name"],
        tax_code=row["edrpou"] or row["ipn"],
        vat_code=row.get("vat_certificate"),
        type="legal" if row.get("is_legal") else "individual",
    )

def dop_to_product(row: dict) -> Product:
    return Product(
        external_id=row["id"],
        name=row["name"],
        sku_code=row.get("sku"),
        unit_code=row.get("unit", "шт"),
        vat_rate=int(row.get("vat_rate", 20)),
    )

def dop_to_invoice(row: dict) -> SaleInvoice:
    lines = [
        InvoiceLine(
            product_id=line["product_id"],
            quantity=Decimal(str(line["qty"])),
            price=Decimal(str(line["price"])),
            vat_rate=int(line.get("vat_rate", 20)),
            amount=Decimal(str(line["amount"])),
            vat_amount=Decimal(str(line["vat_amount"])),
        )
        for line in row["lines"]
    ]
    return SaleInvoice(
        external_id=row["id"],
        date=row["date"],
        number=row["number"],
        contractor_id=row["contractor_id"],
        warehouse_code=row.get("warehouse", "Основний"),
        lines=lines,
    )

6.4 Головний сервіс синхронізації

# src/sync/sync_service.py
from .baf_client import BafClient
from .mapper import dop_to_contractor, dop_to_product, dop_to_invoice
import logging

logger = logging.getLogger(__name__)

class SyncService:
    def __init__(self, db_session):
        self.db = db_session

    def sync_all(self):
        """Повний цикл синхронізації в правильному порядку."""
        with BafClient() as client:
            self._sync_products(client)
            self._sync_contractors(client)
            self._sync_invoices(client)

    def _sync_products(self, client: BafClient):
        products = self.db.query("SELECT * FROM products WHERE needs_sync = true")
        for row in products:
            try:
                product = dop_to_product(row)
                ref_id = client.upsert_product(product)
                self.db.execute(
                    "UPDATE products SET baf_ref_id=%s, needs_sync=false WHERE id=%s",
                    (ref_id, row["id"])
                )
            except Exception as e:
                logger.error("Product sync failed %s: %s", row["id"], e)

    def _sync_contractors(self, client: BafClient):
        contractors = self.db.query(
            "SELECT * FROM contractors WHERE needs_sync = true")
        for row in contractors:
            try:
                contractor = dop_to_contractor(row)
                ref_id = client.upsert_contractor(contractor)
                self.db.execute(
                    "UPDATE contractors SET baf_ref_id=%s, needs_sync=false WHERE id=%s",
                    (ref_id, row["id"])
                )
            except Exception as e:
                logger.error("Contractor sync failed %s: %s", row["id"], e)

    def _sync_invoices(self, client: BafClient):
        invoices = self.db.query(
            "SELECT * FROM sale_invoices WHERE needs_sync = true")
        for row in invoices:
            try:
                invoice = dop_to_invoice(row)
                ref_id = client.push_invoice(invoice)
                self.db.execute(
                    "UPDATE sale_invoices SET baf_ref_id=%s, needs_sync=false WHERE id=%s",
                    (ref_id, row["id"])
                )
            except Exception as e:
                logger.error("Invoice sync failed %s: %s", row["id"], e)

7. Порядок синхронізації

Обов'язкова послідовність (залежності знизу вгору):

1. ОдиниціВиміру     ← немає залежностей
2. Склади            ← немає залежностей
3. Номенклатура      ← залежить від ОдиниціВиміру
4. Контрагенти       ← немає залежностей
5. ДоговориКонтрагентів ← залежить від Контрагентів
6. РозхідніНакладні  ← залежить від усього вище

Правило: Якщо об'єкт не знайдений у BAF — спочатку синхронізуй залежності, потім повторно відправляй документ.


8. Обробка помилок та ідемпотентність

8.1 Поле needs_sync у БД DOP

-- Міграція: додаємо поля синхронізації
ALTER TABLE contractors ADD COLUMN baf_ref_id VARCHAR(36);
ALTER TABLE contractors ADD COLUMN needs_sync BOOLEAN DEFAULT TRUE;
ALTER TABLE contractors ADD COLUMN last_synced_at TIMESTAMP;

ALTER TABLE products ADD COLUMN baf_ref_id VARCHAR(36);
ALTER TABLE products ADD COLUMN needs_sync BOOLEAN DEFAULT TRUE;
ALTER TABLE products ADD COLUMN last_synced_at TIMESTAMP;

ALTER TABLE sale_invoices ADD COLUMN baf_ref_id VARCHAR(36);
ALTER TABLE sale_invoices ADD COLUMN needs_sync BOOLEAN DEFAULT TRUE;
ALTER TABLE sale_invoices ADD COLUMN last_synced_at TIMESTAMP;
ALTER TABLE sale_invoices ADD COLUMN sync_error TEXT;

8.2 Тригер — автоматичне позначення

-- При оновленні запису — знову позначити для синхронізації
CREATE OR REPLACE FUNCTION mark_needs_sync()
RETURNS TRIGGER AS $$
BEGIN
  NEW.needs_sync = TRUE;
  RETURN NEW;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER trg_contractor_sync
  BEFORE UPDATE ON contractors
  FOR EACH ROW EXECUTE FUNCTION mark_needs_sync();

8.3 Retry-логіка

# src/sync/retry.py
import time
import logging
from functools import wraps

logger = logging.getLogger(__name__)

def with_retry(max_attempts=3, delay=2.0):
    def decorator(func):
        @wraps(func)
        def wrapper(*args, **kwargs):
            for attempt in range(1, max_attempts + 1):
                try:
                    return func(*args, **kwargs)
                except Exception as e:
                    if attempt == max_attempts:
                        raise
                    logger.warning("Attempt %d failed: %s. Retrying...", attempt, e)
                    time.sleep(delay * attempt)
        return wrapper
    return decorator

9. Моніторинг та логування

9.1 Структуроване логування

# src/sync/logging_config.py
import logging
import json

class JSONFormatter(logging.Formatter):
    def format(self, record):
        return json.dumps({
            "time": self.formatTime(record),
            "level": record.levelname,
            "module": record.module,
            "message": record.getMessage(),
            "extra": getattr(record, "extra", {}),
        })

logging.basicConfig(level=logging.INFO)
logging.getLogger().handlers[0].setFormatter(JSONFormatter())

9.2 Метрики (опціонально)

# Таблиця для збереження результатів синхронізації
CREATE TABLE sync_log (
    id          SERIAL PRIMARY KEY,
    entity_type VARCHAR(50),       -- contractor, product, invoice
    entity_id   UUID,
    status      VARCHAR(20),       -- success, error
    baf_ref_id  VARCHAR(36),
    error_text  TEXT,
    synced_at   TIMESTAMP DEFAULT NOW()
);

10. CI/CD і версіонування BAF-конфігурації

10.1 .github/workflows/sync-baf.yml

name: Sync BAF config

on:
  push:
    paths: ["src/baf/**"]

jobs:
  sync:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Install OneScript
        run: |
          wget https://oscript.io/downloads/latest/oscript_linux.tar.gz
          tar xzf oscript_linux.tar.gz
      - name: Run gitsync
        run: |
          ./oscript gitsync sync \
            --storage ./src/baf \
            --infobase "${{ secrets.BAF_INFOBASE }}"

10.2 Корисні команди gitsync

# Вивантаження змін із BAF у файли (після правок у Конфігураторі)
gitsync export

# Завантаження файлів у базу BAF (після правок у VS Code / IDE)
gitsync import

# Перевірка різниці
gitsync diff

Чеклист реалізації

  • [ ] Налаштовано BAF IDE + gitsync + VS Code BSL
  • [ ] Створено HTTP-сервіс DOP_API у конфігурації BAF
  • [ ] Додано реквізит ЗовнішнійІД до Контрагентів, Номенклатури, РеалізаціїТоварів
  • [ ] Написано загальний модуль ЗовнішнійОбмін
  • [ ] Реалізовано обробники POST /contractors, /products, /invoices
  • [ ] BAF-сервіс опублікований через веб-сервер
  • [ ] Написано Python моделі (Pydantic)
  • [ ] Написано BafClient з retry-логікою
  • [ ] Написано mapper.py для перетворення DOP → BAF формат
  • [ ] Додано поля baf_ref_id, needs_sync до таблиць БД
  • [ ] Написано SyncService з правильним порядком синхронізації
  • [ ] Налаштовано логування та таблицю sync_log
  • [ ] Написано тести для mapper та client

🔮 Deferred / Ideas

Повна двостороння реалізація BAF Sync (pull з BAF)

Мотивація: клієнти використовують BAF як legacy-систему; хочуть, щоб оплати/акти з BAF автоматично підтягувались у DOP. Чому відкладено: уточнюється формат API BAF + scope двостороннього обміну. Trigger: підписання договору з першим клієнтом, що має live BAF.

Conflict resolution UI

Мотивація: при двосторонньому sync можливі конфлікти (одне поле змінено в обох системах). Чому відкладено: потрібна кнопка «merge» з UX-дизайном. Trigger: після MVP-релізу.

Schedule + monitoring dashboard

Мотивація: sync має йти за розкладом (cron) з моніторингом помилок. Чому відкладено: після стабілізації основного flow. Trigger: перший production-deploy.


Пов'язане

  • M.E.Doc — податкова звітність (суміжна інтеграція)
  • Plugin instruction — як побудовано платні плагіни

Файл згенеровано як план реалізації. Почніть з кроку 1 та дотримуйтесь чеклисту.