package lkgateway
// participant_form.go — анкета участника сервиса M2M (M2MTransferParticipantForm).
// Требование НРД при подключении: зарегистрировать нашу организацию в справочнике
// участников M2M, иначе сервис МОСТ отбивает запросы кодом M2M14 «Код ЭДО НРД
// отправителя отсутствует в справочнике участников M2M». Здесь bj формирует
// заполненную анкету (предзаполнение из автоопределения DEPO), даёт проверить
// (валидация + показ XML в windows-1251) и отправляет её в НРД: в боевом контуре —
// через ИШ пакетом #M2MPF (businessLine=MOST, подпись боевым контейнером на стороне
// ИШ), либо запасным каналом — письмом на M2MOST@nsd.ru с вложением.
import (
"context"
"encoding/base64"
"fmt"
"html"
"net/http"
"regexp"
"strings"
"time"
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/m2m"
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/nsdadapter"
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/nsdadapter/igw"
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/nsdxml"
)
// participantFormRecipient — адрес НРД для регистрации участника M2M (обычный канал).
const participantFormRecipient = "M2MOST@nsd.ru"
var pfINNRe = regexp.MustCompile(`^\d{10}$`)
var pfCodeRe = regexp.MustCompile(`^MC\d{10}$`)
// isProdIGW — текущий канал ИШ боевой. Анкету участника в НРД шлём ТОЛЬКО в бою:
// в тестовом/гостевом контуре отправка через шлюз заблокирована.
func isProdIGW(channel string) bool {
return strings.HasPrefix(strings.ToUpper(strings.TrimSpace(channel)), "PROD")
}
// participantFormData — поля анкеты (юридические реквизиты организации).
type participantFormData struct {
INN string
FullName string
ShortName string
DisplayName string
EngFull string
EngShort string
EngDisplay string
DepoCode string // DepositoryPlace/ParticipantCode (MC+10)
BrokerCode string // BrokerPlace/ParticipantCode (MC+10), опционально
}
// participantFormPrefill — предзаполнение из автоопределения DEPO и настроек НРД.
func participantFormPrefill(rc *RuntimeConfig) participantFormData {
var d participantFormData
if r := depoDetectResult(); r != nil {
d.INN = strings.TrimSpace(r.OrgINN)
d.FullName = strings.TrimSpace(r.OrgName)
d.ShortName = strings.TrimSpace(r.OrgShort)
d.DisplayName = strings.TrimSpace(r.OrgShort)
d.EngFull = strings.TrimSpace(r.OrgEng)
if d.DepoCode == "" {
d.DepoCode = strings.TrimSpace(r.DeponentCode)
}
}
if rc != nil {
s := rc.Snapshot()
// Реквизиты нашей организации из настроек НРД и справочника участников —
// заполняем то, чего не дало автоопределение DEPO.
if d.INN == "" {
d.INN = orgINN(s)
}
if d.DepoCode == "" {
d.DepoCode = strings.ToUpper(strings.TrimSpace(pick(s.NSD.DeponentCode, s.NSD.OrgCode)))
}
// Карточка нашей фирмы в справочнике участников НРД: по MC-коду, затем по ИНН.
hb, ok := handbookByCode(orgCode(s))
if !ok && d.INN != "" {
hb, ok = handbookByINN(d.INN)
}
if ok {
// НРД сверяет наименования анкеты со своей клиентской базой (M2M37) — карточка в справочнике
// НРД главнее DEPO: в DEPO «наименование» бывает кратким (21.10.2026 краткое ушло в FullName).
// Английские — только если их ввели: в справочнике есть лишь краткое, полного нет.
if n := strings.TrimSpace(hb.FullName); n != "" {
d.FullName = n
}
if n := strings.TrimSpace(hb.Name); n != "" && hb.FullName != "" {
d.ShortName, d.DisplayName = n, n // отображаемое НРД советует как краткое
}
if strings.TrimSpace(hb.FullName) != "" {
d.EngFull = ""
} else if d.EngFull == "" {
d.EngFull = strings.TrimSpace(hb.EngName)
}
if hb.Kind == "broker" && d.BrokerCode == "" {
d.BrokerCode = strings.ToUpper(strings.TrimSpace(hb.Code))
}
}
if d.FullName == "" {
d.FullName = strings.TrimSpace(s.NSD.OrgName)
}
}
// DisplayName/Short обязательны в анкете — подставим из того, что есть.
if d.DisplayName == "" {
d.DisplayName = strings.TrimSpace(pick(d.ShortName, d.FullName))
}
if d.ShortName == "" {
d.ShortName = strings.TrimSpace(pick(d.DisplayName, d.FullName))
}
return d
}
// participantFormValidate — проверка обязательных полей и форматов. Пустой срез — ок.
func participantFormValidate(d participantFormData) []string {
var errs []string
if !pfINNRe.MatchString(strings.TrimSpace(d.INN)) {
errs = append(errs, "ИНН: нужно 10 цифр (юридическое лицо)")
}
if strings.TrimSpace(d.FullName) == "" {
errs = append(errs, "Полное наименование: обязательно")
}
if strings.TrimSpace(d.DisplayName) == "" {
errs = append(errs, "Отображаемое наименование (DisplayName): обязательно")
}
if !pfCodeRe.MatchString(strings.TrimSpace(d.DepoCode)) {
errs = append(errs, "Код депозитария (DepositoryPlace): формат MC + 10 цифр")
}
if strings.TrimSpace(d.BrokerCode) != "" && !pfCodeRe.MatchString(strings.TrimSpace(d.BrokerCode)) {
errs = append(errs, "Код брокера (BrokerPlace): формат MC + 10 цифр (или оставьте пустым)")
}
// Структурная проверка типами НРД (ИНН/коды).
p := m2m.HandbookParticipant{
INN: m2m.OrganizationINN(strings.TrimSpace(d.INN)),
Names: m2m.HandbookNames{Rus: m2m.HandbookRusName{FullName: d.FullName, ShortName: d.ShortName, DisplayName: d.DisplayName}},
}
if c := strings.TrimSpace(d.DepoCode); c != "" {
p.DepositoryPlace = &m2m.DepositoryPlaces{ParticipantCode: m2m.DeponentCode(c)}
}
if c := strings.TrimSpace(d.BrokerCode); c != "" {
p.BrokerPlace = &m2m.BrokerPlaces{ParticipantCode: m2m.DeponentCode(c)}
}
if err := p.Validate(); err != nil && len(errs) == 0 {
errs = append(errs, "Проверка НРД: "+err.Error())
}
return errs
}
// pfQuotes — кавычки как в клиентской базе НРД: прямые ".
var pfQuotes = strings.NewReplacer("«", `"`, "»", `"`, "“", `"`, "”", `"`, "„", `"`)
// pfNames — наименования к виду базы НРД (кавычки, лишние пробелы).
func pfNames(d *participantFormData) {
for _, p := range []*string{&d.FullName, &d.ShortName, &d.DisplayName, &d.EngFull, &d.EngShort, &d.EngDisplay} {
*p = strings.Join(strings.Fields(pfQuotes.Replace(*p)), " ")
}
}
// pfx — экранирование текста XML-элемента (только & < >); кавычки в тексте
// оставляем литеральными, как в образце анкеты НРД.
var pfXMLEsc = strings.NewReplacer("&", "&", "<", "<", ">", ">")
func pfx(s string) string { return pfXMLEsc.Replace(strings.TrimSpace(s)) }
// participantFormXML — анкета в виде XML-строки (UTF-8) по образцу НРД
// (M2MTransferParticipantForm.xsd). CreationTimestamp — по МСК.
func participantFormXML(d participantFormData) string {
pfNames(&d)
msk := time.FixedZone("МСК", 3*3600)
ts := time.Now().In(msk).Format("2006-01-02T15:04:05") + "(МСК)"
var b strings.Builder
b.WriteString(`` + "\n")
b.WriteString(`` + "\n")
b.WriteString("\t" + ts + "\n")
b.WriteString("\t\n")
b.WriteString("\t\t" + pfx(d.INN) + "\n")
b.WriteString("\t\t\n")
b.WriteString("\t\t\t\n")
b.WriteString("\t\t\t\t" + pfx(d.FullName) + "\n")
if strings.TrimSpace(d.ShortName) != "" {
b.WriteString("\t\t\t\t" + pfx(d.ShortName) + "\n")
}
b.WriteString("\t\t\t\t" + pfx(d.DisplayName) + "\n")
b.WriteString("\t\t\t\n")
if strings.TrimSpace(d.EngFull) != "" || strings.TrimSpace(d.EngDisplay) != "" {
b.WriteString("\t\t\t\n")
b.WriteString("\t\t\t\t" + pfx(d.EngFull) + "\n")
if strings.TrimSpace(d.EngShort) != "" {
b.WriteString("\t\t\t\t" + pfx(d.EngShort) + "\n")
}
disp := d.EngDisplay
if strings.TrimSpace(disp) == "" {
disp = d.EngShort
}
b.WriteString("\t\t\t\t" + pfx(disp) + "\n")
b.WriteString("\t\t\t\n")
}
b.WriteString("\t\t\n")
b.WriteString("\t\t\n")
b.WriteString("\t\t\t" + pfx(d.DepoCode) + "\n")
b.WriteString("\t\t\n")
if strings.TrimSpace(d.BrokerCode) != "" {
b.WriteString("\t\t\n")
b.WriteString("\t\t\t" + pfx(d.BrokerCode) + "\n")
b.WriteString("\t\t\n")
}
b.WriteString("\t\n")
b.WriteString("\n")
return b.String()
}
// participantFormBytes — анкета в windows-1251 (как требует НРД: кодировка из пролога).
func participantFormBytes(d participantFormData) ([]byte, error) {
return nsdxml.EncodeWindows1251([]byte(participantFormXML(d)))
}
// participantEmailBody — текст письма на M2MOST@nsd.ru (обычный канал регистрации).
func participantEmailBody(d participantFormData) string {
var b strings.Builder
b.WriteString("Добрый день!\r\n\r\n")
b.WriteString("Просим зарегистрировать нашу организацию в справочнике участников сервиса M2M (МОСТ).\r\n")
b.WriteString("Заполненная анкета участника (M2MTransferParticipantForm) во вложении.\r\n\r\n")
b.WriteString("Реквизиты:\r\n")
b.WriteString(" - Депозитарный код участника: " + strings.TrimSpace(d.DepoCode) + "\r\n")
if strings.TrimSpace(d.BrokerCode) != "" {
b.WriteString(" - Брокерский код участника: " + strings.TrimSpace(d.BrokerCode) + "\r\n")
}
b.WriteString(" - Полное наименование: " + strings.TrimSpace(d.FullName) + "\r\n")
b.WriteString(" - ИНН: " + strings.TrimSpace(d.INN) + "\r\n\r\n")
b.WriteString("После регистрации просим подтвердить.\r\n\r\n")
b.WriteString("С уважением,\r\n" + strings.TrimSpace(d.ShortName) + "\r\n")
return b.String()
}
// buildMIMEAttach — MIME multipart/mixed: текст письма (UTF-8) + вложение (base64).
func buildMIMEAttach(from string, to []string, subject, body, filename string, attach []byte, ctype string) []byte {
boundary := fmt.Sprintf("bjpart_%d", time.Now().UnixNano())
var b strings.Builder
b.WriteString("From: " + from + "\r\n")
b.WriteString("To: " + strings.Join(to, ", ") + "\r\n")
b.WriteString("Subject: " + encodeHeaderWord(subject) + "\r\n")
b.WriteString("MIME-Version: 1.0\r\n")
b.WriteString("Content-Type: multipart/mixed; boundary=\"" + boundary + "\"\r\n\r\n")
b.WriteString("--" + boundary + "\r\n")
b.WriteString("Content-Type: text/plain; charset=UTF-8\r\n")
b.WriteString("Content-Transfer-Encoding: 8bit\r\n\r\n")
b.WriteString(body + "\r\n")
b.WriteString("--" + boundary + "\r\n")
b.WriteString("Content-Type: " + ctype + "; name=\"" + filename + "\"\r\n")
b.WriteString("Content-Transfer-Encoding: base64\r\n")
b.WriteString("Content-Disposition: attachment; filename=\"" + filename + "\"\r\n\r\n")
enc := base64.StdEncoding.EncodeToString(attach)
for i := 0; i < len(enc); i += 76 {
j := i + 76
if j > len(enc) {
j = len(enc)
}
b.WriteString(enc[i:j] + "\r\n")
}
b.WriteString("--" + boundary + "--\r\n")
return []byte(b.String())
}
// sendSMTPAttach — письмо с вложением (переиспользует транспорт smtpDeliver).
func sendSMTPAttach(s SMTPSettings, subject, body, filename string, attach []byte, ctype string) error {
if strings.TrimSpace(s.Host) == "" {
return fmt.Errorf("не задан хост SMTP")
}
if strings.TrimSpace(s.From) == "" {
return fmt.Errorf("не задан отправитель (From)")
}
rcpts := splitRecipients(s.To)
if len(rcpts) == 0 {
return fmt.Errorf("не заданы получатели (To)")
}
return smtpDeliver(s, rcpts, buildMIMEAttach(s.From, rcpts, subject, body, filename, attach, ctype))
}
// registerParticipantForm — страница «Регистрация участника M2M»: форма, проверка,
// скачивание анкеты, отправка письмом на M2MOST@nsd.ru.
func registerParticipantForm(mux *http.ServeMux, rc *RuntimeConfig, igwClient *igw.Client, igwChannel string) {
mux.HandleFunc("/admin/setup/participant-form", func(w http.ResponseWriter, r *http.Request) {
d := participantFormPrefill(rc)
action := ""
if r.Method == http.MethodPost {
_ = r.ParseForm()
action = r.FormValue("action")
d = participantFormData{
INN: r.FormValue("inn"), FullName: r.FormValue("full"), ShortName: r.FormValue("short"),
DisplayName: r.FormValue("display"), EngFull: r.FormValue("eng_full"), EngShort: r.FormValue("eng_short"),
EngDisplay: r.FormValue("eng_display"), DepoCode: r.FormValue("depo"), BrokerCode: r.FormValue("broker"),
}
}
pfNames(&d) // кавычки и пробелы — как в базе НРД, и в анкете, и в форме, и в статусе
errs := participantFormValidate(d)
if action == "download" {
if len(errs) > 0 {
http.Error(w, "анкета не прошла проверку: "+strings.Join(errs, "; "), http.StatusBadRequest)
return
}
body, err := participantFormBytes(d)
if err != nil {
http.Error(w, "кодировка windows-1251: "+err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/xml; charset=windows-1251")
w.Header().Set("Content-Disposition", `attachment; filename="M2MTransferParticipantForm.xml"`)
_, _ = w.Write(body)
return
}
var flash, flashKind string
if action == "send" {
to := strings.TrimSpace(r.FormValue("to"))
if to == "" {
to = participantFormRecipient
}
switch {
case len(errs) > 0:
flash, flashKind = "Анкета не прошла проверку — исправьте поля ниже.", "err"
default:
cfg := rc.Snapshot()
s := cfg.Alerts.SMTP
if !s.Enabled || strings.TrimSpace(s.Host) == "" {
flash, flashKind = "SMTP не настроен (раздел «Почта») — отправить письмо нельзя. Пока скачайте анкету и отправьте вручную.", "err"
break
}
s.To = to
body, encErr := participantFormBytes(d)
if encErr != nil {
flash, flashKind = "кодировка windows-1251: "+encErr.Error(), "err"
break
}
subject := "Регистрация участника сервиса M2M (МОСТ), код " + strings.TrimSpace(d.DepoCode)
if err := sendSMTPAttach(s, subject, participantEmailBody(d), "M2MTransferParticipantForm.xml", body, "application/xml; charset=windows-1251"); err != nil {
flash, flashKind = "письмо не отправлено: "+err.Error(), "err"
break
}
flash, flashKind = "Анкета отправлена письмом на "+to+" (с вложением). Ждём подтверждения регистрации от НРД.", "ok"
}
}
if action == "send_igw" {
switch {
case len(errs) > 0:
flash, flashKind = "Анкета не прошла проверку — исправьте поля ниже.", "err"
case igwClient == nil || igwChannel == "":
flash, flashKind = "Боевой ИШ не настроен (раздел «НРД», профиль prod-*). Отправка через шлюз недоступна — переключитесь в боевой контур.", "err"
case !isProdIGW(igwChannel):
flash, flashKind = "Сейчас выбран тестовый контур. Анкету участника шлём только в боевом контуре — переключитесь на профиль prod-* («НРД») и повторите.", "err"
default:
body, encErr := participantFormBytes(d)
if encErr != nil {
flash, flashKind = "кодировка windows-1251: "+encErr.Error(), "err"
break
}
pkg, _ := nsdadapter.RouteToPackageType(nsdadapter.KindParticipantForm)
zipBody, zErr := igw.PackXML(body, "M2MTransferParticipantForm.xml", string(pkg))
if zErr != nil {
flash, flashKind = "упаковка пакета: "+zErr.Error(), "err"
break
}
ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second)
id, sErr := igwClient.SendPackage(ctx, igwChannel, string(pkg), zipBody)
cancel()
if sErr != nil {
flash, flashKind = "отправка через ИШ не удалась: "+sErr.Error(), "err"
break
}
flash, flashKind = "Анкета отправлена через ИШ ("+string(pkg)+", пакет id="+id+"). Ждём подтверждения регистрации от НРД.", "ok"
saveParticipantReg(func(rg *participantReg) {
*rg = participantReg{SentAt: time.Now(), PkgID: id, Name: d.FullName, Channel: igwChannel}
})
}
}
xmlPreview := participantFormXML(d)
participantFormRender(w, d, errs, action, flash, flashKind, xmlPreview, participantRegStatus(rc), igwClient != nil && igwChannel != "", isProdIGW(igwChannel))
})
}
// participantRegStatus — подтверждение регистрации появлением в справочнике участников
// M2M текущего контура: если наша организация (по MC-коду или ИНН) уже в справочнике —
// анкета принята. Это второй сигнал, помимо ответного документа от НРД.
func participantRegStatus(rc *RuntimeConfig) string {
if rc == nil {
return ""
}
var out strings.Builder
// Последняя отправка анкеты и ответ НРД на неё (учёт в participant-reg.json).
if reg := loadParticipantReg(); !reg.SentAt.IsZero() {
out.WriteString(`
Анкета отправлена через ИШ ` + reg.SentAt.Format("02.01.2006 15:04") + `, пакет ` + html.EscapeString(reg.PkgID))
if reg.Name != "" {
out.WriteString(`, наименование «` + html.EscapeString(reg.Name) + `»`)
}
out.WriteString(`. `)
switch {
case reg.RespAt.IsZero():
out.WriteString(`Ответ НРД пока не получен.`)
case strings.EqualFold(reg.Status, "ERROR"):
out.WriteString(`НРД отклонил ` + reg.RespAt.Format("02.01 15:04") + `: ` + html.EscapeString(reg.RespText) + `. Исправьте указанное и отправьте снова.`)
default:
out.WriteString(`НРД принял ` + reg.RespAt.Format("02.01 15:04") + `. ` + html.EscapeString(reg.RespText))
}
out.WriteString(`
`)
}
s := rc.Snapshot()
contour := map[string]string{"prod": "боевого", "guest": "гостевого", "test3": "тестового"}[contourOf(currentNSDProfile())]
if contour == "" {
contour = "текущего"
}
hb, ok := handbookByCode(orgCode(s))
if !ok {
if inn := orgINN(s); inn != "" {
hb, ok = handbookByINN(inn)
}
}
if ok {
out.WriteString(`
В справочнике участников M2M ` + contour + ` контура: есть — ` +
html.EscapeString(pickStr(hb.Name, hb.FullName)) + `, код ` + html.EscapeString(hb.Code) +
`, ИНН ` + html.EscapeString(hb.INN) + `. Регистрация подтверждена.
`)
return out.String()
}
out.WriteString(`
В справочнике участников M2M ` + contour + ` контура нашей организации пока нет. ` +
`После приёма анкеты НРД организация появится в справочнике (обновляется каждые ~10 минут) — тогда здесь будет «есть». ` +
`Регистрацию подтверждает также ответный документ от НРД.
`
}
var flashHTML string
if flash != "" {
cls := "ok"
if flashKind == "err" {
cls = "bad"
}
flashHTML = `
` + html.EscapeString(flash) + `
`
}
igwBtn := ``
igwStatus := `
Боевой контур: да — анкету можно отправить через шлюз (#M2MPF).
`
if !igwProd {
igwBtn = ``
if igwConfigured {
igwStatus = `
Контур тестовый — анкету участника в НРД шлём только в боевом контуре. Переключитесь на профиль prod-* («НРД»). Пока доступны «Скачать» и «Отправить письмом».
`
} else {
igwStatus = `
Боевой ИШ: не настроен (раздел «НРД», профиль prod-*). Пока доступны «Скачать» и «Отправить письмом».
`
}
}
page := `Регистрация участника M2M
Регистрация участника сервиса M2M (МОСТ)
Требование НРД при подключении: зарегистрировать организацию в справочнике участников M2M.
Без регистрации сервис отбивает запросы кодом M2M14. В боевом контуре анкета подаётся через ИШ
пакетом #M2MPF (businessLine=MOST, подпись боевым контейнером на стороне ИШ); письмо на
` + participantFormRecipient + ` оставлено запасным каналом. Поля предзаполнены из
автоопределения DEPO — проверьте юридические реквизиты.
` + regHTML + flashHTML + vErr + `
Предпросмотр анкеты (windows-1251)
` + html.EscapeString(xmlPreview) + `
`
_, _ = w.Write([]byte(page))
}
func escapeAll(ss []string) []string {
out := make([]string, len(ss))
for i, s := range ss {
out[i] = html.EscapeString(s)
}
return out
}