4cbefa41d5
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014FsEKyJrYF69Cj9ZXzFLRi
231 lines
8.4 KiB
Go
231 lines
8.4 KiB
Go
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
|
|
}
|