Files
Bridge-and-Join-s/internal/lkgateway/participant_form.go
T
fontvielle c021fb8273 Анкета участника M2M: сформировать → проверить → отправить в НРД (обычный канал)
Требование НРД при подключении к сервису M2M (МОСТ): зарегистрировать организацию
в справочнике участников анкетой M2MTransferParticipantForm, иначе сервис отбивает
запросы кодом M2M14. Регистрация идёт ОБЫЧНЫМ каналом (письмом на M2MOST@nsd.ru с
вложением), не через M2M-шлюз.

- Новая страница /admin/setup/participant-form (participant_form.go):
  предзаполнение из автоопределения DEPO (ИНН, наименования, код депозитария),
  правка оператором, валидация (формат ИНН/кодов + m2m.HandbookParticipant.Validate),
  предпросмотр анкеты, «Скачать» (windows-1251) и «Отправить в НРД» письмом с
  вложением-анкетой.
- Генерация XML строкой-шаблоном по образцу НРД (namespace participant/form,
  CreationTimestamp по МСК, блоки Eng/BrokerPlace — опциональны), кодировка
  windows-1251 через nsdxml.EncodeWindows1251; экранирование текста только & < >.
- Почта: выделен транспорт smtpDeliver из sendSMTP; добавлены buildMIMEAttach
  (multipart/mixed + base64-вложение) и sendSMTPAttach.
- Тесты: валидация, генерация XML (Eng/BrokerPlace), cp1251 round-trip, MIME-вложение.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XG3zNMMf2LMoDpXo22mwMX
2026-10-15 18:20:03 +03:00

356 lines
18 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package lkgateway
// participant_form.go — анкета участника сервиса M2M (M2MTransferParticipantForm).
// Требование НРД при подключении: зарегистрировать нашу организацию в справочнике
// участников M2M, иначе сервис МОСТ отбивает запросы кодом M2M14 «Код ЭДО НРД
// отправителя отсутствует в справочнике участников M2M». Здесь bj формирует
// заполненную анкету (предзаполнение из автоопределения DEPO), даёт проверить
// (валидация + показ XML в windows-1251) и предлагает отправить её в НРД обычным
// каналом — письмом на M2MOST@nsd.ru с вложением (не через M2M-шлюз).
import (
"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/nsdxml"
)
// participantFormRecipient — адрес НРД для регистрации участника M2M (обычный канал).
const participantFormRecipient = "M2MOST@nsd.ru"
var pfINNRe = regexp.MustCompile(`^\d{10}$`)
var pfCodeRe = regexp.MustCompile(`^MC\d{10}$`)
// 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 d.DepoCode == "" && rc != nil {
d.DepoCode = strings.TrimSpace(rc.Snapshot().NSD.DeponentCode)
}
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
}
// pfx — экранирование текста XML-элемента (только & < >); кавычки в тексте
// оставляем литеральными, как в образце анкеты НРД.
var pfXMLEsc = strings.NewReplacer("&", "&amp;", "<", "&lt;", ">", "&gt;")
func pfx(s string) string { return pfXMLEsc.Replace(strings.TrimSpace(s)) }
// participantFormXML — анкета в виде XML-строки (UTF-8) по образцу НРД
// (M2MTransferParticipantForm.xsd). CreationTimestamp — по МСК.
func participantFormXML(d participantFormData) string {
msk := time.FixedZone("МСК", 3*3600)
ts := time.Now().In(msk).Format("2006-01-02T15:04:05") + "(МСК)"
var b strings.Builder
b.WriteString(`<?xml version="1.0" encoding="windows-1251"?>` + "\n")
b.WriteString(`<pf:M2MTransferParticipantForm xmlns:m2m="http://nsd.ru/schemas/m2m/types" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:pf="http://nsd.ru/schemas/m2m/participant/form" xsi:schemaLocation="http://nsd.ru/schemas/m2m/participant/form M2MTransferParticipantForm.xsd">` + "\n")
b.WriteString("\t<pf:CreationTimestamp>" + ts + "</pf:CreationTimestamp>\n")
b.WriteString("\t<pf:Participant>\n")
b.WriteString("\t\t<m2m:INN>" + pfx(d.INN) + "</m2m:INN>\n")
b.WriteString("\t\t<m2m:Names>\n")
b.WriteString("\t\t\t<m2m:Rus>\n")
b.WriteString("\t\t\t\t<m2m:FullName>" + pfx(d.FullName) + "</m2m:FullName>\n")
if strings.TrimSpace(d.ShortName) != "" {
b.WriteString("\t\t\t\t<m2m:ShortName>" + pfx(d.ShortName) + "</m2m:ShortName>\n")
}
b.WriteString("\t\t\t\t<m2m:DisplayName>" + pfx(d.DisplayName) + "</m2m:DisplayName>\n")
b.WriteString("\t\t\t</m2m:Rus>\n")
if strings.TrimSpace(d.EngFull) != "" || strings.TrimSpace(d.EngDisplay) != "" {
b.WriteString("\t\t\t<m2m:Eng>\n")
b.WriteString("\t\t\t\t<m2m:FullName>" + pfx(d.EngFull) + "</m2m:FullName>\n")
if strings.TrimSpace(d.EngShort) != "" {
b.WriteString("\t\t\t\t<m2m:ShortName>" + pfx(d.EngShort) + "</m2m:ShortName>\n")
}
disp := d.EngDisplay
if strings.TrimSpace(disp) == "" {
disp = d.EngShort
}
b.WriteString("\t\t\t\t<m2m:DisplayName>" + pfx(disp) + "</m2m:DisplayName>\n")
b.WriteString("\t\t\t</m2m:Eng>\n")
}
b.WriteString("\t\t</m2m:Names>\n")
b.WriteString("\t\t<m2m:DepositoryPlace>\n")
b.WriteString("\t\t\t<m2m:ParticipantCode>" + pfx(d.DepoCode) + "</m2m:ParticipantCode>\n")
b.WriteString("\t\t</m2m:DepositoryPlace>\n")
if strings.TrimSpace(d.BrokerCode) != "" {
b.WriteString("\t\t<m2m:BrokerPlace>\n")
b.WriteString("\t\t\t<m2m:ParticipantCode>" + pfx(d.BrokerCode) + "</m2m:ParticipantCode>\n")
b.WriteString("\t\t</m2m:BrokerPlace>\n")
}
b.WriteString("\t</pf:Participant>\n")
b.WriteString("</pf:M2MTransferParticipantForm>\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) {
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"),
}
}
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"
}
}
xmlPreview := participantFormXML(d)
participantFormRender(w, d, errs, action, flash, flashKind, xmlPreview)
})
}
func participantFormRender(w http.ResponseWriter, d participantFormData, errs []string, action, flash, flashKind, xmlPreview string) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
inp := func(name, label, val, ph string) string {
return `<label>` + label + `<input name="` + name + `" value="` + html.EscapeString(val) + `" placeholder="` + ph + `"></label>`
}
var vErr string
if (action == "preview" || action == "send") && len(errs) > 0 {
vErr = `<div class="bad"><b>Проверка не пройдена:</b><ul><li>` + strings.Join(escapeAll(errs), "</li><li>") + `</li></ul></div>`
} else if action == "preview" {
vErr = `<div class="ok">Проверка пройдена — анкета корректна.</div>`
}
var flashHTML string
if flash != "" {
cls := "ok"
if flashKind == "err" {
cls = "bad"
}
flashHTML = `<div class="` + cls + `">` + html.EscapeString(flash) + `</div>`
}
page := `<!doctype html><html lang="ru"><head><meta charset="utf-8"><title>Регистрация участника M2M</title>
<style>body{font:14px/1.5 system-ui,Arial;margin:24px;max-width:900px;color:#111}
h1{font-size:20px}label{display:block;margin:8px 0}label input{display:block;width:100%;padding:6px;margin-top:2px;box-sizing:border-box}
.row{display:flex;gap:16px}.row>label{flex:1}fieldset{border:1px solid #ccc;border-radius:8px;margin:14px 0;padding:10px 14px}
legend{color:#555;padding:0 6px}.btns{margin:16px 0;display:flex;gap:10px;flex-wrap:wrap}
button{padding:8px 16px;border:1px solid #888;border-radius:6px;background:#f3f3f3;cursor:pointer}
button.primary{background:#1763cc;color:#fff;border-color:#1763cc}
pre{background:#f6f8fa;border:1px solid #ddd;border-radius:8px;padding:12px;overflow:auto;white-space:pre-wrap}
.ok{background:#e7f6e7;border:1px solid #58a858;padding:8px 12px;border-radius:6px;margin:10px 0}
.bad{background:#fdecec;border:1px solid #d66;padding:8px 12px;border-radius:6px;margin:10px 0}
.note{color:#555;font-size:13px}</style></head><body>
<h1>Регистрация участника сервиса M2M (МОСТ)</h1>
<p class="note">Требование НРД при подключении: зарегистрировать организацию в справочнике участников M2M.
Без регистрации сервис отбивает запросы кодом <b>M2M14</b>. Отправляется <b>обычным каналом</b> — письмом
на <code>` + participantFormRecipient + `</code> с вложенной анкетой (не через M2M-шлюз). Поля предзаполнены
из автоопределения DEPO — проверьте юридические реквизиты.</p>
` + flashHTML + vErr + `
<form method="post">
<fieldset><legend>Организация</legend>
` + inp("inn", "ИНН (10 цифр)", d.INN, "7700000000") + `
` + inp("full", "Полное наименование (по уставу)", d.FullName, "") + `
<div class="row">` + inp("short", "Сокращённое", d.ShortName, "") + inp("display", "Отображаемое (DisplayName)", d.DisplayName, "") + `</div>
</fieldset>
<fieldset><legend>Английские наименования (опционально)</legend>
` + inp("eng_full", "Full name", d.EngFull, "") + `
<div class="row">` + inp("eng_short", "Short name", d.EngShort, "") + inp("eng_display", "Display name", d.EngDisplay, "") + `</div>
</fieldset>
<fieldset><legend>Коды участника (MC + 10 цифр)</legend>
<div class="row">` + inp("depo", "Код депозитария (DepositoryPlace)", d.DepoCode, "MC0000000000") + inp("broker", "Код брокера (BrokerPlace, если брокер)", d.BrokerCode, "оставьте пустым, если не брокер") + `</div>
</fieldset>
<fieldset><legend>Отправка</legend>
` + inp("to", "Кому (НРД)", participantFormRecipient, participantFormRecipient) + `
</fieldset>
<div class="btns">
<button type="submit" name="action" value="preview">Проверить</button>
<button type="submit" name="action" value="download" formtarget="_blank">Скачать анкету (XML)</button>
<button type="submit" name="action" value="send" class="primary">Отправить в НРД (письмом)</button>
</div>
</form>
<h3>Предпросмотр анкеты (windows-1251)</h3>
<pre>` + html.EscapeString(xmlPreview) + `</pre>
</body></html>`
_, _ = 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
}