Путь заявки из всех источников: сделка, QUIK, события DEPO (с обработкой/ошибкой), поручение 16 и документы НРД (регистрация, GS036, GS116 сквитовано, MS010); «Обновлена» — последнее событие; статус учитывает сквитовку (GS116), время MS010 в строке статуса
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014FsEKyJrYF69Cj9ZXzFLRi
This commit is contained in:
@@ -627,6 +627,9 @@ func (a *admin) renderClaim(w http.ResponseWriter, r *http.Request, svc *Service
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
view.Timeline = buildClaimTimeline(r.Context(), view)
|
view.Timeline = buildClaimTimeline(r.Context(), view)
|
||||||
|
if last := timelineLast(view.Timeline); last.After(view.UpdatedAt) {
|
||||||
|
view.UpdatedAt = last // «Обновлена» — последнее событие по заявке, а не последняя смена состояния сделки
|
||||||
|
}
|
||||||
var depo *OutgoingDeal
|
var depo *OutgoingDeal
|
||||||
if view.M2MGUID != "" && globalOutgoingStore != nil {
|
if view.M2MGUID != "" && globalOutgoingStore != nil {
|
||||||
if d, ok := scanForm16Deal(globalOutgoingStore, string(view.M2MGUID)); ok {
|
if d, ok := scanForm16Deal(globalOutgoingStore, string(view.M2MGUID)); ok {
|
||||||
@@ -689,46 +692,7 @@ func buildClaimTimeline(ctx context.Context, v ClaimView) []TimelineEvent {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Происхождение из ЛК ЕСИА Финанс (путь напрямую через коннектор, минуя
|
return claimTimelineFull(ctx, v)
|
||||||
// Fancy): показываем стартовый шаг с номером заявки в ЛК.
|
|
||||||
if v.M2MGUID != "" {
|
|
||||||
if lkID, ok := LKClaimIDByGUID(string(v.M2MGUID)); ok {
|
|
||||||
add("Заявка из ЛК ЕСИА Финанс", "db",
|
|
||||||
fmt.Sprintf("Забрана коннектором из личного кабинета (заявка ЛК #%d).", lkID),
|
|
||||||
v.CreatedAt, time.Time{})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Отправлена в шлюз НРД (Deal создаётся в момент отправки).
|
|
||||||
add("Отправлена в интеграционный шлюз НРД", "gateway", "Подписана ключом и передана в ИШ.", v.CreatedAt, time.Time{})
|
|
||||||
|
|
||||||
// Ответ НРД (если получен).
|
|
||||||
if v.M2MDecision != nil {
|
|
||||||
conf, rej := 0, 0
|
|
||||||
for _, s := range v.M2MDecision.Securities {
|
|
||||||
if s.Outcome == "confirmed" {
|
|
||||||
conf++
|
|
||||||
} else {
|
|
||||||
rej++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
title := "Получено решение контрагента (НРД)"
|
|
||||||
if devMode(globalRC) {
|
|
||||||
title = "Получено решение НРД (робот)"
|
|
||||||
}
|
|
||||||
add(title, "response",
|
|
||||||
fmt.Sprintf("Принято %d, отклонено %d.", conf, rej), v.UpdatedAt, v.CreatedAt)
|
|
||||||
} else if v.M2MResponse != nil {
|
|
||||||
var codes string
|
|
||||||
for _, e := range v.M2MResponse.Responses {
|
|
||||||
if e.Code != "" {
|
|
||||||
codes += e.Code + " "
|
|
||||||
}
|
|
||||||
}
|
|
||||||
add("Ответ сервиса МОСТ (НРД)", "response",
|
|
||||||
"Статус: "+v.M2MResponse.StatusCode+". "+strings.TrimSpace(codes), v.UpdatedAt, v.CreatedAt)
|
|
||||||
}
|
|
||||||
return tl
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// formatSince — человекочитаемая длительность между шагами таймлайна.
|
// formatSince — человекочитаемая длительность между шагами таймлайна.
|
||||||
|
|||||||
@@ -0,0 +1,276 @@
|
|||||||
|
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 МНП: получатель указан",
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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 {
|
||||||
|
title := quikActionTitles[st.Action]
|
||||||
|
if title == "" {
|
||||||
|
title = "QUIK МНП: " + st.Action
|
||||||
|
}
|
||||||
|
kind := "quik"
|
||||||
|
if !st.OK {
|
||||||
|
kind = "reject"
|
||||||
|
}
|
||||||
|
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":
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -140,6 +140,9 @@ func liveOutgoingState(c ClaimView) LiveStatus {
|
|||||||
case depo.Done && depo.Rejected:
|
case depo.Done && depo.Rejected:
|
||||||
return LiveStatus{Text: "поручение 16 завершено отказом НРД (MS010)", Kind: "bad"}
|
return LiveStatus{Text: "поручение 16 завершено отказом НРД (MS010)", Kind: "bad"}
|
||||||
case depo.Done:
|
case depo.Done:
|
||||||
|
if watch != nil && !watch.MS010At.IsZero() {
|
||||||
|
return LiveStatus{Text: "поручение 16 исполнено: MS010 получен " + watch.MS010At.In(mskZone).Format("02.01.2006 15:04"), Kind: "ok"}
|
||||||
|
}
|
||||||
return LiveStatus{Text: "поручение 16 исполнено: MS010 получен", Kind: "ok"}
|
return LiveStatus{Text: "поручение 16 исполнено: MS010 получен", Kind: "ok"}
|
||||||
case watch != nil:
|
case watch != nil:
|
||||||
k := watch.Kind()
|
k := watch.Kind()
|
||||||
|
|||||||
@@ -39,7 +39,16 @@ func TestNoCounterNotice(t *testing.T) {
|
|||||||
t.Fatalf("входящий: %q", s)
|
t.Fatalf("входящий: %q", s)
|
||||||
}
|
}
|
||||||
w.Direction, w.MS010At = "out", at.Add(53*time.Minute)
|
w.Direction, w.MS010At = "out", at.Add(53*time.Minute)
|
||||||
if s := w.StatusRu(); !strings.Contains(s, "исполнено") || !strings.Contains(s, "задержка на стороне контрагента") {
|
if s := w.StatusRu(); !strings.Contains(s, "исполнено") || !strings.Contains(s, "подано не сразу") {
|
||||||
t.Fatalf("исполнено с задержкой: %q", s)
|
t.Fatalf("исполнено, встречное не сразу: %q", s)
|
||||||
|
}
|
||||||
|
// сквитовано через минуту, исполнение НРД через 52 мин — задержка у НРД, не у контрагента
|
||||||
|
w.MatchedAt = at.Add(time.Minute)
|
||||||
|
if s := w.StatusRu(); strings.Contains(s, "не сразу") || !strings.Contains(s, "сквитовано со встречным в 17:51, исполнение НРД — через 52 мин") {
|
||||||
|
t.Fatalf("исполнено после сквитовки: %q", s)
|
||||||
|
}
|
||||||
|
w.MS010At = time.Time{}
|
||||||
|
if s := w.StatusRu(); !strings.Contains(s, "сквитовано со встречным") || !strings.Contains(s, "ждём исполнения НРД") {
|
||||||
|
t.Fatalf("сквитовано, ждём: %q", s)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,6 +38,8 @@ type OrderWatch struct {
|
|||||||
// NoCounterAt — НРД прислал уведомление GS036 «об отсутствии встречного поручения»: контрагент
|
// NoCounterAt — НРД прислал уведомление GS036 «об отсутствии встречного поручения»: контрагент
|
||||||
// ещё не подал своё поручение (к нашей 16 — его 16/1, к нашей 16/1 — его 16). Сбрасывается MS010.
|
// ещё не подал своё поручение (к нашей 16 — его 16/1, к нашей 16/1 — его 16). Сбрасывается MS010.
|
||||||
NoCounterAt time.Time `json:"no_counter_at,omitempty"`
|
NoCounterAt time.Time `json:"no_counter_at,omitempty"`
|
||||||
|
// MatchedAt — НРД сквитовал поручения со встречными (GS116, MACH) по всем строкам: дальше — исполнение.
|
||||||
|
MatchedAt time.Time `json:"matched_at,omitempty"`
|
||||||
// Lines — состояние по каждой строке пакета (одно депо-поручение = одна бумага),
|
// Lines — состояние по каждой строке пакета (одно депо-поручение = одна бумага),
|
||||||
// ключ — contr_d_id. По договорённости с Fancy (18.09.2026) исходы пишутся в DEPO
|
// ключ — contr_d_id. По договорённости с Fancy (18.09.2026) исходы пишутся в DEPO
|
||||||
// по строкам с REFERENCE и сразу по MS010 этой строки, не дожидаясь остальных:
|
// по строкам с REFERENCE и сразу по MS010 этой строки, не дожидаясь остальных:
|
||||||
@@ -53,6 +55,7 @@ type OrderLineWatch struct {
|
|||||||
Rejected bool `json:"rejected,omitempty"`
|
Rejected bool `json:"rejected,omitempty"`
|
||||||
ExpiredAt time.Time `json:"expired_at,omitempty"`
|
ExpiredAt time.Time `json:"expired_at,omitempty"`
|
||||||
NoCounterAt time.Time `json:"no_counter_at,omitempty"` // GS036 по этой строке
|
NoCounterAt time.Time `json:"no_counter_at,omitempty"` // GS036 по этой строке
|
||||||
|
MatchedAt time.Time `json:"matched_at,omitempty"` // GS116 MACH по этой строке
|
||||||
}
|
}
|
||||||
|
|
||||||
// counterName — чьё встречное поручение ждём: к нашей 16 — 16/1 принимающего, к нашей 16/1 — 16 передающего.
|
// counterName — чьё встречное поручение ждём: к нашей 16 — 16/1 принимающего, к нашей 16/1 — 16 передающего.
|
||||||
@@ -120,14 +123,19 @@ func (w *OrderWatch) StatusRu() string {
|
|||||||
case !w.MS010At.IsZero():
|
case !w.MS010At.IsZero():
|
||||||
late := ""
|
late := ""
|
||||||
if w.MS010At.Sub(w.OrderAt) > orderMS010Within {
|
if w.MS010At.Sub(w.OrderAt) > orderMS010Within {
|
||||||
late = fmt.Sprintf(", с опозданием %s", w.MS010At.Sub(w.OrderAt).Round(time.Minute))
|
late = ", с опозданием " + humanDur(w.MS010At.Sub(w.OrderAt))
|
||||||
}
|
}
|
||||||
if !w.NoCounterAt.IsZero() {
|
switch {
|
||||||
late += "; задержка на стороне контрагента — встречное " + w.counterName() + " подано не сразу (уведомление НРД " + w.NoCounterAt.Local().Format("15:04") + ")"
|
case !w.MatchedAt.IsZero() && w.MS010At.Sub(w.MatchedAt) > orderMS010Within:
|
||||||
|
late += "; сквитовано со встречным в " + w.MatchedAt.Local().Format("15:04") + ", исполнение НРД — через " + humanDur(w.MS010At.Sub(w.MatchedAt))
|
||||||
|
case w.MatchedAt.IsZero() && !w.NoCounterAt.IsZero():
|
||||||
|
late += "; встречное " + w.counterName() + " подано не сразу (уведомление НРД " + w.NoCounterAt.Local().Format("15:04") + ")"
|
||||||
}
|
}
|
||||||
return "исполнено: MS010 получен " + w.MS010At.Local().Format("02.01.2006 15:04") + late
|
return "исполнено: MS010 получен " + w.MS010At.Local().Format("02.01.2006 15:04") + late
|
||||||
case !w.ExpiredAt.IsZero():
|
case !w.ExpiredAt.IsZero():
|
||||||
return "СРОК 30 ДНЕЙ ИСТЁК " + w.ExpiredAt.Local().Format("02.01.2006") + " — поручение считается отменённым"
|
return "СРОК 30 ДНЕЙ ИСТЁК " + w.ExpiredAt.Local().Format("02.01.2006") + " — поручение считается отменённым"
|
||||||
|
case !w.MatchedAt.IsZero():
|
||||||
|
return "поручение сквитовано со встречным (НРД " + w.MatchedAt.Local().Format("02.01 15:04") + "), ждём исполнения НРД (MS010)"
|
||||||
case !w.NoCounterAt.IsZero():
|
case !w.NoCounterAt.IsZero():
|
||||||
return "НРД: контрагент ещё не подал встречное " + w.counterName() + " (уведомление НРД " + w.NoCounterAt.Local().Format("02.01 15:04") + "); наше поручение принято, ждём исполнения"
|
return "НРД: контрагент ещё не подал встречное " + w.counterName() + " (уведомление НРД " + w.NoCounterAt.Local().Format("02.01 15:04") + "); наше поручение принято, ждём исполнения"
|
||||||
case !w.LateAt.IsZero():
|
case !w.LateAt.IsZero():
|
||||||
@@ -341,7 +349,9 @@ func orderWatchCheck(ctx context.Context, rc *RuntimeConfig, guid, direction str
|
|||||||
}
|
}
|
||||||
if fresh {
|
if fresh {
|
||||||
why := "отчёт MS010 не получен"
|
why := "отчёт MS010 не получен"
|
||||||
if !w.NoCounterAt.IsZero() {
|
if !w.MatchedAt.IsZero() {
|
||||||
|
why = "поручения сквитованы со встречными (" + w.MatchedAt.Local().Format("15:04") + "), ждём исполнения НРД"
|
||||||
|
} else if !w.NoCounterAt.IsZero() {
|
||||||
why = "НРД сообщил, что контрагент ещё не подал встречное " + w.counterName() + " — наше поручение принято, исполнение ждёт контрагента"
|
why = "НРД сообщил, что контрагент ещё не подал встречное " + w.counterName() + " — наше поручение принято, исполнение ждёт контрагента"
|
||||||
}
|
}
|
||||||
raiseSystemAlert(rc, "НРД: депо-поручение не исполнено в срок 5 минут",
|
raiseSystemAlert(rc, "НРД: депо-поручение не исполнено в срок 5 минут",
|
||||||
@@ -489,6 +499,36 @@ func orderWatchRefs(guid string) []string {
|
|||||||
func orderNoteNoCounter(guid string, w *OrderWatch, lines []orderLine) {
|
func orderNoteNoCounter(guid string, w *OrderWatch, lines []orderLine) {
|
||||||
for _, ln := range lines {
|
for _, ln := range lines {
|
||||||
st := w.Lines[ln.ContrDID]
|
st := w.Lines[ln.ContrDID]
|
||||||
|
// отчёт GS116 «сквитовано» (MACH) — по номеру поручения
|
||||||
|
if st == nil || st.MatchedAt.IsZero() {
|
||||||
|
if at, ok := findMatchedNotice(ln.ContrDID); ok {
|
||||||
|
globalOrderWatch.update(guid, func(w *OrderWatch) {
|
||||||
|
if w.Lines == nil {
|
||||||
|
w.Lines = map[string]*OrderLineWatch{}
|
||||||
|
}
|
||||||
|
l := w.Lines[ln.ContrDID]
|
||||||
|
if l == nil {
|
||||||
|
l = &OrderLineWatch{Ref: ln.Ref, ISIN: ln.ISIN}
|
||||||
|
w.Lines[ln.ContrDID] = l
|
||||||
|
}
|
||||||
|
l.MatchedAt = at
|
||||||
|
all, last := true, time.Time{}
|
||||||
|
for _, x := range lines {
|
||||||
|
lx := w.Lines[x.ContrDID]
|
||||||
|
if lx == nil || lx.MatchedAt.IsZero() {
|
||||||
|
all = false
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if lx.MatchedAt.After(last) {
|
||||||
|
last = lx.MatchedAt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if all {
|
||||||
|
w.MatchedAt = last
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
// уведомление «нет встречного поручения» (GS036) — по REFERENCE строки
|
// уведомление «нет встречного поручения» (GS036) — по REFERENCE строки
|
||||||
if (st == nil || st.NoCounterAt.IsZero()) && ln.Ref != "" {
|
if (st == nil || st.NoCounterAt.IsZero()) && ln.Ref != "" {
|
||||||
if at, ok := findNoCounterNotice(ln.Ref); ok {
|
if at, ok := findNoCounterNotice(ln.Ref); ok {
|
||||||
@@ -511,3 +551,16 @@ func orderNoteNoCounter(guid string, w *OrderWatch, lines []orderLine) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// findMatchedNotice — отчёт НРД GS116 «сквитовано со встречным» (<Mtchd>) по номеру поручения.
|
||||||
|
func findMatchedNotice(contrDID string) (time.Time, bool) {
|
||||||
|
if contrDID == "" {
|
||||||
|
return time.Time{}, false
|
||||||
|
}
|
||||||
|
for _, pk := range pkgIndex() {
|
||||||
|
if strings.Contains(pk.body, "<Mtchd>") && strings.Contains(pk.body, "GS116") && strings.Contains(pk.body, ">"+contrDID+"<") {
|
||||||
|
return pk.mt, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return time.Time{}, false
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
//go:build live
|
||||||
|
|
||||||
|
package lkgateway
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/m2mcore"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Путь заявки на живых данных: go test -c -tags live; запуск от bj с TL_ID=<id сделки>.
|
||||||
|
func TestTimelineLive(t *testing.T) {
|
||||||
|
rc, err := NewRuntimeConfig("/var/lib/bj/.bj/setup.json")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
globalRC = rc
|
||||||
|
globalOutgoingStore = NewOutgoingStore()
|
||||||
|
repo, err := m2mcore.NewPostgresRepository(context.Background(), rc.Snapshot().Postgres.DSN)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
svc := NewService(Config{Repository: repo})
|
||||||
|
for _, id := range strings.Split(os.Getenv("TL_ID"), ",") {
|
||||||
|
v, err := svc.GetClaim(context.Background(), id)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
v.Timeline = buildClaimTimeline(context.Background(), v)
|
||||||
|
fmt.Printf("=== %s создана %s обновлена(сделка) %s, последнее событие %s\n", id[:8], v.CreatedAt.Format("15:04:05 MST"), v.UpdatedAt.Format("15:04:05 MST"), timelineLast(v.Timeline).Format("15:04:05"))
|
||||||
|
for _, e := range v.Timeline {
|
||||||
|
fmt.Printf(" %-9s %-8s [%s] %s — %s\n", e.Time.Format("15:04:05"), e.Since, e.Kind, e.Title, e.Detail)
|
||||||
|
}
|
||||||
|
if os.Getenv("TL_HTML") != "" {
|
||||||
|
a, _ := newAdmin()
|
||||||
|
var b bytes.Buffer
|
||||||
|
if err := a.claim.ExecuteTemplate(&b, "layout", claimData{page: page{Title: "Заявка"}, Claim: v, Watch: globalOrderWatch.Get(string(v.M2MGUID))}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
txt := regexp.MustCompile(`(?s)<style.*?</style>|<script.*?</script>`).ReplaceAllString(b.String(), "")
|
||||||
|
txt = regexp.MustCompile(`<[^>]+>`).ReplaceAllString(txt, " ")
|
||||||
|
txt = regexp.MustCompile(`\s+`).ReplaceAllString(txt, " ")
|
||||||
|
i := strings.Index(txt, "Заявка ")
|
||||||
|
fmt.Println(txt[i:min(len(txt), i+1500)])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -22,6 +22,7 @@
|
|||||||
border-left:5px solid transparent; border-right:5px solid transparent; border-top:7px solid var(--c) }
|
border-left:5px solid transparent; border-right:5px solid transparent; border-top:7px solid var(--c) }
|
||||||
.tl-arrow .dt { font-size:10.5px; color:var(--muted,#999) }
|
.tl-arrow .dt { font-size:10.5px; color:var(--muted,#999) }
|
||||||
.tl-db{--c:#2563eb} .tl-crane{--c:#16a34a} .tl-gateway{--c:#7c3aed} .tl-response{--c:#0891b2} .tl-reject{--c:#dc2626}
|
.tl-db{--c:#2563eb} .tl-crane{--c:#16a34a} .tl-gateway{--c:#7c3aed} .tl-response{--c:#0891b2} .tl-reject{--c:#dc2626}
|
||||||
|
.tl-quik{--c:#b45309} .tl-ok{--c:#16a34a} .tl-warn{--c:#d97706} .tl-wait{--c:#94a3b8}
|
||||||
/* нет места справа (узкие/средние экраны) — таймлайн в потоке под контентом */
|
/* нет места справа (узкие/средние экраны) — таймлайн в потоке под контентом */
|
||||||
@media (max-width:1640px){ .claim-tl{ position:static; width:auto; max-height:none; margin-top:16px; box-shadow:none } }
|
@media (max-width:1640px){ .claim-tl{ position:static; width:auto; max-height:none; margin-top:16px; box-shadow:none } }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user