Отправка в НРД с подтверждением доставки: 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:
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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) }
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -300,7 +300,7 @@ var (
|
||||
_ Repository = (*PostgresRepository)(nil)
|
||||
)
|
||||
|
||||
// DeleteDraft удаляет сделку в состоянии draft вместе с её событиями (одной транзакцией; состояние
|
||||
// DeleteDraft удаляет сделку в состоянии draft/validated вместе с её событиями (одной транзакцией; состояние
|
||||
// проверяется в самом запросе — заявку, успевшую уйти в НРД, не удалить).
|
||||
func (r *PostgresRepository) DeleteDraft(ctx context.Context, id string) error {
|
||||
tx, err := r.pool.Begin(ctx)
|
||||
@@ -312,13 +312,13 @@ func (r *PostgresRepository) DeleteDraft(ctx context.Context, id string) error {
|
||||
if err := tx.QueryRow(ctx, `SELECT state FROM m2m_core.deals WHERE id=$1::uuid FOR UPDATE`, id).Scan(&state); err != nil {
|
||||
return ErrNotFound
|
||||
}
|
||||
if state != string(StateDraft) {
|
||||
if !IsUnsent(State(state)) {
|
||||
return ErrNotDraft
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `DELETE FROM m2m_core.deal_events WHERE deal_id=$1::uuid`, id); err != nil {
|
||||
return fmt.Errorf("PostgresRepository.DeleteDraft events: %w", err)
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `DELETE FROM m2m_core.deals WHERE id=$1::uuid AND state=$2`, id, string(StateDraft)); err != nil {
|
||||
if _, err := tx.Exec(ctx, `DELETE FROM m2m_core.deals WHERE id=$1::uuid AND state IN ($2,$3)`, id, string(StateDraft), string(StateValidated)); err != nil {
|
||||
return fmt.Errorf("PostgresRepository.DeleteDraft: %w", err)
|
||||
}
|
||||
return tx.Commit(ctx)
|
||||
|
||||
@@ -162,7 +162,7 @@ type DraftDeleter interface {
|
||||
DeleteDraft(ctx context.Context, id string) error
|
||||
}
|
||||
|
||||
// DeleteDraft удаляет сделку, если она в состоянии draft.
|
||||
// DeleteDraft удаляет сделку, если она ещё не ушла в НРД (draft или validated).
|
||||
func (r *MemoryRepository) DeleteDraft(_ context.Context, id string) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
@@ -170,7 +170,7 @@ func (r *MemoryRepository) DeleteDraft(_ context.Context, id string) error {
|
||||
if !ok {
|
||||
return ErrNotFound
|
||||
}
|
||||
if d.State != StateDraft {
|
||||
if !IsUnsent(d.State) {
|
||||
return ErrNotDraft
|
||||
}
|
||||
delete(r.byID, id)
|
||||
@@ -178,3 +178,6 @@ func (r *MemoryRepository) DeleteDraft(_ context.Context, id string) error {
|
||||
delete(r.events, id)
|
||||
return nil
|
||||
}
|
||||
|
||||
// IsUnsent — заявка ещё не ушла в НРД: черновик или «Не отправлена» (НРД не принял пакет).
|
||||
func IsUnsent(s State) bool { return s == StateDraft || s == StateValidated }
|
||||
|
||||
@@ -11,10 +11,11 @@
|
||||
// - выдаёт всё это клиенту через REST API.
|
||||
//
|
||||
// REST-эндпоинты (все по 200/JSON):
|
||||
// POST /api/package/{channel}/file — отправить ZIP, вернёт id
|
||||
// GET /api/package/status/{id} — статус: NEW | SENT | ERROR
|
||||
// GET /api/package?channel=&type=... — список входящих
|
||||
// GET /api/package/{id} — тело пакета (ZIP в base64)
|
||||
//
|
||||
// POST /api/package/{channel}/file — отправить ZIP, вернёт id
|
||||
// GET /api/package/status/{id} — статус: NEW | SENT | ERROR
|
||||
// GET /api/package?channel=&type=... — список входящих
|
||||
// GET /api/package/{id} — тело пакета (ZIP в base64)
|
||||
package igw
|
||||
|
||||
import (
|
||||
@@ -39,6 +40,8 @@ type Client struct {
|
||||
httpClient *http.Client
|
||||
retryMax int
|
||||
retryWait time.Duration
|
||||
// confirmWait — сколько ждать от ИШ итога передачи пакета в НРД (SENT/ERROR). 0 — не ждать.
|
||||
confirmWait time.Duration
|
||||
}
|
||||
|
||||
// Option настраивает Client.
|
||||
@@ -55,13 +58,34 @@ func WithRetry(max int, wait time.Duration) Option {
|
||||
return func(cl *Client) { cl.retryMax = max; cl.retryWait = wait }
|
||||
}
|
||||
|
||||
// WithConfirmWait задаёт ожидание подтверждения передачи пакета в НРД (0 — не ждать).
|
||||
func WithConfirmWait(d time.Duration) Option {
|
||||
return func(cl *Client) { cl.confirmWait = d }
|
||||
}
|
||||
|
||||
// NotDeliveredError — ИШ принял пакет, но передать его в НРД не смог (статус ERROR): например,
|
||||
// контур НРД недоступен (ERRORXML_UPLOAD_FAILED). Пакет в НРД не ушёл — отправку нужно повторить.
|
||||
type NotDeliveredError struct {
|
||||
PackageID string
|
||||
Code string
|
||||
}
|
||||
|
||||
func (e *NotDeliveredError) Error() string {
|
||||
code := e.Code
|
||||
if code == "" {
|
||||
code = "без кода"
|
||||
}
|
||||
return fmt.Sprintf("НРД не принял пакет (ИШ, пакет %s: %s) — пакет не доставлен, отправку нужно повторить", e.PackageID, code)
|
||||
}
|
||||
|
||||
// NewClient собирает клиента к ИШ по URL.
|
||||
func NewClient(baseURL string, opts ...Option) *Client {
|
||||
c := &Client{
|
||||
baseURL: strings.TrimRight(baseURL, "/"),
|
||||
httpClient: &http.Client{Timeout: 30 * time.Second},
|
||||
retryMax: 3,
|
||||
retryWait: time.Second,
|
||||
baseURL: strings.TrimRight(baseURL, "/"),
|
||||
httpClient: &http.Client{Timeout: 30 * time.Second},
|
||||
retryMax: 3,
|
||||
retryWait: time.Second,
|
||||
confirmWait: 20 * time.Second,
|
||||
}
|
||||
for _, o := range opts {
|
||||
o(c)
|
||||
@@ -131,12 +155,55 @@ func (c *Client) SendPackage(ctx context.Context, channel, packageType string, z
|
||||
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
|
||||
return "", fmt.Errorf("igw: decode SendPackage response: %w", err)
|
||||
}
|
||||
id := ""
|
||||
for _, v := range []json.Number{out.ID, out.PackageID, out.IDAlt} {
|
||||
if s := string(v); s != "" {
|
||||
return s, nil
|
||||
id = s
|
||||
break
|
||||
}
|
||||
}
|
||||
if id == "" {
|
||||
return "", errors.New("igw: пустой id в ответе ИШ")
|
||||
}
|
||||
if err := c.waitDelivered(ctx, id); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// waitDelivered — ИШ отвечает id сразу, а в НРД передаёт пакет следом (обычно за доли секунды).
|
||||
// Ждём итог: SENT — доставлен; ERROR — не доставлен (*NotDeliveredError). Если статус узнать
|
||||
// не удалось или пакет всё ещё в очереди ИШ — считаем отправленным, как раньше.
|
||||
func (c *Client) waitDelivered(ctx context.Context, id string) error {
|
||||
if c.confirmWait <= 0 {
|
||||
return nil
|
||||
}
|
||||
deadline := time.Now().Add(c.confirmWait)
|
||||
for wait := 300 * time.Millisecond; ; {
|
||||
st, err := c.GetStatus(ctx, id)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
switch strings.ToUpper(st.Status) {
|
||||
case StatusSent:
|
||||
return nil
|
||||
case StatusError:
|
||||
return &NotDeliveredError{PackageID: id, Code: st.Error}
|
||||
case "": // ИШ не сообщает статус — ждать нечего
|
||||
return nil
|
||||
}
|
||||
if time.Now().Add(wait).After(deadline) {
|
||||
return nil
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil
|
||||
case <-time.After(wait):
|
||||
}
|
||||
if wait < 2*time.Second {
|
||||
wait *= 2
|
||||
}
|
||||
}
|
||||
return "", errors.New("igw: пустой id в ответе ИШ")
|
||||
}
|
||||
|
||||
// --------------- GET /api/package/status/{id} ---------------
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
@@ -18,6 +19,10 @@ import (
|
||||
// Сценарий по DOC/instr-ish-rest-api.pdf раздел 2.5.1.
|
||||
func TestSendPackageHappyPath(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/api/package/status/123" {
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"id": 123, "status": "SENT"})
|
||||
return
|
||||
}
|
||||
if r.URL.Path != "/api/package/CH1/file" {
|
||||
t.Errorf("неожиданный путь %q", r.URL.Path)
|
||||
}
|
||||
@@ -206,3 +211,27 @@ func TestGetPackageBase64InJSON(t *testing.T) {
|
||||
t.Errorf("decoded = %q, ожидалось %q", body, zipBytes)
|
||||
}
|
||||
}
|
||||
|
||||
// Пакет принят ИШ, но в НРД не ушёл (контур недоступен) — отправка должна вернуть ошибку.
|
||||
func TestSendPackageNotDelivered(t *testing.T) {
|
||||
polls := 0
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.HasPrefix(r.URL.Path, "/api/package/status/") {
|
||||
polls++
|
||||
st := map[string]any{"id": 187, "status": "NEW"}
|
||||
if polls > 1 {
|
||||
st = map[string]any{"id": 187, "status": "ERROR", "error": "ERRORXML_UPLOAD_FAILED"}
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(st)
|
||||
return
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"id": 187})
|
||||
}))
|
||||
defer srv.Close()
|
||||
c := igw.NewClient(srv.URL, igw.WithRetry(0, time.Millisecond))
|
||||
_, err := c.SendPackage(context.Background(), "CH1", "#M2MTR", []byte("PK"))
|
||||
var nd *igw.NotDeliveredError
|
||||
if !errors.As(err, &nd) || nd.PackageID != "187" || nd.Code != "ERRORXML_UPLOAD_FAILED" {
|
||||
t.Fatalf("ожидалась ошибка доставки, получено %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user