DOP ↔ BAF — План синхронізації¶
Розхідні накладні + довідники | Python + React → BAF (HTTP-сервіс на BSL-подібному рушії)
Статус: ⚠️ Частково реалізовано — MVP-інфраструктура (2026-04-22)
✅ Реалізовано (Phase F-3): - Окремий backend app
baf_sync/з 3 моделями:BAFSyncSettings,BAFEntityMapping,BAFSyncLog. - Pluggable transport:BAFTransportProtocol +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. - FrontendBAFSyncPage.tsx— settings form (URL/token/enabled/dry-run) + manual triggers + log viewer з status badges. Зареєстровано як PROCESS-item уessentials.ts+ ProcessPanel case'bafSync'.appBAFSync→ statusinstalled.📋 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)¶
Зміст¶
- Налаштування середовища розробки
- Вибір способу обміну
- Структура проєкту
- Маппінг об'єктів
- HTTP-сервіс у BAF
- Python-клієнт синхронізації
- Порядок синхронізації
- Обробка помилок та ідемпотентність
- Моніторинг та логування
- 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 та дотримуйтесь чеклисту.