890e575d3d
Автоопределение читало исходную запись лица (2016) и брало MNEM как краткое наименование. Теперь — действующая версия (SELF_ID/LAST_FLAG=1): краткое — OD_FACES.NAME, полное — OD_U_FACES.FULL_NAME, английские — ENG_FULL_NAME и ENG_NAME. Анкета участника заполняется из DEPO без справочника НРД; английские — только парой. Проверено на DEPO 28: полное есть у 141 из 145 участников, английские после замены кавычек совпали с ответом НРД. Если OD_U_FACES нет — прежний запрос и подсказка уточнить у НРД. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q7eREaYwUt1i4fNLZcGnjn
594 lines
32 KiB
Go
594 lines
32 KiB
Go
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
|
||
r := depoDetectResult()
|
||
if r == nil && rc != nil && strings.TrimSpace(rc.Snapshot().Fancy.Server) != "" {
|
||
// после перезапуска bj автоопределения в памяти нет — прочитать DEPO сейчас
|
||
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
|
||
if res, err := depoDetectRequisites(ctx, rc.Snapshot().Fancy); err == nil {
|
||
lastDepoDetect.mu.Lock()
|
||
lastDepoDetect.r = res
|
||
lastDepoDetect.mu.Unlock()
|
||
r = res
|
||
}
|
||
cancel()
|
||
}
|
||
if r != nil {
|
||
// DEPO: полное — OD_U_FACES, краткое — OD_FACES.NAME, отображаемое = краткое (так советует НРД);
|
||
// английские — только парой (полное + краткое), иначе их не указываем
|
||
d.INN = strings.TrimSpace(r.OrgINN)
|
||
d.FullName = strings.TrimSpace(pick(r.OrgFull, r.OrgName))
|
||
d.ShortName = strings.TrimSpace(r.OrgName)
|
||
d.DisplayName = strings.TrimSpace(r.OrgName)
|
||
if strings.TrimSpace(r.OrgEngFull) != "" {
|
||
d.EngFull = strings.TrimSpace(r.OrgEngFull)
|
||
d.EngShort = strings.TrimSpace(r.OrgEng)
|
||
d.EngDisplay = d.EngShort
|
||
}
|
||
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).
|
||
// Английские — из DEPO: в справочнике НРД есть лишь краткое.
|
||
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 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
|
||
}
|
||
|
||
// pfAbbrRe — наименование начинается с сокращённой формы (ООО, АО…) — для полного это ошибка.
|
||
var pfAbbrRe = regexp.MustCompile(`^(ООО|ОАО|ЗАО|ПАО|НАО|АО|АКБ|КБ|НКО|ИК)[\s"«]`)
|
||
|
||
// pfAskNSD — текст запроса в НРД о точных наименованиях участника (ответ НРД — источник истины).
|
||
func pfAskNSD(code string) string {
|
||
if code == "" {
|
||
code = "наш код участника"
|
||
}
|
||
return "Просим сообщить точное наименование, под которым участник " + code +
|
||
" зарегистрирован в клиентской базе НРД: полное (FullName), сокращённое (ShortName), " +
|
||
"а также английские полное и сокращённое, если они есть."
|
||
}
|
||
|
||
// pfNameCheck — что в наименованиях надо уточнить у НРД. НРД сверяет их со своей клиентской
|
||
// базой (M2M37): точный источник — карточка нашей фирмы в справочнике НРД или ответ НРД;
|
||
// данные DEPO — только черновик.
|
||
func pfNameCheck(rc *RuntimeConfig, d participantFormData) []string {
|
||
var w []string
|
||
hb, ok := participantCard(rc, d.INN)
|
||
switch {
|
||
case ok && strings.TrimSpace(hb.FullName) != "":
|
||
x := participantFormData{FullName: hb.FullName}
|
||
pfNames(&x)
|
||
if x.FullName != d.FullName {
|
||
w = append(w, "Полное наименование отличается от карточки нашей фирмы в справочнике НРД: «"+x.FullName+"». НРД сверяет с ней — без причины не меняйте.")
|
||
}
|
||
case strings.TrimSpace(d.FullName) == "":
|
||
w = append(w, "Полного наименования нет ни в DEPO, ни в справочнике НРД — уточните его у НРД и впишите как есть.")
|
||
}
|
||
if pfAbbrRe.MatchString(d.FullName) || (d.FullName != "" && d.FullName == d.ShortName) {
|
||
w = append(w, "Полное наименование похоже на сокращённое — нужно полное («Общество с ограниченной ответственностью …»), как в клиентской базе НРД.")
|
||
}
|
||
if (d.EngShort != "" || d.EngDisplay != "") && d.EngFull == "" {
|
||
w = append(w, "Английские: указано краткое без полного — уточните полное у НРД или оставьте английские пустыми.")
|
||
}
|
||
return w
|
||
}
|
||
|
||
// participantCard — карточка нашей фирмы в справочнике НРД: по MC-коду, затем по ИНН.
|
||
func participantCard(rc *RuntimeConfig, inn string) (HandbookEntry, bool) {
|
||
if rc == nil {
|
||
return HandbookEntry{}, false
|
||
}
|
||
s := rc.Snapshot()
|
||
if hb, ok := handbookByCode(orgCode(s)); ok {
|
||
return hb, true
|
||
}
|
||
if inn = strings.TrimSpace(pick(inn, orgINN(s))); inn != "" {
|
||
return handbookByINN(inn)
|
||
}
|
||
return HandbookEntry{}, false
|
||
}
|
||
|
||
// 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(`<?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, 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)
|
||
reg := participantRegStatus(rc)
|
||
if ws := pfNameCheck(rc, d); len(ws) > 0 {
|
||
code := ""
|
||
if rc != nil {
|
||
code = orgCode(rc.Snapshot())
|
||
}
|
||
reg += `<div class="warn"><b>Уточните у НРД:</b><ul><li>` + strings.Join(escapeAll(ws), "</li><li>") +
|
||
`</li></ul>Запрос в НРД (можно скопировать):<pre>` + html.EscapeString(pfAskNSD(code)) + `</pre></div>`
|
||
}
|
||
participantFormRender(w, d, errs, action, flash, flashKind, xmlPreview, reg, 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(`<div class="note">Анкета отправлена через ИШ ` + 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(`<b style="color:#b00">НРД отклонил</b> ` + reg.RespAt.Format("02.01 15:04") + `: ` + html.EscapeString(reg.RespText) + `. Исправьте указанное и отправьте снова.`)
|
||
if containsStr(reg.Codes, "M2M37") {
|
||
out.WriteString(` Наименование не совпало с клиентской базой НРД — запросите у НРД точные наименования (текст запроса ниже) и впишите их без изменений.`)
|
||
}
|
||
default:
|
||
out.WriteString(`<b style="color:#2a7a2a">НРД принял</b> ` + reg.RespAt.Format("02.01 15:04") + `. ` + html.EscapeString(reg.RespText))
|
||
}
|
||
out.WriteString(`</div>`)
|
||
}
|
||
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(`<div class="ok">В справочнике участников M2M ` + contour + ` контура: <b>есть</b> — ` +
|
||
html.EscapeString(pickStr(hb.Name, hb.FullName)) + `, код ` + html.EscapeString(hb.Code) +
|
||
`, ИНН ` + html.EscapeString(hb.INN) + `. Регистрация подтверждена.</div>`)
|
||
return out.String()
|
||
}
|
||
out.WriteString(`<div class="note">В справочнике участников M2M ` + contour + ` контура нашей организации пока <b>нет</b>. ` +
|
||
`После приёма анкеты НРД организация появится в справочнике (обновляется каждые ~10 минут) — тогда здесь будет «есть». ` +
|
||
`Регистрацию подтверждает также ответный документ от НРД.</div>`)
|
||
return out.String()
|
||
}
|
||
|
||
func participantFormRender(w http.ResponseWriter, d participantFormData, errs []string, action, flash, flashKind, xmlPreview, regHTML string, igwConfigured, igwProd bool) {
|
||
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>`
|
||
}
|
||
igwBtn := `<button type="submit" name="action" value="send_igw" class="primary">Отправить через шлюз (боевой ИШ)</button>`
|
||
igwStatus := `<p class="note">Боевой контур: <b style="color:#2a7a2a">да</b> — анкету можно отправить через шлюз (#M2MPF).</p>`
|
||
if !igwProd {
|
||
igwBtn = `<button type="button" class="primary" disabled title="Доступно только в боевом контуре">Отправить через шлюз (только в бою)</button>`
|
||
if igwConfigured {
|
||
igwStatus = `<p class="note">Контур <b>тестовый</b> — анкету участника в НРД шлём <b>только в боевом</b> контуре. Переключитесь на профиль <code>prod-*</code> («НРД»). Пока доступны «Скачать» и «Отправить письмом».</p>`
|
||
} else {
|
||
igwStatus = `<p class="note">Боевой ИШ: <b style="color:#b00">не настроен</b> (раздел «НРД», профиль <code>prod-*</code>). Пока доступны «Скачать» и «Отправить письмом».</p>`
|
||
}
|
||
}
|
||
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}
|
||
.warn{background:#fff7e0;border:1px solid #e0b040;padding:8px 12px;border-radius:6px;margin:10px 0}.warn pre{margin:6px 0 0}</style></head><body>
|
||
<h1>Регистрация участника сервиса M2M (МОСТ)</h1>
|
||
<p class="note">Требование НРД при подключении: зарегистрировать организацию в справочнике участников M2M.
|
||
Без регистрации сервис отбивает запросы кодом <b>M2M14</b>. В боевом контуре анкета подаётся <b>через ИШ</b>
|
||
пакетом <code>#M2MPF</code> (businessLine=MOST, подпись боевым контейнером на стороне ИШ); письмо на
|
||
<code>` + participantFormRecipient + `</code> оставлено запасным каналом. Наименования должны точно совпадать с
|
||
клиентской базой НРД (иначе <b>M2M37</b>): bj берёт их из карточки нашей фирмы в справочнике НРД и из DEPO (полное — карточка юрлица).
|
||
Чего нет или в чём bj не уверен — уточните у НРД. Кавычки — прямые <code>"</code>.</p>
|
||
` + regHTML + 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>
|
||
` + igwStatus + 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>
|
||
` + igwBtn + `
|
||
<button type="submit" name="action" value="send">Отправить письмом (запасной канал)</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
|
||
}
|