feat(m2m): доменная модель сообщений + парсер windows-1251 + round-trip тесты
- internal/m2m/types.go: enum'ы и simple-типы из XSD НРД (M2MSchemas_260408) - internal/m2m/validators.go: pattern-валидаторы ReferenceID/ISIN/INN/UUID/SecurityCode/IdentityDocSerial/AccountID + перечисления - internal/m2m/messages.go: структуры 6 типов сообщений M2M, choice-типы через указатели, IsM2M=true автоматически в MarshalXML - internal/nsdxml/datetime.go: тип NSDDateTime (формат "YYYY-MM-DDThh:mm:ss(МСК+N)") - internal/nsdxml/codec.go: Marshal/Unmarshal XML в windows-1251 (собственный кодек CP1251, без внешних зависимостей) - internal/m2m/messages_test.go: round-trip тесты на 6 примерах + 2 эталонах из DOC/ Покрытие: m2m 73.9%, nsdxml 92.5%. make ci зелёный. Отклонение от спеки: вместо golang.org/x/text/encoding/charmap собственная таблица CP1251 на ~60 строк, потому что прокси zetit блокирует proxy.golang.org, goproxy.cn и redirect-хосты Go-модулей. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
+42
-15
@@ -1,24 +1,51 @@
|
||||
# internal/m2m — доменные модели сообщений M2M
|
||||
|
||||
Go-модели, генерируемые/выровненные по XSD из `DOC/M2MSchemas_260408/`
|
||||
Go-модели, выровненные по XSD из `DOC/M2MSchemas_260408/`
|
||||
(namespace `http://nsd.ru/schemas/m2m/...`, version `2026-04-08`).
|
||||
|
||||
Состав:
|
||||
## Что реализовано
|
||||
|
||||
- `M2MTransferRequest` — запрос на перевод.
|
||||
- `M2MTransferDecision` — решение принимающей стороны.
|
||||
- `M2MTransferResponse` — тех. ответ НРД (`StatusCode ∈ {INFO, ERROR}`).
|
||||
- `M2MTransferHandbook(+Request)` — справочник участников.
|
||||
- `M2MTransferParticipantForm` — карточка участника.
|
||||
- Все 6 типов сообщений M2M (`messages.go`):
|
||||
- `M2MTransferRequest` — запрос на перевод.
|
||||
- `M2MTransferDecision` — решение принимающей стороны.
|
||||
- `M2MTransferResponse` — тех. ответ НРД (`StatusCode ∈ {INFO, ERROR}`).
|
||||
- `M2MTransferHandbook` + `M2MTransferHandbookRequest` — справочник
|
||||
участников.
|
||||
- `M2MTransferParticipantForm` — карточка участника.
|
||||
- Simple-типы и enum'ы из XSD (`types.go`):
|
||||
`DeponentCode`, `ReferenceID`, `ISIN`, `OrganizationINN`, `UUID`,
|
||||
`AccountID`, `SecurityCode`, `IdentityDocSerial`,
|
||||
`StatusCode`, `IIAContractType`, `SecurityClassification`,
|
||||
`SecurityCategory`, `IdentityDocumentCode`, `IsolationStatus`.
|
||||
- Метод `Validate() error` на всех типах (`validators.go`).
|
||||
- Choice-типы реализованы как структуры с указателями на
|
||||
взаимоисключающие поля: `CostInfo`, `Quantity`, `SecurityDetails`,
|
||||
`IdentificationDetails`, `DecisionTransfer`. Метод `Validate`
|
||||
проверяет «ровно одно поле задано» (ошибка `ErrChoice`).
|
||||
- `IsM2M=true` проставляется автоматически в `RequestData.MarshalXML`
|
||||
и не выносится в структуру.
|
||||
|
||||
Точные ограничения (валидаторы):
|
||||
## Точные ограничения валидаторов
|
||||
|
||||
- `ReferenceId` — длина 16, pattern `M2M[A-Z0-9]{13}`.
|
||||
- `DeponentCode` — до 12 символов, `[A-Z0-9]*`.
|
||||
- `ISIN` — длина 12, `[A-Z]{2}[A-Z0-9]{9}[0-9]`.
|
||||
- `ReferenceID` — длина 16, `^M2M[A-Z0-9]{13}$`.
|
||||
- `DeponentCode` — 1..12 символов, `^[A-Z0-9]+$`.
|
||||
- `ISIN` — длина 12, `^[A-Z]{2}[A-Z0-9]{9}[0-9]$`.
|
||||
- `OrganizationINN` — ровно 10 цифр.
|
||||
- `IIAContractType` — `T12 | T03`.
|
||||
- `SecurityClassification` — `BOND | SHAR | MFUN`.
|
||||
- `IsolationStatus` — единственное значение `SGDN`.
|
||||
- `UUID` — 8-4-4-4-12 hex-символов с дефисами (XSD НРД не требует
|
||||
битов версии/варианта по RFC).
|
||||
- `SecurityCode` — длина 12, `^[0-9A-Z_/-]+$`.
|
||||
- `IdentityDocSerial` — `^\S+$` (от 1 символа, без пробельных).
|
||||
- `AccountID` — 1..50 символов.
|
||||
- Перечисления валидируются как принадлежность к множеству значений
|
||||
из XSD.
|
||||
|
||||
Реализация — задача M1 (см. план).
|
||||
## Тесты
|
||||
|
||||
- Round-trip на всех XML из `DOC/Примеры/` и
|
||||
`DOC/Эталонные сообщения/` (`messages_test.go`).
|
||||
- Юнит-тесты валидаторов на позитив и негатив (`TestValidatorsPositive`,
|
||||
`TestValidatorsNegative`, `TestChoiceValidators`).
|
||||
- Покрытие — 73.9%.
|
||||
|
||||
Сериализация и парсинг — пакет `internal/nsdxml` (XML windows-1251 и
|
||||
`NSDDateTime`).
|
||||
|
||||
@@ -0,0 +1,800 @@
|
||||
package m2m
|
||||
|
||||
import (
|
||||
"encoding/xml"
|
||||
"errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
|
||||
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/nsdxml"
|
||||
)
|
||||
|
||||
// Namespaces — целевые namespace из XSD M2MSchemas_260408.
|
||||
const (
|
||||
NSTypes = "http://nsd.ru/schemas/m2m/types"
|
||||
NSRequest = "http://nsd.ru/schemas/m2m/request"
|
||||
NSDecision = "http://nsd.ru/schemas/m2m/decision"
|
||||
NSResponse = "http://nsd.ru/schemas/m2m/response"
|
||||
NSHandbook = "http://nsd.ru/schemas/m2m/handbook"
|
||||
NSHandbookReq = "http://nsd.ru/schemas/m2m/handbook/request"
|
||||
NSParticipantForm = "http://nsd.ru/schemas/m2m/participant/form"
|
||||
)
|
||||
|
||||
// ErrChoice возвращается, если в choice-типе задано не ровно одно поле.
|
||||
var ErrChoice = errors.New("m2m: в choice-типе должно быть задано ровно одно поле")
|
||||
|
||||
// reDecimal16 — паттерн допустимого десятичного числа с не более чем
|
||||
// 16 знаков после точки.
|
||||
var reDecimal16 = regexp.MustCompile(`^[0-9]+(\.[0-9]{1,16})?$`)
|
||||
|
||||
// Decimal16 — десятичное число (XSD Decimal16): неотрицательное, до 16
|
||||
// знаков после точки, до 38 значащих цифр всего. Хранится строкой ради
|
||||
// точности и стабильности round-trip.
|
||||
type Decimal16 string
|
||||
|
||||
// Validate проверяет формат и общее число цифр.
|
||||
func (d Decimal16) Validate() error {
|
||||
if d == "" {
|
||||
return fmt.Errorf("%w: Decimal16 пустое значение", ErrInvalid)
|
||||
}
|
||||
if !reDecimal16.MatchString(string(d)) {
|
||||
return fmt.Errorf("%w: Decimal16 %q не соответствует формату", ErrInvalid, string(d))
|
||||
}
|
||||
digits := 0
|
||||
for _, r := range d {
|
||||
if r >= '0' && r <= '9' {
|
||||
digits++
|
||||
}
|
||||
}
|
||||
if digits > 38 {
|
||||
return fmt.Errorf("%w: Decimal16 %d значащих цифр, ожидается не более 38", ErrInvalid, digits)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// IdentityDocument — документ, удостоверяющий личность.
|
||||
type IdentityDocument struct {
|
||||
DocumentType IdentityDocumentCode `xml:"http://nsd.ru/schemas/m2m/types DocumentType"`
|
||||
DocumentSeries *IdentityDocSerial `xml:"http://nsd.ru/schemas/m2m/types DocumentSeries,omitempty"`
|
||||
DocumentNumber IdentityDocSerial `xml:"http://nsd.ru/schemas/m2m/types DocumentNumber"`
|
||||
}
|
||||
|
||||
// Validate проверяет тип и заполненность номера документа.
|
||||
func (d IdentityDocument) Validate() error {
|
||||
if err := d.DocumentType.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
if d.DocumentSeries != nil {
|
||||
if err := d.DocumentSeries.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return d.DocumentNumber.Validate()
|
||||
}
|
||||
|
||||
// InvestorInformation — анкета инвестора.
|
||||
type InvestorInformation struct {
|
||||
LastName string `xml:"http://nsd.ru/schemas/m2m/types LastName"`
|
||||
FirstName string `xml:"http://nsd.ru/schemas/m2m/types FirstName"`
|
||||
MiddleName string `xml:"http://nsd.ru/schemas/m2m/types MiddleName,omitempty"`
|
||||
IdentityDocument IdentityDocument `xml:"http://nsd.ru/schemas/m2m/types IdentityDocument"`
|
||||
}
|
||||
|
||||
// Validate проверяет длину полей и документ.
|
||||
func (i InvestorInformation) Validate() error {
|
||||
if l := len(i.LastName); l < 1 || l > 50 {
|
||||
return fmt.Errorf("%w: LastName длина %d, ожидается 1..50", ErrInvalid, l)
|
||||
}
|
||||
if l := len(i.FirstName); l < 1 || l > 50 {
|
||||
return fmt.Errorf("%w: FirstName длина %d, ожидается 1..50", ErrInvalid, l)
|
||||
}
|
||||
if l := len(i.MiddleName); l > 50 {
|
||||
return fmt.Errorf("%w: MiddleName длина %d, ожидается не более 50", ErrInvalid, l)
|
||||
}
|
||||
return i.IdentityDocument.Validate()
|
||||
}
|
||||
|
||||
// SettlementRequisites — реквизиты депозитария (содержит только ИНН).
|
||||
type SettlementRequisites struct {
|
||||
INN OrganizationINN `xml:"http://nsd.ru/schemas/m2m/types INN"`
|
||||
}
|
||||
|
||||
// Validate проверяет ИНН.
|
||||
func (s SettlementRequisites) Validate() error { return s.INN.Validate() }
|
||||
|
||||
// SettlementDepositoryLocation — реквизиты счёта депо.
|
||||
type SettlementDepositoryLocation struct {
|
||||
DeponentCode string `xml:"http://nsd.ru/schemas/m2m/types DeponentCode"`
|
||||
AccountID AccountID `xml:"http://nsd.ru/schemas/m2m/types AccountId"`
|
||||
SectionID string `xml:"http://nsd.ru/schemas/m2m/types SectionId"`
|
||||
}
|
||||
|
||||
// Validate проверяет длины и формат счёта.
|
||||
func (s SettlementDepositoryLocation) Validate() error {
|
||||
if l := len(s.DeponentCode); l < 1 || l > 50 {
|
||||
return fmt.Errorf("%w: DeponentCode длина %d, ожидается 1..50", ErrInvalid, l)
|
||||
}
|
||||
if err := s.AccountID.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
if l := len(s.SectionID); l < 1 || l > 50 {
|
||||
return fmt.Errorf("%w: SectionID длина %d, ожидается 1..50", ErrInvalid, l)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RequestSettlementAccount — счёт в запросе перевода.
|
||||
type RequestSettlementAccount struct {
|
||||
SettlementRequisites SettlementRequisites `xml:"http://nsd.ru/schemas/m2m/types SettlementRequisites"`
|
||||
SettlementLocation SettlementDepositoryLocation `xml:"http://nsd.ru/schemas/m2m/types SettlementLocation"`
|
||||
}
|
||||
|
||||
// Validate последовательно валидирует реквизиты и место хранения.
|
||||
func (a RequestSettlementAccount) Validate() error {
|
||||
if err := a.SettlementRequisites.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
return a.SettlementLocation.Validate()
|
||||
}
|
||||
|
||||
// DecisionSettlementAccount — счёт в решении (структурно совпадает с
|
||||
// запросом, но именован отдельно в XSD).
|
||||
type DecisionSettlementAccount = RequestSettlementAccount
|
||||
|
||||
// IIAAgreementDetails — реквизиты договора ИИС.
|
||||
type IIAAgreementDetails struct {
|
||||
AgreementType IIAContractType `xml:"http://nsd.ru/schemas/m2m/types AgreementType"`
|
||||
AgreementNumber string `xml:"http://nsd.ru/schemas/m2m/types AgreementNumber"`
|
||||
AgreementDate string `xml:"http://nsd.ru/schemas/m2m/types AgreementDate"`
|
||||
BrokerINN OrganizationINN `xml:"http://nsd.ru/schemas/m2m/types BrokerINN"`
|
||||
}
|
||||
|
||||
// Validate проверяет тип, номер договора и ИНН брокера.
|
||||
func (d IIAAgreementDetails) Validate() error {
|
||||
if err := d.AgreementType.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
if l := len(d.AgreementNumber); l < 1 || l > 128 {
|
||||
return fmt.Errorf("%w: AgreementNumber длина %d, ожидается 1..128", ErrInvalid, l)
|
||||
}
|
||||
if d.AgreementDate == "" {
|
||||
return fmt.Errorf("%w: AgreementDate пуста", ErrInvalid)
|
||||
}
|
||||
return d.BrokerINN.Validate()
|
||||
}
|
||||
|
||||
// FundShares — реквизиты пая инвестиционного фонда.
|
||||
type FundShares struct {
|
||||
RegNumber string `xml:"http://nsd.ru/schemas/m2m/types RegNumber"`
|
||||
Class string `xml:"http://nsd.ru/schemas/m2m/types Class,omitempty"`
|
||||
}
|
||||
|
||||
// Validate проверяет длины полей.
|
||||
func (f FundShares) Validate() error {
|
||||
if l := len(f.RegNumber); l < 1 || l > 256 {
|
||||
return fmt.Errorf("%w: RegNumber длина %d, ожидается 1..256", ErrInvalid, l)
|
||||
}
|
||||
if l := len(f.Class); l > 120 {
|
||||
return fmt.Errorf("%w: Class длина %d, ожидается не более 120", ErrInvalid, l)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// IdentificationDetails — choice: либо рег.номер выпуска, либо ПИФ.
|
||||
type IdentificationDetails struct {
|
||||
RegNumber *string `xml:"http://nsd.ru/schemas/m2m/types RegNumber,omitempty"`
|
||||
FundShares *FundShares `xml:"http://nsd.ru/schemas/m2m/types FundShares,omitempty"`
|
||||
}
|
||||
|
||||
// Validate проверяет, что задано ровно одно поле choice.
|
||||
func (i IdentificationDetails) Validate() error {
|
||||
count := 0
|
||||
if i.RegNumber != nil {
|
||||
if l := len(*i.RegNumber); l > 20 {
|
||||
return fmt.Errorf("%w: RegNumber длина %d, ожидается не более 20", ErrInvalid, l)
|
||||
}
|
||||
count++
|
||||
}
|
||||
if i.FundShares != nil {
|
||||
if err := i.FundShares.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
count++
|
||||
}
|
||||
if count != 1 {
|
||||
return fmt.Errorf("%w: IdentificationDetails задано %d полей", ErrChoice, count)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SecurityDescription — описание ценной бумаги без ISIN.
|
||||
type SecurityDescription struct {
|
||||
SecurityClassification SecurityClassification `xml:"http://nsd.ru/schemas/m2m/types SecurityClassification"`
|
||||
SecurityCategory SecurityCategory `xml:"http://nsd.ru/schemas/m2m/types SecurityCategory"`
|
||||
SecurityType string `xml:"http://nsd.ru/schemas/m2m/types SecurityType,omitempty"`
|
||||
SecuritySeries string `xml:"http://nsd.ru/schemas/m2m/types SecuritySeries,omitempty"`
|
||||
IdentificationDetails IdentificationDetails `xml:"http://nsd.ru/schemas/m2m/types IdentificationDetails"`
|
||||
}
|
||||
|
||||
// Validate проверяет классификацию, категорию и идентификацию.
|
||||
func (s SecurityDescription) Validate() error {
|
||||
if err := s.SecurityClassification.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.SecurityCategory.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
if l := len(s.SecurityType); l > 256 {
|
||||
return fmt.Errorf("%w: SecurityType длина %d, ожидается не более 256", ErrInvalid, l)
|
||||
}
|
||||
return s.IdentificationDetails.Validate()
|
||||
}
|
||||
|
||||
// SecurityDetails — choice: либо ISIN, либо описание ценной бумаги.
|
||||
type SecurityDetails struct {
|
||||
ISIN *ISIN `xml:"http://nsd.ru/schemas/m2m/types ISIN,omitempty"`
|
||||
SecurityInfo *SecurityDescription `xml:"http://nsd.ru/schemas/m2m/types SecurityInfo,omitempty"`
|
||||
}
|
||||
|
||||
// Validate проверяет, что задано ровно одно поле choice.
|
||||
func (s SecurityDetails) Validate() error {
|
||||
count := 0
|
||||
if s.ISIN != nil {
|
||||
if err := s.ISIN.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
count++
|
||||
}
|
||||
if s.SecurityInfo != nil {
|
||||
if err := s.SecurityInfo.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
count++
|
||||
}
|
||||
if count != 1 {
|
||||
return fmt.Errorf("%w: SecurityDetails задано %d полей", ErrChoice, count)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Quantity — choice: целое или дробное количество ценных бумаг.
|
||||
type Quantity struct {
|
||||
Whole *uint64 `xml:"http://nsd.ru/schemas/m2m/types Whole,omitempty"`
|
||||
Fractional *Decimal16 `xml:"http://nsd.ru/schemas/m2m/types Fractional,omitempty"`
|
||||
}
|
||||
|
||||
// Validate проверяет, что задано ровно одно поле choice.
|
||||
func (q Quantity) Validate() error {
|
||||
count := 0
|
||||
if q.Whole != nil {
|
||||
if *q.Whole == 0 {
|
||||
return fmt.Errorf("%w: Whole должно быть положительным", ErrInvalid)
|
||||
}
|
||||
count++
|
||||
}
|
||||
if q.Fractional != nil {
|
||||
if err := q.Fractional.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
count++
|
||||
}
|
||||
if count != 1 {
|
||||
return fmt.Errorf("%w: Quantity задано %d полей", ErrChoice, count)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CostInfoYes — тело варианта "учёт ведётся" (DecisionYesType и
|
||||
// RequestYesType структурно совпадают).
|
||||
type CostInfoYes struct {
|
||||
Code DeponentCode `xml:"http://nsd.ru/schemas/m2m/types Code"`
|
||||
}
|
||||
|
||||
// CostInfoNo — тело варианта "учёт не ведётся" (NoType пустой).
|
||||
type CostInfoNo struct{}
|
||||
|
||||
// CostInfo — choice: учёт стоимости приобретения ведётся (Yes) или нет.
|
||||
type CostInfo struct {
|
||||
Yes *CostInfoYes `xml:"http://nsd.ru/schemas/m2m/types Yes,omitempty"`
|
||||
No *CostInfoNo `xml:"http://nsd.ru/schemas/m2m/types No,omitempty"`
|
||||
}
|
||||
|
||||
// Validate проверяет, что задано ровно одно поле choice.
|
||||
func (c CostInfo) Validate() error {
|
||||
count := 0
|
||||
if c.Yes != nil {
|
||||
if err := c.Yes.Code.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
count++
|
||||
}
|
||||
if c.No != nil {
|
||||
count++
|
||||
}
|
||||
if count != 1 {
|
||||
return fmt.Errorf("%w: CostInfo задано %d полей", ErrChoice, count)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Confirmation — подтверждение приёма ценных бумаг по решению.
|
||||
type Confirmation struct {
|
||||
SettlementAccount DecisionSettlementAccount `xml:"http://nsd.ru/schemas/m2m/types SettlementAccount"`
|
||||
}
|
||||
|
||||
// Validate валидирует счёт зачисления.
|
||||
func (c Confirmation) Validate() error { return c.SettlementAccount.Validate() }
|
||||
|
||||
// Rejection — отказ от приёма ценных бумаг по решению.
|
||||
type Rejection struct {
|
||||
Codes []string `xml:"http://nsd.ru/schemas/m2m/types Code"`
|
||||
}
|
||||
|
||||
// Validate проверяет, что коды отказа заданы и каждый не длиннее 6 символов.
|
||||
func (r Rejection) Validate() error {
|
||||
if len(r.Codes) == 0 {
|
||||
return fmt.Errorf("%w: Rejection без кодов отказа", ErrInvalid)
|
||||
}
|
||||
for _, code := range r.Codes {
|
||||
if l := len(code); l < 1 || l > 6 {
|
||||
return fmt.Errorf("%w: Rejection.Code длина %d, ожидается 1..6", ErrInvalid, l)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DecisionTransfer — choice решения: подтверждение или отказ.
|
||||
type DecisionTransfer struct {
|
||||
Rejection *Rejection `xml:"http://nsd.ru/schemas/m2m/types Rejection,omitempty"`
|
||||
Confirmation *Confirmation `xml:"http://nsd.ru/schemas/m2m/types Confirmation,omitempty"`
|
||||
}
|
||||
|
||||
// Validate проверяет, что задано ровно одно поле choice.
|
||||
func (t DecisionTransfer) Validate() error {
|
||||
count := 0
|
||||
if t.Rejection != nil {
|
||||
if err := t.Rejection.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
count++
|
||||
}
|
||||
if t.Confirmation != nil {
|
||||
if err := t.Confirmation.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
count++
|
||||
}
|
||||
if count != 1 {
|
||||
return fmt.Errorf("%w: DecisionTransfer задано %d полей", ErrChoice, count)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RequestHeader — заголовок сообщения "Запрос на перевод M2M".
|
||||
type RequestHeader struct {
|
||||
GUID UUID `xml:"http://nsd.ru/schemas/m2m/types GUID"`
|
||||
CreationTimestamp nsdxml.NSDDateTime `xml:"http://nsd.ru/schemas/m2m/types CreationTimestamp"`
|
||||
SenderCode DeponentCode `xml:"http://nsd.ru/schemas/m2m/types SenderCode"`
|
||||
ReceiverCode DeponentCode `xml:"http://nsd.ru/schemas/m2m/types ReceiverCode"`
|
||||
CostInfo CostInfo `xml:"http://nsd.ru/schemas/m2m/types CostInfo"`
|
||||
IIAAgreementDetails *IIAAgreementDetails `xml:"http://nsd.ru/schemas/m2m/types IIAAgreementDetails,omitempty"`
|
||||
}
|
||||
|
||||
// Validate валидирует поля заголовка запроса.
|
||||
func (h RequestHeader) Validate() error {
|
||||
if err := h.GUID.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := h.SenderCode.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := h.ReceiverCode.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := h.CostInfo.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
if h.IIAAgreementDetails != nil {
|
||||
if err := h.IIAAgreementDetails.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DecisionHeader — заголовок сообщения "Решение по запросу M2M".
|
||||
type DecisionHeader struct {
|
||||
GUID UUID `xml:"http://nsd.ru/schemas/m2m/types GUID"`
|
||||
CreationTimestamp nsdxml.NSDDateTime `xml:"http://nsd.ru/schemas/m2m/types CreationTimestamp"`
|
||||
SenderCode DeponentCode `xml:"http://nsd.ru/schemas/m2m/types SenderCode"`
|
||||
ReceiverCode DeponentCode `xml:"http://nsd.ru/schemas/m2m/types ReceiverCode"`
|
||||
CostInfo CostInfo `xml:"http://nsd.ru/schemas/m2m/types CostInfo"`
|
||||
}
|
||||
|
||||
// Validate валидирует поля заголовка решения.
|
||||
func (h DecisionHeader) Validate() error {
|
||||
if err := h.GUID.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := h.SenderCode.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := h.ReceiverCode.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
return h.CostInfo.Validate()
|
||||
}
|
||||
|
||||
// RequestSecurity — описание одной ценной бумаги в запросе перевода.
|
||||
type RequestSecurity struct {
|
||||
ReferenceID ReferenceID `xml:"http://nsd.ru/schemas/m2m/types ReferenceId"`
|
||||
SecurityCode SecurityCode `xml:"http://nsd.ru/schemas/m2m/types SecurityCode"`
|
||||
SecurityDetails SecurityDetails `xml:"http://nsd.ru/schemas/m2m/types SecurityDetails"`
|
||||
Quantity Quantity `xml:"http://nsd.ru/schemas/m2m/types Quantity"`
|
||||
SettlementAccount []RequestSettlementAccount `xml:"http://nsd.ru/schemas/m2m/types SettlementAccount"`
|
||||
IsolationStatus IsolationStatus `xml:"http://nsd.ru/schemas/m2m/types IsolationStatus"`
|
||||
}
|
||||
|
||||
// Validate валидирует все поля ценной бумаги запроса.
|
||||
func (s RequestSecurity) Validate() error {
|
||||
if err := s.ReferenceID.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.SecurityCode.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.SecurityDetails.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.Quantity.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(s.SettlementAccount) == 0 {
|
||||
return fmt.Errorf("%w: SettlementAccount должен содержать хотя бы один счёт", ErrInvalid)
|
||||
}
|
||||
for i := range s.SettlementAccount {
|
||||
if err := s.SettlementAccount[i].Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return s.IsolationStatus.Validate()
|
||||
}
|
||||
|
||||
// RequestTransferredSecurities — список переводимых ценных бумаг.
|
||||
type RequestTransferredSecurities struct {
|
||||
Securities []RequestSecurity `xml:"http://nsd.ru/schemas/m2m/types Security"`
|
||||
}
|
||||
|
||||
// Validate проверяет непустоту списка и валидирует каждую запись.
|
||||
func (t RequestTransferredSecurities) Validate() error {
|
||||
if len(t.Securities) == 0 {
|
||||
return fmt.Errorf("%w: TransferredSecurities пуст", ErrInvalid)
|
||||
}
|
||||
for i := range t.Securities {
|
||||
if err := t.Securities[i].Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RequestData — содержательная часть запроса. IsM2M фиксировано true и
|
||||
// проставляется в MarshalXML, в структуре поле не хранится.
|
||||
type RequestData struct {
|
||||
InvestorInformation InvestorInformation `xml:"http://nsd.ru/schemas/m2m/types InvestorInformation"`
|
||||
TransferringDepository SettlementRequisites `xml:"http://nsd.ru/schemas/m2m/types TransferringDepository"`
|
||||
ReceivingDepository SettlementRequisites `xml:"http://nsd.ru/schemas/m2m/types ReceivingDepository"`
|
||||
TransferredSecurities RequestTransferredSecurities `xml:"http://nsd.ru/schemas/m2m/types TransferredSecurities"`
|
||||
}
|
||||
|
||||
// requestDataXML — внутренний alias с явным полем IsM2M для XML-кодека.
|
||||
type requestDataXML struct {
|
||||
IsM2M bool `xml:"http://nsd.ru/schemas/m2m/types IsM2M"`
|
||||
InvestorInformation InvestorInformation `xml:"http://nsd.ru/schemas/m2m/types InvestorInformation"`
|
||||
TransferringDepository SettlementRequisites `xml:"http://nsd.ru/schemas/m2m/types TransferringDepository"`
|
||||
ReceivingDepository SettlementRequisites `xml:"http://nsd.ru/schemas/m2m/types ReceivingDepository"`
|
||||
TransferredSecurities RequestTransferredSecurities `xml:"http://nsd.ru/schemas/m2m/types TransferredSecurities"`
|
||||
}
|
||||
|
||||
// MarshalXML всегда эмитирует IsM2M=true первым элементом.
|
||||
func (d RequestData) MarshalXML(e *xml.Encoder, start xml.StartElement) error {
|
||||
return e.EncodeElement(requestDataXML{
|
||||
IsM2M: true,
|
||||
InvestorInformation: d.InvestorInformation,
|
||||
TransferringDepository: d.TransferringDepository,
|
||||
ReceivingDepository: d.ReceivingDepository,
|
||||
TransferredSecurities: d.TransferredSecurities,
|
||||
}, start)
|
||||
}
|
||||
|
||||
// UnmarshalXML принимает и отбрасывает IsM2M, не вынося его в структуру.
|
||||
func (d *RequestData) UnmarshalXML(dec *xml.Decoder, start xml.StartElement) error {
|
||||
var x requestDataXML
|
||||
if err := dec.DecodeElement(&x, &start); err != nil {
|
||||
return err
|
||||
}
|
||||
d.InvestorInformation = x.InvestorInformation
|
||||
d.TransferringDepository = x.TransferringDepository
|
||||
d.ReceivingDepository = x.ReceivingDepository
|
||||
d.TransferredSecurities = x.TransferredSecurities
|
||||
return nil
|
||||
}
|
||||
|
||||
// Validate валидирует содержательную часть запроса.
|
||||
func (d RequestData) Validate() error {
|
||||
if err := d.InvestorInformation.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := d.TransferringDepository.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := d.ReceivingDepository.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
return d.TransferredSecurities.Validate()
|
||||
}
|
||||
|
||||
// DecisionSecurity — решение по одной ценной бумаге из запроса.
|
||||
type DecisionSecurity struct {
|
||||
ReferenceID ReferenceID `xml:"http://nsd.ru/schemas/m2m/types ReferenceId"`
|
||||
TransferDecision DecisionTransfer `xml:"http://nsd.ru/schemas/m2m/types TransferDecision"`
|
||||
}
|
||||
|
||||
// Validate валидирует ReferenceID и решение по бумаге.
|
||||
func (s DecisionSecurity) Validate() error {
|
||||
if err := s.ReferenceID.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.TransferDecision.Validate()
|
||||
}
|
||||
|
||||
// DecisionData — содержательная часть решения.
|
||||
type DecisionData struct {
|
||||
ReceivingDepository SettlementRequisites `xml:"http://nsd.ru/schemas/m2m/types ReceivingDepository"`
|
||||
Securities []DecisionSecurity `xml:"http://nsd.ru/schemas/m2m/types Security"`
|
||||
}
|
||||
|
||||
// Validate проверяет получателя и каждое решение по бумагам.
|
||||
func (d DecisionData) Validate() error {
|
||||
if err := d.ReceivingDepository.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(d.Securities) == 0 {
|
||||
return fmt.Errorf("%w: DecisionData без Security", ErrInvalid)
|
||||
}
|
||||
for i := range d.Securities {
|
||||
if err := d.Securities[i].Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Response — элемент комментария НРД к обработке сообщения.
|
||||
type Response struct {
|
||||
ReferenceID *ReferenceID `xml:"http://nsd.ru/schemas/m2m/types ReferenceId,omitempty"`
|
||||
Code string `xml:"http://nsd.ru/schemas/m2m/types Code"`
|
||||
Text string `xml:"http://nsd.ru/schemas/m2m/types Text,omitempty"`
|
||||
}
|
||||
|
||||
// Validate проверяет ссылку (если задана), код и текст.
|
||||
func (r Response) Validate() error {
|
||||
if r.ReferenceID != nil {
|
||||
if err := r.ReferenceID.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if l := len(r.Code); l < 1 || l > 5 {
|
||||
return fmt.Errorf("%w: Response.Code длина %d, ожидается 1..5", ErrInvalid, l)
|
||||
}
|
||||
if l := len(r.Text); l > 1024 {
|
||||
return fmt.Errorf("%w: Response.Text длина %d, ожидается не более 1024", ErrInvalid, l)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// NSDInfo — обёртка над списком комментариев НРД.
|
||||
type NSDInfo struct {
|
||||
Info []Response `xml:"http://nsd.ru/schemas/m2m/types Info"`
|
||||
}
|
||||
|
||||
// Validate валидирует каждый Response.
|
||||
func (n NSDInfo) Validate() error {
|
||||
for i := range n.Info {
|
||||
if err := n.Info[i].Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// HandbookRusName — наименования участника на русском языке.
|
||||
type HandbookRusName struct {
|
||||
FullName string `xml:"http://nsd.ru/schemas/m2m/types FullName"`
|
||||
ShortName string `xml:"http://nsd.ru/schemas/m2m/types ShortName,omitempty"`
|
||||
DisplayName string `xml:"http://nsd.ru/schemas/m2m/types DisplayName"`
|
||||
}
|
||||
|
||||
// HandbookEngName — наименования участника на английском языке.
|
||||
type HandbookEngName = HandbookRusName
|
||||
|
||||
// HandbookNames — пара RUS+ENG наименований.
|
||||
type HandbookNames struct {
|
||||
Rus HandbookRusName `xml:"http://nsd.ru/schemas/m2m/types Rus"`
|
||||
Eng *HandbookEngName `xml:"http://nsd.ru/schemas/m2m/types Eng,omitempty"`
|
||||
}
|
||||
|
||||
// DepositoryPlaces — место хранения (депозитарий).
|
||||
type DepositoryPlaces struct {
|
||||
ParticipantCode DeponentCode `xml:"http://nsd.ru/schemas/m2m/types ParticipantCode"`
|
||||
}
|
||||
|
||||
// BrokerPlaces — место хранения (брокер).
|
||||
type BrokerPlaces struct {
|
||||
ParticipantCode DeponentCode `xml:"http://nsd.ru/schemas/m2m/types ParticipantCode"`
|
||||
}
|
||||
|
||||
// HandbookParticipant — запись в справочнике участников.
|
||||
type HandbookParticipant struct {
|
||||
INN OrganizationINN `xml:"http://nsd.ru/schemas/m2m/types INN"`
|
||||
Names HandbookNames `xml:"http://nsd.ru/schemas/m2m/types Names"`
|
||||
DepositoryPlace *DepositoryPlaces `xml:"http://nsd.ru/schemas/m2m/types DepositoryPlace,omitempty"`
|
||||
BrokerPlace *BrokerPlaces `xml:"http://nsd.ru/schemas/m2m/types BrokerPlace,omitempty"`
|
||||
}
|
||||
|
||||
// Validate валидирует ИНН и коды участников (если заданы).
|
||||
func (p HandbookParticipant) Validate() error {
|
||||
if err := p.INN.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
if p.DepositoryPlace != nil {
|
||||
if err := p.DepositoryPlace.ParticipantCode.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if p.BrokerPlace != nil {
|
||||
if err := p.BrokerPlace.ParticipantCode.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// HandbookParticipants — обёртка над списком участников.
|
||||
type HandbookParticipants struct {
|
||||
Participants []HandbookParticipant `xml:"http://nsd.ru/schemas/m2m/types Participant"`
|
||||
}
|
||||
|
||||
// Place — место расчётов в справочнике.
|
||||
type Place struct {
|
||||
INN OrganizationINN `xml:"http://nsd.ru/schemas/m2m/types INN"`
|
||||
ShortName string `xml:"http://nsd.ru/schemas/m2m/types ShortName"`
|
||||
DisplayName string `xml:"http://nsd.ru/schemas/m2m/types DisplayName"`
|
||||
}
|
||||
|
||||
// SettlementPlaces — обёртка над списком мест расчётов.
|
||||
type SettlementPlaces struct {
|
||||
Places []Place `xml:"http://nsd.ru/schemas/m2m/types Place"`
|
||||
}
|
||||
|
||||
// M2MTransferRequest — корневой элемент сообщения "Запрос на перевод M2M".
|
||||
type M2MTransferRequest struct {
|
||||
XMLName xml.Name `xml:"http://nsd.ru/schemas/m2m/request M2MTransferRequest"`
|
||||
Header RequestHeader `xml:"http://nsd.ru/schemas/m2m/request Header"`
|
||||
Data RequestData `xml:"http://nsd.ru/schemas/m2m/request Data"`
|
||||
NSDInfo *NSDInfo `xml:"http://nsd.ru/schemas/m2m/request NSDInfo,omitempty"`
|
||||
}
|
||||
|
||||
// Validate каскадно валидирует заголовок, тело и комментарии НРД.
|
||||
func (m M2MTransferRequest) Validate() error {
|
||||
if err := m.Header.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := m.Data.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
if m.NSDInfo != nil {
|
||||
return m.NSDInfo.Validate()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// M2MTransferDecision — корневой элемент сообщения "Решение по запросу M2M".
|
||||
type M2MTransferDecision struct {
|
||||
XMLName xml.Name `xml:"http://nsd.ru/schemas/m2m/decision M2MTransferDecision"`
|
||||
Header DecisionHeader `xml:"http://nsd.ru/schemas/m2m/decision Header"`
|
||||
Data DecisionData `xml:"http://nsd.ru/schemas/m2m/decision Data"`
|
||||
NSDInfo *NSDInfo `xml:"http://nsd.ru/schemas/m2m/decision NSDInfo,omitempty"`
|
||||
}
|
||||
|
||||
// Validate каскадно валидирует заголовок, тело и комментарии НРД.
|
||||
func (m M2MTransferDecision) Validate() error {
|
||||
if err := m.Header.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := m.Data.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
if m.NSDInfo != nil {
|
||||
return m.NSDInfo.Validate()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// M2MTransferResponse — служебный ответ НРД на сообщение M2M.
|
||||
// GUID/StatusCode/Response объявлены локально в M2MTransferResponse.xsd
|
||||
// (elementFormDefault="qualified") — namespace элементов response, не
|
||||
// types, хотя их типы из types.
|
||||
type M2MTransferResponse struct {
|
||||
XMLName xml.Name `xml:"http://nsd.ru/schemas/m2m/response M2MTransferResponse"`
|
||||
GUID UUID `xml:"http://nsd.ru/schemas/m2m/response GUID"`
|
||||
StatusCode StatusCode `xml:"http://nsd.ru/schemas/m2m/response StatusCode"`
|
||||
Responses []Response `xml:"http://nsd.ru/schemas/m2m/response Response"`
|
||||
}
|
||||
|
||||
// Validate валидирует GUID, статус и каждый Response.
|
||||
func (m M2MTransferResponse) Validate() error {
|
||||
if err := m.GUID.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := m.StatusCode.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(m.Responses) == 0 {
|
||||
return fmt.Errorf("%w: M2MTransferResponse без Response", ErrInvalid)
|
||||
}
|
||||
for i := range m.Responses {
|
||||
if err := m.Responses[i].Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// M2MTransferHandbook — справочник участников M2M. CreationTimestamp,
|
||||
// SettlementPlaces, Participants — локальные элементы из
|
||||
// M2MTransferHandbook.xsd, namespace handbook.
|
||||
type M2MTransferHandbook struct {
|
||||
XMLName xml.Name `xml:"http://nsd.ru/schemas/m2m/handbook M2MTransferHandbook"`
|
||||
CreationTimestamp nsdxml.NSDDateTime `xml:"http://nsd.ru/schemas/m2m/handbook CreationTimestamp"`
|
||||
SettlementPlaces SettlementPlaces `xml:"http://nsd.ru/schemas/m2m/handbook SettlementPlaces"`
|
||||
Participants HandbookParticipants `xml:"http://nsd.ru/schemas/m2m/handbook Participants"`
|
||||
}
|
||||
|
||||
// Validate валидирует каждого участника и каждое место расчётов.
|
||||
func (m M2MTransferHandbook) Validate() error {
|
||||
for i := range m.SettlementPlaces.Places {
|
||||
if err := m.SettlementPlaces.Places[i].INN.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
for i := range m.Participants.Participants {
|
||||
if err := m.Participants.Participants[i].Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// M2MTransferHandbookRequest — запрос актуального справочника M2M.
|
||||
// Тело пустое (пустой complexType в XSD).
|
||||
type M2MTransferHandbookRequest struct {
|
||||
XMLName xml.Name `xml:"http://nsd.ru/schemas/m2m/handbook/request M2MTransferHandbookRequest"`
|
||||
}
|
||||
|
||||
// Validate всегда возвращает nil — содержимое отсутствует по XSD.
|
||||
func (m M2MTransferHandbookRequest) Validate() error { return nil }
|
||||
|
||||
// M2MTransferParticipantForm — анкета участника M2M. CreationTimestamp
|
||||
// и Participant — локальные элементы из M2MTransferParticipantForm.xsd,
|
||||
// namespace participant/form.
|
||||
type M2MTransferParticipantForm struct {
|
||||
XMLName xml.Name `xml:"http://nsd.ru/schemas/m2m/participant/form M2MTransferParticipantForm"`
|
||||
CreationTimestamp nsdxml.NSDDateTime `xml:"http://nsd.ru/schemas/m2m/participant/form CreationTimestamp"`
|
||||
Participant HandbookParticipant `xml:"http://nsd.ru/schemas/m2m/participant/form Participant"`
|
||||
}
|
||||
|
||||
// Validate валидирует участника.
|
||||
func (m M2MTransferParticipantForm) Validate() error {
|
||||
return m.Participant.Validate()
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
package m2m_test
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/m2m"
|
||||
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/nsdxml"
|
||||
)
|
||||
|
||||
// validator — общий интерфейс для каскадной валидации в тестах.
|
||||
type validator interface {
|
||||
Validate() error
|
||||
}
|
||||
|
||||
// roundTripCase описывает приёмочный кейс round-trip на эталонном файле.
|
||||
type roundTripCase struct {
|
||||
path string
|
||||
mk func() any
|
||||
}
|
||||
|
||||
// roundTripCases — соответствие "файл -> ожидаемый Go-тип сообщения".
|
||||
var roundTripCases = []roundTripCase{
|
||||
{filepath.Join("..", "..", "DOC", "Примеры", "M2MTransferRequest.xml"), func() any { return new(m2m.M2MTransferRequest) }},
|
||||
{filepath.Join("..", "..", "DOC", "Примеры", "M2MTransferDecision.xml"), func() any { return new(m2m.M2MTransferDecision) }},
|
||||
{filepath.Join("..", "..", "DOC", "Примеры", "M2MTransferResponse.xml"), func() any { return new(m2m.M2MTransferResponse) }},
|
||||
{filepath.Join("..", "..", "DOC", "Примеры", "M2MTransferHandbook.xml"), func() any { return new(m2m.M2MTransferHandbook) }},
|
||||
{filepath.Join("..", "..", "DOC", "Примеры", "M2MTransferHandbookRequest.xml"), func() any { return new(m2m.M2MTransferHandbookRequest) }},
|
||||
{filepath.Join("..", "..", "DOC", "Примеры", "M2MTransferParticipantForm.xml"), func() any { return new(m2m.M2MTransferParticipantForm) }},
|
||||
{filepath.Join("..", "..", "DOC", "Эталонные сообщения", "M2MTransferRequest_эталон.xml"), func() any { return new(m2m.M2MTransferRequest) }},
|
||||
{filepath.Join("..", "..", "DOC", "Эталонные сообщения", "M2MTransferDecision_эталон.xml"), func() any { return new(m2m.M2MTransferDecision) }},
|
||||
}
|
||||
|
||||
func TestRoundTrip(t *testing.T) {
|
||||
for _, c := range roundTripCases {
|
||||
c := c
|
||||
t.Run(filepath.Base(c.path), func(t *testing.T) {
|
||||
b1, err := os.ReadFile(c.path)
|
||||
if err != nil {
|
||||
t.Fatalf("read %s: %v", c.path, err)
|
||||
}
|
||||
|
||||
s1 := c.mk()
|
||||
if err := nsdxml.Unmarshal(b1, s1); err != nil {
|
||||
t.Fatalf("unmarshal оригинала: %v", err)
|
||||
}
|
||||
|
||||
if v, ok := s1.(validator); ok {
|
||||
if err := v.Validate(); err != nil {
|
||||
t.Fatalf("Validate после первого Unmarshal: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
b2, err := nsdxml.Marshal(s1)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
|
||||
// Проверяем, что пролог windows-1251 проставлен.
|
||||
if !strings.HasPrefix(string(nsdxml.DecodeWindows1251(b2)), `<?xml version="1.0" encoding="windows-1251"?>`) {
|
||||
t.Fatalf("в выходе нет windows-1251 пролога")
|
||||
}
|
||||
|
||||
s2 := c.mk()
|
||||
if err := nsdxml.Unmarshal(b2, s2); err != nil {
|
||||
t.Fatalf("unmarshal после Marshal: %v", err)
|
||||
}
|
||||
|
||||
if !reflect.DeepEqual(s1, s2) {
|
||||
t.Errorf("round-trip структуры разошлись:\nS1 = %+v\nS2 = %+v", s1, s2)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidatorsPositive(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
v validator
|
||||
}{
|
||||
{"ReferenceId", m2m.ReferenceID("M2M2026030200001")},
|
||||
{"ISIN", m2m.ISIN("RU0007661625")},
|
||||
{"OrganizationINN", m2m.OrganizationINN("7702070139")},
|
||||
{"DeponentCode", m2m.DeponentCode("MC0079200000")},
|
||||
{"UUID", m2m.UUID("c02a1d5e-c2af-4799-bab4-953f133c5133")},
|
||||
{"SecurityCode", m2m.SecurityCode("MM0766162534")},
|
||||
{"IdentityDocSerial", m2m.IdentityDocSerial("4512")},
|
||||
{"AccountId", m2m.AccountID("31MC0021900000F01")},
|
||||
{"StatusCode", m2m.StatusInfo},
|
||||
{"IIAContractType", m2m.IIAContractT03},
|
||||
{"SecurityClassification", m2m.SecurityBond},
|
||||
{"SecurityCategory", m2m.CategoryOrdn},
|
||||
{"IdentityDocumentCode", m2m.DocCode21},
|
||||
{"IsolationStatus", m2m.IsolationSGDN},
|
||||
{"Decimal16", m2m.Decimal16("2500.75")},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
if err := c.v.Validate(); err != nil {
|
||||
t.Errorf("ожидалось без ошибок, получено: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidatorsNegative(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
v validator
|
||||
}{
|
||||
{"ReferenceId короткий", m2m.ReferenceID("M2M123")},
|
||||
{"ReferenceId без префикса", m2m.ReferenceID("XYZ2026030200001")},
|
||||
{"ISIN короткий", m2m.ISIN("RU0007")},
|
||||
{"ISIN с lowercase", m2m.ISIN("ru0007661625")},
|
||||
{"INN короткий", m2m.OrganizationINN("123")},
|
||||
{"INN с буквами", m2m.OrganizationINN("770207013A")},
|
||||
{"DeponentCode пустой", m2m.DeponentCode("")},
|
||||
{"DeponentCode lowercase", m2m.DeponentCode("mc007920")},
|
||||
{"DeponentCode слишком длинный", m2m.DeponentCode("AAAAAAAAAAAAA")},
|
||||
{"UUID без дефисов", m2m.UUID("c02a1d5ec2af4799bab4953f133c5133aaaa")},
|
||||
{"UUID с не-hex символом", m2m.UUID("c02a1d5e-c2af-4799-babZ-953f133c5133")},
|
||||
{"UUID короткий", m2m.UUID("c02a1d5e-c2af-4799-bab4-953f133c513")},
|
||||
{"SecurityCode короткий", m2m.SecurityCode("ABC")},
|
||||
{"SecurityCode с lowercase", m2m.SecurityCode("mm0766162534")},
|
||||
{"IdentityDocSerial пустой", m2m.IdentityDocSerial("")},
|
||||
{"IdentityDocSerial с пробелом", m2m.IdentityDocSerial("45 12")},
|
||||
{"AccountId пустой", m2m.AccountID("")},
|
||||
{"AccountId длиннее 50", m2m.AccountID(strings.Repeat("A", 51))},
|
||||
{"StatusCode unknown", m2m.StatusCode("OK")},
|
||||
{"IIAContractType T01", m2m.IIAContractType("T01")},
|
||||
{"SecurityClassification unknown", m2m.SecurityClassification("STCK")},
|
||||
{"SecurityCategory unknown", m2m.SecurityCategory("XXXX")},
|
||||
{"IdentityDocumentCode 99", m2m.IdentityDocumentCode("99")},
|
||||
{"IsolationStatus FOO", m2m.IsolationStatus("FOO")},
|
||||
{"Decimal16 пустой", m2m.Decimal16("")},
|
||||
{"Decimal16 с буквами", m2m.Decimal16("12a.5")},
|
||||
{"Decimal16 слишком много дробных", m2m.Decimal16("1.12345678901234567")},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
err := c.v.Validate()
|
||||
if err == nil {
|
||||
t.Errorf("ожидалась ошибка")
|
||||
return
|
||||
}
|
||||
if !errors.Is(err, m2m.ErrInvalid) {
|
||||
t.Errorf("ожидалась ErrInvalid, получено: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestChoiceValidators(t *testing.T) {
|
||||
t.Run("CostInfo пустой", func(t *testing.T) {
|
||||
if err := (m2m.CostInfo{}).Validate(); !errors.Is(err, m2m.ErrChoice) {
|
||||
t.Errorf("ожидалась ErrChoice, получено: %v", err)
|
||||
}
|
||||
})
|
||||
t.Run("CostInfo оба поля", func(t *testing.T) {
|
||||
c := m2m.CostInfo{
|
||||
Yes: &m2m.CostInfoYes{Code: "MC0010300032"},
|
||||
No: &m2m.CostInfoNo{},
|
||||
}
|
||||
if err := c.Validate(); !errors.Is(err, m2m.ErrChoice) {
|
||||
t.Errorf("ожидалась ErrChoice, получено: %v", err)
|
||||
}
|
||||
})
|
||||
t.Run("Quantity пустой", func(t *testing.T) {
|
||||
if err := (m2m.Quantity{}).Validate(); !errors.Is(err, m2m.ErrChoice) {
|
||||
t.Errorf("ожидалась ErrChoice, получено: %v", err)
|
||||
}
|
||||
})
|
||||
t.Run("Quantity оба поля", func(t *testing.T) {
|
||||
whole := uint64(100)
|
||||
frac := m2m.Decimal16("1.5")
|
||||
q := m2m.Quantity{Whole: &whole, Fractional: &frac}
|
||||
if err := q.Validate(); !errors.Is(err, m2m.ErrChoice) {
|
||||
t.Errorf("ожидалась ErrChoice, получено: %v", err)
|
||||
}
|
||||
})
|
||||
t.Run("SecurityDetails пустой", func(t *testing.T) {
|
||||
if err := (m2m.SecurityDetails{}).Validate(); !errors.Is(err, m2m.ErrChoice) {
|
||||
t.Errorf("ожидалась ErrChoice, получено: %v", err)
|
||||
}
|
||||
})
|
||||
t.Run("IdentificationDetails пустой", func(t *testing.T) {
|
||||
if err := (m2m.IdentificationDetails{}).Validate(); !errors.Is(err, m2m.ErrChoice) {
|
||||
t.Errorf("ожидалась ErrChoice, получено: %v", err)
|
||||
}
|
||||
})
|
||||
t.Run("DecisionTransfer пустой", func(t *testing.T) {
|
||||
if err := (m2m.DecisionTransfer{}).Validate(); !errors.Is(err, m2m.ErrChoice) {
|
||||
t.Errorf("ожидалась ErrChoice, получено: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
// Package m2m реализует доменную модель сообщений M2M по XSD НРД
|
||||
// (M2MSchemas_260408): simple-типы, enum'ы и структуры шести типов
|
||||
// сообщений. Все имена типов и полей соответствуют XSD.
|
||||
package m2m
|
||||
|
||||
// StatusCode — код статуса обработки (StatusCodeEnum в XSD).
|
||||
type StatusCode string
|
||||
|
||||
const (
|
||||
StatusInfo StatusCode = "INFO"
|
||||
StatusError StatusCode = "ERROR"
|
||||
)
|
||||
|
||||
// IIAContractType — тип договора ИИС (IIAContractTypeEnum в XSD).
|
||||
// T12 — открытие/обмен ИИС-1 или ИИС-2; T03 — открытие/обмен ИИС-3.
|
||||
type IIAContractType string
|
||||
|
||||
const (
|
||||
IIAContractT12 IIAContractType = "T12"
|
||||
IIAContractT03 IIAContractType = "T03"
|
||||
)
|
||||
|
||||
// SecurityClassification — вид ценной бумаги (SecurityClassificationEnum).
|
||||
type SecurityClassification string
|
||||
|
||||
const (
|
||||
SecurityBond SecurityClassification = "BOND"
|
||||
SecurityShar SecurityClassification = "SHAR"
|
||||
SecurityMfun SecurityClassification = "MFUN"
|
||||
)
|
||||
|
||||
// SecurityCategory — категория ценной бумаги (SecurityCategoryEnum).
|
||||
type SecurityCategory string
|
||||
|
||||
const (
|
||||
CategoryOrdn SecurityCategory = "ORDN"
|
||||
CategoryPref SecurityCategory = "PREF"
|
||||
CategoryUkwn SecurityCategory = "UKWN"
|
||||
)
|
||||
|
||||
// IdentityDocumentCode — код документа, удостоверяющего личность
|
||||
// (IdentityDocumentCodeEnum). Допустимые значения — двузначные строки
|
||||
// согласно справочнику НРД.
|
||||
type IdentityDocumentCode string
|
||||
|
||||
const (
|
||||
DocCode01 IdentityDocumentCode = "01"
|
||||
DocCode02 IdentityDocumentCode = "02"
|
||||
DocCode03 IdentityDocumentCode = "03"
|
||||
DocCode04 IdentityDocumentCode = "04"
|
||||
DocCode05 IdentityDocumentCode = "05"
|
||||
DocCode06 IdentityDocumentCode = "06"
|
||||
DocCode07 IdentityDocumentCode = "07"
|
||||
DocCode09 IdentityDocumentCode = "09"
|
||||
DocCode10 IdentityDocumentCode = "10"
|
||||
DocCode11 IdentityDocumentCode = "11"
|
||||
DocCode12 IdentityDocumentCode = "12"
|
||||
DocCode13 IdentityDocumentCode = "13"
|
||||
DocCode14 IdentityDocumentCode = "14"
|
||||
DocCode21 IdentityDocumentCode = "21"
|
||||
DocCode22 IdentityDocumentCode = "22"
|
||||
DocCode23 IdentityDocumentCode = "23"
|
||||
DocCode26 IdentityDocumentCode = "26"
|
||||
DocCode27 IdentityDocumentCode = "27"
|
||||
DocCode91 IdentityDocumentCode = "91"
|
||||
)
|
||||
|
||||
// IsolationStatus — статус обособления ценных бумаг
|
||||
// (SecurityIsolationEnum). Единственное допустимое значение — SGDN.
|
||||
type IsolationStatus string
|
||||
|
||||
const IsolationSGDN IsolationStatus = "SGDN"
|
||||
|
||||
// DeponentCode — код депонента (DeponentCodeType): 1..12 символов из
|
||||
// множества [A-Z0-9].
|
||||
type DeponentCode string
|
||||
|
||||
// ReferenceID — идентификатор операции (ReferenceIDType): ровно
|
||||
// 16 символов, формат M2M + 13 символов [A-Z0-9].
|
||||
type ReferenceID string
|
||||
|
||||
// ISIN — международный идентификатор ценной бумаги (ISINtype): 12
|
||||
// символов, формат [A-Z]{2}[A-Z0-9]{9}[0-9].
|
||||
type ISIN string
|
||||
|
||||
// OrganizationINN — ИНН юридического лица (OrganizationINNType): ровно
|
||||
// 10 цифр.
|
||||
type OrganizationINN string
|
||||
|
||||
// UUID — глобальный идентификатор сообщения (UUIDType): 36 символов
|
||||
// формата UUID.
|
||||
type UUID string
|
||||
|
||||
// AccountID — номер (код) счёта депо (AccountIDType): 1..50 символов.
|
||||
type AccountID string
|
||||
|
||||
// SecurityCode — идентификатор ценной бумаги в кодах НРД
|
||||
// (SecurityCodeType): ровно 12 символов из [0-9A-Z_/-].
|
||||
type SecurityCode string
|
||||
|
||||
// IdentityDocSerial — серия или номер документа
|
||||
// (IdentityDocSerialType): не короче 1 символа, без пробельных.
|
||||
type IdentityDocSerial string
|
||||
@@ -0,0 +1,177 @@
|
||||
package m2m
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
)
|
||||
|
||||
// ErrInvalid — базовая ошибка валидации простого типа M2M.
|
||||
var ErrInvalid = errors.New("m2m: invalid value")
|
||||
|
||||
// Скомпилированные паттерны из XSD НРД (M2MTypesNSD.xsd).
|
||||
var (
|
||||
reReferenceID = regexp.MustCompile(`^M2M[A-Z0-9]{13}$`)
|
||||
reISIN = regexp.MustCompile(`^[A-Z]{2}[A-Z0-9]{9}[0-9]$`)
|
||||
reOrganizationINN = regexp.MustCompile(`^[0-9]{10}$`)
|
||||
reDeponentCode = regexp.MustCompile(`^[A-Z0-9]+$`)
|
||||
reUUID = regexp.MustCompile(`^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$`)
|
||||
reSecurityCode = regexp.MustCompile(`^[0-9A-Z_/-]+$`)
|
||||
reIdentityDocSerial = regexp.MustCompile(`^\S+$`)
|
||||
)
|
||||
|
||||
// Validate проверяет, что значение соответствует ReferenceIDType
|
||||
// (M2M + 13 символов [A-Z0-9], ровно 16 символов).
|
||||
func (r ReferenceID) Validate() error {
|
||||
if len(r) != 16 {
|
||||
return fmt.Errorf("%w: ReferenceID длина %d, ожидается 16", ErrInvalid, len(r))
|
||||
}
|
||||
if !reReferenceID.MatchString(string(r)) {
|
||||
return fmt.Errorf("%w: ReferenceID %q не соответствует ^M2M[A-Z0-9]{13}$", ErrInvalid, string(r))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Validate проверяет, что значение соответствует ISINtype
|
||||
// (2 буквы + 9 буквоцифр + цифра, ровно 12 символов).
|
||||
func (i ISIN) Validate() error {
|
||||
if len(i) != 12 {
|
||||
return fmt.Errorf("%w: ISIN длина %d, ожидается 12", ErrInvalid, len(i))
|
||||
}
|
||||
if !reISIN.MatchString(string(i)) {
|
||||
return fmt.Errorf("%w: ISIN %q не соответствует формату", ErrInvalid, string(i))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Validate проверяет, что значение соответствует OrganizationINNType
|
||||
// (ровно 10 цифр).
|
||||
func (n OrganizationINN) Validate() error {
|
||||
if len(n) != 10 {
|
||||
return fmt.Errorf("%w: ИНН длина %d, ожидается 10", ErrInvalid, len(n))
|
||||
}
|
||||
if !reOrganizationINN.MatchString(string(n)) {
|
||||
return fmt.Errorf("%w: ИНН %q содержит не цифры", ErrInvalid, string(n))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Validate проверяет, что значение соответствует DeponentCodeType
|
||||
// (1..12 символов из [A-Z0-9]).
|
||||
func (c DeponentCode) Validate() error {
|
||||
if len(c) == 0 || len(c) > 12 {
|
||||
return fmt.Errorf("%w: DeponentCode длина %d, ожидается 1..12", ErrInvalid, len(c))
|
||||
}
|
||||
if !reDeponentCode.MatchString(string(c)) {
|
||||
return fmt.Errorf("%w: DeponentCode %q содержит недопустимые символы", ErrInvalid, string(c))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Validate проверяет, что значение соответствует UUIDType (XSD НРД):
|
||||
// 8-4-4-4-12 шестнадцатеричных символов, всего 36 с дефисами. Биты
|
||||
// версии/варианта по UUID RFC не контролируются — XSD НРД допускает
|
||||
// произвольный hex (в эталонах встречается "11111111-1111-...").
|
||||
func (u UUID) Validate() error {
|
||||
if len(u) != 36 {
|
||||
return fmt.Errorf("%w: UUID длина %d, ожидается 36", ErrInvalid, len(u))
|
||||
}
|
||||
if !reUUID.MatchString(string(u)) {
|
||||
return fmt.Errorf("%w: UUID %q не соответствует формату [hex]-[hex]-...", ErrInvalid, string(u))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Validate проверяет, что значение соответствует SecurityCodeType
|
||||
// (ровно 12 символов из [0-9A-Z_/-]).
|
||||
func (c SecurityCode) Validate() error {
|
||||
if len(c) != 12 {
|
||||
return fmt.Errorf("%w: SecurityCode длина %d, ожидается 12", ErrInvalid, len(c))
|
||||
}
|
||||
if !reSecurityCode.MatchString(string(c)) {
|
||||
return fmt.Errorf("%w: SecurityCode %q содержит недопустимые символы", ErrInvalid, string(c))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Validate проверяет, что значение соответствует IdentityDocSerialType
|
||||
// (хотя бы 1 символ, без пробельных).
|
||||
func (s IdentityDocSerial) Validate() error {
|
||||
if len(s) == 0 {
|
||||
return fmt.Errorf("%w: IdentityDocSerial пустая строка", ErrInvalid)
|
||||
}
|
||||
if !reIdentityDocSerial.MatchString(string(s)) {
|
||||
return fmt.Errorf("%w: IdentityDocSerial %q содержит пробельные символы", ErrInvalid, string(s))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Validate проверяет, что значение соответствует AccountIDType
|
||||
// (1..50 символов).
|
||||
func (a AccountID) Validate() error {
|
||||
if len(a) == 0 || len(a) > 50 {
|
||||
return fmt.Errorf("%w: AccountID длина %d, ожидается 1..50", ErrInvalid, len(a))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Validate проверяет принадлежность к множеству {INFO, ERROR}.
|
||||
func (s StatusCode) Validate() error {
|
||||
switch s {
|
||||
case StatusInfo, StatusError:
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("%w: StatusCode %q вне {INFO, ERROR}", ErrInvalid, string(s))
|
||||
}
|
||||
|
||||
// Validate проверяет принадлежность к множеству {T12, T03}.
|
||||
func (t IIAContractType) Validate() error {
|
||||
switch t {
|
||||
case IIAContractT12, IIAContractT03:
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("%w: IIAContractType %q вне {T12, T03}", ErrInvalid, string(t))
|
||||
}
|
||||
|
||||
// Validate проверяет принадлежность к множеству {BOND, SHAR, MFUN}.
|
||||
func (s SecurityClassification) Validate() error {
|
||||
switch s {
|
||||
case SecurityBond, SecurityShar, SecurityMfun:
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("%w: SecurityClassification %q вне {BOND, SHAR, MFUN}", ErrInvalid, string(s))
|
||||
}
|
||||
|
||||
// Validate проверяет принадлежность к множеству {ORDN, PREF, UKWN}.
|
||||
func (c SecurityCategory) Validate() error {
|
||||
switch c {
|
||||
case CategoryOrdn, CategoryPref, CategoryUkwn:
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("%w: SecurityCategory %q вне {ORDN, PREF, UKWN}", ErrInvalid, string(c))
|
||||
}
|
||||
|
||||
// validIdentityDocumentCodes — справочник допустимых кодов документов
|
||||
// из XSD НРД (IdentityDocumentCodeEnum).
|
||||
var validIdentityDocumentCodes = map[IdentityDocumentCode]struct{}{
|
||||
DocCode01: {}, DocCode02: {}, DocCode03: {}, DocCode04: {}, DocCode05: {},
|
||||
DocCode06: {}, DocCode07: {}, DocCode09: {}, DocCode10: {}, DocCode11: {},
|
||||
DocCode12: {}, DocCode13: {}, DocCode14: {}, DocCode21: {}, DocCode22: {},
|
||||
DocCode23: {}, DocCode26: {}, DocCode27: {}, DocCode91: {},
|
||||
}
|
||||
|
||||
// Validate проверяет принадлежность к справочнику кодов документов НРД.
|
||||
func (c IdentityDocumentCode) Validate() error {
|
||||
if _, ok := validIdentityDocumentCodes[c]; ok {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("%w: IdentityDocumentCode %q вне справочника НРД", ErrInvalid, string(c))
|
||||
}
|
||||
|
||||
// Validate проверяет принадлежность к множеству {SGDN}.
|
||||
func (s IsolationStatus) Validate() error {
|
||||
if s == IsolationSGDN {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("%w: IsolationStatus %q вне {SGDN}", ErrInvalid, string(s))
|
||||
}
|
||||
Reference in New Issue
Block a user