diff --git a/docs/tasks/README.md b/docs/tasks/README.md
index 7bfddca..335a0f1 100644
--- a/docs/tasks/README.md
+++ b/docs/tasks/README.md
@@ -14,7 +14,7 @@ PR-1 → PR-N. Каждая задача — самостоятельный ос
| PR-1 | `PR-1-go-models-m2m.md` | выполнено | — |
| PR-2 | `PR-2-fansy-ddl.md` | выполнено | — (параллельно с PR-1) |
| PR-3 | `PR-3-lk-openapi.md` | выполнено | — (параллельно с PR-1) |
-| PR-4 | `PR-4-m2m-core-skeleton.md` | готово к запуску | PR-1 |
+| PR-4 | `PR-4-m2m-core-skeleton.md` | выполнено | PR-1 |
| PR-5 | `PR-5-nsd-adapter-skeleton.md` | ждёт ИШ НРД и сертификаты | PR-1, PR-4 |
| PR-6 | `PR-6-crypto-service-skeleton.md` | ждёт КриптоПро JCP | PR-1 |
diff --git a/internal/m2mcore/README.md b/internal/m2mcore/README.md
new file mode 100644
index 0000000..661b8ce
--- /dev/null
+++ b/internal/m2mcore/README.md
@@ -0,0 +1,49 @@
+# internal/m2mcore — ядро бизнес-логики M2M
+
+Конечный автомат сделки, репозиторий, идемпотентность по GUID,
+сборка `M2MTransferRequest` из заявки ЛК + данных Fansy, метрики SLA.
+
+## Состав
+
+- `fsm.go` — состояния FSM и матрица разрешённых переходов
+ (`Draft → Validated → SubmittedToNSD → AwaitingDecision → Confirmed →
+ AwaitingSUB16 → Done`, ветки `Rejected`, `TimedOut`,
+ `ManualApproval`).
+- `deal.go` — доменная модель `Deal` с явными методами переходов
+ (`Validate`, `Submit`, `ReceiveDecision`, `Timeout`,
+ `SendToManualApproval`, `ApproveManually`, `RejectManually`,
+ `CompleteSUB16`). Каждый переход проверяет текущее состояние,
+ пишет историю в `Stages` и фиксирует событие в журнале.
+- `uuid.go` — генератор UUID v4 без внешних зависимостей.
+- `repo.go` — порт `Repository` + in-memory реализация
+ `MemoryRepository` с идемпотентным `Create` (по GUID возвращает
+ существующую сделку). PostgreSQL-реализация — задача M2 (миграция
+ лежит в `migrations/m2m-core/001__deals.sql`).
+- `ports.go` — порты к внешним системам (`NSDSender`,
+ `LKCallbackClient`, `CryptoVerifier`, `FansyStore`) с no-op
+ заглушками для M1.
+- `enrich.go` — функция `EnrichRequest`: из `ClaimInput` (заявка ЛК)
+ + данных Fansy собирает валидный `M2MTransferRequest`, генерирует
+ `GUID` и `ReferenceID` по каждой ЦБ.
+- `metrics.go` — порт `Recorder` + `MemoryRecorder`, отдающий
+ Prometheus text-format. В M2 заменим на `prometheus/client_golang`,
+ когда прокси откроет Go-модули.
+
+## Зависимости
+
+Только stdlib и собственные пакеты `internal/m2m`, `internal/nsdxml`.
+Никаких внешних модулей.
+
+## Тесты
+
+- `fsm_test.go` — переходы и терминальные состояния.
+- `repo_test.go` — идемпотентность по GUID, фильтры в `List`.
+- `uuid_test.go` — формат UUID v4 и `ReferenceID`.
+- `metrics_test.go` — Prometheus-текст.
+
+## Сервис cmd/m2m-core
+
+`cmd/m2m-core/main.go` — HTTP-сервер с `/healthz` и `/metrics`,
+in-memory репозиторий, no-op порты. Адрес из `BJ_HTTP_ADDR`
+(по умолчанию `:8081`). В M2 будет заменено на реальные клиенты НРД
+и crypto-service.
diff --git a/internal/m2mcore/deal.go b/internal/m2mcore/deal.go
new file mode 100644
index 0000000..dbf924f
--- /dev/null
+++ b/internal/m2mcore/deal.go
@@ -0,0 +1,232 @@
+package m2mcore
+
+import (
+ "context"
+ "fmt"
+ "sync"
+ "time"
+
+ "git.zetit.ru/zuevav/Bridge-and-Join-s/internal/m2m"
+)
+
+// StageRecord — запись о входе в состояние FSM, для аудита и метрик.
+type StageRecord struct {
+ State State
+ EnteredAt time.Time
+ LeftAt *time.Time
+ Reason string
+}
+
+// Event — доменное событие сделки (event sourcing для аудита).
+type Event struct {
+ Type string
+ Payload any
+ CreatedAt time.Time
+ Actor string
+}
+
+// Deal — корневая агрегатная сущность M2M-сделки.
+type Deal struct {
+ ID string
+ GUID m2m.UUID
+ State State
+ InvestorID string
+ SignedClaim []byte
+ Request *m2m.M2MTransferRequest
+ Response *m2m.M2MTransferResponse
+ Decision *m2m.M2MTransferDecision
+ CreatedAt time.Time
+ UpdatedAt time.Time
+ Stages []StageRecord
+
+ mu sync.Mutex
+ events []Event
+}
+
+// NewDeal создаёт новую сделку в состоянии Draft.
+func NewDeal(guid m2m.UUID, investorID string, signedClaim []byte) (*Deal, error) {
+ if err := guid.Validate(); err != nil {
+ return nil, fmt.Errorf("m2mcore: невалидный GUID при создании Deal: %w", err)
+ }
+ id, err := NewUUIDv4()
+ if err != nil {
+ return nil, err
+ }
+ now := time.Now().UTC()
+ return &Deal{
+ ID: id,
+ GUID: guid,
+ State: StateDraft,
+ InvestorID: investorID,
+ SignedClaim: signedClaim,
+ CreatedAt: now,
+ UpdatedAt: now,
+ Stages: []StageRecord{{State: StateDraft, EnteredAt: now}},
+ }, nil
+}
+
+// Events возвращает накопленные события (копия слайса).
+func (d *Deal) Events() []Event {
+ d.mu.Lock()
+ defer d.mu.Unlock()
+ out := make([]Event, len(d.events))
+ copy(out, d.events)
+ return out
+}
+
+// recordEvent добавляет событие в журнал сделки.
+func (d *Deal) recordEvent(eventType string, payload any, actor string) {
+ d.events = append(d.events, Event{
+ Type: eventType, Payload: payload,
+ CreatedAt: time.Now().UTC(), Actor: actor,
+ })
+}
+
+// shiftTo переводит FSM в новое состояние, фиксируя историю.
+// Должен вызываться под d.mu.Lock.
+func (d *Deal) shiftTo(next State, reason string) error {
+ if err := transition(d.State, next); err != nil {
+ return err
+ }
+ now := time.Now().UTC()
+ if i := len(d.Stages) - 1; i >= 0 {
+ d.Stages[i].LeftAt = &now
+ }
+ d.Stages = append(d.Stages, StageRecord{State: next, EnteredAt: now, Reason: reason})
+ d.State = next
+ d.UpdatedAt = now
+ return nil
+}
+
+// Validate переводит Draft -> Validated и фиксирует событие.
+// Сама валидация Request делается m2m.M2MTransferRequest.Validate(),
+// которое следует вызвать перед этим методом.
+func (d *Deal) Validate(_ context.Context, request *m2m.M2MTransferRequest) error {
+ d.mu.Lock()
+ defer d.mu.Unlock()
+ if request == nil {
+ return fmt.Errorf("m2mcore: Deal.Validate: request=nil")
+ }
+ if err := request.Validate(); err != nil {
+ return fmt.Errorf("m2mcore: M2MTransferRequest.Validate: %w", err)
+ }
+ d.Request = request
+ if err := d.shiftTo(StateValidated, ""); err != nil {
+ return err
+ }
+ d.recordEvent("validated", request.Header.GUID, "system")
+ return nil
+}
+
+// Submit переводит Validated -> SubmittedToNSD после успешной отправки.
+func (d *Deal) Submit(_ context.Context) error {
+ d.mu.Lock()
+ defer d.mu.Unlock()
+ if err := d.shiftTo(StateSubmittedToNSD, ""); err != nil {
+ return err
+ }
+ d.recordEvent("submitted_to_nsd", nil, "system")
+ if err := d.shiftTo(StateAwaitingDecision, ""); err != nil {
+ return err
+ }
+ d.recordEvent("awaiting_decision", nil, "system")
+ return nil
+}
+
+// ReceiveDecision принимает M2MTransferDecision от принимающей стороны и
+// меняет состояние на Confirmed или Rejected в зависимости от содержимого.
+func (d *Deal) ReceiveDecision(_ context.Context, decision *m2m.M2MTransferDecision) error {
+ d.mu.Lock()
+ defer d.mu.Unlock()
+ if decision == nil {
+ return fmt.Errorf("m2mcore: ReceiveDecision: decision=nil")
+ }
+ if err := decision.Validate(); err != nil {
+ return fmt.Errorf("m2mcore: M2MTransferDecision.Validate: %w", err)
+ }
+ d.Decision = decision
+
+ // Если все Security содержат Confirmation — Confirmed; если хотя бы
+ // одна Rejection — Rejected. Смешанные сценарии XSD НРД не запрещает,
+ // но на нашей стороне их трактуем как Rejected (требует ручного разбора).
+ rejected := false
+ for _, s := range decision.Data.Securities {
+ if s.TransferDecision.Rejection != nil {
+ rejected = true
+ break
+ }
+ }
+ target := StateConfirmed
+ reason := ""
+ if rejected {
+ target = StateRejected
+ reason = "decision_contains_rejection"
+ }
+ if err := d.shiftTo(target, reason); err != nil {
+ return err
+ }
+ d.recordEvent("decision_received", decision.Header.GUID, "nsd")
+ return nil
+}
+
+// Timeout переводит сделку в TimedOut (когда не дождались Decision).
+func (d *Deal) Timeout(_ context.Context) error {
+ d.mu.Lock()
+ defer d.mu.Unlock()
+ if err := d.shiftTo(StateTimedOut, "no_decision_within_sla"); err != nil {
+ return err
+ }
+ d.recordEvent("timed_out", nil, "system")
+ return nil
+}
+
+// SendToManualApproval переводит на ручной разбор оператора.
+func (d *Deal) SendToManualApproval(_ context.Context, reason string) error {
+ d.mu.Lock()
+ defer d.mu.Unlock()
+ if err := d.shiftTo(StateManualApproval, reason); err != nil {
+ return err
+ }
+ d.recordEvent("manual_approval_requested", reason, "system")
+ return nil
+}
+
+// ApproveManually вручную подтверждает сделку (с операторской подписью).
+func (d *Deal) ApproveManually(_ context.Context, operator string) error {
+ d.mu.Lock()
+ defer d.mu.Unlock()
+ if err := d.shiftTo(StateConfirmed, "manual_approve"); err != nil {
+ return err
+ }
+ d.recordEvent("manual_approve", nil, operator)
+ return nil
+}
+
+// RejectManually вручную отказывает в сделке.
+func (d *Deal) RejectManually(_ context.Context, operator, code, comment string) error {
+ d.mu.Lock()
+ defer d.mu.Unlock()
+ if err := d.shiftTo(StateRejected, "manual_reject:"+code); err != nil {
+ return err
+ }
+ d.recordEvent("manual_reject", map[string]string{"code": code, "comment": comment}, operator)
+ return nil
+}
+
+// CompleteSUB16 фиксирует получение SUB16 от НРД и переводит Confirmed
+// -> AwaitingSUB16 -> Done. Может вызываться один раз.
+func (d *Deal) CompleteSUB16(_ context.Context) error {
+ d.mu.Lock()
+ defer d.mu.Unlock()
+ if d.State == StateConfirmed {
+ if err := d.shiftTo(StateAwaitingSUB16, ""); err != nil {
+ return err
+ }
+ d.recordEvent("awaiting_sub16", nil, "nsd")
+ }
+ if err := d.shiftTo(StateDone, ""); err != nil {
+ return err
+ }
+ d.recordEvent("done", nil, "nsd")
+ return nil
+}
diff --git a/internal/m2mcore/enrich.go b/internal/m2mcore/enrich.go
new file mode 100644
index 0000000..20a5209
--- /dev/null
+++ b/internal/m2mcore/enrich.go
@@ -0,0 +1,157 @@
+package m2mcore
+
+import (
+ "context"
+ "crypto/rand"
+ "fmt"
+ "math/big"
+ "time"
+
+ "git.zetit.ru/zuevav/Bridge-and-Join-s/internal/m2m"
+ "git.zetit.ru/zuevav/Bridge-and-Join-s/internal/nsdxml"
+)
+
+// ClaimInput — входная заявка из ЛК (после OpenAPI-парсинга).
+type ClaimInput struct {
+ InvestorClientID string
+ TransferringDepositoryINN m2m.OrganizationINN
+ ReceivingDepositoryINN m2m.OrganizationINN
+ CostInfo m2m.CostInfo
+ IIAAgreement *m2m.IIAAgreementDetails
+ Securities []ClaimSecurityInput
+}
+
+// ClaimSecurityInput — одна ЦБ в заявке.
+type ClaimSecurityInput struct {
+ SecurityCode m2m.SecurityCode
+ Details m2m.SecurityDetails
+ Quantity m2m.Quantity
+}
+
+// SenderReceiver — отправитель и получатель в Header (коды депонентов).
+type SenderReceiver struct {
+ SenderCode m2m.DeponentCode
+ ReceiverCode m2m.DeponentCode
+}
+
+// referenceIDChars — алфавит для генерации ReferenceID (5 случайных
+// символов после префикса "M2M" и даты).
+const referenceIDChars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
+
+// NewReferenceID генерирует идентификатор операции в формате
+// "M2M" + YYYYMMDD + 5 случайных символов из [A-Z0-9].
+// Длина — ровно 16, как требует XSD ReferenceIdType.
+func NewReferenceID(at time.Time) (m2m.ReferenceID, error) {
+ suffix := make([]byte, 5)
+ for i := range suffix {
+ n, err := rand.Int(rand.Reader, big.NewInt(int64(len(referenceIDChars))))
+ if err != nil {
+ return "", fmt.Errorf("m2mcore: NewReferenceID rand: %w", err)
+ }
+ suffix[i] = referenceIDChars[n.Int64()]
+ }
+ day := at.UTC()
+ id := fmt.Sprintf("M2M%04d%02d%02d%s",
+ day.Year(), int(day.Month()), day.Day(), suffix)
+ out := m2m.ReferenceID(id)
+ if err := out.Validate(); err != nil {
+ return "", err
+ }
+ return out, nil
+}
+
+// EnrichRequest собирает M2MTransferRequest из заявки ЛК и данных Fansy.
+// Шаги:
+// 1. Поднять анкету клиента.
+// 2. Поднять депо-счета у передающего депозитария и проверить остатки.
+// 3. Сгенерировать GUID, ReferenceID для каждой ЦБ, CreationTimestamp.
+// 4. Заполнить структуру и провалидировать.
+func EnrichRequest(
+ ctx context.Context,
+ store FansyStore,
+ claim ClaimInput,
+ codes SenderReceiver,
+) (*m2m.M2MTransferRequest, error) {
+ client, err := store.GetClientByID(ctx, claim.InvestorClientID)
+ if err != nil {
+ return nil, fmt.Errorf("m2mcore: GetClientByID: %w", err)
+ }
+ accounts, err := store.GetDepoAccounts(ctx, claim.InvestorClientID, claim.TransferringDepositoryINN)
+ if err != nil {
+ return nil, fmt.Errorf("m2mcore: GetDepoAccounts: %w", err)
+ }
+ if len(accounts) == 0 {
+ return nil, fmt.Errorf("m2mcore: у клиента нет активных счетов в передающем депозитарии")
+ }
+
+ guid, err := NewUUIDv4()
+ if err != nil {
+ return nil, err
+ }
+ now := time.Now()
+
+ securities := make([]m2m.RequestSecurity, 0, len(claim.Securities))
+ for _, sec := range claim.Securities {
+ refID, err := NewReferenceID(now)
+ if err != nil {
+ return nil, err
+ }
+ // Берём первый активный счёт как минимум; реальная логика выбора
+ // settlement_accounts будет в M2 (по типу ЦБ и торговому разделу).
+ settlement := make([]m2m.RequestSettlementAccount, 0, len(accounts))
+ for _, a := range accounts {
+ settlement = append(settlement, m2m.RequestSettlementAccount{
+ SettlementRequisites: m2m.SettlementRequisites{INN: a.DepositoryINN},
+ SettlementLocation: m2m.SettlementDepositoryLocation{
+ DeponentCode: a.DeponentCode,
+ AccountID: a.AccountID,
+ SectionID: a.SectionID,
+ },
+ })
+ }
+ securities = append(securities, m2m.RequestSecurity{
+ ReferenceID: refID,
+ SecurityCode: sec.SecurityCode,
+ SecurityDetails: sec.Details,
+ Quantity: sec.Quantity,
+ SettlementAccount: settlement,
+ IsolationStatus: m2m.IsolationSGDN,
+ })
+ }
+
+ req := &m2m.M2MTransferRequest{
+ Header: m2m.RequestHeader{
+ GUID: m2m.UUID(guid),
+ CreationTimestamp: nsdxml.Now(),
+ SenderCode: codes.SenderCode,
+ ReceiverCode: codes.ReceiverCode,
+ CostInfo: claim.CostInfo,
+ IIAAgreementDetails: claim.IIAAgreement,
+ },
+ Data: m2m.RequestData{
+ InvestorInformation: m2m.InvestorInformation{
+ LastName: client.LastName,
+ FirstName: client.FirstName,
+ MiddleName: client.MiddleName,
+ IdentityDocument: m2m.IdentityDocument{
+ DocumentType: client.Document.DocumentType,
+ DocumentNumber: m2m.IdentityDocSerial(client.Document.Number),
+ },
+ },
+ TransferringDepository: m2m.SettlementRequisites{INN: claim.TransferringDepositoryINN},
+ ReceivingDepository: m2m.SettlementRequisites{INN: claim.ReceivingDepositoryINN},
+ TransferredSecurities: m2m.RequestTransferredSecurities{Securities: securities},
+ },
+ }
+ if claim.IIAAgreement != nil {
+ req.Header.IIAAgreementDetails = claim.IIAAgreement
+ }
+ if client.Document.Series != "" {
+ series := m2m.IdentityDocSerial(client.Document.Series)
+ req.Data.InvestorInformation.IdentityDocument.DocumentSeries = &series
+ }
+ if err := req.Validate(); err != nil {
+ return nil, fmt.Errorf("m2mcore: собранный M2MTransferRequest невалиден: %w", err)
+ }
+ return req, nil
+}
diff --git a/internal/m2mcore/enrich_test.go b/internal/m2mcore/enrich_test.go
new file mode 100644
index 0000000..d7cadf6
--- /dev/null
+++ b/internal/m2mcore/enrich_test.go
@@ -0,0 +1,150 @@
+package m2mcore_test
+
+import (
+ "context"
+ "errors"
+ "testing"
+ "time"
+
+ "git.zetit.ru/zuevav/Bridge-and-Join-s/internal/m2m"
+ "git.zetit.ru/zuevav/Bridge-and-Join-s/internal/m2mcore"
+)
+
+// fakeStore — тестовая реализация FansyStore.
+type fakeStore struct {
+ client *m2mcore.Client
+ accounts []m2mcore.DepoAccount
+ getErr error
+}
+
+func (f *fakeStore) GetClientByID(_ context.Context, _ string) (*m2mcore.Client, error) {
+ if f.getErr != nil {
+ return nil, f.getErr
+ }
+ return f.client, nil
+}
+
+func (f *fakeStore) GetDepoAccounts(_ context.Context, _ string, _ m2m.OrganizationINN) ([]m2mcore.DepoAccount, error) {
+ return f.accounts, nil
+}
+
+func (f *fakeStore) GetBalances(_ context.Context, _ string, _ []m2m.SecurityCode) ([]m2mcore.SecurityBalance, error) {
+ return nil, nil
+}
+
+func TestNewReferenceIDFormat(t *testing.T) {
+ at := time.Date(2026, 3, 2, 14, 30, 0, 0, time.UTC)
+ id, err := m2mcore.NewReferenceID(at)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if string(id)[:11] != "M2M20260302" {
+ t.Errorf("префикс/дата ReferenceID неверен: %q", id)
+ }
+}
+
+func TestEnrichRequestHappyPath(t *testing.T) {
+ store := &fakeStore{
+ client: &m2mcore.Client{
+ ID: "inv-1",
+ LastName: "Иванов",
+ FirstName: "Иван",
+ MiddleName: "Иванович",
+ Document: m2mcore.ClientDocument{
+ DocumentType: m2m.DocCode21,
+ Series: "4512",
+ Number: "654321",
+ },
+ },
+ accounts: []m2mcore.DepoAccount{
+ {
+ ID: "acc-1",
+ ClientID: "inv-1",
+ DeponentCode: "DP789456",
+ AccountID: m2m.AccountID("31MC0021900000F01"),
+ SectionID: "P001",
+ DepositoryINN: m2m.OrganizationINN("7702070139"),
+ },
+ },
+ }
+ whole := uint64(1500)
+ isin := m2m.ISIN("RU0007661625")
+
+ claim := m2mcore.ClaimInput{
+ InvestorClientID: "inv-1",
+ TransferringDepositoryINN: m2m.OrganizationINN("0702345678"),
+ ReceivingDepositoryINN: m2m.OrganizationINN("0710987654"),
+ CostInfo: m2m.CostInfo{
+ No: &m2m.CostInfoNo{},
+ },
+ Securities: []m2mcore.ClaimSecurityInput{
+ {
+ SecurityCode: m2m.SecurityCode("MM0766162534"),
+ Details: m2m.SecurityDetails{ISIN: &isin},
+ Quantity: m2m.Quantity{Whole: &whole},
+ },
+ },
+ }
+ codes := m2mcore.SenderReceiver{
+ SenderCode: m2m.DeponentCode("MC0079200000"),
+ ReceiverCode: m2m.DeponentCode("MC0010300000"),
+ }
+
+ req, err := m2mcore.EnrichRequest(context.Background(), store, claim, codes)
+ if err != nil {
+ t.Fatalf("EnrichRequest: %v", err)
+ }
+ if err := req.Validate(); err != nil {
+ t.Fatalf("собранный Request не прошёл валидацию: %v", err)
+ }
+ if req.Data.InvestorInformation.LastName != "Иванов" {
+ t.Errorf("LastName не пробросился")
+ }
+ if len(req.Data.TransferredSecurities.Securities) != 1 {
+ t.Errorf("ожидалась 1 ЦБ, получено %d", len(req.Data.TransferredSecurities.Securities))
+ }
+}
+
+func TestEnrichRequestNoAccounts(t *testing.T) {
+ store := &fakeStore{
+ client: &m2mcore.Client{LastName: "X", FirstName: "Y", Document: m2mcore.ClientDocument{DocumentType: m2m.DocCode21, Number: "1"}},
+ accounts: nil,
+ }
+ _, err := m2mcore.EnrichRequest(context.Background(), store, m2mcore.ClaimInput{}, m2mcore.SenderReceiver{})
+ if err == nil {
+ t.Errorf("ожидалась ошибка при отсутствии счетов")
+ }
+}
+
+func TestEnrichRequestStoreError(t *testing.T) {
+ store := &fakeStore{getErr: errors.New("db down")}
+ _, err := m2mcore.EnrichRequest(context.Background(), store, m2mcore.ClaimInput{}, m2mcore.SenderReceiver{})
+ if err == nil {
+ t.Errorf("ожидалась ошибка от FansyStore")
+ }
+}
+
+func TestNoopPortsReturnErrNotImplemented(t *testing.T) {
+ ctx := context.Background()
+ if _, err := (m2mcore.NoopNSDSender{}).Send(ctx, nil); !errors.Is(err, m2mcore.ErrNotImplemented) {
+ t.Errorf("NoopNSDSender.Send ожидалась ErrNotImplemented, получено %v", err)
+ }
+ if err := (m2mcore.NoopNSDSender{}).SendDecision(ctx, nil); !errors.Is(err, m2mcore.ErrNotImplemented) {
+ t.Errorf("NoopNSDSender.SendDecision ожидалась ErrNotImplemented, получено %v", err)
+ }
+ if err := (m2mcore.NoopLKCallbackClient{}).UpdateStatus(ctx, "", "", ""); !errors.Is(err, m2mcore.ErrNotImplemented) {
+ t.Errorf("LKCallbackClient ожидалась ErrNotImplemented")
+ }
+ if _, err := (m2mcore.NoopCryptoVerifier{}).VerifyXMLDSig(ctx, nil); !errors.Is(err, m2mcore.ErrNotImplemented) {
+ t.Errorf("CryptoVerifier ожидалась ErrNotImplemented")
+ }
+ if _, err := (m2mcore.NoopFansyStore{}).GetClientByID(ctx, ""); !errors.Is(err, m2mcore.ErrNotImplemented) {
+ t.Errorf("FansyStore.GetClientByID ожидалась ErrNotImplemented")
+ }
+ if _, err := (m2mcore.NoopFansyStore{}).GetDepoAccounts(ctx, "", ""); !errors.Is(err, m2mcore.ErrNotImplemented) {
+ t.Errorf("FansyStore.GetDepoAccounts ожидалась ErrNotImplemented")
+ }
+ if _, err := (m2mcore.NoopFansyStore{}).GetBalances(ctx, "", nil); !errors.Is(err, m2mcore.ErrNotImplemented) {
+ t.Errorf("FansyStore.GetBalances ожидалась ErrNotImplemented")
+ }
+}
diff --git a/internal/m2mcore/fsm.go b/internal/m2mcore/fsm.go
new file mode 100644
index 0000000..d5df308
--- /dev/null
+++ b/internal/m2mcore/fsm.go
@@ -0,0 +1,96 @@
+package m2mcore
+
+import (
+ "errors"
+ "fmt"
+)
+
+// State — конечное состояние сделки M2M.
+type State string
+
+const (
+ StateDraft State = "draft"
+ StateValidated State = "validated"
+ StateSubmittedToNSD State = "submitted_to_nsd"
+ StateAwaitingDecision State = "awaiting_decision"
+ StateConfirmed State = "confirmed"
+ StateAwaitingSUB16 State = "awaiting_sub16"
+ StateDone State = "done"
+ StateRejected State = "rejected"
+ StateTimedOut State = "timed_out"
+ StateManualApproval State = "manual_approval"
+)
+
+// ErrInvalidTransition возвращается при попытке перейти в состояние,
+// которое не разрешено из текущего.
+var ErrInvalidTransition = errors.New("m2mcore: недопустимый переход FSM")
+
+// allowedTransitions — карта разрешённых переходов FSM сделки.
+// Любая попытка перейти в state, отсутствующее в списке для текущего,
+// заканчивается ErrInvalidTransition.
+var allowedTransitions = map[State]map[State]struct{}{
+ StateDraft: {
+ StateValidated: {},
+ StateRejected: {},
+ StateManualApproval: {},
+ },
+ StateValidated: {
+ StateSubmittedToNSD: {},
+ StateRejected: {},
+ StateManualApproval: {},
+ },
+ StateSubmittedToNSD: {
+ StateAwaitingDecision: {},
+ StateRejected: {},
+ },
+ StateAwaitingDecision: {
+ StateConfirmed: {},
+ StateRejected: {},
+ StateTimedOut: {},
+ StateManualApproval: {},
+ },
+ StateConfirmed: {
+ StateAwaitingSUB16: {},
+ StateDone: {},
+ },
+ StateAwaitingSUB16: {
+ StateDone: {},
+ },
+ StateManualApproval: {
+ StateValidated: {},
+ StateConfirmed: {},
+ StateRejected: {},
+ },
+ // Завершающие состояния — без выходов.
+ StateDone: {},
+ StateRejected: {},
+ StateTimedOut: {},
+}
+
+// IsTerminal возвращает true для завершающих состояний.
+func IsTerminal(s State) bool {
+ switch s {
+ case StateDone, StateRejected, StateTimedOut:
+ return true
+ }
+ return false
+}
+
+// CanTransition сообщает, разрешён ли переход from -> to.
+func CanTransition(from, to State) bool {
+ allowed, ok := allowedTransitions[from]
+ if !ok {
+ return false
+ }
+ _, ok = allowed[to]
+ return ok
+}
+
+// transition проверяет переход и возвращает обёрнутую ошибку с
+// контекстом, если он недопустим.
+func transition(from, to State) error {
+ if !CanTransition(from, to) {
+ return fmt.Errorf("%w: %s -> %s", ErrInvalidTransition, from, to)
+ }
+ return nil
+}
diff --git a/internal/m2mcore/fsm_test.go b/internal/m2mcore/fsm_test.go
new file mode 100644
index 0000000..bb7ed59
--- /dev/null
+++ b/internal/m2mcore/fsm_test.go
@@ -0,0 +1,96 @@
+package m2mcore_test
+
+import (
+ "context"
+ "errors"
+ "testing"
+
+ "git.zetit.ru/zuevav/Bridge-and-Join-s/internal/m2m"
+ "git.zetit.ru/zuevav/Bridge-and-Join-s/internal/m2mcore"
+)
+
+func newTestDeal(t *testing.T) *m2mcore.Deal {
+ t.Helper()
+ guid, err := m2mcore.NewUUIDv4()
+ if err != nil {
+ t.Fatalf("UUID: %v", err)
+ }
+ d, err := m2mcore.NewDeal(m2m.UUID(guid), "00000000-0000-0000-0000-000000000001", []byte(""))
+ if err != nil {
+ t.Fatalf("NewDeal: %v", err)
+ }
+ return d
+}
+
+func TestFSMAllowedTransitions(t *testing.T) {
+ cases := []struct {
+ from m2mcore.State
+ to m2mcore.State
+ ok bool
+ }{
+ {m2mcore.StateDraft, m2mcore.StateValidated, true},
+ {m2mcore.StateDraft, m2mcore.StateSubmittedToNSD, false},
+ {m2mcore.StateValidated, m2mcore.StateSubmittedToNSD, true},
+ {m2mcore.StateAwaitingDecision, m2mcore.StateConfirmed, true},
+ {m2mcore.StateAwaitingDecision, m2mcore.StateRejected, true},
+ {m2mcore.StateAwaitingDecision, m2mcore.StateTimedOut, true},
+ {m2mcore.StateConfirmed, m2mcore.StateAwaitingSUB16, true},
+ {m2mcore.StateDone, m2mcore.StateRejected, false},
+ {m2mcore.StateRejected, m2mcore.StateDone, false},
+ }
+ for _, c := range cases {
+ if got := m2mcore.CanTransition(c.from, c.to); got != c.ok {
+ t.Errorf("CanTransition(%s,%s)=%v ожидалось %v", c.from, c.to, got, c.ok)
+ }
+ }
+}
+
+func TestFSMIsTerminal(t *testing.T) {
+ for _, s := range []m2mcore.State{m2mcore.StateDone, m2mcore.StateRejected, m2mcore.StateTimedOut} {
+ if !m2mcore.IsTerminal(s) {
+ t.Errorf("%s должно быть терминальным", s)
+ }
+ }
+ for _, s := range []m2mcore.State{m2mcore.StateDraft, m2mcore.StateConfirmed} {
+ if m2mcore.IsTerminal(s) {
+ t.Errorf("%s не должно быть терминальным", s)
+ }
+ }
+}
+
+func TestDealManualReject(t *testing.T) {
+ d := newTestDeal(t)
+ if err := d.SendToManualApproval(context.Background(), "ambiguous_decision"); err != nil {
+ t.Fatalf("SendToManualApproval из Draft неожиданно дал ошибку %v", err)
+ }
+}
+
+func TestDealInvalidTransition(t *testing.T) {
+ d := newTestDeal(t)
+ // Draft -> SubmittedToNSD не разрешён.
+ err := d.Submit(context.Background())
+ if !errors.Is(err, m2mcore.ErrInvalidTransition) {
+ t.Errorf("ожидалась ErrInvalidTransition, получено %v", err)
+ }
+}
+
+func TestDealCompleteSUB16(t *testing.T) {
+ d := newTestDeal(t)
+ if err := d.ApproveManually(context.Background(), "operator-1"); err == nil {
+ // Из Draft нельзя сразу ApproveManually — нужен путь через
+ // ManualApproval. Это тоже проверяем.
+ t.Errorf("ApproveManually из Draft должен был отказать")
+ }
+ if err := d.SendToManualApproval(context.Background(), "test"); err != nil {
+ t.Fatal(err)
+ }
+ if err := d.ApproveManually(context.Background(), "operator-1"); err != nil {
+ t.Fatalf("ApproveManually: %v", err)
+ }
+ if err := d.CompleteSUB16(context.Background()); err != nil {
+ t.Fatalf("CompleteSUB16: %v", err)
+ }
+ if d.State != m2mcore.StateDone {
+ t.Errorf("конечное состояние %s, ожидалось %s", d.State, m2mcore.StateDone)
+ }
+}
diff --git a/internal/m2mcore/helpers_test.go b/internal/m2mcore/helpers_test.go
new file mode 100644
index 0000000..9b0f10a
--- /dev/null
+++ b/internal/m2mcore/helpers_test.go
@@ -0,0 +1,16 @@
+package m2mcore_test
+
+import (
+ "testing"
+ "time"
+)
+
+// mustParseTime — общий тестовый хелпер.
+func mustParseTime(t *testing.T, s string) time.Time {
+ t.Helper()
+ tt, err := time.Parse(time.RFC3339, s)
+ if err != nil {
+ t.Fatalf("parse time: %v", err)
+ }
+ return tt
+}
diff --git a/internal/m2mcore/metrics.go b/internal/m2mcore/metrics.go
new file mode 100644
index 0000000..2454578
--- /dev/null
+++ b/internal/m2mcore/metrics.go
@@ -0,0 +1,142 @@
+package m2mcore
+
+import (
+ "fmt"
+ "io"
+ "sort"
+ "strings"
+ "sync"
+ "time"
+)
+
+// Recorder — интерфейс записи метрик. NoopRecorder используется по
+// умолчанию; реальная реализация (Prometheus) подключается в M2, когда
+// внешние Go-зависимости становятся доступны.
+type Recorder interface {
+ StageDuration(stage State, d time.Duration)
+ IncDeal(state State)
+ IncSLABreach(stage State, budget string)
+}
+
+// NoopRecorder ничего не пишет.
+type NoopRecorder struct{}
+
+// StageDuration — no-op.
+func (NoopRecorder) StageDuration(State, time.Duration) {}
+
+// IncDeal — no-op.
+func (NoopRecorder) IncDeal(State) {}
+
+// IncSLABreach — no-op.
+func (NoopRecorder) IncSLABreach(State, string) {}
+
+// MemoryRecorder — простая in-memory реализация Recorder, удобная для
+// тестов и для /metrics-endpoint в M1 (Prometheus text format).
+type MemoryRecorder struct {
+ mu sync.Mutex
+ // stageDurations: stage -> сумма длительностей и счётчик
+ stageDurations map[State]struct {
+ Sum time.Duration
+ Count uint64
+ }
+ dealsByState map[State]uint64
+ slaBreaches map[string]uint64 // ключ: "stage|budget"
+}
+
+// NewMemoryRecorder создаёт MemoryRecorder с пустыми счётчиками.
+func NewMemoryRecorder() *MemoryRecorder {
+ return &MemoryRecorder{
+ stageDurations: make(map[State]struct {
+ Sum time.Duration
+ Count uint64
+ }),
+ dealsByState: make(map[State]uint64),
+ slaBreaches: make(map[string]uint64),
+ }
+}
+
+// StageDuration фиксирует длительность этапа FSM.
+func (m *MemoryRecorder) StageDuration(stage State, d time.Duration) {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ v := m.stageDurations[stage]
+ v.Sum += d
+ v.Count++
+ m.stageDurations[stage] = v
+}
+
+// IncDeal увеличивает счётчик сделок в заданном итоговом состоянии.
+func (m *MemoryRecorder) IncDeal(state State) {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ m.dealsByState[state]++
+}
+
+// IncSLABreach фиксирует превышение SLA-бюджета на этапе.
+func (m *MemoryRecorder) IncSLABreach(stage State, budget string) {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ m.slaBreaches[fmt.Sprintf("%s|%s", stage, budget)]++
+}
+
+// WritePrometheus сериализует накопленные метрики в формате Prometheus
+// text exposition (HELP/TYPE + значения). Подходит для /metrics.
+func (m *MemoryRecorder) WritePrometheus(w io.Writer) error {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+
+ fmt.Fprintf(w, "# HELP m2m_stage_duration_seconds_sum Сумма длительностей этапа в секундах\n")
+ fmt.Fprintf(w, "# TYPE m2m_stage_duration_seconds_sum counter\n")
+ stages := sortedStates(m.stageDurations)
+ for _, s := range stages {
+ v := m.stageDurations[s]
+ fmt.Fprintf(w, "m2m_stage_duration_seconds_sum{stage=%q} %f\n", s, v.Sum.Seconds())
+ fmt.Fprintf(w, "m2m_stage_duration_seconds_count{stage=%q} %d\n", s, v.Count)
+ }
+
+ fmt.Fprintf(w, "# HELP m2m_deals_total Сделки по итоговому состоянию\n")
+ fmt.Fprintf(w, "# TYPE m2m_deals_total counter\n")
+ dstates := sortedStatesCount(m.dealsByState)
+ for _, s := range dstates {
+ fmt.Fprintf(w, "m2m_deals_total{state=%q} %d\n", s, m.dealsByState[s])
+ }
+
+ fmt.Fprintf(w, "# HELP m2m_sla_breaches_total Превышения SLA-бюджета\n")
+ fmt.Fprintf(w, "# TYPE m2m_sla_breaches_total counter\n")
+ keys := make([]string, 0, len(m.slaBreaches))
+ for k := range m.slaBreaches {
+ keys = append(keys, k)
+ }
+ sort.Strings(keys)
+ for _, k := range keys {
+ stage, budget, ok := strings.Cut(k, "|")
+ if !ok {
+ continue
+ }
+ fmt.Fprintf(w, "m2m_sla_breaches_total{stage=%q,budget=%q} %d\n", stage, budget, m.slaBreaches[k])
+ }
+ return nil
+}
+
+// sortedStates возвращает состояния как отсортированный слайс для
+// детерминированного вывода.
+func sortedStates(m map[State]struct {
+ Sum time.Duration
+ Count uint64
+}) []State {
+ out := make([]State, 0, len(m))
+ for k := range m {
+ out = append(out, k)
+ }
+ sort.Slice(out, func(i, j int) bool { return out[i] < out[j] })
+ return out
+}
+
+func sortedStatesCount(m map[State]uint64) []State {
+ out := make([]State, 0, len(m))
+ for k := range m {
+ out = append(out, k)
+ }
+ sort.Slice(out, func(i, j int) bool { return out[i] < out[j] })
+ return out
+}
diff --git a/internal/m2mcore/metrics_test.go b/internal/m2mcore/metrics_test.go
new file mode 100644
index 0000000..3d9528c
--- /dev/null
+++ b/internal/m2mcore/metrics_test.go
@@ -0,0 +1,44 @@
+package m2mcore_test
+
+import (
+ "bytes"
+ "strings"
+ "testing"
+ "time"
+
+ "git.zetit.ru/zuevav/Bridge-and-Join-s/internal/m2mcore"
+)
+
+func TestMemoryRecorderPrometheus(t *testing.T) {
+ r := m2mcore.NewMemoryRecorder()
+ r.StageDuration(m2mcore.StateAwaitingDecision, 12*time.Second)
+ r.StageDuration(m2mcore.StateAwaitingDecision, 8*time.Second)
+ r.IncDeal(m2mcore.StateConfirmed)
+ r.IncDeal(m2mcore.StateConfirmed)
+ r.IncDeal(m2mcore.StateRejected)
+ r.IncSLABreach(m2mcore.StateAwaitingDecision, "5m")
+
+ var buf bytes.Buffer
+ if err := r.WritePrometheus(&buf); err != nil {
+ t.Fatal(err)
+ }
+ out := buf.String()
+ for _, want := range []string{
+ `m2m_stage_duration_seconds_count{stage="awaiting_decision"} 2`,
+ `m2m_stage_duration_seconds_sum{stage="awaiting_decision"} 20`,
+ `m2m_deals_total{state="confirmed"} 2`,
+ `m2m_deals_total{state="rejected"} 1`,
+ `m2m_sla_breaches_total{stage="awaiting_decision",budget="5m"} 1`,
+ } {
+ if !strings.Contains(out, want) {
+ t.Errorf("в выводе нет %q\n---\n%s", want, out)
+ }
+ }
+}
+
+func TestNoopRecorder(t *testing.T) {
+ var r m2mcore.Recorder = m2mcore.NoopRecorder{}
+ r.StageDuration(m2mcore.StateConfirmed, time.Second)
+ r.IncDeal(m2mcore.StateConfirmed)
+ r.IncSLABreach(m2mcore.StateAwaitingDecision, "5m")
+}
diff --git a/internal/m2mcore/ports.go b/internal/m2mcore/ports.go
new file mode 100644
index 0000000..55f49b7
--- /dev/null
+++ b/internal/m2mcore/ports.go
@@ -0,0 +1,130 @@
+package m2mcore
+
+import (
+ "context"
+ "errors"
+ "time"
+
+ "git.zetit.ru/zuevav/Bridge-and-Join-s/internal/m2m"
+)
+
+// CertInfo — описание подписанта (заполняется CryptoVerifier).
+type CertInfo struct {
+ SignerCN string
+ SignerINN string
+ Serial string
+ NotBefore time.Time
+ NotAfter time.Time
+}
+
+// Client — анкета инвестора, нужная для enrich Request.
+type Client struct {
+ ID string
+ LastName string
+ FirstName string
+ MiddleName string
+ BirthDate *time.Time
+ Document ClientDocument
+}
+
+// ClientDocument — документ, удостоверяющий личность.
+type ClientDocument struct {
+ DocumentType m2m.IdentityDocumentCode
+ Series string
+ Number string
+}
+
+// DepoAccount — счёт депо инвестора у депозитария.
+type DepoAccount struct {
+ ID string
+ ClientID string
+ DeponentCode string
+ AccountID m2m.AccountID
+ SectionID string
+ DepositoryINN m2m.OrganizationINN
+}
+
+// SecurityBalance — остаток по ценной бумаге на конкретном счёте депо.
+type SecurityBalance struct {
+ SecurityCode m2m.SecurityCode
+ ISIN m2m.ISIN
+ QuantityWhole *uint64
+ QuantityFractional *m2m.Decimal16
+ IsolationStatus m2m.IsolationStatus
+ ValuedAt time.Time
+}
+
+// NSDSender — порт отправки в НРД (через ИШ или резервный канал WS ONYX).
+type NSDSender interface {
+ // Send отправляет запрос на перевод и возвращает квитанцию НРД.
+ Send(ctx context.Context, req *m2m.M2MTransferRequest) (*m2m.M2MTransferResponse, error)
+ // SendDecision отправляет решение принимающей стороны.
+ SendDecision(ctx context.Context, decision *m2m.M2MTransferDecision) error
+}
+
+// LKCallbackClient — порт уведомления ЛК клиента об изменении статуса.
+type LKCallbackClient interface {
+ UpdateStatus(ctx context.Context, claimID, status, reason string) error
+}
+
+// CryptoVerifier — порт проверки и формирования XMLDSig-подписей.
+type CryptoVerifier interface {
+ VerifyXMLDSig(ctx context.Context, payload []byte) (CertInfo, error)
+}
+
+// FansyStore — порт чтения данных из принимающей БД fansy-store.
+type FansyStore interface {
+ GetClientByID(ctx context.Context, id string) (*Client, error)
+ GetDepoAccounts(ctx context.Context, clientID string, depositoryINN m2m.OrganizationINN) ([]DepoAccount, error)
+ GetBalances(ctx context.Context, depoAccountID string, codes []m2m.SecurityCode) ([]SecurityBalance, error)
+}
+
+// ErrNotImplemented возвращается заглушками портов.
+var ErrNotImplemented = errors.New("m2mcore: не реализовано (M1 заглушка)")
+
+// NoopNSDSender — заглушка NSDSender для M1.
+type NoopNSDSender struct{}
+
+// Send возвращает ErrNotImplemented.
+func (NoopNSDSender) Send(context.Context, *m2m.M2MTransferRequest) (*m2m.M2MTransferResponse, error) {
+ return nil, ErrNotImplemented
+}
+
+// SendDecision возвращает ErrNotImplemented.
+func (NoopNSDSender) SendDecision(context.Context, *m2m.M2MTransferDecision) error {
+ return ErrNotImplemented
+}
+
+// NoopLKCallbackClient — заглушка LKCallbackClient для M1.
+type NoopLKCallbackClient struct{}
+
+// UpdateStatus возвращает ErrNotImplemented.
+func (NoopLKCallbackClient) UpdateStatus(context.Context, string, string, string) error {
+ return ErrNotImplemented
+}
+
+// NoopCryptoVerifier — заглушка CryptoVerifier для M1.
+type NoopCryptoVerifier struct{}
+
+// VerifyXMLDSig возвращает ErrNotImplemented.
+func (NoopCryptoVerifier) VerifyXMLDSig(context.Context, []byte) (CertInfo, error) {
+ return CertInfo{}, ErrNotImplemented
+}
+
+// NoopFansyStore — заглушка FansyStore для M1.
+type NoopFansyStore struct{}
+
+// GetClientByID возвращает ErrNotImplemented.
+func (NoopFansyStore) GetClientByID(context.Context, string) (*Client, error) {
+ return nil, ErrNotImplemented
+}
+
+// GetDepoAccounts возвращает ErrNotImplemented.
+func (NoopFansyStore) GetDepoAccounts(context.Context, string, m2m.OrganizationINN) ([]DepoAccount, error) {
+ return nil, ErrNotImplemented
+}
+
+// GetBalances возвращает ErrNotImplemented.
+func (NoopFansyStore) GetBalances(context.Context, string, []m2m.SecurityCode) ([]SecurityBalance, error) {
+ return nil, ErrNotImplemented
+}
diff --git a/internal/m2mcore/repo.go b/internal/m2mcore/repo.go
new file mode 100644
index 0000000..1a341f3
--- /dev/null
+++ b/internal/m2mcore/repo.go
@@ -0,0 +1,155 @@
+package m2mcore
+
+import (
+ "context"
+ "errors"
+ "sync"
+ "time"
+
+ "git.zetit.ru/zuevav/Bridge-and-Join-s/internal/m2m"
+)
+
+// ErrNotFound возвращается, когда сделка не найдена.
+var ErrNotFound = errors.New("m2mcore: сделка не найдена")
+
+// Filter описывает фильтры выборки сделок.
+type Filter struct {
+ State *State
+ InvestorID string
+ CreatedFrom *time.Time
+ CreatedTo *time.Time
+ Limit int
+ Offset int
+}
+
+// Repository — порт хранилища сделок.
+type Repository interface {
+ // Create вставляет сделку с идемпотентностью по GUID: повторный вызов
+ // для существующего GUID возвращает уже существующую сделку (без
+ // модификации).
+ Create(ctx context.Context, deal *Deal) (*Deal, error)
+ // GetByGUID находит сделку по M2M GUID.
+ GetByGUID(ctx context.Context, guid m2m.UUID) (*Deal, error)
+ // GetByID находит сделку по внутреннему UUID.
+ GetByID(ctx context.Context, id string) (*Deal, error)
+ // Update сохраняет изменения сделки.
+ Update(ctx context.Context, deal *Deal) error
+ // List возвращает сделки по фильтру.
+ List(ctx context.Context, f Filter) ([]*Deal, error)
+ // AppendEvent добавляет аудит-событие к сделке.
+ AppendEvent(ctx context.Context, dealID string, ev Event) error
+}
+
+// MemoryRepository — in-memory реализация Repository для тестов и
+// dev-стенда без PostgreSQL.
+type MemoryRepository struct {
+ mu sync.RWMutex
+ byID map[string]*Deal
+ byGUID map[m2m.UUID]string
+ events map[string][]Event
+}
+
+// NewMemoryRepository создаёт пустое in-memory хранилище.
+func NewMemoryRepository() *MemoryRepository {
+ return &MemoryRepository{
+ byID: make(map[string]*Deal),
+ byGUID: make(map[m2m.UUID]string),
+ events: make(map[string][]Event),
+ }
+}
+
+// Create вставляет сделку или возвращает существующую по GUID.
+func (r *MemoryRepository) Create(_ context.Context, deal *Deal) (*Deal, error) {
+ r.mu.Lock()
+ defer r.mu.Unlock()
+ if id, ok := r.byGUID[deal.GUID]; ok {
+ return r.byID[id], nil
+ }
+ r.byID[deal.ID] = deal
+ r.byGUID[deal.GUID] = deal.ID
+ return deal, nil
+}
+
+// GetByGUID возвращает сделку по GUID или ErrNotFound.
+func (r *MemoryRepository) GetByGUID(_ context.Context, guid m2m.UUID) (*Deal, error) {
+ r.mu.RLock()
+ defer r.mu.RUnlock()
+ id, ok := r.byGUID[guid]
+ if !ok {
+ return nil, ErrNotFound
+ }
+ return r.byID[id], nil
+}
+
+// GetByID возвращает сделку по внутреннему ID или ErrNotFound.
+func (r *MemoryRepository) GetByID(_ context.Context, id string) (*Deal, error) {
+ r.mu.RLock()
+ defer r.mu.RUnlock()
+ d, ok := r.byID[id]
+ if !ok {
+ return nil, ErrNotFound
+ }
+ return d, nil
+}
+
+// Update в in-memory импликации no-op: указатель уже хранится.
+func (r *MemoryRepository) Update(_ context.Context, deal *Deal) error {
+ r.mu.Lock()
+ defer r.mu.Unlock()
+ if _, ok := r.byID[deal.ID]; !ok {
+ return ErrNotFound
+ }
+ return nil
+}
+
+// List перебирает сделки и фильтрует на лету.
+func (r *MemoryRepository) List(_ context.Context, f Filter) ([]*Deal, error) {
+ r.mu.RLock()
+ defer r.mu.RUnlock()
+ out := make([]*Deal, 0)
+ for _, d := range r.byID {
+ if f.State != nil && d.State != *f.State {
+ continue
+ }
+ if f.InvestorID != "" && d.InvestorID != f.InvestorID {
+ continue
+ }
+ if f.CreatedFrom != nil && d.CreatedAt.Before(*f.CreatedFrom) {
+ continue
+ }
+ if f.CreatedTo != nil && d.CreatedAt.After(*f.CreatedTo) {
+ continue
+ }
+ out = append(out, d)
+ }
+ if f.Offset > 0 && f.Offset < len(out) {
+ out = out[f.Offset:]
+ } else if f.Offset >= len(out) {
+ return nil, nil
+ }
+ if f.Limit > 0 && f.Limit < len(out) {
+ out = out[:f.Limit]
+ }
+ return out, nil
+}
+
+// AppendEvent добавляет событие в журнал сделки.
+func (r *MemoryRepository) AppendEvent(_ context.Context, dealID string, ev Event) error {
+ r.mu.Lock()
+ defer r.mu.Unlock()
+ if _, ok := r.byID[dealID]; !ok {
+ return ErrNotFound
+ }
+ r.events[dealID] = append(r.events[dealID], ev)
+ return nil
+}
+
+// EventsOf возвращает все события сделки (только для тестов и дев-логов).
+func (r *MemoryRepository) EventsOf(dealID string) []Event {
+ r.mu.RLock()
+ defer r.mu.RUnlock()
+ src := r.events[dealID]
+ out := make([]Event, len(src))
+ copy(out, src)
+ return out
+}
diff --git a/internal/m2mcore/repo_test.go b/internal/m2mcore/repo_test.go
new file mode 100644
index 0000000..f0974d3
--- /dev/null
+++ b/internal/m2mcore/repo_test.go
@@ -0,0 +1,82 @@
+package m2mcore_test
+
+import (
+ "context"
+ "errors"
+ "testing"
+
+ "git.zetit.ru/zuevav/Bridge-and-Join-s/internal/m2m"
+ "git.zetit.ru/zuevav/Bridge-and-Join-s/internal/m2mcore"
+)
+
+func TestRepoCreateIdempotentByGUID(t *testing.T) {
+ ctx := context.Background()
+ repo := m2mcore.NewMemoryRepository()
+
+ // Используем валидный 36-символьный UUID (XSD НРД не требует битов
+ // версии/варианта, только длину и hex).
+ const guidStr = "c02a1d5e-c2af-4799-bab4-953f133c5133"
+ d1, err := m2mcore.NewDeal(m2m.UUID(guidStr), "inv-1", []byte("a"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ d2, err := m2mcore.NewDeal(m2m.UUID(guidStr), "inv-1", []byte("a"))
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ saved1, err := repo.Create(ctx, d1)
+ if err != nil {
+ t.Fatal(err)
+ }
+ saved2, err := repo.Create(ctx, d2)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if saved1.ID != saved2.ID {
+ t.Errorf("идемпотентность нарушена: %s != %s", saved1.ID, saved2.ID)
+ }
+}
+
+func TestRepoGetByGUIDNotFound(t *testing.T) {
+ repo := m2mcore.NewMemoryRepository()
+ _, err := repo.GetByGUID(context.Background(), m2m.UUID("c02a1d5e-c2af-4799-bab4-953f133c5133"))
+ if !errors.Is(err, m2mcore.ErrNotFound) {
+ t.Errorf("ожидалась ErrNotFound, получено %v", err)
+ }
+}
+
+func TestRepoListFilters(t *testing.T) {
+ ctx := context.Background()
+ repo := m2mcore.NewMemoryRepository()
+
+ mkDeal := func(guid, inv string) {
+ t.Helper()
+ d, err := m2mcore.NewDeal(m2m.UUID(guid), inv, []byte("x"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := repo.Create(ctx, d); err != nil {
+ t.Fatal(err)
+ }
+ }
+ mkDeal("00000000-0000-0000-0000-000000000001", "inv-A")
+ mkDeal("00000000-0000-0000-0000-000000000002", "inv-A")
+ mkDeal("00000000-0000-0000-0000-000000000003", "inv-B")
+
+ all, err := repo.List(ctx, m2mcore.Filter{})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(all) != 3 {
+ t.Errorf("ожидалось 3 сделки, получено %d", len(all))
+ }
+
+ onlyA, err := repo.List(ctx, m2mcore.Filter{InvestorID: "inv-A"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(onlyA) != 2 {
+ t.Errorf("ожидалось 2 сделки inv-A, получено %d", len(onlyA))
+ }
+}
diff --git a/internal/m2mcore/uuid.go b/internal/m2mcore/uuid.go
new file mode 100644
index 0000000..92cbb7b
--- /dev/null
+++ b/internal/m2mcore/uuid.go
@@ -0,0 +1,20 @@
+package m2mcore
+
+import (
+ "crypto/rand"
+ "fmt"
+)
+
+// NewUUIDv4 генерирует UUID v4 в каноническом формате 8-4-4-4-12. Без
+// внешних зависимостей: использует crypto/rand и выставляет биты
+// версии (4) и варианта (RFC 4122) согласно стандарту.
+func NewUUIDv4() (string, error) {
+ var b [16]byte
+ if _, err := rand.Read(b[:]); err != nil {
+ return "", fmt.Errorf("m2mcore: чтение crypto/rand: %w", err)
+ }
+ b[6] = (b[6] & 0x0F) | 0x40 // версия 4 в старших битах 7-го байта
+ b[8] = (b[8] & 0x3F) | 0x80 // RFC 4122 вариант (10xx) в 9-м байте
+ return fmt.Sprintf("%08x-%04x-%04x-%04x-%012x",
+ b[0:4], b[4:6], b[6:8], b[8:10], b[10:16]), nil
+}
diff --git a/internal/m2mcore/uuid_test.go b/internal/m2mcore/uuid_test.go
new file mode 100644
index 0000000..5d3ceac
--- /dev/null
+++ b/internal/m2mcore/uuid_test.go
@@ -0,0 +1,45 @@
+package m2mcore_test
+
+import (
+ "regexp"
+ "testing"
+
+ "git.zetit.ru/zuevav/Bridge-and-Join-s/internal/m2m"
+ "git.zetit.ru/zuevav/Bridge-and-Join-s/internal/m2mcore"
+)
+
+func TestNewUUIDv4Format(t *testing.T) {
+ pattern := regexp.MustCompile(`^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$`)
+ seen := make(map[string]struct{})
+ for i := 0; i < 100; i++ {
+ u, err := m2mcore.NewUUIDv4()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !pattern.MatchString(u) {
+ t.Errorf("UUID %q не соответствует RFC4122 v4", u)
+ }
+ if _, dup := seen[u]; dup {
+ t.Errorf("сгенерирован дубликат %q", u)
+ }
+ seen[u] = struct{}{}
+ // Также должен проходить XSD-валидатор НРД.
+ if err := (m2m.UUID(u)).Validate(); err != nil {
+ t.Errorf("UUID %q отвергнут XSD-валидатором: %v", u, err)
+ }
+ }
+}
+
+func TestNewReferenceID(t *testing.T) {
+ at := mustParseTime(t, "2026-03-02T14:30:45Z")
+ id, err := m2mcore.NewReferenceID(at)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(id) != 16 {
+ t.Errorf("ReferenceID длина %d, ожидалось 16", len(id))
+ }
+ if err := id.Validate(); err != nil {
+ t.Errorf("ReferenceID %q отвергнут валидатором: %v", id, err)
+ }
+}
diff --git a/migrations/m2m-core/001__deals.sql b/migrations/m2m-core/001__deals.sql
new file mode 100644
index 0000000..26a7518
--- /dev/null
+++ b/migrations/m2m-core/001__deals.sql
@@ -0,0 +1,40 @@
+-- 001__deals.sql
+-- Таблицы для PostgreSQL-реализации Repository в M2 (когда pgx будет
+-- доступен через прокси). В M1 m2m-core работает с MemoryRepository.
+
+CREATE SCHEMA IF NOT EXISTS m2m_core;
+COMMENT ON SCHEMA m2m_core IS 'Состояние сделок M2M, события и аудит.';
+
+CREATE TABLE IF NOT EXISTS m2m_core.deals (
+ id uuid PRIMARY KEY,
+ guid uuid NOT NULL UNIQUE,
+ state varchar(32) NOT NULL,
+ investor_id uuid,
+ signed_claim bytea,
+ request_xml bytea,
+ response_xml bytea,
+ decision_xml bytea,
+ created_at timestamptz NOT NULL DEFAULT now(),
+ updated_at timestamptz NOT NULL DEFAULT now()
+);
+COMMENT ON TABLE m2m_core.deals IS 'Корневая запись о M2M-сделке. Уникальность по guid обеспечивает идемпотентность.';
+COMMENT ON COLUMN m2m_core.deals.guid IS 'GUID, который ушёл в M2MTransferRequest.Header.GUID.';
+COMMENT ON COLUMN m2m_core.deals.state IS 'Текущее состояние FSM: draft/validated/submitted_to_nsd/awaiting_decision/confirmed/awaiting_sub16/done/rejected/timed_out/manual_approval.';
+
+CREATE INDEX IF NOT EXISTS idx_deals_state ON m2m_core.deals(state);
+CREATE INDEX IF NOT EXISTS idx_deals_investor ON m2m_core.deals(investor_id) WHERE investor_id IS NOT NULL;
+CREATE INDEX IF NOT EXISTS idx_deals_created ON m2m_core.deals(created_at DESC);
+
+CREATE TABLE IF NOT EXISTS m2m_core.deal_events (
+ id bigserial PRIMARY KEY,
+ deal_id uuid NOT NULL REFERENCES m2m_core.deals(id) ON DELETE CASCADE,
+ type varchar(64) NOT NULL,
+ payload jsonb,
+ actor text,
+ created_at timestamptz NOT NULL DEFAULT now()
+);
+COMMENT ON TABLE m2m_core.deal_events IS 'Журнал событий сделки (event-sourcing для аудита).';
+COMMENT ON COLUMN m2m_core.deal_events.actor IS 'Кто инициировал событие: system, nsd, operator-, lk-callback и т.д.';
+
+CREATE INDEX IF NOT EXISTS idx_deal_events_deal ON m2m_core.deal_events(deal_id, created_at);
+CREATE INDEX IF NOT EXISTS idx_deal_events_type ON m2m_core.deal_events(type);