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>
This commit is contained in:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user