Compare commits

...

2 Commits

Author SHA1 Message Date
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
fontvielle c476cfd7a3 ЛК-карточка и M2M: операторский путь заявки, баннеры «в НРД не отправлено», дубль-поручение, idempotency, фикс DEPO-алертов
- Карточка заявки: операторский «Путь заявки» (claimTimelineOperator) короткими
  понятными вехами + сворачиваемый технический журнал (buildClaimTimeline);
  QUIK показан пошагово (register/sign/deny/deposit) с верными временами, отказ
  регистрации больше не выглядит успехом (quikActionFailTitles).
- Баннеры: «в НРД не отправлено», когда пакет сформирован, но не ушёл (нет
  submitted_to_nsd и не Fail); заголовок карточки и бейдж учитывают это; красный
  баннер QUIK при отказе, строгий «нет бумаг» только при реальном шорт/недостатке.
- НРД-ответ «Поручение с исходящим номером … уже существует» теперь REG_DUP
  (предупреждение-дубль, не красный «Отказ в регистрации»); не влияет на od.Rejected.
- Idempotency: submitguard.go (inflightSubmit) + защита точек подачи от
  двойного срабатывания (manual + auto-loop, двойной клик): submitOutgoingFor,
  submitIncomingOrderFor, QuikOutRegister (ранний возврат при registered/signed).
- DEPO-алерты: водяной знак depoFailAlertedID засеивается текущим MAX(ID) на
  старте процесса (Hlth03) — после рестарта bj больше не поднимает повторно
  алерты про уже известные ошибки обработки событий BJ_*.
- Тесты: claimnotsent_render_test.go (хелперы + рендер карточки).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XG3zNMMf2LMoDpXo22mwMX
2026-10-15 17:47:28 +03:00
16 changed files with 1015 additions and 13 deletions
+68 -3
View File
@@ -512,6 +512,63 @@ type claimData struct {
SubstAs string
Flash string
Fail *SendFailure // последняя неудачная отправка в НРД (sendfail.go)
// SentToNSD — пакет M2MTransferRequest действительно ушёл в НРД (сделка прошла
// стадию submitted_to_nsd). false → заявка остановлена ДО отправки (например,
// отказ на проверке в QUIK МНП): в НРД по ней ничего не отправлялось.
SentToNSD bool
// QuikFailISIN/QuikFailReply — если регистрация «Вывод ЦБ (M2M)» в QUIK МНП не
// прошла (бумаги не удалось заблокировать), это ISIN и дословный ответ модуля QUIK.
// Пусто — такой неудачи нет. QuikNoSec — ответ модуля однозначно про нехватку/
// отсутствие бумаг на счёте клиента (шорт/короткая позиция/недостаточно ЦБ), а не
// про иную причину отказа; только тогда показываем «нет бумаг».
QuikFailISIN string
QuikFailReply string
QuikNoSec bool
}
// quikReplyIsNoSecurities — ответ модуля QUIK об отказе в регистрации означает,
// что на счёте клиента нет/недостаточно бумаг для блокировки (списания). QUIK не
// возвращает остаток, но на попытку заблокировать больше, чем есть, отвечает про
// «шорт»/«короткую позицию»/«недостаточно ЦБ».
func quikReplyIsNoSecurities(reply string) bool {
r := strings.ToLower(reply)
for _, s := range []string{"шорт", "короткая позиц", "недостаточно", "нет бумаг", "нет цб", "не хватает"} {
if strings.Contains(r, s) {
return true
}
}
return false
}
// claimSentToNSD — прошла ли сделка стадию отправки пакета в НРД.
func claimSentToNSD(v ClaimView) bool {
for _, st := range v.Stages {
if st.State == "submitted_to_nsd" {
return true
}
}
return false
}
// quikRegisterFailure — если по заявке была неуспешная регистрация «Вывод ЦБ (M2M)»
// в QUIK МНП (бумаги не заблокированы), возвращает ISIN и дословный ответ модуля.
// Это достоверное подтверждение «на счёте клиента нет/недостаточно бумаг»: проверку
// достаточности делает сам QUIK при sp_NewInstrSec (в БД МНП остатков нет).
func quikRegisterFailure(links []*QuikLink) (isin, reply string) {
for _, l := range links {
if l == nil || (l.State != "error" && l.State != "manual") {
continue
}
for i := len(l.Steps) - 1; i >= 0; i-- {
if st := l.Steps[i]; st.Action == "register" && !st.OK && strings.TrimSpace(st.Reply) != "" {
return l.ISIN, strings.TrimSpace(st.Reply)
}
}
if strings.TrimSpace(l.Err) != "" {
return l.ISIN, strings.TrimSpace(l.Err)
}
}
return "", ""
}
// statusData — данные страницы статуса.
@@ -741,9 +798,14 @@ func (a *admin) renderClaim(w http.ResponseWriter, r *http.Request, svc *Service
http.Error(w, err.Error(), http.StatusNotFound)
return
}
view.Timeline = buildClaimTimeline(r.Context(), view)
if last := timelineLast(view.Timeline); last.After(view.UpdatedAt) {
view.UpdatedAt = last // «Обновлена» — последнее событие по заявке, а не последняя смена состояния сделки
// На экране: краткий операторский путь (ключевые вехи), полный технический —
// под сворачиваемым «Технический журнал». «Обновлена» — по последнему событию
// полного пути (а не последней смене состояния сделки).
full := buildClaimTimeline(r.Context(), view)
view.TimelineTech = full
view.Timeline = claimTimelineOperator(r.Context(), view)
if last := timelineLast(full); last.After(view.UpdatedAt) {
view.UpdatedAt = last
}
var depo *OutgoingDeal
if view.M2MGUID != "" && globalOutgoingStore != nil {
@@ -773,10 +835,13 @@ func (a *admin) renderClaim(w http.ResponseWriter, r *http.Request, svc *Service
substAs = rt.As // в НРД ушли данные тест-инвестора вместо клиента (режим «подмена»)
}
}
qfISIN, qfReply := quikRegisterFailure(quik)
render(w, a.claim, claimData{
Quik: quik, Watch: watch, DepoL: depoL, Fail: fail,
page: nowPage("Заявка", "claims"),
Claim: view, Depo: depo, SubstAs: substAs, Flash: r.URL.Query().Get("flash"),
SentToNSD: claimSentToNSD(view), QuikFailISIN: qfISIN, QuikFailReply: qfReply,
QuikNoSec: quikReplyIsNoSecurities(qfReply),
})
}
+7 -1
View File
@@ -61,12 +61,18 @@ func sendSMTP(s SMTPSettings, subject, body string) error {
if len(rcpts) == 0 {
return fmt.Errorf("не заданы получатели (To)")
}
return smtpDeliver(s, rcpts, buildMIME(s.From, rcpts, subject, body))
}
// smtpDeliver — транспорт SMTP: соединение, TLS, аутентификация и отправка
// готового MIME-сообщения. Выделено из sendSMTP для переиспользования в
// письмах с вложением (sendSMTPAttach, participant_form.go).
func smtpDeliver(s SMTPSettings, rcpts []string, msg []byte) error {
port := s.Port
if port == 0 {
port = 587
}
addr := net.JoinHostPort(s.Host, strconv.Itoa(port))
msg := buildMIME(s.From, rcpts, subject, body)
dialer := &net.Dialer{Timeout: 15 * time.Second}
var client *smtp.Client
@@ -0,0 +1,100 @@
package lkgateway
import (
"bytes"
"strings"
"testing"
"time"
)
// buildClaimDataFromView повторяет вычисления renderClaim для теста.
func buildClaimDataFromView(v ClaimView, quik []*QuikLink, fail *SendFailure) claimData {
qfISIN, qfReply := quikRegisterFailure(quik)
return claimData{
page: page{Title: "Заявка"},
Claim: v, Quik: quik, Fail: fail,
SentToNSD: claimSentToNSD(v), QuikFailISIN: qfISIN, QuikFailReply: qfReply,
QuikNoSec: quikReplyIsNoSecurities(qfReply),
}
}
func TestClaimHelpers(t *testing.T) {
sent := ClaimView{Stages: []StageView{{State: "draft"}, {State: "validated"}, {State: "submitted_to_nsd"}, {State: "awaiting_decision"}}}
if !claimSentToNSD(sent) {
t.Fatal("submitted_to_nsd должна считаться отправкой в НРД")
}
notSent := ClaimView{Stages: []StageView{{State: "draft"}, {State: "validated"}, {State: "rejected"}}}
if claimSentToNSD(notSent) {
t.Fatal("validated→rejected без submitted_to_nsd — в НРД не отправлялось")
}
for _, r := range []string{"Данный инструмент запрещен для операции шорт", "Недостаточно ценных бумаг", "короткая позиция"} {
if !quikReplyIsNoSecurities(r) {
t.Fatalf("ответ %q должен распознаваться как нехватка бумаг", r)
}
}
if quikReplyIsNoSecurities("Неизвестный депозитарий получателя") {
t.Fatal("ошибка про депозитарий — не нехватка бумаг")
}
links := []*QuikLink{{GUID: "g", ISIN: "RU0009029540", State: "error",
Err: "МНП отверг регистрацию: Данный инструмент запрещен для операции шорт",
Steps: []QuikStep{{Action: "register", Reply: "Данный инструмент запрещен для операции шорт", OK: false, At: time.Now()}}}}
isin, reply := quikRegisterFailure(links)
if isin != "RU0009029540" || !strings.Contains(reply, "шорт") {
t.Fatalf("quikRegisterFailure вернул %q / %q", isin, reply)
}
}
// Отклонена до отправки + QUIK сообщил о нехватке бумаг (как e3607d2a).
func TestClaimRenderNotSentNoSec(t *testing.T) {
a, err := newAdmin()
if err != nil {
t.Fatal(err)
}
v := ClaimView{ID: "e3607d2a-2608-441b-b49a-e99ef3e4edd1", Status: "rejected",
RequestRawXML: "<M2MTransferRequest/>",
Stages: []StageView{{State: "draft"}, {State: "validated"}, {State: "rejected"}}}
quik := []*QuikLink{{GUID: "67f09cec", ISIN: "RU0009029540", State: "error",
Err: "МНП отверг регистрацию: Данный инструмент запрещен для операции шорт",
Steps: []QuikStep{{Action: "register", Reply: "Данный инструмент запрещен для операции шорт", OK: false, At: time.Now()}}}}
var b bytes.Buffer
if err := a.claim.ExecuteTemplate(&b, "layout", buildClaimDataFromView(v, quik, nil)); err != nil {
t.Fatal(err)
}
s := b.String()
for _, want := range []string{
"В НРД ничего не отправлено", // баннер отмены до отправки
"Сформированный пакет (M2MTransferRequest)", // заголовок карточки
"в НРД не отправлен", // бейдж
"нет (недостаточно) бумаг", // карточка QUIK: нехватка
"Данный инструмент запрещен для операции шорт", // дословный ответ QUIK
} {
if !strings.Contains(s, want) {
t.Fatalf("нет %q", want)
}
}
if strings.Contains(s, "Отправленный пакет (M2MTransferRequest)") {
t.Fatal("для неотправленной заявки не должно быть заголовка «Отправленный пакет»")
}
}
// Нормально отправленная заявка — старый заголовок сохраняется, баннеров нет.
func TestClaimRenderSentOK(t *testing.T) {
a, err := newAdmin()
if err != nil {
t.Fatal(err)
}
v := ClaimView{ID: "11111111-2222-3333-4444-555555555555", Status: "awaiting_decision",
RequestRawXML: "<M2MTransferRequest/>",
Stages: []StageView{{State: "draft"}, {State: "validated"}, {State: "submitted_to_nsd"}, {State: "awaiting_decision"}}}
var b bytes.Buffer
if err := a.claim.ExecuteTemplate(&b, "layout", buildClaimDataFromView(v, nil, nil)); err != nil {
t.Fatal(err)
}
s := b.String()
if !strings.Contains(s, "Отправленный пакет (M2MTransferRequest)") {
t.Fatal("отправленная заявка должна показывать «Отправленный пакет»")
}
if strings.Contains(s, "В НРД ничего не отправлено") || strings.Contains(s, "в НРД не отправлен") {
t.Fatal("для отправленной заявки баннеры «не отправлено» показывать нельзя")
}
}
+221 -3
View File
@@ -116,6 +116,16 @@ var quikActionTitles = map[string]string{
"recipient": "QUIK МНП: получатель указан",
}
// quikActionFailTitles — заголовки для НЕуспешных шагов QUIK МНП (OK=false),
// чтобы в пути заявки не было ложного «зарегистрирован, заблокировано» при отказе.
var quikActionFailTitles = map[string]string{
"register": "QUIK МНП: бумаги не заблокированы (отказ)",
"sign": "QUIK МНП: подпись отклонена",
"deny": "QUIK МНП: снятие отклонено",
"deposit": "QUIK МНП: зачисление отклонено",
"recipient": "QUIK МНП: получатель не указан",
}
func claimTimelineFull(ctx context.Context, v ClaimView) []TimelineEvent {
guid := string(v.M2MGUID)
var items []tlItem
@@ -194,13 +204,18 @@ func claimTimelineFull(ctx context.Context, v ClaimView) []TimelineEvent {
// QUIK МНП
for _, l := range globalQuikLinks.ForGUID(guid) {
for _, st := range l.Steps {
kind := "quik"
title := quikActionTitles[st.Action]
if title == "" {
title = "QUIK МНП: " + st.Action
}
kind := "quik"
if !st.OK {
if !st.OK { // провал: не показываем успешный заголовок («зарегистрирован, заблокировано»)
kind = "reject"
if ft := quikActionFailTitles[st.Action]; ft != "" {
title = ft
} else {
title = "QUIK МНП: " + st.Action + " — отклонено"
}
}
add(st.At, title, kind, fmt.Sprintf("%s × %v. %s", l.ISIN, l.Qty, st.Reply))
}
@@ -232,7 +247,7 @@ func claimTimelineFull(ctx context.Context, v ClaimView) []TimelineEvent {
switch {
case d.Rejected || d.Code == "REG_REJECT" || d.Code == "ERROR":
kind = "reject"
case d.Code == "GS036":
case d.Code == "GS036" || d.Code == "REG_DUP": // REG_DUP — дубль доставки, не провал
kind = "warn"
case d.Done:
kind = "ok"
@@ -280,3 +295,206 @@ func timelineLast(tl []TimelineEvent) time.Time {
}
return last
}
// claimTimelineOperator — КРАТКИЙ путь заявки для оператора: только ключевые вехи
// простым языком, по одной на этап, без технической «кухни» (события FTV/Fancy,
// дубли QUIK, служебные коды FSM вроде manual_reject:QUIK). Оператору нужно быстро
// понять, что произошло и какие этапы пройдены. Полный технический путь —
// claimTimelineFull (в карточке под сворачиваемым «Технический журнал»).
func claimTimelineOperator(ctx context.Context, v ClaimView) []TimelineEvent {
guid := string(v.M2MGUID)
var items []tlItem
add := func(at time.Time, title, kind, detail string) {
if title == "" {
return
}
items = append(items, tlItem{at: at, title: title, kind: kind, detail: detail})
}
stageAt := func(state string) (time.Time, bool) {
for _, s := range v.Stages {
if s.State == state {
return s.EnteredAt, true
}
}
return time.Time{}, false
}
// шаги ЛК: первый («заявка получена») и последний статус для клиента
var lkFirst, lkStatus *LKFlowStep
if guid != "" {
if f := globalLKFlows.Get(guid); f != nil {
for i := range f.Steps {
st := &f.Steps[i]
if lkFirst == nil && strings.HasPrefix(st.Title, "Заявка из") {
lkFirst = st
}
if strings.Contains(st.Title, "Статус в ЛК") {
lkStatus = st
}
}
}
}
// 1) заявка получена
if lkFirst != nil {
add(lkFirst.At, "Заявка получена", "db", lkFirst.Detail)
} else {
src := "Создана в bj."
if guid != "" {
if lkID, ok := LKClaimIDByGUID(guid); ok {
src = fmt.Sprintf("Из личного кабинета ЕСИА Финанс (заявка #%d).", lkID)
}
}
add(v.CreatedAt, "Заявка получена", "db", src)
}
// 2) проверка перед исполнением
if t, ok := stageAt("validated"); ok {
add(t, "Проверка пройдена — можно исполнять", "ok", "")
}
// 3) QUIK МНП — по шагам (блокировка, затем списание) с их реальным временем,
// а не схлопнутая строка по финальному состоянию (иначе «списано» встало бы на
// время регистрации и раньше отправки в НРД). По одному шагу каждого действия.
quikFailed := false
for _, l := range globalQuikLinks.ForGUID(guid) {
if l == nil {
continue
}
qty := fmt.Sprintf("%s × %v.", l.ISIN, l.Qty)
var reg, sign, deny, dep *QuikStep
for i := range l.Steps {
switch l.Steps[i].Action {
case "register":
reg = &l.Steps[i]
case "sign":
sign = &l.Steps[i]
case "deny":
deny = &l.Steps[i]
case "deposit":
dep = &l.Steps[i]
}
}
if reg != nil {
if reg.OK {
add(reg.At, "Бумаги заблокированы в QUIK", "quik", qty)
} else {
quikFailed = true
reason := strings.TrimSpace(reg.replyOr(l.Err))
d := qty
if quikReplyIsNoSecurities(reason) {
d = "На счёте клиента нет бумаг для списания. " + qty
}
if reason != "" {
d += " Ответ QUIK: " + reason
}
add(reg.At, "Бумаги не заблокированы в QUIK", "reject", d)
}
}
if sign != nil && sign.OK {
add(sign.At, "Бумаги списаны в QUIK", "quik", qty)
}
if deny != nil {
add(deny.At, "Блокировка в QUIK снята", "db", qty)
}
if dep != nil && dep.OK {
add(dep.At, "Бумаги зачислены в QUIK", "quik", qty)
}
}
// 4) отправлено в НРД
if t, ok := stageAt("submitted_to_nsd"); ok {
add(t, "Отправлено в НРД", "gateway", "Запрос перевода подписан ИШ, НРД принял пакет.")
} else if f := sendFailGet(guid); f != nil && !quikFailed {
add(f.At, "НРД не принял пакет", "reject", sendFailText(f))
}
// 5) решение контрагента / сервисный ответ НРД
for _, s := range v.Stages {
switch s.State {
case "confirmed":
add(s.EnteredAt, "Контрагент согласился на перевод", "response", "")
case "timed_out":
add(s.EnteredAt, "Истёк срок ответа контрагента", "reject", s.Reason)
}
}
if v.M2MResponse != nil && v.M2MResponse.StatusCode == "ERROR" {
add(v.UpdatedAt, "НРД отклонил заявку", "reject", "Сервис МОСТ вернул ERROR — подробности в карточке ответа ниже.")
}
// 6) списание: поручение 16 → исполнение (MS010). Время итога берём из самого
// отчёта НРД (MS010), а не из времени подачи. Дубли доставки (REG_DUP) и
// промежуточные уведомления (GS036) в операторский путь не выводим.
if guid != "" && globalOutgoingStore != nil {
if od, ok := scanForm16Deal(globalOutgoingStore, guid); ok {
if od.Submitted {
add(od.OrderAt, "Поручение на списание подано в НРД", "gateway", "")
}
// время итога — из самого отчёта MS010; исход (исполнено/отказ) — из od
// (orderRespVerdict по тексту MS010), а не из времени подачи.
var ms010At time.Time
for _, dc := range nsdOrderDocs(append(append([]string{}, od.ContrDIDs...), orderWatchRefs(guid)...)...) {
if dc.Code == "MS010" && dc.Done {
ms010At = dc.Time
}
}
switch {
case od.Done && od.Rejected:
add(ms010At, "НРД отказал в списании", "reject", "Отчёт MS010 — операция не исполнена.")
case od.Done:
add(ms010At, "Списание проведено — бумаги переданы контрагенту", "ok", "Отчёт НРД MS010.")
}
}
}
// 7) чем закончилось на проверке (если отклонена до QUIK/НРД и это нигде не отражено)
if v.Status == "rejected" && !quikFailed && v.M2MResponse == nil {
if _, sent := stageAt("submitted_to_nsd"); !sent {
if t, ok := stageAt("rejected"); ok {
add(t, "Отклонена при проверке", "reject", operatorReason(rejectStageReason(v)))
}
}
}
// 8) итог для клиента в ЛК
if lkStatus != nil {
add(lkStatus.At, lkStatus.Title, lkStatus.Kind, lkStatus.Detail)
}
if t, ok := stageAt("done"); ok {
add(t, "Перевод исполнен", "ok", "")
}
return timelineFinish(items, v)
}
// replyOr — текст ответа шага, либо запасной.
func (s *QuikStep) replyOr(fallback string) string {
if s != nil && strings.TrimSpace(s.Reply) != "" {
return s.Reply
}
return fallback
}
// rejectStageReason — причина из стадии rejected (служебная строка FSM).
func rejectStageReason(v ClaimView) string {
for _, s := range v.Stages {
if s.State == "rejected" {
return s.Reason
}
}
return ""
}
// operatorReason — служебную причину FSM в человеческий вид для оператора.
func operatorReason(r string) string {
r = strings.TrimSpace(r)
switch {
case r == "":
return ""
case strings.HasPrefix(r, "manual_reject:"):
return "отклонено оператором/проверкой (" + strings.TrimPrefix(r, "manual_reject:") + ")"
case strings.HasPrefix(r, "service_error:"):
return "ошибка сервиса НРД: " + m2mCodeText(strings.TrimPrefix(r, "service_error:"))
}
return r
}
+17 -1
View File
@@ -160,11 +160,27 @@ func depoDismissFailures(ctx context.Context, db *sql.DB, days int) (orders, eve
return orders, events, nil
}
var depoFailAlertedID int64
var (
depoFailAlertedID int64
depoFailSeeded bool // водяной знак засеян текущим максимумом на старте процесса
)
// depoAlertEventFailures — оповещение о новых ошибках обработки событий BJ_* (поручения с
// ошибкой оповещает depoPollOrders).
//
// Водяной знак depoFailAlertedID — в памяти процесса. Чтобы при каждом РЕСТАРТЕ bj не
// поднимать повторно алерты про уже известные (и, как правило, уже разобранные) ошибки,
// на первом проходе засеиваем знак текущим максимумом ID событий BJ_*: алертим только про
// НОВЫЕ ошибки, появившиеся уже после запуска. (События BJ_* пишет сам bj, пока он лежит —
// новых не появляется, поэтому ничего не пропускаем.)
func depoAlertEventFailures(ctx context.Context, db *sql.DB, rc *RuntimeConfig) {
if !depoFailSeeded {
var mx sql.NullInt64
if e := db.QueryRowContext(ctx, fsql.Hlth03).Scan(&mx); e == nil && mx.Int64 > depoFailAlertedID {
depoFailAlertedID = mx.Int64
}
depoFailSeeded = true
}
var id sql.NullInt64
var typ, guid, comment string
var n int
+4
View File
@@ -11,3 +11,7 @@ var Hlth01 = `SELECT TOP (@n) * FROM (
var Hlth02 = `SELECT COUNT(*) OVER (), ID, EVENT_TYPE, CAST(GUID AS varchar(36)), ISNULL(COMMENT,'')
FROM dbo.FTV_M2M_EVENTS WHERE STATE='ERROR' AND EVENT_TYPE LIKE 'BJ[_]%' AND ID > @last AND ISNULL(TimeEnd, TimeNew) > DATEADD(day, -1, GETDATE())
ORDER BY ID DESC OFFSET 0 ROWS FETCH NEXT 1 ROWS ONLY`
// Hlth03 — текущий максимум ID событий BJ_* (для засева водяного знака алертов на
// старте процесса: не поднимать повторно уже известные ошибки после рестарта bj).
var Hlth03 = `SELECT ISNULL(MAX(ID),0) FROM dbo.FTV_M2M_EVENTS WHERE EVENT_TYPE LIKE 'BJ[_]%'`
+9
View File
@@ -86,6 +86,15 @@ func submitIncomingOrderFor(ctx context.Context, deps incomingDeps, it *Incoming
if deps.igwClient == nil || deps.igwChan == "" {
return nil, "", fmt.Errorf("ИШ не настроен — нельзя отправить поручение")
}
// защита от параллельной/повторной подачи (авто-поллер + ручная кнопка/двойной клик)
if !beginSubmit("in:" + it.ID) {
return nil, "", fmt.Errorf("поручение 16/1 по этому переводу уже отправляется (параллельный запрос) — повторная подача отменена")
}
defer endSubmit("in:" + it.ID)
if cur, ok := deps.store.Get(it.ID); ok && cur.OrderSubmitted && strings.TrimSpace(cur.OrderPkgs) != "" {
// уже подано ранее — в НРД повторно НЕ шлём (идемпотентность)
return strings.Split(cur.OrderPkgs, ", "), "", nil
}
// только .xml (без .SGN) — подпись НРД нам не нужна, ИШ подпишет своей ГОСТ.
var xmls []string
for _, p := range findMF010Files(it.GUID) {
+21 -3
View File
@@ -152,9 +152,19 @@ func classifyNSDDoc(pkg, body string) nsdDoc {
case strings.Contains(body, "<reg_no>") || strings.Contains(body, "<contragent_doc_id>"):
abort, cause := xmlTag(body, "abort_code"), xmlTag(body, "abort_cause")
if abort != "" || cause != "" {
d.Code, d.Rejected = "REG_REJECT", true
d.Title = "Отказ в регистрации поручения"
d.About = "НРД не принял поручение к исполнению: " + strings.TrimSpace(abort+" "+cause) + "."
if isDuplicateOrderAbort(cause) {
// НРД отклонил ПОВТОРНУЮ доставку того же поручения по исходящему
// номеру — защита от двойного исполнения (идемпотентность). Исходный
// экземпляр принят отдельной квитанцией; на статус перевода НЕ влияет,
// поэтому это НЕ отказ (Rejected=false), а информ* уведомление.
d.Code = "REG_DUP"
d.Title = "Повторная доставка поручения отклонена (дубликат)"
d.About = "НРД отклонил повторную доставку того же поручения по исходящему номеру (защита от двойного исполнения). Исходный экземпляр зарегистрирован и обрабатывается — на перевод не влияет."
} else {
d.Code, d.Rejected = "REG_REJECT", true
d.Title = "Отказ в регистрации поручения"
d.About = "НРД не принял поручение к исполнению: " + strings.TrimSpace(abort+" "+cause) + "."
}
} else {
d.Code = "REG"
d.Title = "Квитанция о регистрации поручения"
@@ -361,3 +371,11 @@ func htmlToPDF(ctx context.Context, page []byte) ([]byte, error) {
}
return os.ReadFile(out)
}
// isDuplicateOrderAbort — причина отказа НРД означает повторную доставку того же
// поручения по исходящему номеру (НРД отсекает дубль — защита от двойного
// исполнения), а не содержательный отказ. Такой ответ не влияет на статус перевода.
func isDuplicateOrderAbort(cause string) bool {
c := strings.ToLower(cause)
return strings.Contains(c, "уже существ")
}
+9
View File
@@ -299,6 +299,15 @@ func submitOutgoingFor(ctx context.Context, deps outgoingDeps, guid string) (pkg
if deps.igwClient == nil || deps.igwChan == "" {
return nil, nil, "", fmt.Errorf("ИШ не настроен")
}
// защита от параллельной/повторной подачи (авто-поллер + ручная кнопка/двойной клик)
if !beginSubmit("out:" + guid) {
return nil, nil, "", fmt.Errorf("поручение 16 по этой сделке уже отправляется (параллельный запрос) — повторная подача отменена")
}
defer endSubmit("out:" + guid)
if sub, ok := deps.store.Get(guid); ok && strings.TrimSpace(sub.Pkgs) != "" {
// уже подано ранее — в НРД повторно НЕ шлём (идемпотентность), отдаём прежний результат
return strings.Split(sub.Pkgs, ", "), sub.ContrDIDs, sub.OrderXML, nil
}
deal, ok := scanForm16Deal(deps.store, guid)
if !ok || len(deal.Secs) == 0 {
return nil, nil, "", fmt.Errorf("черновик формы 16 по этой сделке в INBOX не найден")
+355
View File
@@ -0,0 +1,355 @@
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
}
+100
View File
@@ -0,0 +1,100 @@
package lkgateway
import (
"strings"
"testing"
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/nsdxml"
)
func validPF() participantFormData {
return participantFormData{
INN: "7703807489", FullName: `ООО "Фонтвьель"`, ShortName: "Фонтвьель",
DisplayName: "Фонтвьель", DepoCode: "MC0413600000",
}
}
func TestParticipantFormValidate(t *testing.T) {
if errs := participantFormValidate(validPF()); len(errs) != 0 {
t.Fatalf("корректная анкета должна проходить, получили: %v", errs)
}
bad := validPF()
bad.INN = "770380748" // 9 цифр
if errs := participantFormValidate(bad); len(errs) == 0 {
t.Fatal("ИНН из 9 цифр должен отклоняться")
}
bad = validPF()
bad.DepoCode = "XX0413600000"
if errs := participantFormValidate(bad); len(errs) == 0 {
t.Fatal("код не в формате MC+10 должен отклоняться")
}
bad = validPF()
bad.DisplayName = ""
if errs := participantFormValidate(bad); len(errs) == 0 {
t.Fatal("пустой DisplayName должен отклоняться")
}
bad = validPF()
bad.BrokerCode = "MC12" // неверный формат
if errs := participantFormValidate(bad); len(errs) == 0 {
t.Fatal("неверный код брокера должен отклоняться")
}
}
func TestParticipantFormXML(t *testing.T) {
x := participantFormXML(validPF())
for _, want := range []string{
`encoding="windows-1251"`,
`<pf:M2MTransferParticipantForm`,
`http://nsd.ru/schemas/m2m/participant/form`,
`<m2m:INN>7703807489</m2m:INN>`,
`<m2m:DepositoryPlace>`,
`<m2m:ParticipantCode>MC0413600000</m2m:ParticipantCode>`,
`(МСК)`,
} {
if !strings.Contains(x, want) {
t.Errorf("XML должен содержать %q\n---\n%s", want, x)
}
}
if strings.Contains(x, "<m2m:Eng>") {
t.Error("без англ. полей блок Eng не должен появляться")
}
if strings.Contains(x, "<m2m:BrokerPlace>") {
t.Error("без кода брокера блок BrokerPlace не должен появляться")
}
// С англ. и брокером — блоки появляются.
d := validPF()
d.EngFull, d.BrokerCode = "Fontvielle LLC", "MC0413600001"
x2 := participantFormXML(d)
if !strings.Contains(x2, "<m2m:Eng>") || !strings.Contains(x2, "Fontvielle LLC") {
t.Error("с англ. наименованием блок Eng обязателен")
}
if !strings.Contains(x2, "<m2m:BrokerPlace>") || !strings.Contains(x2, "MC0413600001") {
t.Error("с кодом брокера блок BrokerPlace обязателен")
}
}
func TestParticipantFormBytesCP1251(t *testing.T) {
b, err := participantFormBytes(validPF())
if err != nil {
t.Fatal(err)
}
// Декод обратно из 1251 — кириллица восстанавливается.
got := string(nsdxml.DecodeWindows1251(b))
if !strings.Contains(got, `ООО "Фонтвьель"`) {
t.Errorf("после cp1251 round-trip потеряна кириллица:\n%s", got)
}
}
func TestBuildMIMEAttach(t *testing.T) {
msg := string(buildMIMEAttach("a@b.ru", []string{"M2MOST@nsd.ru"}, "Тема", "тело", "form.xml", []byte("<xml/>"), "application/xml"))
for _, want := range []string{
"multipart/mixed; boundary=",
"Content-Disposition: attachment; filename=\"form.xml\"",
"Content-Transfer-Encoding: base64",
"To: M2MOST@nsd.ru",
} {
if !strings.Contains(msg, want) {
t.Errorf("MIME должен содержать %q", want)
}
}
}
+14
View File
@@ -548,6 +548,20 @@ func QuikOutRegister(ctx context.Context, rc *RuntimeConfig, guid, clientID, con
l := globalQuikLinks.update(guid, isin, func(l *QuikLink) {
l.Direction, l.ClientID, l.Contract, l.ISIN, l.Qty = "out", clientID, contract, isin, qty
})
// идемпотентность: уже зарегистрировано/подписано — повторно НЕ блокируем
// (как у QuikOutSign/Deny/Deposit). Иначе двойной клик/stale-страница создадут
// второе поручение «Вывод ЦБ (M2M)» и двойную блокировку.
if l != nil && l.InstrNo != "" && (l.State == "registered" || l.State == "signed") {
return l, nil
}
// защита от ПАРАЛЛЕЛЬНОЙ регистрации того же (guid,isin) до записи состояния
if !beginSubmit("quikreg:" + guid + ":" + isin) {
return l, fmt.Errorf("регистрация «Вывод ЦБ (M2M)» по этой бумаге уже выполняется (параллельный запрос)")
}
defer endSubmit("quikreg:" + guid + ":" + isin)
if cur := globalQuikLinks.Get(guid, isin); cur != nil && cur.InstrNo != "" && (cur.State == "registered" || cur.State == "signed") {
return cur, nil // успели зарегистрировать параллельно
}
if s.Emulate {
return quikEmulated(ctx, rc, guid, isin, "register", "registered", "вывод зарегистрирован, бумаги заблокированы")
}
+1
View File
@@ -233,6 +233,7 @@ func NewServer(cfg ServerConfig) (*Server, error) {
registerSeedListing(mux, store)
registerHandbookRequest(mux, igwClient, igwChannel)
registerChainTest(mux, rc, igwClient != nil) // «Проверить всю цепочку» (мастер, глава «Запуск»)
registerParticipantForm(mux, rc) // анкета участника M2M (регистрация в НРД, participant_form.go)
return &Server{
cfg: cfg,
+40
View File
@@ -0,0 +1,40 @@
package lkgateway
import "sync"
// submitguard.go — защита от повторной/параллельной отправки одного и того же
// поручения в НРД. Триггеры подачи независимы (авто-поллер m2mauto + ручная
// кнопка в карточке + возможный двойной клик), а точка отправки одна. НРД и так
// отсекает дубль по исходящему номеру (идемпотентность на стороне НРД — см.
// isDuplicateOrderAbort), но полагаться только на это нельзя: дубль поднимает
// лишние документы и путает оператора. Поэтому защищаемся и на стороне bj.
//
// beginSubmit даёт атомарный «захват» по ключу: пока один вызов шлёт поручение,
// параллельный получит false и повторно отправлять не станет. После отправки
// (успех или ошибка) ключ освобождается endSubmit — поэтому законный повтор
// после ошибки работает. Идемпотентность «после УСПЕШНОЙ подачи» обеспечивается
// отдельно — проверкой сохранённого факта подачи в сторе (в submitOutgoingFor /
// submitIncomingOrderFor): если поручение уже подано, повторно в НРД не шлём.
var inflightSubmit = struct {
mu sync.Mutex
set map[string]bool
}{set: map[string]bool{}}
// beginSubmit пытается захватить ключ. true — захвачено (можно слать); false —
// по этому ключу уже идёт отправка в другом вызове.
func beginSubmit(key string) bool {
inflightSubmit.mu.Lock()
defer inflightSubmit.mu.Unlock()
if inflightSubmit.set[key] {
return false
}
inflightSubmit.set[key] = true
return true
}
// endSubmit освобождает ключ (вызывать через defer сразу после beginSubmit).
func endSubmit(key string) {
inflightSubmit.mu.Lock()
delete(inflightSubmit.set, key)
inflightSubmit.mu.Unlock()
}
+5
View File
@@ -153,7 +153,12 @@ type ClaimView struct {
TestResult *TestResultView `json:"test_result,omitempty"`
// Timeline — путь заявки во времени: положена в БД Fancy → вердикт «крана»
// → отправлена в шлюз НРД → ответ НРД. С длительностями между этапами.
// На экране карточки это КРАТКИЙ операторский путь (claimTimelineOperator);
// в выгрузках и JSON — полный (buildClaimTimeline).
Timeline []TimelineEvent `json:"timeline,omitempty"`
// TimelineTech — полный технический путь (все события: FTV/Fancy, QUIK,
// поручение 16). Показывается в карточке под сворачиваемым «Технический журнал».
TimelineTech []TimelineEvent `json:"timeline_tech,omitempty"`
}
// TimelineEvent — один шаг в истории движения заявки.
@@ -25,6 +25,11 @@
.tl-quik{--c:#b45309} .tl-ok{--c:#16a34a} .tl-warn{--c:#d97706} .tl-wait{--c:#94a3b8}
/* нет места справа (узкие/средние экраны) — таймлайн в потоке под контентом */
@media (max-width:1640px){ .claim-tl{ position:static; width:auto; max-height:none; margin-top:16px; box-shadow:none } }
.tl-tech{ margin-top:12px; border-top:1px dashed var(--border,#e3e3e8); padding-top:8px }
.tl-tech>summary{ cursor:pointer; font-size:11px; color:var(--muted,#888); list-style:none; user-select:none }
.tl-tech>summary::-webkit-details-marker{ display:none }
.tl-tech>summary::before{ content:'▸ '; }
.tl-tech[open]>summary::before{ content:'▾ '; }
</style>
<div class="claim-grid">
<div class="claim-main">
@@ -40,6 +45,10 @@
<b>Перевод не доставлен в НРД</b> — {{sendFailText .}}.<br>
<span class="muted">Попытка {{.At.Format "02.01.2006 15:04:05"}}{{if .PackageID}}, пакет ИШ {{.PackageID}}{{end}}{{if gt .Attempts 1}}, попыток: {{.Attempts}}{{end}}. Отправка в DEPO не отмечена, блокировка в QUIK МНП сохраняется. {{if .GaveUp}}Сутки прошли — автоповтор остановлен: отправьте вручную или удалите заявку.{{else}}Ничего нажимать не нужно: как только НРД снова ответит, bj сам отправит тот же запрос с тем же GUID (повтор — в течение суток). Кнопка «Отправить повторно» — если нужно попробовать прямо сейчас.{{end}}</span>
</div>{{end}}
{{if and .Claim.RequestRawXML (not .SentToNSD) (not .Fail)}}<div class="card" style="background:var(--warn-weak,#fbf3d9);margin:8px 0">
<b>⛔ В НРД ничего не отправлено — отмена до отправки.</b><br>
<span class="muted">Пакет <code>M2MTransferRequest</code> сформирован, но в НРД <b>не направлялся</b>: заявка остановлена на проверке перед отправкой{{if .QuikFailReply}} — в QUIK МНП не удалось заблокировать бумаги{{if .QuikFailISIN}} ({{.QuikFailISIN}}){{end}}{{if .QuikNoSec}}: на счёте клиента нет/недостаточно бумаг для списания{{end}}{{end}}. Регистрация «Вывод ЦБ (M2M)» и блокировка идут в QUIK МНП <u>до</u> отправки в НРД, поэтому поручение 16 и запрос перевода в НРД не создавались. Устраните причину{{if .QuikNoSec}} (зачислите бумаги на счёт клиента){{end}} и отправьте заявку повторно.{{if .QuikFailReply}}<br>Ответ модуля QUIK: <code>{{.QuikFailReply}}</code>{{end}}</span>
</div>{{end}}
{{$l := liveOut .Claim}}<div class="live live-big live-{{$l.Kind}}" data-live="out:{{.Claim.ID}}" data-sig="{{$l.Sig}}"{{if $l.Active}} data-active="1"{{else}} title="Завершено: состояние больше не проверяется"{{end}}>{{$l.Text}}</div>
<table>
<tr><td style="width:200px" class="muted">Создана</td><td>{{.Claim.CreatedAt.Format "02.01.2006 15:04:05"}}</td></tr>
@@ -122,6 +131,17 @@
<h2 style="margin:0">QUIK МНП — блокировка и списание у клиента</h2>
<a href="/admin/quik" class="muted" style="font-size:12px">все поручения →</a>
</div>
{{if .QuikFailReply}}<div style="margin:10px 0 0;padding:12px 14px;border-radius:10px;background:color-mix(in srgb,#dc2626 12%,var(--card));color:#b3261e;font-size:13px">
{{if .QuikNoSec}}
<b>❌ Бумаги на счёте клиента в QUIK не заблокированы — списывать нечего.</b><br>
<span style="font-weight:600">На торговом счёте клиента в QUIK нет (недостаточно) бумаг{{if .QuikFailISIN}} <code>{{.QuikFailISIN}}</code>{{end}} для списания.</span> QUIK проверяет остаток на своей стороне при регистрации поручения и отклонил «Вывод ЦБ (M2M)» (заблокировать больше, чем есть на счёте, нельзя).
{{else}}
<b>❌ Регистрация «Вывод ЦБ (M2M)» в QUIK МНП не прошла — бумаги не заблокированы.</b><br>
<span style="font-weight:600">QUIK отклонил поручение{{if .QuikFailISIN}} по <code>{{.QuikFailISIN}}</code>{{end}}.</span>
{{end}}
Поэтому в НРД по заявке <b>ничего не отправлялось</b>: регистрация и блокировка в QUIK МНП идут <u>до</u> отправки пакета в НРД.<br>
<span class="muted" style="color:inherit;opacity:.85">Ответ модуля QUIK: <code>{{.QuikFailReply}}</code></span>
</div>{{end}}
{{if .DepoL}}<div style="margin:8px 0 0;padding:8px 12px;border-radius:9px;font-size:13px;{{if .DepoL.Errors}}background:color-mix(in srgb,#dc2626 12%,var(--card));color:#b3261e{{else}}background:var(--card-2){{end}}">🗂 DEPO (bj2depo): {{if .DepoL.DocNo}}поручение <b>№{{.DepoL.DocNo}}</b> создано {{msk .DepoL.DocAt}}{{if .DepoL.DocState}} · состояние в DEPO: <b>{{.DepoL.DocState}}</b>{{end}}{{else}}поручение ещё не создано{{end}}{{range .DepoL.Errors}}<div>ошибка: {{.}}</div>{{end}}</div>{{end}}
<p class="muted" style="font-size:12.5px;margin:6px 0 0">При отправке в НРД bj регистрирует в Модуле неторговых поручений «Вывод ЦБ (M2M)» — QUIK блокирует бумаги на позиции клиента. Отказ контрагента или НРД → поручение снимается с причиной (клиент видит её в терминале). Приём НРД нашего поручения 16 → подпись депозитария, бумаги списываются.</p>
@@ -249,8 +269,10 @@
{{if .Claim.RequestRawXML}}
<div class="card">
<h2>Отправленный пакет (M2MTransferRequest){{if .SubstAs}} <span class="badge awaiting_decision">подмена личности</span>{{end}}</h2>
{{if .SubstAs}}
<h2>{{if .SentToNSD}}Отправленный пакет (M2MTransferRequest){{else}}Сформированный пакет (M2MTransferRequest){{end}}{{if .SubstAs}} <span class="badge awaiting_decision">подмена личности</span>{{end}}{{if not .SentToNSD}} <span class="badge rejected">в НРД не отправлен</span>{{end}}</h2>
{{if not .SentToNSD}}
<p class="muted">Этот XML был <b>сформирован</b>, но в НРД <b>не отправлялся</b> — заявка остановлена до отправки{{if not .Fail}} (проверка перед отправкой не пройдена){{else}} (НРД не принял пакет — см. выше){{end}}. Тип пакета <code>#M2MTR</code>.</p>
{{else if .SubstAs}}
<div class="dq-warn" style="margin:0 0 10px;padding:12px 14px;border-radius:8px;background:var(--warn-weak,#fbf3d9)">
<b>⚠ В НРД отправлены ПОДМЕНЁННЫЕ данные, не эти.</b> Режим эмуляции «Подмена личности»: в пакете, ушедшем в НРД, блок инвестора заменён на тест-инвестора <b>{{.SubstAs}}</b>. XML ниже — наша запись с <b>реальными данными клиента</b> (так заявка пришла из ЛК); в НРД реальные данные клиента НЕ отправлялись.
</div>
@@ -373,6 +395,26 @@
{{if $e.Detail}}<div class="tl-detail">{{$e.Detail}}</div>{{end}}
</div>
{{end}}
{{if .Claim.TimelineTech}}
<details class="tl-tech">
<summary>Технический журнал · {{len .Claim.TimelineTech}} шаг.</summary>
<div style="margin-top:8px">
{{range $i, $e := .Claim.TimelineTech}}
{{if $i}}
<div class="tl-arrow tl-{{$e.Kind}}">
<span class="ln"></span><span class="ar"></span>
{{if $e.Since}}<span class="dt">через {{$e.Since}}</span>{{end}}
</div>
{{end}}
<div class="tl-step tl-{{$e.Kind}}">
<div class="tl-time">{{if $e.Time.IsZero}}ожидание{{else}}{{$e.Time.Format "02.01 15:04:05"}} МСК{{end}}</div>
<div class="tl-title">{{$e.Title}}</div>
{{if $e.Detail}}<div class="tl-detail">{{$e.Detail}}</div>{{end}}
</div>
{{end}}
</div>
</details>
{{end}}
</aside>
{{end}}
</div>{{/* /claim-grid */}}