Files
Bridge-and-Join-s/internal/m2mcore/enrich.go
T
fontvielle 9e6e95f431 feat(m2m-core): FSM сделки, репозиторий, идемпотентность по GUID, метрики SLA
- internal/m2mcore/fsm.go: конечный автомат с переходами Draft → Validated → SubmittedToNSD → AwaitingDecision → Confirmed → AwaitingSUB16 → Done, ветки Rejected/TimedOut/ManualApproval
- internal/m2mcore/deal.go: доменная модель Deal с методами Validate/Submit/ReceiveDecision/Timeout/SendToManualApproval/ApproveManually/RejectManually/CompleteSUB16, журнал событий
- internal/m2mcore/uuid.go: генератор UUID v4 без внешних зависимостей (crypto/rand)
- internal/m2mcore/repo.go: порт Repository + MemoryRepository с идемпотентным Create по GUID
- internal/m2mcore/ports.go: порты NSDSender/LKCallbackClient/CryptoVerifier/FansyStore с no-op заглушками для M1
- internal/m2mcore/enrich.go: EnrichRequest — сборка M2MTransferRequest из ClaimInput + Fansy, генерация ReferenceID по каждой ЦБ
- internal/m2mcore/metrics.go: порт Recorder + MemoryRecorder в Prometheus-text формате
- cmd/m2m-core/main.go: HTTP-сервер с /healthz и /metrics, graceful shutdown
- migrations/m2m-core/001__deals.sql: схема для PostgreSQL-Repository (для M2)

Покрытие: 63.1%. make ci зелёный. Без внешних Go-зависимостей (pgx и
prometheus подключим в M2, когда прокси zetit откроет Go-модули).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 00:52:12 +03:00

158 lines
5.5 KiB
Go

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
}