package lkgateway // Неудачные отправки перевода в НРД. ИШ принимает пакет сразу, а в НРД передаёт следом; если контур // НРД недоступен, пакет получает статус ERROR (igw.NotDeliveredError). Такая заявка остаётся // «Не отправлена»: BJ_SENT в DEPO не пишется, в карточке — причина и кнопка «Отправить повторно» // (тот же GUID, блокировка в QUIK МНП сохраняется). import ( "context" "encoding/json" "errors" "fmt" "log" "os" "sort" "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"` // последняя попытка FirstAt time.Time `json:"first_at"` // первая попытка: автоповтор — не дольше суток от неё Attempts int `json:"attempts"` GaveUp bool `json:"gave_up,omitempty"` // сутки прошли — автоповтор остановлен } 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 if f.FirstAt.IsZero() { f.FirstAt = f.At } 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 } // sendFailClear — перевод доставлен. Если до этого он не доходил — заявка клиента из «ожидает // отправки» переходит в «отправлена». func sendFailClear(guid string) { sendFails.Lock() _, ok := sendFails.m[guid] if !sendFails.loaded { sendFailLoad() _, ok = sendFails.m[guid] } if ok { delete(sendFails.m, guid) sendFailSave() } sendFails.Unlock() if ok && globalRC != nil { go lkQueuedToSent(globalRC, guid) } } // sendFailList — копия всех недоставленных (GUID → неудача). func sendFailList() map[string]SendFailure { sendFails.Lock() defer sendFails.Unlock() sendFailLoad() out := make(map[string]SendFailure, len(sendFails.m)) for k, v := range sendFails.m { out[k] = v } return out } // sendFailGiveUp — остановить автоповтор (true — только что остановлен, нужно сообщить). func sendFailGiveUp(guid string) bool { sendFails.Lock() defer sendFails.Unlock() sendFailLoad() f, ok := sendFails.m[guid] if !ok || f.GaveUp { return false } f.GaveUp = true sendFails.m[guid] = f sendFailSave() return true } // 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) НЕ доставлен в НРД (попытка %d): %v", shortGUID(guid), shortID(deal.ID), f.Attempts, err) if f.Attempts > 1 { // о повторных неудачах автоповтора не шумим — сообщили при первой return } inv := req.Data.InvestorInformation raiseSystemAlert(globalRC, "Перевод не доставлен в НРД", fmt.Sprintf("Перевод %s (%s %s): %v. Заявка осталась «Не отправлена», в DEPO отправка не отмечена. bj отправит её повторно сам, как только НРД снова ответит (в течение суток); можно и вручную — «Отправить повторно» в карточке заявки.", 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 } // SendFailView — строка «недоставленные переводы» на странице «Статус». type SendFailView struct { GUID, DealID, Text string At, FirstAt time.Time Attempts int GaveUp bool } func sendFailViews() []SendFailView { var out []SendFailView for guid, f := range sendFailList() { first := f.FirstAt if first.IsZero() { first = f.At } out = append(out, SendFailView{GUID: guid, DealID: f.DealID, Text: sendFailText(&f), At: f.At, FirstAt: first, Attempts: f.Attempts, GaveUp: f.GaveUp}) } sort.Slice(out, func(i, j int) bool { return out[i].FirstAt.Before(out[j].FirstAt) }) return out }