Путь заявки из всех источников: сделка, 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
|
||||
}
|
||||
view.Timeline = buildClaimTimeline(r.Context(), view)
|
||||
if last := timelineLast(view.Timeline); last.After(view.UpdatedAt) {
|
||||
view.UpdatedAt = last // «Обновлена» — последнее событие по заявке, а не последняя смена состояния сделки
|
||||
}
|
||||
var depo *OutgoingDeal
|
||||
if view.M2MGUID != "" && globalOutgoingStore != nil {
|
||||
if d, ok := scanForm16Deal(globalOutgoingStore, string(view.M2MGUID)); ok {
|
||||
@@ -689,46 +692,7 @@ func buildClaimTimeline(ctx context.Context, v ClaimView) []TimelineEvent {
|
||||
}
|
||||
}
|
||||
|
||||
// Происхождение из ЛК ЕСИА Финанс (путь напрямую через коннектор, минуя
|
||||
// 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
|
||||
return claimTimelineFull(ctx, v)
|
||||
}
|
||||
|
||||
// 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:
|
||||
return LiveStatus{Text: "поручение 16 завершено отказом НРД (MS010)", Kind: "bad"}
|
||||
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"}
|
||||
case watch != nil:
|
||||
k := watch.Kind()
|
||||
|
||||
@@ -39,7 +39,16 @@ func TestNoCounterNotice(t *testing.T) {
|
||||
t.Fatalf("входящий: %q", s)
|
||||
}
|
||||
w.Direction, w.MS010At = "out", at.Add(53*time.Minute)
|
||||
if s := w.StatusRu(); !strings.Contains(s, "исполнено") || !strings.Contains(s, "задержка на стороне контрагента") {
|
||||
t.Fatalf("исполнено с задержкой: %q", s)
|
||||
if s := w.StatusRu(); !strings.Contains(s, "исполнено") || !strings.Contains(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 «об отсутствии встречного поручения»: контрагент
|
||||
// ещё не подал своё поручение (к нашей 16 — его 16/1, к нашей 16/1 — его 16). Сбрасывается MS010.
|
||||
NoCounterAt time.Time `json:"no_counter_at,omitempty"`
|
||||
// MatchedAt — НРД сквитовал поручения со встречными (GS116, MACH) по всем строкам: дальше — исполнение.
|
||||
MatchedAt time.Time `json:"matched_at,omitempty"`
|
||||
// Lines — состояние по каждой строке пакета (одно депо-поручение = одна бумага),
|
||||
// ключ — contr_d_id. По договорённости с Fancy (18.09.2026) исходы пишутся в DEPO
|
||||
// по строкам с REFERENCE и сразу по MS010 этой строки, не дожидаясь остальных:
|
||||
@@ -53,6 +55,7 @@ type OrderLineWatch struct {
|
||||
Rejected bool `json:"rejected,omitempty"`
|
||||
ExpiredAt time.Time `json:"expired_at,omitempty"`
|
||||
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 передающего.
|
||||
@@ -120,14 +123,19 @@ func (w *OrderWatch) StatusRu() string {
|
||||
case !w.MS010At.IsZero():
|
||||
late := ""
|
||||
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() {
|
||||
late += "; задержка на стороне контрагента — встречное " + w.counterName() + " подано не сразу (уведомление НРД " + w.NoCounterAt.Local().Format("15:04") + ")"
|
||||
switch {
|
||||
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
|
||||
case !w.ExpiredAt.IsZero():
|
||||
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():
|
||||
return "НРД: контрагент ещё не подал встречное " + w.counterName() + " (уведомление НРД " + w.NoCounterAt.Local().Format("02.01 15:04") + "); наше поручение принято, ждём исполнения"
|
||||
case !w.LateAt.IsZero():
|
||||
@@ -341,7 +349,9 @@ func orderWatchCheck(ctx context.Context, rc *RuntimeConfig, guid, direction str
|
||||
}
|
||||
if fresh {
|
||||
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() + " — наше поручение принято, исполнение ждёт контрагента"
|
||||
}
|
||||
raiseSystemAlert(rc, "НРД: депо-поручение не исполнено в срок 5 минут",
|
||||
@@ -489,6 +499,36 @@ func orderWatchRefs(guid string) []string {
|
||||
func orderNoteNoCounter(guid string, w *OrderWatch, lines []orderLine) {
|
||||
for _, ln := range lines {
|
||||
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 строки
|
||||
if (st == nil || st.NoCounterAt.IsZero()) && ln.Ref != "" {
|
||||
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) }
|
||||
.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-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 } }
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user