Отправка в НРД с подтверждением доставки: 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
+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
}