# PR-4: Каркас `m2m-core` — FSM сделки, репозитории, идемпотентность ## Цель Реализовать ядро бизнес-логики M2M-перевода: конечный автомат сделки, типизированный доступ к БД, идемпотентность по GUID, метрики SLA по этапам. Сетевые интеграции (НРД, ЛК, crypto-service) пока **заглушками через интерфейсы** — реальные клиенты подключаются в PR-5 и далее. ## Зависимости - PR-1 (Go-модели M2M) — для типизированных сообщений. - PR-2 (DDL Fansy) — на схему БД. ## Источники - `docs/architecture/plan.md` — раздел «Поток одной заявки end-to-end» и «SLA и регуляторные сроки». - `internal/m2m/` — модели сообщений (после PR-1). ## Состав PR ### 1. Конечный автомат сделки `internal/m2mcore/fsm.go`: ``` Draft → Validated → SubmittedToNSD → AwaitingDecision → Confirmed → AwaitingSUB16 → Done ↘ Rejected ↘ TimedOut (после 10→5 мин без Decision) ↘ ManualApproval (опц. ветка) ``` Переходы — явные методы: - `(d *Deal) Validate() error`, - `(d *Deal) Submit(ctx) error`, - `(d *Deal) ReceiveDecision(ctx, m2m.M2MTransferDecision) error`, - `(d *Deal) Timeout(ctx) error`, - `(d *Deal) SendToManualApproval(ctx, reason string) error`, - `(d *Deal) ApproveManually(ctx, operator string) error`, - `(d *Deal) RejectManually(ctx, operator, code, comment string) error`. Каждый переход: - проверяет текущее состояние (нельзя `Submit` если не `Validated`); - пишет событие в `deal_events` (event sourcing для аудита); - эмитит метрику `m2m_stage_duration_seconds{stage=...}`. ### 2. Доменная модель сделки `internal/m2mcore/deal.go`: ```go type Deal struct { ID uuid.UUID GUID m2m.UUID // тот, что в M2MTransferRequest.Header.GUID State State InvestorID uuid.UUID SignedClaim []byte // подписанный XML от ЛК Request *m2m.M2MTransferRequest Response *m2m.M2MTransferResponse Decision *m2m.M2MTransferDecision CreatedAt time.Time UpdatedAt time.Time Stages []StageRecord // история состояний с timestamp } ``` ### 3. Репозиторий `internal/m2mcore/repo.go`: Интерфейс `Repository` с реализацией на pgx: ```go type Repository interface { Create(ctx, *Deal) error GetByGUID(ctx, m2m.UUID) (*Deal, error) Update(ctx, *Deal) error List(ctx, Filter) ([]*Deal, error) AppendEvent(ctx, *Deal, Event) error } ``` Идемпотентность: `Create` использует `INSERT ... ON CONFLICT (guid) DO NOTHING RETURNING ...` — повторный запрос с тем же GUID не создаёт дубль, а возвращает существующий. ### 4. Внешние зависимости — интерфейсы (для PR-5+) `internal/m2mcore/ports.go`: ```go type NSDSender interface { Send(ctx, *m2m.M2MTransferRequest) (*m2m.M2MTransferResponse, error) SendDecision(ctx, *m2m.M2MTransferDecision) error } type LKCallbackClient interface { UpdateStatus(ctx, claimID, status, reason string) error } type CryptoVerifier interface { VerifyXMLDSig(ctx, payload []byte) (CertInfo, error) } type FansyStore interface { GetClientByID(ctx, uuid.UUID) (*Client, error) GetDepoAccount(ctx, deponentCode, accountID string) (*DepoAccount, error) CheckBalance(ctx, depoAccountID uuid.UUID, isin string) (Quantity, error) } ``` В PR-4 — заглушки `noopNSDSender`, `noopLKCallbackClient` и т. п. Реальные клиенты подключаются в PR-5, PR-6. ### 5. Сборка enrichment Request из заявки `internal/m2mcore/enrich.go`: Функция «из заявки от ЛК + данные из `fansy-store` → `M2MTransferRequest`». Шаги: 1. Распарсить подписанное заявление (XML от ЛК). 2. Поднять реквизиты сторон, депо-счета, остатки из FansyStore. 3. Проверить достаточность остатков по каждой ЦБ. 4. Сгенерировать `GUID` (uuid v4) и `ReferenceId` для каждой ЦБ (`M2M` + дата `YYYYMMDD` + 5 случайных символов A-Z0-9). 5. Заполнить `Header.CreationTimestamp` = `nsdxml.NSDDateTime.Now()`. 6. Вернуть готовый `*m2m.M2MTransferRequest`, валидированный. ### 6. Метрики SLA `internal/m2mcore/metrics.go`: Prometheus метрики: - `m2m_stage_duration_seconds{stage="..."}` — histogram длительности этапа, - `m2m_deals_total{state="..."}` — counter сделок по итоговому состоянию, - `m2m_sla_breaches_total{stage="...",budget="5m|2m"}` — counter превышений 80% бюджета SLA. Endpoint `/metrics` поднимается в `cmd/m2m-core/main.go`. ### 7. Заменить заглушку `cmd/m2m-core/main.go` Минимальный рабочий сервер: - читает конфиг (env: `BJ_DSN`, `BJ_LOG_LEVEL`, ...), - открывает соединение с Postgres, - инициализирует Repository, - поднимает HTTP-эндпоинты `/healthz`, `/metrics`, - логирует все запуски. ### 8. Тесты - Юнит-тесты FSM (все переходы, в т. ч. недопустимые). - Интеграционный тест с PostgreSQL через `testcontainers-go` (или `dockertest`): создал сделку, обновил, прочитал, проверил идемпотентность по GUID. - Тест `enrich.go` на эталонной заявке (fixtures из примеров ESIA Finance API). ## Требования - Без эмодзи. Комментарии — на русском. - `make ci` зелёный. - Покрытие тестами `internal/m2mcore/` — не менее 60%. ## Коммит ``` feat(m2m-core): FSM сделки, репозиторий на pgx, идемпотентность по GUID, метрики SLA - internal/m2mcore/fsm.go: конечный автомат с переходами и аудит-событиями - internal/m2mcore/deal.go: доменная модель сделки - internal/m2mcore/repo.go: репозиторий на pgx с идемпотентным Create - internal/m2mcore/ports.go: интерфейсы для внешних зависимостей (заглушки) - internal/m2mcore/enrich.go: сборка M2MTransferRequest из заявки + Fansy - internal/m2mcore/metrics.go: Prometheus-метрики этапов и SLA - cmd/m2m-core/main.go: минимальный сервер с /healthz и /metrics Co-Authored-By: Claude Opus 4.7 (1M context) ``` ## После коммита Обновить `docs/tasks/README.md`: PR-4 — «выполнено».