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 }