c476cfd7a3
- Карточка заявки: операторский «Путь заявки» (claimTimelineOperator) короткими понятными вехами + сворачиваемый технический журнал (buildClaimTimeline); QUIK показан пошагово (register/sign/deny/deposit) с верными временами, отказ регистрации больше не выглядит успехом (quikActionFailTitles). - Баннеры: «в НРД не отправлено», когда пакет сформирован, но не ушёл (нет submitted_to_nsd и не Fail); заголовок карточки и бейдж учитывают это; красный баннер QUIK при отказе, строгий «нет бумаг» только при реальном шорт/недостатке. - НРД-ответ «Поручение с исходящим номером … уже существует» теперь REG_DUP (предупреждение-дубль, не красный «Отказ в регистрации»); не влияет на od.Rejected. - Idempotency: submitguard.go (inflightSubmit) + защита точек подачи от двойного срабатывания (manual + auto-loop, двойной клик): submitOutgoingFor, submitIncomingOrderFor, QuikOutRegister (ранний возврат при registered/signed). - DEPO-алерты: водяной знак depoFailAlertedID засеивается текущим MAX(ID) на старте процесса (Hlth03) — после рестарта bj больше не поднимает повторно алерты про уже известные ошибки обработки событий BJ_*. - Тесты: claimnotsent_render_test.go (хелперы + рендер карточки). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XG3zNMMf2LMoDpXo22mwMX
501 lines
20 KiB
Go
501 lines
20 KiB
Go
package lkgateway
|
||
|
||
// Путь заявки (исходящий перевод) во времени — из всех источников, а не только из состояний сделки:
|
||
// сделка M2M (создание, отправка, решение контрагента), блокировка/подпись в QUIK МНП, события обмена
|
||
// с DEPO (FTV_M2M_EVENTS: время, обработано/ошибка), поручение 16 и все документы НРД по нему
|
||
// (регистрация, «нет встречного поручения» GS036, исполнение MS010), недоставленные отправки.
|
||
// Шаги сортируются по времени, между соседними — длительность. Последним — чего ждём сейчас.
|
||
|
||
import (
|
||
"context"
|
||
"database/sql"
|
||
"fmt"
|
||
"sort"
|
||
"strings"
|
||
"time"
|
||
)
|
||
|
||
var mskZone = time.FixedZone("МСК", 3*3600)
|
||
|
||
type tlItem struct {
|
||
at time.Time
|
||
title, kind, detail string
|
||
}
|
||
|
||
// depoEventRow — строка FTV_M2M_EVENTS по переводу.
|
||
type depoEventRow struct {
|
||
At time.Time
|
||
Type string
|
||
Ref string
|
||
State string
|
||
Comment string
|
||
}
|
||
|
||
// depoEventsFor — события обмена с DEPO по переводу (EVENT_DATE пишется временем МСК без зоны).
|
||
func depoEventsFor(ctx context.Context, rc *RuntimeConfig, guid string) []depoEventRow {
|
||
if rc == nil || guid == "" {
|
||
return nil
|
||
}
|
||
db, err := depoEventsDB(rc)
|
||
if err != nil {
|
||
return nil
|
||
}
|
||
defer db.Close()
|
||
cctx, cancel := context.WithTimeout(ctx, 4*time.Second)
|
||
defer cancel()
|
||
rows, err := db.QueryContext(cctx, `SELECT EVENT_TYPE, EVENT_DATE, ISNULL(REFERENCE,''), ISNULL(STATE,''), ISNULL(COMMENT,'')
|
||
FROM dbo.FTV_M2M_EVENTS WHERE GUID=@g ORDER BY EVENT_DATE`, sql.Named("g", strings.ToUpper(guid)))
|
||
if err != nil {
|
||
return nil
|
||
}
|
||
defer rows.Close()
|
||
var out []depoEventRow
|
||
for rows.Next() {
|
||
var e depoEventRow
|
||
var at time.Time
|
||
if rows.Scan(&e.Type, &at, &e.Ref, &e.State, &e.Comment) != nil {
|
||
continue
|
||
}
|
||
// datetime без зоны: драйвер отдаёт стенные часы в UTC — это время МСК
|
||
e.At = time.Date(at.Year(), at.Month(), at.Day(), at.Hour(), at.Minute(), at.Second(), at.Nanosecond(), mskZone)
|
||
out = append(out, e)
|
||
}
|
||
return out
|
||
}
|
||
|
||
var depoEventTitles = map[string]string{
|
||
"BJ_SENT": "DEPO: перевод отправлен в НРД",
|
||
"BJ_GATEWAY_REJECT": "DEPO: заявка отклонена шлюзом bj",
|
||
"BJ_SERVICE_ERROR": "DEPO: сервис M2M отверг перевод",
|
||
"BJ_CP_CONFIRM": "DEPO: контрагент согласился",
|
||
"BJ_CP_REJECT": "DEPO: контрагент отказал",
|
||
"BJ_ORDER_SENT": "DEPO: поручение 16 подано в НРД",
|
||
"BJ_ORDER_LATE": "DEPO: поручение не исполнено за 5 минут",
|
||
"BJ_ORDER_ACCEPTED": "DEPO: НРД исполнил поручение (MS010)",
|
||
"BJ_ORDER_REJECTED": "DEPO: НРД отверг поручение (MS010)",
|
||
"BJ_EXECUTED": "DEPO: перевод исполнен",
|
||
"BJ_CANCELLED": "DEPO: поручение отменено (срок 30 дней)",
|
||
"BJ_QUIK_BLOCKED": "DEPO: бумаги заблокированы в QUIK",
|
||
"BJ_QUIK_SIGNED": "DEPO: поручение подписано в QUIK",
|
||
"BJ_QUIK_RELEASED": "DEPO: блокировка в QUIK снята",
|
||
"BJ_QUIK_DEPOSITED": "DEPO: бумаги зачислены в QUIK",
|
||
"BJ_RECEIVED": "DEPO: входящий перевод получен",
|
||
"BJ_DECISION_CONFIRM": "DEPO: наше согласие отправлено",
|
||
"BJ_DECISION_REJECT": "DEPO: наш отказ отправлен",
|
||
}
|
||
|
||
func depoEventDetail(e depoEventRow) (string, string) {
|
||
kind := "db"
|
||
var parts []string
|
||
if e.Ref != "" {
|
||
parts = append(parts, "строка "+e.Ref)
|
||
}
|
||
switch strings.ToUpper(e.State) {
|
||
case "ERROR":
|
||
kind = "reject"
|
||
c := e.Comment
|
||
if i := strings.Index(c, "//"); i > 0 { // служебный хвост обработчика DEPO
|
||
c = c[:i]
|
||
}
|
||
parts = append(parts, "ошибка обработки в DEPO: "+strings.TrimSpace(c))
|
||
case "DONE", "OK", "SUCCESS":
|
||
parts = append(parts, "обработано DEPO")
|
||
case "", "TODO", "NEW":
|
||
parts = append(parts, "ждёт обработки DEPO")
|
||
default:
|
||
parts = append(parts, "DEPO: "+e.State)
|
||
}
|
||
return strings.Join(parts, " · "), kind
|
||
}
|
||
|
||
var quikActionTitles = map[string]string{
|
||
"register": "QUIK МНП: вывод зарегистрирован, бумаги заблокированы",
|
||
"sign": "QUIK МНП: поручение подписано, бумаги списаны",
|
||
"deny": "QUIK МНП: поручение снято, блокировка снята",
|
||
"deposit": "QUIK МНП: бумаги зачислены",
|
||
"recipient": "QUIK МНП: получатель указан",
|
||
}
|
||
|
||
// quikActionFailTitles — заголовки для НЕуспешных шагов QUIK МНП (OK=false),
|
||
// чтобы в пути заявки не было ложного «зарегистрирован, заблокировано» при отказе.
|
||
var quikActionFailTitles = map[string]string{
|
||
"register": "QUIK МНП: бумаги не заблокированы (отказ)",
|
||
"sign": "QUIK МНП: подпись отклонена",
|
||
"deny": "QUIK МНП: снятие отклонено",
|
||
"deposit": "QUIK МНП: зачисление отклонено",
|
||
"recipient": "QUIK МНП: получатель не указан",
|
||
}
|
||
|
||
func claimTimelineFull(ctx context.Context, v ClaimView) []TimelineEvent {
|
||
guid := string(v.M2MGUID)
|
||
var items []tlItem
|
||
add := func(at time.Time, title, kind, detail string) {
|
||
items = append(items, tlItem{at: at, title: title, kind: kind, detail: detail})
|
||
}
|
||
|
||
// создание и откуда пришла
|
||
origin := "Перевод M2M создан в bj."
|
||
if guid != "" {
|
||
if lkID, ok := LKClaimIDByGUID(guid); ok {
|
||
origin = fmt.Sprintf("Заявка из личного кабинета ЕСИА Финанс (заявка ЛК #%d).", lkID)
|
||
}
|
||
}
|
||
if f := globalLKFlows.Get(guid); guid != "" && f != nil && len(f.Steps) > 0 {
|
||
for _, st := range f.Steps { // путь заявки ЛК: забрана из кабинета, проверка шлюза, подмена, статусы в ЛК
|
||
add(st.At, st.Title, st.Kind, st.Detail)
|
||
}
|
||
} else {
|
||
add(v.CreatedAt, "Заявка создана", "db", origin)
|
||
}
|
||
|
||
// состояния сделки M2M
|
||
decided := false
|
||
for _, st := range v.Stages {
|
||
switch st.State {
|
||
case "submitted_to_nsd":
|
||
add(st.EnteredAt, "Запрос перевода отправлен в НРД", "gateway", "M2MTransferRequest подписан шлюзом, НРД принял пакет.")
|
||
case "manual_approval":
|
||
add(st.EnteredAt, "Передана на решение оператора", "warn", st.Reason)
|
||
case "confirmed", "rejected":
|
||
decided = true
|
||
title, kind := "Контрагент согласился на перевод", "response"
|
||
if st.State == "rejected" {
|
||
title, kind = "Перевод отклонён", "reject"
|
||
}
|
||
detail := st.Reason
|
||
if v.M2MDecision != nil {
|
||
conf, rej := 0, 0
|
||
for _, s := range v.M2MDecision.Securities {
|
||
if s.Outcome == "confirmed" {
|
||
conf++
|
||
} else {
|
||
rej++
|
||
}
|
||
}
|
||
detail = fmt.Sprintf("Решение контрагента (M2MTransferDecision): принято бумаг %d, отклонено %d.", conf, rej)
|
||
} else if v.M2MResponse != nil {
|
||
var codes []string
|
||
for _, e := range v.M2MResponse.Responses {
|
||
if e.Code != "" {
|
||
codes = append(codes, e.Code)
|
||
}
|
||
}
|
||
detail = "Ответ сервиса M2M: " + v.M2MResponse.StatusCode + " " + strings.Join(codes, " ")
|
||
}
|
||
add(st.EnteredAt, title, kind, strings.TrimSpace(detail))
|
||
case "timed_out":
|
||
add(st.EnteredAt, "Истёк срок ответа контрагента", "reject", st.Reason)
|
||
case "done":
|
||
add(st.EnteredAt, "Перевод завершён", "ok", "")
|
||
}
|
||
}
|
||
if !decided && v.M2MResponse != nil { // ответ сервиса M2M без смены состояния
|
||
add(v.UpdatedAt, "Ответ сервиса M2M (НРД)", "response", "Статус: "+v.M2MResponse.StatusCode)
|
||
}
|
||
if guid == "" {
|
||
return timelineFinish(items, v)
|
||
}
|
||
|
||
// недоставленная отправка
|
||
if f := sendFailGet(guid); f != nil {
|
||
add(f.At, "НРД не принял пакет", "reject", fmt.Sprintf("%s. Попыток: %d; повтор — автоматически, когда НРД ответит.", sendFailText(f), f.Attempts))
|
||
}
|
||
|
||
// QUIK МНП
|
||
for _, l := range globalQuikLinks.ForGUID(guid) {
|
||
for _, st := range l.Steps {
|
||
kind := "quik"
|
||
title := quikActionTitles[st.Action]
|
||
if title == "" {
|
||
title = "QUIK МНП: " + st.Action
|
||
}
|
||
if !st.OK { // провал: не показываем успешный заголовок («зарегистрирован, заблокировано»)
|
||
kind = "reject"
|
||
if ft := quikActionFailTitles[st.Action]; ft != "" {
|
||
title = ft
|
||
} else {
|
||
title = "QUIK МНП: " + st.Action + " — отклонено"
|
||
}
|
||
}
|
||
add(st.At, title, kind, fmt.Sprintf("%s × %v. %s", l.ISIN, l.Qty, st.Reply))
|
||
}
|
||
}
|
||
|
||
// DEPO: номер поручения и события обмена
|
||
if dl := globalDepoLinks.Get(guid); dl != nil && dl.DocNo != "" {
|
||
add(dl.DocAt, "DEPO создала поручение №"+dl.DocNo, "db", "Поручение в учёте депозитария.")
|
||
}
|
||
for _, e := range depoEventsFor(ctx, globalRC, guid) {
|
||
title := depoEventTitles[e.Type]
|
||
if title == "" {
|
||
title = "DEPO: " + e.Type
|
||
}
|
||
detail, kind := depoEventDetail(e)
|
||
add(e.At, title, kind, detail)
|
||
}
|
||
|
||
// поручение 16 и документы НРД по нему
|
||
if globalOutgoingStore != nil {
|
||
if od, ok := scanForm16Deal(globalOutgoingStore, guid); ok {
|
||
if !od.DraftAt.IsZero() {
|
||
add(od.DraftAt, "НРД прислал черновик поручения 16", "response", fmt.Sprintf("Черновик MF010 по %d бумагам.", len(od.Secs)))
|
||
}
|
||
if od.Submitted {
|
||
add(od.OrderAt, "Поручение 16 подано в НРД", "gateway", "Пакеты ИШ "+od.Pkgs+", номера поручений "+strings.Join(od.ContrDIDs, ", ")+".")
|
||
for _, d := range nsdOrderDocs(append(append([]string{}, od.ContrDIDs...), orderWatchRefs(guid)...)...) {
|
||
kind := "response"
|
||
switch {
|
||
case d.Rejected || d.Code == "REG_REJECT" || d.Code == "ERROR":
|
||
kind = "reject"
|
||
case d.Code == "GS036" || d.Code == "REG_DUP": // REG_DUP — дубль доставки, не провал
|
||
kind = "warn"
|
||
case d.Done:
|
||
kind = "ok"
|
||
}
|
||
add(d.Time, d.Title, kind, d.About)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
if w := globalOrderWatch.Get(guid); w != nil && !w.LateAt.IsZero() {
|
||
add(w.LateAt, "Нет отчёта MS010 за 5 минут", "warn", "Регламентный срок исполнения прошёл; ожидаем исполнения до "+w.OrderAt.Add(orderExecWithin).In(mskZone).Format("02.01.2006")+".")
|
||
}
|
||
return timelineFinish(items, v)
|
||
}
|
||
|
||
// timelineFinish — сортировка по времени, длительности между шагами и «чего ждём» последним.
|
||
func timelineFinish(items []tlItem, v ClaimView) []TimelineEvent {
|
||
sort.SliceStable(items, func(i, j int) bool { return items[i].at.Before(items[j].at) })
|
||
var out []TimelineEvent
|
||
var prev time.Time
|
||
for _, it := range items {
|
||
if it.at.IsZero() {
|
||
continue
|
||
}
|
||
ev := TimelineEvent{Title: it.title, Kind: it.kind, Detail: it.detail, Time: it.at.In(mskZone)}
|
||
if !prev.IsZero() && it.at.After(prev) {
|
||
ev.Since = formatSince(it.at.Sub(prev))
|
||
}
|
||
out = append(out, ev)
|
||
prev = it.at
|
||
}
|
||
if l := liveOutgoingState(v); l.Active {
|
||
out = append(out, TimelineEvent{Title: "Сейчас: " + l.Text, Kind: "wait"})
|
||
}
|
||
return out
|
||
}
|
||
|
||
// timelineLast — время последнего шага (для «Обновлена»).
|
||
func timelineLast(tl []TimelineEvent) time.Time {
|
||
var last time.Time
|
||
for _, e := range tl {
|
||
if e.Time.After(last) {
|
||
last = e.Time
|
||
}
|
||
}
|
||
return last
|
||
}
|
||
|
||
// claimTimelineOperator — КРАТКИЙ путь заявки для оператора: только ключевые вехи
|
||
// простым языком, по одной на этап, без технической «кухни» (события FTV/Fancy,
|
||
// дубли QUIK, служебные коды FSM вроде manual_reject:QUIK). Оператору нужно быстро
|
||
// понять, что произошло и какие этапы пройдены. Полный технический путь —
|
||
// claimTimelineFull (в карточке под сворачиваемым «Технический журнал»).
|
||
func claimTimelineOperator(ctx context.Context, v ClaimView) []TimelineEvent {
|
||
guid := string(v.M2MGUID)
|
||
var items []tlItem
|
||
add := func(at time.Time, title, kind, detail string) {
|
||
if title == "" {
|
||
return
|
||
}
|
||
items = append(items, tlItem{at: at, title: title, kind: kind, detail: detail})
|
||
}
|
||
stageAt := func(state string) (time.Time, bool) {
|
||
for _, s := range v.Stages {
|
||
if s.State == state {
|
||
return s.EnteredAt, true
|
||
}
|
||
}
|
||
return time.Time{}, false
|
||
}
|
||
|
||
// шаги ЛК: первый («заявка получена») и последний статус для клиента
|
||
var lkFirst, lkStatus *LKFlowStep
|
||
if guid != "" {
|
||
if f := globalLKFlows.Get(guid); f != nil {
|
||
for i := range f.Steps {
|
||
st := &f.Steps[i]
|
||
if lkFirst == nil && strings.HasPrefix(st.Title, "Заявка из") {
|
||
lkFirst = st
|
||
}
|
||
if strings.Contains(st.Title, "Статус в ЛК") {
|
||
lkStatus = st
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// 1) заявка получена
|
||
if lkFirst != nil {
|
||
add(lkFirst.At, "Заявка получена", "db", lkFirst.Detail)
|
||
} else {
|
||
src := "Создана в bj."
|
||
if guid != "" {
|
||
if lkID, ok := LKClaimIDByGUID(guid); ok {
|
||
src = fmt.Sprintf("Из личного кабинета ЕСИА Финанс (заявка #%d).", lkID)
|
||
}
|
||
}
|
||
add(v.CreatedAt, "Заявка получена", "db", src)
|
||
}
|
||
|
||
// 2) проверка перед исполнением
|
||
if t, ok := stageAt("validated"); ok {
|
||
add(t, "Проверка пройдена — можно исполнять", "ok", "")
|
||
}
|
||
|
||
// 3) QUIK МНП — по шагам (блокировка, затем списание) с их реальным временем,
|
||
// а не схлопнутая строка по финальному состоянию (иначе «списано» встало бы на
|
||
// время регистрации и раньше отправки в НРД). По одному шагу каждого действия.
|
||
quikFailed := false
|
||
for _, l := range globalQuikLinks.ForGUID(guid) {
|
||
if l == nil {
|
||
continue
|
||
}
|
||
qty := fmt.Sprintf("%s × %v.", l.ISIN, l.Qty)
|
||
var reg, sign, deny, dep *QuikStep
|
||
for i := range l.Steps {
|
||
switch l.Steps[i].Action {
|
||
case "register":
|
||
reg = &l.Steps[i]
|
||
case "sign":
|
||
sign = &l.Steps[i]
|
||
case "deny":
|
||
deny = &l.Steps[i]
|
||
case "deposit":
|
||
dep = &l.Steps[i]
|
||
}
|
||
}
|
||
if reg != nil {
|
||
if reg.OK {
|
||
add(reg.At, "Бумаги заблокированы в QUIK", "quik", qty)
|
||
} else {
|
||
quikFailed = true
|
||
reason := strings.TrimSpace(reg.replyOr(l.Err))
|
||
d := qty
|
||
if quikReplyIsNoSecurities(reason) {
|
||
d = "На счёте клиента нет бумаг для списания. " + qty
|
||
}
|
||
if reason != "" {
|
||
d += " Ответ QUIK: " + reason
|
||
}
|
||
add(reg.At, "Бумаги не заблокированы в QUIK", "reject", d)
|
||
}
|
||
}
|
||
if sign != nil && sign.OK {
|
||
add(sign.At, "Бумаги списаны в QUIK", "quik", qty)
|
||
}
|
||
if deny != nil {
|
||
add(deny.At, "Блокировка в QUIK снята", "db", qty)
|
||
}
|
||
if dep != nil && dep.OK {
|
||
add(dep.At, "Бумаги зачислены в QUIK", "quik", qty)
|
||
}
|
||
}
|
||
|
||
// 4) отправлено в НРД
|
||
if t, ok := stageAt("submitted_to_nsd"); ok {
|
||
add(t, "Отправлено в НРД", "gateway", "Запрос перевода подписан ИШ, НРД принял пакет.")
|
||
} else if f := sendFailGet(guid); f != nil && !quikFailed {
|
||
add(f.At, "НРД не принял пакет", "reject", sendFailText(f))
|
||
}
|
||
|
||
// 5) решение контрагента / сервисный ответ НРД
|
||
for _, s := range v.Stages {
|
||
switch s.State {
|
||
case "confirmed":
|
||
add(s.EnteredAt, "Контрагент согласился на перевод", "response", "")
|
||
case "timed_out":
|
||
add(s.EnteredAt, "Истёк срок ответа контрагента", "reject", s.Reason)
|
||
}
|
||
}
|
||
if v.M2MResponse != nil && v.M2MResponse.StatusCode == "ERROR" {
|
||
add(v.UpdatedAt, "НРД отклонил заявку", "reject", "Сервис МОСТ вернул ERROR — подробности в карточке ответа ниже.")
|
||
}
|
||
|
||
// 6) списание: поручение 16 → исполнение (MS010). Время итога берём из самого
|
||
// отчёта НРД (MS010), а не из времени подачи. Дубли доставки (REG_DUP) и
|
||
// промежуточные уведомления (GS036) в операторский путь не выводим.
|
||
if guid != "" && globalOutgoingStore != nil {
|
||
if od, ok := scanForm16Deal(globalOutgoingStore, guid); ok {
|
||
if od.Submitted {
|
||
add(od.OrderAt, "Поручение на списание подано в НРД", "gateway", "")
|
||
}
|
||
// время итога — из самого отчёта MS010; исход (исполнено/отказ) — из od
|
||
// (orderRespVerdict по тексту MS010), а не из времени подачи.
|
||
var ms010At time.Time
|
||
for _, dc := range nsdOrderDocs(append(append([]string{}, od.ContrDIDs...), orderWatchRefs(guid)...)...) {
|
||
if dc.Code == "MS010" && dc.Done {
|
||
ms010At = dc.Time
|
||
}
|
||
}
|
||
switch {
|
||
case od.Done && od.Rejected:
|
||
add(ms010At, "НРД отказал в списании", "reject", "Отчёт MS010 — операция не исполнена.")
|
||
case od.Done:
|
||
add(ms010At, "Списание проведено — бумаги переданы контрагенту", "ok", "Отчёт НРД MS010.")
|
||
}
|
||
}
|
||
}
|
||
|
||
// 7) чем закончилось на проверке (если отклонена до QUIK/НРД и это нигде не отражено)
|
||
if v.Status == "rejected" && !quikFailed && v.M2MResponse == nil {
|
||
if _, sent := stageAt("submitted_to_nsd"); !sent {
|
||
if t, ok := stageAt("rejected"); ok {
|
||
add(t, "Отклонена при проверке", "reject", operatorReason(rejectStageReason(v)))
|
||
}
|
||
}
|
||
}
|
||
|
||
// 8) итог для клиента в ЛК
|
||
if lkStatus != nil {
|
||
add(lkStatus.At, lkStatus.Title, lkStatus.Kind, lkStatus.Detail)
|
||
}
|
||
if t, ok := stageAt("done"); ok {
|
||
add(t, "Перевод исполнен", "ok", "")
|
||
}
|
||
|
||
return timelineFinish(items, v)
|
||
}
|
||
|
||
// replyOr — текст ответа шага, либо запасной.
|
||
func (s *QuikStep) replyOr(fallback string) string {
|
||
if s != nil && strings.TrimSpace(s.Reply) != "" {
|
||
return s.Reply
|
||
}
|
||
return fallback
|
||
}
|
||
|
||
// rejectStageReason — причина из стадии rejected (служебная строка FSM).
|
||
func rejectStageReason(v ClaimView) string {
|
||
for _, s := range v.Stages {
|
||
if s.State == "rejected" {
|
||
return s.Reason
|
||
}
|
||
}
|
||
return ""
|
||
}
|
||
|
||
// operatorReason — служебную причину FSM в человеческий вид для оператора.
|
||
func operatorReason(r string) string {
|
||
r = strings.TrimSpace(r)
|
||
switch {
|
||
case r == "":
|
||
return ""
|
||
case strings.HasPrefix(r, "manual_reject:"):
|
||
return "отклонено оператором/проверкой (" + strings.TrimPrefix(r, "manual_reject:") + ")"
|
||
case strings.HasPrefix(r, "service_error:"):
|
||
return "ошибка сервиса НРД: " + m2mCodeText(strings.TrimPrefix(r, "service_error:"))
|
||
}
|
||
return r
|
||
}
|