Отправка в НРД с подтверждением доставки: ERROR от ИШ — заявка «Не отправлена», без BJ_SENT; «Отправить повторно» тем же GUID; сторож связи ИШ↔НРД по журналу ИШ

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014FsEKyJrYF69Cj9ZXzFLRi
This commit is contained in:
zuevav
2026-09-25 15:57:07 +03:00
parent eecb3b7923
commit c2aaa09fe3
15 changed files with 651 additions and 33 deletions
+36 -3
View File
@@ -51,6 +51,7 @@ var templateFuncs = template.FuncMap{
"quikCodeMap": quikClientCodeMapText,
"curUser": authTemplateFuncs["curUser"],
"can": authTemplateFuncs["can"],
"sendFailText": sendFailText,
"devOn": authTemplateFuncs["devOn"],
"devLicensed": authTemplateFuncs["devLicensed"],
"mod": func(f string) bool { return globalRC == nil || moduleAllowed(globalRC, f) }, // модуль в лицензии
@@ -172,7 +173,7 @@ func russianState(s string) string {
case "draft":
return "Черновик"
case "validated":
return "Валидирована"
return "Не отправлена"
case "submitted_to_nsd":
return "Отправлена в НРД"
case "awaiting_decision":
@@ -360,6 +361,7 @@ type page struct {
NPR bool // модуль «НПР / УДС» включён и разрешён лицензией
IGWDown string // ИШ НРД не отвечает: «N мин» (пусто — отвечает или не проверялся)
IGWDetail string
NSDDown string // ИШ отвечает, но НРД не отвечает шлюзу (nsdlinkwatch.go)
DevMode bool // режим разработки (сервер лицензий): видны служебные разделы
}
@@ -407,6 +409,7 @@ type claimData struct {
DepoL *DepoLink
Depo *OutgoingDeal // депозитарное поручение 16 (списание) по этой сделке, если черновик пришёл
Flash string
Fail *SendFailure // последняя неудачная отправка в НРД (sendfail.go)
}
// statusData — данные страницы статуса.
@@ -446,6 +449,9 @@ func RegisterAdmin(mux *http.ServeMux, svc *Service, rc *RuntimeConfig, getOpts
case strings.HasPrefix(p, "claims/") && strings.HasSuffix(p, "/export.zip"):
id := strings.TrimSuffix(strings.TrimPrefix(p, "claims/"), "/export.zip")
a.exportClaim(w, r, svc, id)
case strings.HasPrefix(p, "claims/") && strings.HasSuffix(p, "/resend"):
id := strings.TrimSuffix(strings.TrimPrefix(p, "claims/"), "/resend")
a.resendClaim(w, r, svc, id)
case strings.HasPrefix(p, "claims/") && strings.HasSuffix(p, "/delete-draft"):
id := strings.TrimSuffix(strings.TrimPrefix(p, "claims/"), "/delete-draft")
a.deleteDraft(w, r, svc, id)
@@ -551,7 +557,7 @@ func (a *admin) deleteDraft(w http.ResponseWriter, r *http.Request, svc *Service
back("/admin/claims", "Заявка не найдена")
return
}
if d.State != m2mcore.StateDraft {
if !m2mcore.IsUnsent(d.State) {
back("/admin/claims/"+id, "Удалить можно только черновик: эта заявка уже ушла в НРД")
return
}
@@ -586,6 +592,27 @@ func (a *admin) deleteDraft(w http.ResponseWriter, r *http.Request, svc *Service
back("/admin/claims", msg)
}
// resendClaim — POST /admin/claims/{id}/resend: заявка не дошла до НРД (контур был недоступен) —
// отправить тот же запрос (тот же GUID) ещё раз.
func (a *admin) resendClaim(w http.ResponseWriter, r *http.Request, svc *Service, id string) {
if r.Method != http.MethodPost {
http.Error(w, "method", http.StatusMethodNotAllowed)
return
}
id = path.Base(id)
msg := "✓ Отправлено повторно: НРД принял пакет"
if err := svc.ResendClaim(r.Context(), id); err != nil {
msg = "✗ Повторная отправка не удалась: " + err.Error()
} else {
actor := ""
if u := currentUser(w); u != nil {
actor = u.Login
}
log.Printf("admin: заявка %s отправлена в НРД повторно (%s)", id, actor)
}
http.Redirect(w, r, "/admin/claims/"+id+"?flash="+url.QueryEscape(msg), http.StatusSeeOther)
}
func (a *admin) renderClaim(w http.ResponseWriter, r *http.Request, svc *Service, id string) {
id = path.Base(id)
view, err := svc.GetClaim(r.Context(), id)
@@ -612,8 +639,12 @@ func (a *admin) renderClaim(w http.ResponseWriter, r *http.Request, svc *Service
if view.M2MGUID != "" {
depoL = globalDepoLinks.Get(string(view.M2MGUID))
}
var fail *SendFailure
if view.M2MGUID != "" {
fail = sendFailGet(string(view.M2MGUID))
}
render(w, a.claim, claimData{
Quik: quik, Watch: watch, DepoL: depoL,
Quik: quik, Watch: watch, DepoL: depoL, Fail: fail,
page: nowPage("Заявка", "claims"),
Claim: view, Depo: depo, Flash: r.URL.Query().Get("flash"),
})
@@ -777,6 +808,8 @@ func nowPage(title, active string) page {
p.DevMode = devMode(globalRC)
if down, d, det := igwStatus(); down {
p.IGWDown, p.IGWDetail = humanDur(d), det
} else if down, det := nsdLinkStatus(); down {
p.NSDDown, p.IGWDetail = det, det
}
s := globalRC.Snapshot()
switch {
+3
View File
@@ -132,6 +132,9 @@ func igwWatchTick(ctx context.Context, rc *RuntimeConfig) {
wasDown, since, alerted, restarts := !w.Up && !w.DownSince.IsZero(), w.DownSince, w.Alerted, w.Restarts
w.Up, w.LastOK, w.LastErr, w.DownSince, w.Alerted, w.Restarts, w.RestartMsg = true, now, "", time.Time{}, false, 0, ""
w.mu.Unlock()
if igwLocal(base) {
nsdLinkTick(rc, now)
}
if wasDown && alerted {
how := "сам"
if restarts > 0 {
+3
View File
@@ -67,6 +67,9 @@ func liveOutgoing(c ClaimView) LiveStatus {
if guid != "" && globalOrderWatch != nil {
watch = globalOrderWatch.Get(guid)
}
if f := sendFailGet(guid); f != nil && guid != "" {
return LiveStatus{Text: "не доставлена в НРД — " + sendFailText(f) + "; нужна повторная отправка", Kind: "err", Active: true}
}
switch c.Status {
case "draft", "validated":
return LiveStatus{Text: "ожидает отправки в НРД", Kind: "wait", Active: true}
+157
View File
@@ -0,0 +1,157 @@
package lkgateway
// Сторож связи ИШ ↔ НРД. ИШ может отвечать bj, но сам не достукиваться до НРД (контур НРД отдаёт
// 404/500/502): тогда пакеты получают ERROR, входящие не приходят. В REST ИШ такого признака нет,
// поэтому смотрим журнал ИШ (если он на этом сервере): итог последних опросов НРД
// «GetPackageListFull (GET) -> OK | #код». НРД не отвечает nsdAlertAfter — оповещение; снова
// отвечает — оповещение с простоем и числом недоставленных переводов (их нужно отправить повторно).
import (
"fmt"
"io"
"log"
"os"
"regexp"
"strings"
"sync"
"time"
)
const (
nsdAlertAfter = 10 * time.Minute
nsdRemindEvery = 2 * time.Hour
nsdLogTail = 512 << 10
)
var igateLogFile = "/var/lib/igate/.local/share/NSD Integration Gateway/logs-svc/log.txt"
// PID_363248 2026-09-25 15:42:59.1873 T16 DEBUG [WSL] WSL TEST3 GOST: GetPackageListFull (GET) -> #404 (HTTP Error 404: NotFound)
var nsdPollRe = regexp.MustCompile(`^PID_\d+ (\d{4}-\d\d-\d\d \d\d:\d\d:\d\d)\.\d+ .*WSL ([^:]+): GetPackageListFull \(GET\) -> (OK|#\d+[^\r\n]*)`)
type nsdPoll struct {
At time.Time
Contour string
OK bool
Result string
}
// nsdLastPolls — итоги опросов НРД из хвоста журнала ИШ: последний опрос и первый неудачный после
// последнего успешного (начало текущего сбоя; nil — сбоя нет).
func nsdLastPolls(tail []byte) (last, firstFail *nsdPoll) {
for _, ln := range strings.Split(string(tail), "\n") {
m := nsdPollRe.FindStringSubmatch(strings.TrimRight(ln, "\r"))
if m == nil {
continue
}
at, err := time.ParseInLocation("2006-01-02 15:04:05", m[1], time.Local)
if err != nil {
continue
}
p := &nsdPoll{At: at, Contour: m[2], OK: m[3] == "OK", Result: m[3]}
last = p
if p.OK {
firstFail = nil
} else if firstFail == nil {
firstFail = p
}
}
return last, firstFail
}
func readTail(path string, n int64) ([]byte, error) {
f, err := os.Open(path)
if err != nil {
return nil, err
}
defer f.Close()
st, err := f.Stat()
if err != nil {
return nil, err
}
off := st.Size() - n
if off < 0 {
off = 0
}
if _, err := f.Seek(off, io.SeekStart); err != nil {
return nil, err
}
return io.ReadAll(f)
}
var nsdWatch struct {
sync.Mutex
DownSince time.Time // первый неудачный опрос после последнего успешного
LastErr string
Contour string
Alerted bool
LastRemind time.Time
}
// nsdLinkStatus — для страницы статуса: не отвечает ли НРД шлюзу.
func nsdLinkStatus() (down bool, detail string) {
nsdWatch.Lock()
defer nsdWatch.Unlock()
if nsdWatch.DownSince.IsZero() {
return false, ""
}
return true, fmt.Sprintf("НРД (%s) не отвечает шлюзу с %s: %s", nsdWatch.Contour, nsdWatch.DownSince.Format("02.01 15:04"), nsdWatch.LastErr)
}
// nsdLinkTick — вызывается сторожем ИШ, когда сам ИШ отвечает и он на этом сервере.
func nsdLinkTick(rc *RuntimeConfig, now time.Time) {
tail, err := readTail(igateLogFile, nsdLogTail)
if err != nil {
return
}
last, firstFail := nsdLastPolls(tail)
if last == nil {
return
}
w := &nsdWatch
w.Lock()
if last.OK {
since, alerted := w.DownSince, w.Alerted
w.DownSince, w.LastErr, w.Alerted = time.Time{}, "", false
w.Unlock()
if !since.IsZero() {
log.Printf("nsd-watch: НРД (%s) снова отвечает шлюзу, простой %s", last.Contour, humanDur(last.At.Sub(since)))
}
if alerted {
body := fmt.Sprintf("Контур НРД %s снова отвечает интеграционному шлюзу (простой %s, с %s).", last.Contour, humanDur(last.At.Sub(since)), since.Format("02.01 15:04"))
if n := sendFailCount(); n > 0 {
body += fmt.Sprintf(" Недоставленных переводов: %d — отправьте их повторно из карточки заявки («Отправить повторно»).", n)
}
raiseSystemAlert(rc, "НРД снова на связи", body)
}
return
}
if w.DownSince.IsZero() {
w.DownSince = firstFail.At
log.Printf("nsd-watch: НРД (%s) не отвечает шлюзу: %s", last.Contour, last.Result)
}
w.LastErr, w.Contour = last.Result, last.Contour
down := now.Sub(w.DownSince)
alert := !w.Alerted && down >= nsdAlertAfter
remind := w.Alerted && now.Sub(w.LastRemind) >= nsdRemindEvery
if alert || remind {
w.Alerted, w.LastRemind = true, now
}
since, lastErr := w.DownSince, w.LastErr
w.Unlock()
if !alert && !remind {
return
}
title := "НРД не отвечает шлюзу"
if remind {
title = "НРД всё ещё не отвечает шлюзу"
}
raiseSystemAlert(rc, title, fmt.Sprintf("Интеграционный шлюз работает, но контур НРД %s не отвечает %s (с %s): %s. Пока так, отправки в НРД не доходят (заявка остаётся «Не отправлена»), входящие не поступают. Это сторона НРД — при затяжном сбое обратитесь в поддержку НРД. Когда связь восстановится, bj сообщит.",
last.Contour, humanDur(down), since.Format("02.01 15:04"), lastErr))
}
func sendFailCount() int {
sendFails.Lock()
defer sendFails.Unlock()
sendFailLoad()
return len(sendFails.m)
}
+20
View File
@@ -0,0 +1,20 @@
package lkgateway
import "testing"
func TestNsdLastPolls(t *testing.T) {
log := `PID_1 2026-09-25 12:00:01.1 T16 DEBUG [WSL] WSL TEST3 GOST: GetPackageListFull (GET) -> OK (HTTP 200)
PID_1 2026-09-25 15:00:02.1 T16 DEBUG [WSL] WSL TEST3 GOST: GetPackageListFull (GET) -> #404 (HTTP Error 404: NotFound)
PID_1 2026-09-25 15:00:02.1 T16 ERROR [GATE] WSL TEST3 GOST: Failure while fetching packages for MC0413600000
GetPackageListFull (GET): REST call resulted in error #404 "HTTP Error 404: NotFound". REST endpoint: "https://gost-t3.nsd.ru/onyx-ms"
PID_1 2026-09-25 15:40:00.1 T16 DEBUG [WSL] WSL TEST3 GOST: GetPackageListFull (GET) -> #500 (HTTP Error 500: InternalServerError)
`
last, ff := nsdLastPolls([]byte(log))
if last == nil || last.OK || last.Contour != "TEST3 GOST" || ff == nil || ff.At.Format("15:04") != "15:00" {
t.Fatalf("сбой: last=%+v first=%+v", last, ff)
}
last, ff = nsdLastPolls([]byte(log + "PID_1 2026-09-25 16:00:00.1 T16 DEBUG [WSL] WSL TEST3 GOST: GetPackageListFull (GET) -> OK (HTTP 200)\n"))
if !last.OK || ff != nil {
t.Fatalf("восстановление: %+v %+v", last, ff)
}
}
+164
View File
@@ -0,0 +1,164 @@
package lkgateway
// Неудачные отправки перевода в НРД. ИШ принимает пакет сразу, а в НРД передаёт следом; если контур
// НРД недоступен, пакет получает статус ERROR (igw.NotDeliveredError). Такая заявка остаётся
// «Не отправлена»: BJ_SENT в DEPO не пишется, в карточке — причина и кнопка «Отправить повторно»
// (тот же GUID, блокировка в QUIK МНП сохраняется).
import (
"context"
"encoding/json"
"errors"
"fmt"
"log"
"os"
"sync"
"time"
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/m2m"
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/m2mcore"
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/nsdadapter/igw"
)
// SendFailure — последняя неудачная отправка заявки.
type SendFailure struct {
DealID string `json:"deal_id"`
PackageID string `json:"package_id,omitempty"`
Code string `json:"code,omitempty"`
Error string `json:"error"`
At time.Time `json:"at"`
Attempts int `json:"attempts"`
}
var sendFailFile = "/var/lib/bj/.bj/send-failures.json"
var sendFails = struct {
sync.Mutex
loaded bool
m map[string]SendFailure // GUID → неудача
}{}
func sendFailLoad() {
if sendFails.loaded {
return
}
sendFails.loaded = true
sendFails.m = map[string]SendFailure{}
if raw, err := os.ReadFile(sendFailFile); err == nil {
_ = json.Unmarshal(raw, &sendFails.m)
}
}
func sendFailSave() {
if raw, err := json.MarshalIndent(sendFails.m, "", " "); err == nil {
_ = os.WriteFile(sendFailFile, raw, 0o600)
}
}
// sendFailGet — неудачная отправка по GUID (nil — не было или уже отправлено).
func sendFailGet(guid string) *SendFailure {
sendFails.Lock()
defer sendFails.Unlock()
sendFailLoad()
if f, ok := sendFails.m[guid]; ok {
return &f
}
return nil
}
func sendFailNote(guid, dealID string, err error) SendFailure {
sendFails.Lock()
defer sendFails.Unlock()
sendFailLoad()
f := sendFails.m[guid]
f.DealID, f.Error, f.At, f.Attempts = dealID, err.Error(), time.Now(), f.Attempts+1
f.PackageID, f.Code = "", ""
var nd *igw.NotDeliveredError
if errors.As(err, &nd) {
f.PackageID, f.Code = nd.PackageID, nd.Code
}
sendFails.m[guid] = f
sendFailSave()
return f
}
func sendFailClear(guid string) {
sendFails.Lock()
defer sendFails.Unlock()
sendFailLoad()
if _, ok := sendFails.m[guid]; ok {
delete(sendFails.m, guid)
sendFailSave()
}
}
// noteSendFailure — записать неудачу, событие сделки и оповещение.
func (s *Service) noteSendFailure(ctx context.Context, deal *m2mcore.Deal, req *m2m.M2MTransferRequest, err error) {
guid := string(req.Header.GUID)
f := sendFailNote(guid, deal.ID, err)
_ = s.repo.AppendEvent(ctx, deal.ID, m2mcore.Event{Type: "send_failed", Payload: f, CreatedAt: time.Now().UTC(), Actor: "system"})
log.Printf("lk-gateway: перевод %s (заявка %s) НЕ доставлен в НРД: %v", shortGUID(guid), shortID(deal.ID), err)
inv := req.Data.InvestorInformation
raiseSystemAlert(globalRC, "Перевод не доставлен в НРД",
fmt.Sprintf("Перевод %s (%s %s): %v. Заявка осталась «Не отправлена», в DEPO отправка не отмечена. Когда НРД снова будет доступен — «Отправить повторно» в карточке заявки.",
shortGUID(guid), inv.LastName, inv.FirstName, err))
}
// unsentDeal — заявка с этим GUID, которая уже создавалась, но в НРД не ушла (повторная отправка
// идёт по ней же, а не новой заявкой).
func (s *Service) unsentDeal(ctx context.Context, guid m2m.UUID) *m2mcore.Deal {
d, err := s.repo.GetByGUID(ctx, guid)
if err != nil || d == nil || d.State != m2mcore.StateValidated || d.Request == nil {
return nil
}
return d
}
// ResendClaim — «Отправить повторно»: тот же запрос (тот же GUID) ещё раз в НРД. Для заявки
// «Не отправлена» — обычный путь (переход в «Ожидает решение», BJ_SENT в DEPO); для заявки, которую
// старая версия bj отметила отправленной без подтверждения ИШ, — только сама отправка.
func (s *Service) ResendClaim(ctx context.Context, id string) error {
d, err := s.repo.GetByID(ctx, id)
if err != nil {
return err
}
if d.Request == nil {
return errors.New("у заявки нет сохранённого запроса")
}
guid := string(d.Request.Header.GUID)
switch {
case d.State == m2mcore.StateValidated:
_, err := s.SendBuiltRequest(ctx, d.Request, d.InvestorID, d.SignedClaim)
return err
case (d.State == m2mcore.StateAwaitingDecision || d.State == m2mcore.StateSubmittedToNSD) && sendFailGet(guid) != nil:
if _, err := s.sender.Send(ctx, d.Request); err != nil {
s.noteSendFailure(ctx, d, d.Request, err)
return err
}
sendFailClear(guid)
_ = s.repo.AppendEvent(ctx, d.ID, m2mcore.Event{Type: "resent", CreatedAt: time.Now().UTC(), Actor: "operator"})
return nil
}
return errors.New("повторно отправить можно только заявку, которая не дошла до НРД")
}
// quikHasRegistered — по переводу в QUIK МНП уже есть действующая блокировка.
func quikHasRegistered(guid string) bool {
for _, l := range globalQuikLinks.ForGUID(guid) {
if l.State == "registered" {
return true
}
}
return false
}
// sendFailText — причина неудачи понятными словами.
func sendFailText(f *SendFailure) string {
switch {
case f.Code == "ERRORXML_UPLOAD_FAILED":
return "контур НРД не принял пакет (недоступен)"
case f.Code != "":
return "НРД не принял пакет: " + f.Code
}
return f.Error
}
+121
View File
@@ -0,0 +1,121 @@
package lkgateway
import (
"context"
"path/filepath"
"sync/atomic"
"time"
"testing"
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/m2m"
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/m2mcore"
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/nsdadapter/igw"
)
type flakySender struct{ fail bool }
func (f *flakySender) Send(_ context.Context, _ *m2m.M2MTransferRequest) (*m2m.M2MTransferResponse, error) {
if f.fail {
return nil, &igw.NotDeliveredError{PackageID: "187", Code: "ERRORXML_UPLOAD_FAILED"}
}
return nil, nil
}
func (f *flakySender) SendDecision(_ context.Context, _ *m2m.M2MTransferDecision) error { return nil }
// НРД не принял пакет: заявка остаётся «Не отправлена», BJ_SENT не пишется; повтор — тот же GUID.
func TestSendFailureAndResend(t *testing.T) {
old := sendFailFile
sendFailFile = filepath.Join(t.TempDir(), "sf.json")
sendFails.loaded = false
defer func() { sendFailFile, sendFails.loaded = old, false }()
oldHook, oldQ := sentHook, quikSendHook
defer func() { sentHook, quikSendHook = oldHook, oldQ }()
quikSendHook = nil
var sent atomic.Int32
sentHook = func(*m2m.M2MTransferRequest) { sent.Add(1) }
ctx := context.Background()
snd := &flakySender{fail: true}
svc := NewService(Config{Repository: m2mcore.NewMemoryRepository(), Sender: snd})
req := sendFailRequest()
if _, err := svc.SendBuiltRequest(ctx, req, "inv", nil); err == nil {
t.Fatal("ожидалась ошибка доставки")
}
d, err := svc.Repo().GetByGUID(ctx, req.Header.GUID)
if err != nil || d.State != m2mcore.StateValidated || d.Request == nil {
t.Fatalf("заявка должна остаться «Не отправлена» с запросом: %+v %v", d, err)
}
if f := sendFailGet(string(req.Header.GUID)); f == nil || f.PackageID != "187" || sendFailText(f) == "" {
t.Fatalf("неудача не записана: %+v", f)
}
if err := svc.ResendClaim(ctx, d.ID); err == nil {
t.Fatal("НРД всё ещё недоступен — повтор тоже должен вернуть ошибку")
}
if f := sendFailGet(string(req.Header.GUID)); f == nil || f.Attempts != 2 {
t.Fatalf("вторая попытка не учтена: %+v", f)
}
snd.fail = false
if err := svc.ResendClaim(ctx, d.ID); err != nil {
t.Fatal(err)
}
d2, _ := svc.Repo().GetByID(ctx, d.ID)
if d2.ID != d.ID || d2.State != m2mcore.StateAwaitingDecision || sendFailGet(string(req.Header.GUID)) != nil {
t.Fatalf("после повтора: %s %s", d2.ID, d2.State)
}
for i := 0; i < 100 && sent.Load() == 0; i++ {
waitShort()
}
if sent.Load() != 1 {
t.Fatalf("BJ_SENT должен уйти один раз — после доставки, было %d", sent.Load())
}
}
func sendFailRequest() *m2m.M2MTransferRequest {
whole := uint64(100)
isin := m2m.ISIN("RU0007661625")
return &m2m.M2MTransferRequest{
Header: m2m.RequestHeader{
GUID: m2m.UUID("c02a1d5e-c2af-4799-bab4-953f133c5133"),
SenderCode: "MC0079200000",
ReceiverCode: "MC0010300000",
CostInfo: m2m.CostInfo{No: &m2m.CostInfoNo{}},
},
Data: m2m.RequestData{
InvestorInformation: m2m.InvestorInformation{
LastName: "Иванов",
FirstName: "Иван",
IdentityDocument: m2m.IdentityDocument{
DocumentType: m2m.DocCode21,
DocumentNumber: m2m.IdentityDocSerial("654321"),
},
},
TransferringDepository: m2m.SettlementRequisites{INN: "7702070139"},
ReceivingDepository: m2m.SettlementRequisites{INN: "7802031669"},
TransferredSecurities: m2m.RequestTransferredSecurities{
Securities: []m2m.RequestSecurity{
{
ReferenceID: "M2M2026030200001",
SecurityCode: "MM0766162534",
SecurityDetails: m2m.SecurityDetails{ISIN: &isin},
Quantity: m2m.Quantity{Whole: &whole},
SettlementAccount: []m2m.RequestSettlementAccount{
{
SettlementRequisites: m2m.SettlementRequisites{INN: "7702070139"},
SettlementLocation: m2m.SettlementDepositoryLocation{
DeponentCode: "DP789456",
AccountID: "31MC0021900000F01",
SectionID: "P001",
},
},
},
IsolationStatus: m2m.IsolationSGDN,
},
},
},
},
}
}
func waitShort() { time.Sleep(10 * time.Millisecond) }
+25 -12
View File
@@ -131,27 +131,40 @@ func (s *Service) SendBuiltRequest(ctx context.Context, req *m2m.M2MTransferRequ
// SendBuiltRequestVia — SendBuiltRequest через указанный отправитель (имитатор НРД
// для заявок ЛК в тестовом режиме, lkemulate.go). Хуки QUIK и DEPO срабатывают так же.
func (s *Service) SendBuiltRequestVia(ctx context.Context, sender m2mcore.NSDSender, req *m2m.M2MTransferRequest, investorID string, signedDoc []byte) (CreateClaimResponse, error) {
deal, err := m2mcore.NewDeal(req.Header.GUID, investorID, signedDoc)
if err != nil {
return CreateClaimResponse{}, fmt.Errorf("lkgateway: NewDeal: %w", err)
// Заявка с этим GUID уже создавалась, но в НРД не дошла (sendfail.go) — отправляем её же.
saved := s.unsentDeal(ctx, req.Header.GUID)
resend := saved != nil
if !resend {
deal, err := m2mcore.NewDeal(req.Header.GUID, investorID, signedDoc)
if err != nil {
return CreateClaimResponse{}, fmt.Errorf("lkgateway: NewDeal: %w", err)
}
if saved, err = s.repo.Create(ctx, deal); err != nil {
return CreateClaimResponse{}, fmt.Errorf("lkgateway: repo.Create: %w", err)
}
if err := saved.Validate(ctx, req); err != nil {
return CreateClaimResponse{}, fmt.Errorf("lkgateway: deal.Validate: %w", err)
}
// Сохраняем до отправки: если НРД пакет не примет, заявка останется «Не отправлена» с запросом.
if err := s.repo.Update(ctx, saved); err != nil {
return CreateClaimResponse{}, fmt.Errorf("lkgateway: repo.Update: %w", err)
}
} else {
req = saved.Request
}
saved, err := s.repo.Create(ctx, deal)
if err != nil {
return CreateClaimResponse{}, fmt.Errorf("lkgateway: repo.Create: %w", err)
}
if err := saved.Validate(ctx, req); err != nil {
return CreateClaimResponse{}, fmt.Errorf("lkgateway: deal.Validate: %w", err)
}
// QUIK МНП: блокировка бумаг клиента до отправки в НРД (если цикл включён).
if quikSendHook != nil {
// QUIK МНП: блокировка бумаг клиента до отправки в НРД (если цикл включён). При повторной
// отправке бумаги уже заблокированы первой попыткой.
if quikSendHook != nil && !(resend && quikHasRegistered(string(req.Header.GUID))) {
if err := quikSendHook(ctx, req); err != nil {
return CreateClaimResponse{}, fmt.Errorf("lkgateway: %w", err)
}
}
resp, err := sender.Send(ctx, req)
if err != nil {
s.noteSendFailure(ctx, saved, req, err)
return CreateClaimResponse{}, fmt.Errorf("lkgateway: sender.Send: %w", err)
}
sendFailClear(string(req.Header.GUID))
saved.Response = resp
if err := saved.Submit(ctx); err != nil {
return CreateClaimResponse{}, fmt.Errorf("lkgateway: deal.Submit: %w", err)
@@ -30,7 +30,11 @@
<p style="margin:0 0 12px"><a href="/admin/claims" class="btn" onclick="if(history.length>1){history.back();return false}">← Назад</a></p>
{{if .Flash}}<div class="card" style="background:var(--accent-weak,#e7f0fb)">{{.Flash}}</div>{{end}}
<div class="card">
<h2 style="display:flex;align-items:center;gap:10px;flex-wrap:wrap">Заявка <code>{{slice .Claim.ID 0 8}}</code> · <span class="badge {{.Claim.Status}}">{{ruState .Claim.Status}}</span>{{if and (eq .Claim.Status "draft") (can "operate")}}<form method="post" action="/admin/claims/{{.Claim.ID}}/delete-draft" style="margin:0 0 0 auto" onsubmit="return confirm('Удалить черновик? Заявка ещё не уходила в НРД. Если по ней бумаги заблокированы в QUIK МНП, блокировка будет снята.');"><button type="submit" class="btn" style="background:var(--card);color:var(--err,#c0392b);border:1px solid var(--err,#c0392b)">Удалить черновик</button></form>{{end}}</h2>
<h2 style="display:flex;align-items:center;gap:10px;flex-wrap:wrap">Заявка <code>{{slice .Claim.ID 0 8}}</code> · <span class="badge {{.Claim.Status}}">{{ruState .Claim.Status}}</span>{{if and (or .Fail (eq .Claim.Status "validated")) (can "operate")}}<form method="post" action="/admin/claims/{{.Claim.ID}}/resend" style="margin:0 0 0 auto" onsubmit="this.querySelector('button').disabled=true;this.querySelector('button').textContent='Отправляем…';"><button type="submit" class="btn">Отправить повторно</button></form>{{end}}{{if and (or (eq .Claim.Status "draft") (eq .Claim.Status "validated")) (can "operate")}}<form method="post" action="/admin/claims/{{.Claim.ID}}/delete-draft" style="margin:0{{if not (or .Fail (eq .Claim.Status "validated"))}} 0 0 auto{{end}}" onsubmit="return confirm('Удалить заявку? Она ещё не уходила в НРД. Если по ней бумаги заблокированы в QUIK МНП, блокировка будет снята.');"><button type="submit" class="btn" style="background:var(--card);color:var(--err,#c0392b);border:1px solid var(--err,#c0392b)">{{if eq .Claim.Status "draft"}}Удалить черновик{{else}}Удалить{{end}}</button></form>{{end}}</h2>
{{with .Fail}}<div class="card" style="border-left:4px solid var(--err,#c0392b);margin:8px 0">
<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 МНП сохраняется. Когда НРД снова будет доступен, нажмите «Отправить повторно» — уйдёт тот же запрос с тем же GUID.</span>
</div>{{end}}
{{$l := liveOut .Claim}}<div class="live live-big live-{{$l.Kind}}" data-live="out:{{.Claim.ID}}"{{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>
@@ -16,7 +16,7 @@
<td>{{$f := m2mParty .TransferringDepositoryINN}}{{if $f}}<span style="font-size:11px">{{$f}}</span><br>{{end}}<code style="font-size:11px">{{.TransferringDepositoryINN}}</code></td>
<td>{{$t := m2mParty .ReceivingDepositoryINN}}{{if $t}}<span style="font-size:11px">{{$t}}</span><br>{{end}}<code style="font-size:11px">{{.ReceivingDepositoryINN}}</code></td>
<td>{{$l := liveOut .}}<span class="badge {{.Status}}">{{ruState .Status}}</span><div class="live live-{{$l.Kind}}" data-live="out:{{.ID}}"{{if $l.Active}} data-active="1"{{else}} title="Завершено: состояние больше не проверяется"{{end}}>{{$l.Text}}</div></td>
<td><a href="/admin/claims/{{.ID}}">детали →</a>{{if and (eq .Status "draft") (can "operate")}}<form method="post" action="/admin/claims/{{.ID}}/delete-draft" style="margin:4px 0 0" onsubmit="return confirm('Удалить черновик? Заявка ещё не уходила в НРД. Если по ней бумаги заблокированы в QUIK МНП, блокировка будет снята.');"><button type="submit" class="btn" style="padding:2px 10px;font-size:12px;background:var(--card);color:var(--err,#c0392b);border:1px solid var(--err,#c0392b)">Удалить</button></form>{{end}}</td>
<td><a href="/admin/claims/{{.ID}}">детали →</a>{{if and (or (eq .Status "draft") (eq .Status "validated")) (can "operate")}}<form method="post" action="/admin/claims/{{.ID}}/delete-draft" style="margin:4px 0 0" onsubmit="return confirm('Удалить заявку? Она ещё не уходила в НРД. Если по ней бумаги заблокированы в QUIK МНП, блокировка будет снята.');"><button type="submit" class="btn" style="padding:2px 10px;font-size:12px;background:var(--card);color:var(--err,#c0392b);border:1px solid var(--err,#c0392b)">Удалить</button></form>{{end}}</td>
</tr>
{{end}}
</tbody>
+2 -1
View File
@@ -344,7 +344,8 @@ label { font-size:13px; font-weight:500; }
<div class="topbar-right">
<span class="sys-block">
{{if and .IGWDown (not .IsMockMode)}}<span class="sys-status err" title="{{.IGWDetail}}" style="background:var(--err-weak,#fbe9e7);color:var(--err,#b3261e)">
<span class="pulse"></span>ИШ НРД недоступен · {{.IGWDown}}</span>{{else}}<span class="sys-status {{if .IsMockMode}}warn{{end}}" title="Состояние обмена с НРД">
<span class="pulse"></span>ИШ НРД недоступен · {{.IGWDown}}</span>{{else if and .NSDDown (not .IsMockMode)}}<span class="sys-status err" title="{{.IGWDetail}}" style="background:var(--err-weak,#fbe9e7);color:var(--err,#b3261e)">
<span class="pulse"></span>НРД не отвечает</span>{{else}}<span class="sys-status {{if .IsMockMode}}warn{{end}}" title="Состояние обмена с НРД">
<span class="pulse"></span>{{if .IsMockMode}}{{if devOn}}Эмуляция{{else}}Нет связи с НРД{{end}}{{else}}НРД на связи{{end}}</span>{{end}}
<span class="clock">{{.Now}}</span>
</span>