# EWS ↔ Битрикс24: двусторонняя синхронизация календарей (уровень компании)

Сервис-прослойка на своём сервере. С одной стороны — Exchange на LanCloud по **EWS**
(одна сервисная учётка с импёрсонацией на все ящики), с другой — **облачный Битрикс24**
по REST. Никакой пер-юзерной настройки: соответствие по email.

```
Exchange (LanCloud, EWS)                      Битрикс24 (облако, REST)
        │                                              │
        │  SyncFolderItems (delta, per-mailbox)        │
        ▼                                              ▼
  bin/pull.php  ─────────►  event_map (MySQL)  ◄─────  bin/reconcile.php
  (EWS → Б24, крон 1-2 мин)      ▲                     (Б24 → EWS, крон 1 мин)
                                 │                            ▲
                       public/webhook.php  ◄── исходящий вебхук Б24 (Add/Update/Delete)
```

Направления разведены:
- **EWS → Б24** — инкрементально через `SyncFolderItems` (SyncState хранится по ящику).
- **Б24 → EWS** — оконный reconcile (create/update) + точечные удаления из вебхука.
  Вебхук стреляет один раз без ретраев, поэтому reconcile ещё и страховочно гоняется раз в 10 минут.

Защита от петель — канонический `content_hash` (см. `EventMapper::hash`, проверено тестом).

## Требования
- PHP 8.1+ с `pdo_mysql`, `curl`, `mbstring`, `json`; Composer; MySQL 8 / MariaDB 10.4+.

## Установка
```bash
composer install
mysql -u root -p ews_b24 < db/schema.sql
# заполнить sync_users: по строке на сотрудника (email = логин Exchange = email в Б24, ews_smtp = ящик)
```

## Переменные окружения
```
EWS_SERVER=mail.med168.ru           # без протокола
EWS_USER=svc-sync@med168.ru         # сервисная учётка (ApplicationImpersonation)
EWS_PASS=...
EWS_VERSION=Exchange2013            # уточнить у LanCloud
EWS_TZID="Russian Standard Time"    # EWS TimezoneId, НЕ PHP-таймзона

B24_WEBHOOK=https://portal.bitrix24.ru/rest/1/xxxx/   # входящий вебхук ОТ АДМИНА, scope: calendar, user
B24_OUTBOUND_TOKEN=...              # application_token исходящего вебхука

DB_DSN="mysql:host=127.0.0.1;dbname=ews_b24;charset=utf8mb4"
DB_USER=ews_b24
DB_PASS=...
```

## Крон
```
* * * * *  php /path/ews-b24-sync/bin/pull.php
* * * * *  php /path/ews-b24-sync/bin/reconcile.php
```

## Исходящий вебхук Б24
Разработчикам → Другое → Исходящий вебхук. URL — на `public/webhook.php`,
события: `OnCalendarEntryAdd`, `OnCalendarEntryUpdate`, `OnCalendarEntryDelete`.
`application_token` из настроек вебхука положить в `B24_OUTBOUND_TOKEN`.

---

## [LANCLOUD] Предпосылки — без них не поедет
1. **EWS доступен снаружи** (`https://<server>/EWS/Exchange.asmx`), известна версия Exchange.
2. **Аутентификация:** сервисной учётке разрешён Basic auth на EWS. Если у LanCloud только
   modern auth/OAuth — `API::withUsernameAndPassword` не подойдёт, нужен OAuth-flow (тогда
   переписывается только конструктор `ExchangeClient`).
3. **Роль `ApplicationImpersonation`** у сервисной учётки — иначе доступ ко всем ящикам без
   хранения паролей юзеров невозможен, и вся модель «на уровне компании» рушится.

## Сборка и тестирование (по шагам, безопасно)

Двусторонняя синхронизация с багом устраивает шторм дублей/удалений, поэтому обкатка — строго поэтапная. Ничего не пускаем на реальные календари, пока не пройдены шаги 1–4.

```bash
composer install
php bin/selftest.php          # 1) чистая логика маппера + эхо-хэш, без сети и БД
```

**2) Preflight.** Проверяет три предпосылки LanCloud и Б24 по отдельности:
```bash
mysql ews_b24 < db/schema.sql
export B24_WEBHOOK=... DB_DSN=... DB_USER=... DB_PASS=...
php bin/doctor.php                       # DB + Б24 (EWS пропустит, если не задан ящик)
export EWS_SERVER=... EWS_USER=... EWS_PASS=... EWS_VERSION=Exchange2013
php bin/doctor.php test@med168.ru        # + проверка EWS-доступа и импёрсонации на ящике
```
Пока LanCloud не выдал импёрсонацию — DB и Б24 покажут OK, а EWS даст понятную причину (Basic auth / ApplicationImpersonation / EWS снаружи).

**3) Сухой прогон (dry-run).** Логика гоняется по-настоящему (читает обе стороны, считает дельты, диффит хэши), но НЕ пишет — только лог `DRY ...`:
```bash
DRY_RUN=1 php bin/pull.php               # что улетело бы EWS -> Б24
DRY_RUN=1 php bin/reconcile.php           # что улетело бы Б24 -> EWS
```
Смотрим лог: адекватны ли create/update/delete, нет ли лишних правок на пустом ходу (это выдало бы дырку в эхо-защите).

**4) Офлайн-воспроизведение EWS (httpPlayback).** Один раз пишем реальные ответы Exchange, дальше тестируем без сервера и без риска:
```bash
EWS_PLAYBACK=record   php bin/pull.php    # записать трафик в tests/ews-recordings.json
EWS_PLAYBACK=playback php bin/pull.php    # гонять из файла, сервер не трогается
```

**5) Один живой пользователь.** В `sync_users` оставляем одну тестовую строку (свой ящик), снимаем `DRY_RUN`, гоняем `pull.php`/`reconcile.php` руками. Проверяем оба направления и, отдельно, повторяющиеся события, all-day, таймзоны, удаления.

**6) Вебхук.** Настраиваем исходящий вебхук Б24 на `public/webhook.php`, создаём/меняем событие в Б24 у тестового юзера, смотрим `b24_inbox` и что `reconcile.php` подхватил.

**7) Раскатка.** Наполняем `sync_users` всей компанией, ставим оба крона, первый прогон — под наблюдением лога.

## Сверить на живом Exchange (остаточное)
Разбор ответов php-ews сверён с исходниками v0.11.2 — здесь сюрпризов быть не должно. Что реально проверить с боевым сервером на шаге 5:
- **Повторяющиеся события и исключения**, all-day, таймзоны (МСК ↔ EWS TimezoneId) — типовые грабли любой календарной синхронизации.
- **Аутентификация:** конструктор на Basic auth (`withUsernameAndPassword` + опция `impersonation`). Если у LanCloud только OAuth — меняется только `ExchangeClient::buildApi`.
- **Б24 не умеет get-by-id для события.** `calendar.event.get` только по диапазону дат.
  Поэтому Add/Update из вебхука не тянутся точечно — их подхватывает оконный reconcile.
- **Участники и статусы (принял/отклонил) в v1 не синхронизируются** — это отдельная фаза
  (маппинг участников по email на юзеров портала, деградация внешних).
- **Удаления Б24 → EWS — только из вебхука** (оконный reconcile не удаляет, чтобы события вне
  окна не выглядели ложно удалёнными).
- **Конфликты:** по умолчанию `CONFLICT_WINS=exchange`. Одновременные правки с двух сторон
  между циклами разрешаются «кто последний записал»; хэш гасит только эхо, не гонки.
