diff --git a/internal/lkgateway/admin.go b/internal/lkgateway/admin.go index f09f2f1..0f3dcab 100644 --- a/internal/lkgateway/admin.go +++ b/internal/lkgateway/admin.go @@ -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 { diff --git a/internal/lkgateway/igwwatch.go b/internal/lkgateway/igwwatch.go index f27cdd2..49f1afd 100644 --- a/internal/lkgateway/igwwatch.go +++ b/internal/lkgateway/igwwatch.go @@ -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 { diff --git a/internal/lkgateway/livestatus.go b/internal/lkgateway/livestatus.go index 9de26a0..e5ca730 100644 --- a/internal/lkgateway/livestatus.go +++ b/internal/lkgateway/livestatus.go @@ -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} diff --git a/internal/lkgateway/nsdlinkwatch.go b/internal/lkgateway/nsdlinkwatch.go new file mode 100644 index 0000000..013cac2 --- /dev/null +++ b/internal/lkgateway/nsdlinkwatch.go @@ -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) +} diff --git a/internal/lkgateway/nsdlinkwatch_test.go b/internal/lkgateway/nsdlinkwatch_test.go new file mode 100644 index 0000000..2152110 --- /dev/null +++ b/internal/lkgateway/nsdlinkwatch_test.go @@ -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) + } +} diff --git a/internal/lkgateway/sendfail.go b/internal/lkgateway/sendfail.go new file mode 100644 index 0000000..f0106be --- /dev/null +++ b/internal/lkgateway/sendfail.go @@ -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 +} diff --git a/internal/lkgateway/sendfail_test.go b/internal/lkgateway/sendfail_test.go new file mode 100644 index 0000000..0b72074 --- /dev/null +++ b/internal/lkgateway/sendfail_test.go @@ -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) } diff --git a/internal/lkgateway/service.go b/internal/lkgateway/service.go index 2c5f4ae..051bf6d 100644 --- a/internal/lkgateway/service.go +++ b/internal/lkgateway/service.go @@ -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) diff --git a/internal/lkgateway/web/templates/admin_claim.html b/internal/lkgateway/web/templates/admin_claim.html index efc92be..4cb719f 100644 --- a/internal/lkgateway/web/templates/admin_claim.html +++ b/internal/lkgateway/web/templates/admin_claim.html @@ -30,7 +30,11 @@
{{if .Flash}}{{slice .Claim.ID 0 8}} · {{ruState .Claim.Status}}{{if and (eq .Claim.Status "draft") (can "operate")}}{{slice .Claim.ID 0 8}} · {{ruState .Claim.Status}}{{if and (or .Fail (eq .Claim.Status "validated")) (can "operate")}}| Создана | {{.Claim.CreatedAt.Format "02.01.2006 15:04:05"}} | {{$f := m2mParty .TransferringDepositoryINN}}{{if $f}}{{$f}} {{end}} {{.TransferringDepositoryINN}} |
{{$t := m2mParty .ReceivingDepositoryINN}}{{if $t}}{{$t}} {{end}} {{.ReceivingDepositoryINN}} |
{{$l := liveOut .}}{{ruState .Status}} {{$l.Text}} |
- детали →{{if and (eq .Status "draft") (can "operate")}}{{end}} | +детали →{{if and (or (eq .Status "draft") (eq .Status "validated")) (can "operate")}}{{end}} | {{end}} diff --git a/internal/lkgateway/web/templates/layout.html b/internal/lkgateway/web/templates/layout.html index 70d7525..eae68fd 100644 --- a/internal/lkgateway/web/templates/layout.html +++ b/internal/lkgateway/web/templates/layout.html @@ -344,7 +344,8 @@ label { font-size:13px; font-weight:500; }