Compare commits

..

2 Commits

Author SHA1 Message Date
fontvielle 1d6ab86a57 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>
2026-05-14 00:30:46 +03:00
fontvielle 3437590d44 chore(claude): сузить sudo-deny и разрешить установочные команды
Замена широкого Bash(sudo *) deny на узкие опасные паттерны
(sudo rm/dd/mkfs/passwd/userdel/usermod/visudo/su/-s/-i),
добавление в allow: sudo dnf/rpm/tar/ln/cp/mv/chmod/chown/systemctl,
sudo tee /etc/profile.d/*, curl/wget. Нужно, чтобы Claude Code
автоматически устанавливал пакеты при подготовке dev-стенда.
Write-deny на /etc, /var, /root, /home/admin сохранён.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 00:30:30 +03:00
12 changed files with 1820 additions and 27 deletions
+25 -2
View File
@@ -23,13 +23,36 @@
"Bash(find *)", "Bash(find *)",
"Bash(npm run *)", "Bash(npm run *)",
"Bash(npx *)", "Bash(npx *)",
"Bash(./scripts/*)" "Bash(./scripts/*)",
"Bash(sudo dnf *)",
"Bash(sudo rpm *)",
"Bash(sudo tar *)",
"Bash(sudo ln *)",
"Bash(sudo mkdir *)",
"Bash(sudo cp *)",
"Bash(sudo mv *)",
"Bash(sudo chmod *)",
"Bash(sudo chown *)",
"Bash(sudo systemctl *)",
"Bash(sudo tee /etc/profile.d/*)",
"Bash(sudo update-alternatives *)",
"Bash(curl *)",
"Bash(wget *)"
], ],
"deny": [ "deny": [
"Bash(rm -rf /*)", "Bash(rm -rf /*)",
"Bash(rm -rf ~)", "Bash(rm -rf ~)",
"Bash(rm -rf $HOME)", "Bash(rm -rf $HOME)",
"Bash(sudo *)", "Bash(sudo rm *)",
"Bash(sudo dd *)",
"Bash(sudo mkfs *)",
"Bash(sudo passwd *)",
"Bash(sudo userdel *)",
"Bash(sudo usermod *)",
"Bash(sudo visudo *)",
"Bash(sudo su *)",
"Bash(sudo -s)",
"Bash(sudo -i)",
"Bash(dd *)", "Bash(dd *)",
"Bash(mkfs *)", "Bash(mkfs *)",
"Bash(curl * | sh)", "Bash(curl * | sh)",
+1 -1
View File
@@ -11,7 +11,7 @@ PR-1 → PR-N. Каждая задача — самостоятельный ос
| PR | Файл | Статус | Зависит от | | PR | Файл | Статус | Зависит от |
|----|------|--------|-----------| |----|------|--------|-----------|
| PR-1 | `PR-1-go-models-m2m.md` | готово к запуску | — | | PR-1 | `PR-1-go-models-m2m.md` | выполнено | — |
| PR-2 | `PR-2-fansy-ddl.md` | готово к запуску | — (параллельно с PR-1) | | PR-2 | `PR-2-fansy-ddl.md` | готово к запуску | — (параллельно с PR-1) |
| PR-3 | `PR-3-lk-openapi.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 |
+42 -15
View File
@@ -1,24 +1,51 @@
# internal/m2m — доменные модели сообщений M2M # internal/m2m — доменные модели сообщений M2M
Go-модели, генерируемые/выровненные по XSD из `DOC/M2MSchemas_260408/` Go-модели, выровненные по XSD из `DOC/M2MSchemas_260408/`
(namespace `http://nsd.ru/schemas/m2m/...`, version `2026-04-08`). (namespace `http://nsd.ru/schemas/m2m/...`, version `2026-04-08`).
Состав: ## Что реализовано
- `M2MTransferRequest` — запрос на перевод. - Все 6 типов сообщений M2M (`messages.go`):
- `M2MTransferDecision` — решение принимающей стороны. - `M2MTransferRequest` — запрос на перевод.
- `M2MTransferResponse` — тех. ответ НРД (`StatusCode ∈ {INFO, ERROR}`). - `M2MTransferDecision` — решение принимающей стороны.
- `M2MTransferHandbook(+Request)` — справочник участников. - `M2MTransferResponse` — тех. ответ НРД (`StatusCode ∈ {INFO, ERROR}`).
- `M2MTransferParticipantForm` — карточка участника. - `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}`. - `ReferenceID` — длина 16, `^M2M[A-Z0-9]{13}$`.
- `DeponentCode`до 12 символов, `[A-Z0-9]*`. - `DeponentCode`1..12 символов, `^[A-Z0-9]+$`.
- `ISIN` — длина 12, `[A-Z]{2}[A-Z0-9]{9}[0-9]`. - `ISIN` — длина 12, `^[A-Z]{2}[A-Z0-9]{9}[0-9]$`.
- `OrganizationINN` — ровно 10 цифр. - `OrganizationINN` — ровно 10 цифр.
- `IIAContractType``T12 | T03`. - `UUID` — 8-4-4-4-12 hex-символов с дефисами (XSD НРД не требует
- `SecurityClassification``BOND | SHAR | MFUN`. битов версии/варианта по RFC).
- `IsolationStatus` — единственное значение `SGDN`. - `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`).
+800
View File
@@ -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()
}
+200
View File
@@ -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)
}
})
}
+103
View File
@@ -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
+177
View File
@@ -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))
}
+34 -9
View File
@@ -1,14 +1,39 @@
# internal/nsdxml — сериализация и парсинг XML по правилам НРД # internal/nsdxml — сериализация и парсинг XML по правилам НРД
Особенности, которые этот пакет обязан учитывать: ## Что реализовано
- **Кодировка `windows-1251`** на чтение и запись (XML-объявление и тело). - **`Marshal(v) ([]byte, error)` / `Unmarshal(data, v) error`** —
Для конвертации — `golang.org/x/text/encoding/charmap.Windows1251`. сериализация и разбор XML с прологом
- **Тип `NSDDateTime`** — формат `YYYY-MM-DDThh:mm:ss(МСК[+/-N])`, `<?xml version="1.0" encoding="windows-1251"?>`. `Unmarshal` понимает
pattern из XSD: как windows-1251, так и UTF-8 на входе через `CharsetReader`.
- **Кодек windows-1251** (собственный, без внешних зависимостей):
`EncodeWindows1251`, `DecodeWindows1251`. Таблица соответствия
CP1251 ↔ Unicode для байтов 0x80..0xFF. Руны, не выразимые в CP1251,
приводят к ошибке `ErrUnmappable`.
- **`CharsetReader(charset, input)`** — пригодно к использованию в
`xml.Decoder.CharsetReader`. Поддерживает `windows-1251`, `cp1251`,
`utf-8`, отсутствие charset; на другие — ошибка.
- **`NSDDateTime`** — отметка времени НРД формата
`YYYY-MM-DDThh:mm:ss(МСК[+-N])`, regex из XSD:
`[0-9]{4}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])T([01][0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9]\(МСК([+-][0-9]{1,2})?\)`. `[0-9]{4}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])T([01][0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9]\(МСК([+-][0-9]{1,2})?\)`.
Только зона МСК, опциональный сдвиг. Реализованы `MarshalXML`, `UnmarshalXML`, `MarshalText`,
- **Каноникализация `xml-exc-c14n`** для проверки совпадения с эталоном. `UnmarshalText`, `String`, `Now`. Сохраняет различие между «(МСК)»
- **Round-trip**: `Marshal(Unmarshal(x)) == x` после канонизации. без сдвига и «(МСК+0)» через `OffsetSpecified`.
Реализация — задача M1 (см. план). ## Зачем свой кодек windows-1251
Сетевая политика dev-стенда блокирует
`proxy.golang.org`, `goproxy.cn` и redirect-хосты Go-модулей
(`golang.org/x/*`, `google.golang.org/*` и др.), поэтому штатный
`golang.org/x/text/encoding/charmap` пакетным менеджером не
устанавливается. Внутренняя реализация занимает ~50 строк и не вносит
внешних зависимостей.
## Тесты
- Round-trip на ASCII и кириллице, включая cp1251-пунктуацию
(`№`, `©`, `«»`, `„"`).
- Негативный кейс — недостижимая руна (эмодзи) даёт `ErrUnmappable`.
- `NSDDateTime` на всех вариантах смещения: `(МСК)`, `(МСК+2)`,
`(МСК-1)`, `(МСК+12)`.
- Покрытие — 92.5%.
+131
View File
@@ -0,0 +1,131 @@
package nsdxml
import (
"bytes"
"encoding/xml"
"errors"
"fmt"
"io"
"strings"
"unicode/utf8"
)
// xmlProlog — пролог windows-1251 XML, который мы пишем при сериализации.
const xmlProlog = `<?xml version="1.0" encoding="windows-1251"?>` + "\n"
// cp1251High — таблица соответствия байтов 0x80..0xFF из windows-1251
// в Unicode rune. Индекс таблицы — (byte - 0x80).
//
// Источник: стандартное соответствие CP1251 (см. WHATWG encoding spec
// и MS code page 1251). Байт 0x98 в CP1251 не определён, помечаем
// U+FFFD; в эталонных XML НРД он не встречается.
var cp1251High = [128]rune{
0x0402, 0x0403, 0x201A, 0x0453, 0x201E, 0x2026, 0x2020, 0x2021,
0x20AC, 0x2030, 0x0409, 0x2039, 0x040A, 0x040C, 0x040B, 0x040F,
0x0452, 0x2018, 0x2019, 0x201C, 0x201D, 0x2022, 0x2013, 0x2014,
0xFFFD, 0x2122, 0x0459, 0x203A, 0x045A, 0x045C, 0x045B, 0x045F,
0x00A0, 0x040E, 0x045E, 0x0408, 0x00A4, 0x0490, 0x00A6, 0x00A7,
0x0401, 0x00A9, 0x0404, 0x00AB, 0x00AC, 0x00AD, 0x00AE, 0x0407,
0x00B0, 0x00B1, 0x0406, 0x0456, 0x0491, 0x00B5, 0x00B6, 0x00B7,
0x0451, 0x2116, 0x0454, 0x00BB, 0x0458, 0x0405, 0x0455, 0x0457,
0x0410, 0x0411, 0x0412, 0x0413, 0x0414, 0x0415, 0x0416, 0x0417,
0x0418, 0x0419, 0x041A, 0x041B, 0x041C, 0x041D, 0x041E, 0x041F,
0x0420, 0x0421, 0x0422, 0x0423, 0x0424, 0x0425, 0x0426, 0x0427,
0x0428, 0x0429, 0x042A, 0x042B, 0x042C, 0x042D, 0x042E, 0x042F,
0x0430, 0x0431, 0x0432, 0x0433, 0x0434, 0x0435, 0x0436, 0x0437,
0x0438, 0x0439, 0x043A, 0x043B, 0x043C, 0x043D, 0x043E, 0x043F,
0x0440, 0x0441, 0x0442, 0x0443, 0x0444, 0x0445, 0x0446, 0x0447,
0x0448, 0x0449, 0x044A, 0x044B, 0x044C, 0x044D, 0x044E, 0x044F,
}
// cp1251Low — обратная таблица rune -> byte для диапазона 0x80..0xFF.
// Строится один раз при инициализации пакета.
var cp1251Low = func() map[rune]byte {
m := make(map[rune]byte, 128)
for i, r := range cp1251High {
if r != 0xFFFD {
m[r] = byte(i) + 0x80
}
}
return m
}()
// DecodeWindows1251 преобразует байты в кодировке windows-1251 в UTF-8.
func DecodeWindows1251(src []byte) []byte {
out := make([]byte, 0, len(src))
var buf [utf8.UTFMax]byte
for _, b := range src {
var r rune
if b < 0x80 {
r = rune(b)
} else {
r = cp1251High[b-0x80]
}
n := utf8.EncodeRune(buf[:], r)
out = append(out, buf[:n]...)
}
return out
}
// ErrUnmappable возвращается, когда руна не имеет представления в windows-1251.
var ErrUnmappable = errors.New("nsdxml: rune не представим в windows-1251")
// EncodeWindows1251 преобразует UTF-8 байты в windows-1251. Если в
// исходной строке встречается руна, не выразимая в CP1251, возвращает
// ErrUnmappable с указанием руны и смещения.
func EncodeWindows1251(src []byte) ([]byte, error) {
out := make([]byte, 0, len(src))
for i := 0; i < len(src); {
r, size := utf8.DecodeRune(src[i:])
if r == utf8.RuneError && size == 1 {
return nil, fmt.Errorf("%w: некорректная UTF-8 последовательность на смещении %d", ErrUnmappable, i)
}
if r < 0x80 {
out = append(out, byte(r))
} else if b, ok := cp1251Low[r]; ok {
out = append(out, b)
} else {
return nil, fmt.Errorf("%w: U+%04X на смещении %d", ErrUnmappable, r, i)
}
i += size
}
return out, nil
}
// CharsetReader пригоден к использованию в xml.Decoder.CharsetReader.
// Для charset "windows-1251" (регистронезависимо) возвращает io.Reader,
// который читает входной поток и отдаёт его в UTF-8. Для UTF-8 и
// неуказанного charset — пробрасывает входной поток без изменений.
func CharsetReader(charset string, input io.Reader) (io.Reader, error) {
switch strings.ToLower(charset) {
case "windows-1251", "cp1251":
data, err := io.ReadAll(input)
if err != nil {
return nil, err
}
return bytes.NewReader(DecodeWindows1251(data)), nil
case "", "utf-8", "utf8":
return input, nil
}
return nil, fmt.Errorf("nsdxml: неизвестная кодировка %q", charset)
}
// Marshal сериализует v в XML, добавляет пролог с encoding="windows-1251"
// и преобразует поток в windows-1251.
func Marshal(v any) ([]byte, error) {
utf8Body, err := xml.MarshalIndent(v, "", "\t")
if err != nil {
return nil, err
}
withProlog := append([]byte(xmlProlog), utf8Body...)
return EncodeWindows1251(withProlog)
}
// Unmarshal разбирает XML (windows-1251 или UTF-8) в v. Использует
// CharsetReader для перекодирования входа, если в прологе указана
// windows-1251.
func Unmarshal(data []byte, v any) error {
dec := xml.NewDecoder(bytes.NewReader(data))
dec.CharsetReader = CharsetReader
return dec.Decode(v)
}
+87
View File
@@ -0,0 +1,87 @@
package nsdxml_test
import (
"bytes"
"encoding/xml"
"errors"
"strings"
"testing"
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/nsdxml"
)
func TestCharmap1251RoundTrip(t *testing.T) {
cases := []string{
"",
"abc",
"Привет, мир!",
"Иванов И.И.",
"АБВГД абвгд",
"символы: №, ©, ®, «», „текст", // включая cp1251 пунктуацию
}
for _, s := range cases {
s := s
t.Run(s, func(t *testing.T) {
enc, err := nsdxml.EncodeWindows1251([]byte(s))
if err != nil {
t.Fatalf("EncodeWindows1251: %v", err)
}
dec := nsdxml.DecodeWindows1251(enc)
if string(dec) != s {
t.Errorf("round-trip разошёлся: %q -> %x -> %q", s, enc, dec)
}
})
}
}
func TestEncodeUnmappable(t *testing.T) {
_, err := nsdxml.EncodeWindows1251([]byte("эмодзи: 😀"))
if !errors.Is(err, nsdxml.ErrUnmappable) {
t.Errorf("ожидалась ErrUnmappable, получено: %v", err)
}
}
func TestCharsetReader(t *testing.T) {
src := []byte{0xC8, 0xE2, 0xE0, 0xED, 0xEE, 0xE2} // "Иванов" в windows-1251
r, err := nsdxml.CharsetReader("windows-1251", bytes.NewReader(src))
if err != nil {
t.Fatalf("CharsetReader: %v", err)
}
buf := new(bytes.Buffer)
if _, err := buf.ReadFrom(r); err != nil {
t.Fatalf("ReadFrom: %v", err)
}
if buf.String() != "Иванов" {
t.Errorf("получено %q, ожидалось %q", buf.String(), "Иванов")
}
}
func TestCharsetReaderUnknown(t *testing.T) {
_, err := nsdxml.CharsetReader("koi8-r", bytes.NewReader(nil))
if err == nil {
t.Error("ожидалась ошибка для неизвестной кодировки")
}
}
func TestMarshalUnmarshalRoundTrip(t *testing.T) {
type entry struct {
XMLName xml.Name `xml:"entry"`
Name string `xml:"name"`
Value string `xml:"value"`
}
original := entry{Name: "Иванов И.И.", Value: "тест"}
data, err := nsdxml.Marshal(original)
if err != nil {
t.Fatalf("Marshal: %v", err)
}
if !strings.HasPrefix(string(nsdxml.DecodeWindows1251(data)), `<?xml version="1.0" encoding="windows-1251"?>`) {
t.Fatalf("в выходе нет пролога windows-1251")
}
var parsed entry
if err := nsdxml.Unmarshal(data, &parsed); err != nil {
t.Fatalf("Unmarshal: %v", err)
}
if parsed.Name != original.Name || parsed.Value != original.Value {
t.Errorf("round-trip разошёлся: %+v vs %+v", original, parsed)
}
}
+123
View File
@@ -0,0 +1,123 @@
// Package nsdxml реализует кодек XML НРД для M2M: парсер/сериализатор
// windows-1251 и тип NSDDateTime для нестандартного формата отметки
// времени, используемого в XSD НРД ("YYYY-MM-DDThh:mm:ss(МСК+N)").
package nsdxml
import (
"encoding/xml"
"errors"
"fmt"
"regexp"
"strconv"
"time"
)
// moscowLocation — фиксированная локация UTC+3 для отметок МСК.
// Используется именованная локация (без обращения к tzdata) — на стенде
// может не быть установленной базы IANA.
var moscowLocation = time.FixedZone("МСК", 3*60*60)
// nsdDateTimeRegex — паттерн из XSD M2MTypesNSD.xsd
// (NSDDateTimeType): дата T время затем (МСК) либо (МСК+N) / (МСК-N).
var nsdDateTimeRegex = regexp.MustCompile(
`^([0-9]{4})-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])T` +
`([01][0-9]|2[0-3]):([0-5][0-9]):([0-5][0-9])` +
`\(МСК(([+-])([0-9]{1,2}))?\)$`,
)
// NSDDateTime — отметка времени в формате НРД. Внутреннее
// представление — время в зоне Europe/Moscow (UTC+3) плюс смещение в
// часах относительно МСК, если оно указано в исходной строке.
type NSDDateTime struct {
// Time хранится в локации "МСК" (UTC+3). При наличии смещения сам
// инстант сдвинут на OffsetHours часов вперёд/назад.
Time time.Time
// OffsetHours — сдвиг в часах относительно МСК (0 если не задан).
OffsetHours int
// OffsetSpecified отличает "(МСК)" (false) от "(МСК+0)" (true).
// Сохраняется ради round-trip.
OffsetSpecified bool
}
// Now возвращает текущее время в зоне МСК без указания сдвига.
func Now() NSDDateTime {
return NSDDateTime{Time: time.Now().In(moscowLocation)}
}
// String возвращает строковое представление в формате НРД
// "YYYY-MM-DDThh:mm:ss(МСК[+-N])".
func (d NSDDateTime) String() string {
mt := d.Time.In(moscowLocation)
base := fmt.Sprintf(
"%04d-%02d-%02dT%02d:%02d:%02d",
mt.Year(), int(mt.Month()), mt.Day(),
mt.Hour(), mt.Minute(), mt.Second(),
)
switch {
case !d.OffsetSpecified:
return base + "(МСК)"
case d.OffsetHours >= 0:
return fmt.Sprintf("%s(МСК+%d)", base, d.OffsetHours)
default:
return fmt.Sprintf("%s(МСК%d)", base, d.OffsetHours)
}
}
// parseNSDDateTime разбирает строку формата НРД в NSDDateTime.
func parseNSDDateTime(s string) (NSDDateTime, error) {
m := nsdDateTimeRegex.FindStringSubmatch(s)
if m == nil {
return NSDDateTime{}, fmt.Errorf("nsdxml: NSDDateTime %q не соответствует формату", s)
}
year, _ := strconv.Atoi(m[1])
month, _ := strconv.Atoi(m[2])
day, _ := strconv.Atoi(m[3])
hour, _ := strconv.Atoi(m[4])
min, _ := strconv.Atoi(m[5])
sec, _ := strconv.Atoi(m[6])
t := time.Date(year, time.Month(month), day, hour, min, sec, 0, moscowLocation)
out := NSDDateTime{Time: t}
if m[7] != "" {
val, _ := strconv.Atoi(m[9])
if m[8] == "-" {
val = -val
}
out.OffsetHours = val
out.OffsetSpecified = true
}
return out, nil
}
// MarshalText сериализует NSDDateTime в строку формата НРД.
func (d NSDDateTime) MarshalText() ([]byte, error) {
return []byte(d.String()), nil
}
// UnmarshalText разбирает строку формата НРД в NSDDateTime.
func (d *NSDDateTime) UnmarshalText(b []byte) error {
if len(b) == 0 {
return errors.New("nsdxml: пустое значение NSDDateTime")
}
parsed, err := parseNSDDateTime(string(b))
if err != nil {
return err
}
*d = parsed
return nil
}
// MarshalXML кодирует NSDDateTime в строку формата НРД.
func (d NSDDateTime) MarshalXML(e *xml.Encoder, start xml.StartElement) error {
return e.EncodeElement(d.String(), start)
}
// UnmarshalXML декодирует строку формата НРД в NSDDateTime.
func (d *NSDDateTime) UnmarshalXML(dec *xml.Decoder, start xml.StartElement) error {
var s string
if err := dec.DecodeElement(&s, &start); err != nil {
return err
}
return d.UnmarshalText([]byte(s))
}
+97
View File
@@ -0,0 +1,97 @@
package nsdxml_test
import (
"encoding/xml"
"strings"
"testing"
"time"
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/nsdxml"
)
func TestNSDDateTimeParse(t *testing.T) {
cases := []struct {
in string
expectOffset int
expectSpecified bool
}{
{"2026-03-02T14:30:45(МСК)", 0, false},
{"2026-03-02T14:30:45(МСК+2)", 2, true},
{"2026-03-02T14:30:45(МСК-1)", -1, true},
{"2026-03-02T14:30:45(МСК+12)", 12, true},
}
for _, c := range cases {
c := c
t.Run(c.in, func(t *testing.T) {
var d nsdxml.NSDDateTime
if err := d.UnmarshalText([]byte(c.in)); err != nil {
t.Fatalf("UnmarshalText: %v", err)
}
if d.OffsetSpecified != c.expectSpecified {
t.Errorf("OffsetSpecified = %v, ожидалось %v", d.OffsetSpecified, c.expectSpecified)
}
if d.OffsetHours != c.expectOffset {
t.Errorf("OffsetHours = %d, ожидалось %d", d.OffsetHours, c.expectOffset)
}
out := d.String()
if out != c.in {
t.Errorf("String() = %q, ожидалось %q", out, c.in)
}
})
}
}
func TestNSDDateTimeNow(t *testing.T) {
d := nsdxml.Now()
if d.OffsetSpecified {
t.Errorf("Now() не должен задавать смещение")
}
if zone, _ := d.Time.Zone(); zone != "МСК" {
t.Errorf("Now() location зона = %q, ожидалось \"МСК\"", zone)
}
if time.Since(d.Time) > time.Minute {
t.Errorf("Now() вернул время, далёкое от текущего")
}
}
func TestNSDDateTimeXMLRoundTrip(t *testing.T) {
type wrap struct {
XMLName xml.Name `xml:"wrap"`
TS nsdxml.NSDDateTime `xml:"ts"`
}
src := `<wrap><ts>2026-03-02T14:30:45(МСК+2)</ts></wrap>`
var w wrap
if err := xml.Unmarshal([]byte(src), &w); err != nil {
t.Fatalf("Unmarshal: %v", err)
}
if w.TS.OffsetHours != 2 {
t.Errorf("OffsetHours = %d, ожидалось 2", w.TS.OffsetHours)
}
b, err := xml.Marshal(w)
if err != nil {
t.Fatalf("Marshal: %v", err)
}
if !strings.Contains(string(b), "2026-03-02T14:30:45(МСК+2)") {
t.Errorf("Marshal не сохранил формат: %s", b)
}
}
func TestNSDDateTimeParseErrors(t *testing.T) {
bad := []string{
"",
"2026-13-02T14:30:45(МСК)",
"2026-03-02 14:30:45(МСК)",
"2026-03-02T14:30:45",
"2026-03-02T14:30:45(MSK)",
"2026-03-02T14:30:45(МСК+200)",
}
for _, s := range bad {
s := s
t.Run(s, func(t *testing.T) {
var d nsdxml.NSDDateTime
if err := d.UnmarshalText([]byte(s)); err == nil {
t.Errorf("ожидалась ошибка для %q", s)
}
})
}
}