ЛК: итог «Исполнена» по MS010, GUID без учёта регистра, заявка при отказе МНП берётся один раз; тестовый контур не трогает боевой ЛК

Перевод 97EAD00C (заявка ЛК #71741, 23.10.2026) исполнен в QUIK и НРД (MS010),
но в ЛК статус не изменился, а сделка осталась «Подтверждена»:
- связки guid → заявка ЛК искались с учётом регистра: мы шлём GUID строчными,
  НРД отвечает заглавными — OnLKOutcome молча не находил заявку. Ключи —
  строчными (lkGUIDKey), старые записи приводятся при загрузке; итог модуля
  ЛК (lk.transfer_requests) — lower(guid);
- после MS010 по всем строкам поручения 16 сделка → «Исполнена» (CompleteSUB16),
  итог — в ЛК (outOnExecuted, каждый проход order-watch, повтор безвреден);
  в бою согласие контрагента — ещё не «Исполнена»: статус ждёт MS010, как в
  тесте «боевой ЛК → TEST3»; имитатор НРД — итог сразу.

Тестовый 28 с боевым ЛК взял ту же заявку и после отказа МНП («запрещен для
операции шорт» — бумагу уже заблокировал 9) брал её каждые 10 секунд: 108
сделок с новыми GUID и столько же писем.
- Сделка заведена, а отправка не прошла (МНП не заблокировал) — заявка
  помечается взятой и связывается с переводом: дальше — оператор в карточке.
- Боевой ЛК при тестовом профиле ИШ (lkProdOnTestNSD): заявки клиентов не
  берутся (кроме теста «боевой ЛК → тестовый НРД»), портфели не заполняются,
  предупреждение в настройках ЛК.
- Письма: в теме и первой строке — сервер bj и контур НРД («[bj ТЕСТ …]»
  у тестового); письма о входящих — метка только у тестового.
- История FSM — по МСК с датой, «текущее состояние» вместо «сейчас».
- Карточка теста «боевой ЛК → тестовый НРД» — только в режиме разработки
  (включённый тест виден всегда, чтобы его выключить).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q7eREaYwUt1i4fNLZcGnjn
This commit is contained in:
fontvielle
2026-10-23 16:04:44 +03:00
parent ffd69b0698
commit 32d88fc42a
11 changed files with 333 additions and 20 deletions
+84 -10
View File
@@ -39,7 +39,7 @@ func lkClaimInvestorKey(cl map[string]any) string {
// lkClaimLinks — guid отправленного в НРД запроса → id заявки в ЛК, чтобы по
// ответу НРД PATCH-нуть статус на их стороне. Переживает рестарт.
const lkLinksFile = "/var/lib/bj/.bj/lk-claim-links.json"
var lkLinksFile = "/var/lib/bj/.bj/lk-claim-links.json" // var — тесты подменяют путь
var (
lkLinksMu sync.Mutex
@@ -47,10 +47,24 @@ var (
lkLoaded bool
)
// lkGUIDKey — ключ связок по GUID без учёта регистра: мы шлём GUID строчными, НРД отвечает
// заглавными (23.10.2026: по ответу 97EAD00C-… связка 97ead00c-… не нашлась — статус в ЛК не ушёл).
func lkGUIDKey(guid string) string { return strings.ToLower(strings.TrimSpace(guid)) }
// lkNormKeys — ключи связок из файла к одному регистру (записи до 23.10.2026).
func lkNormKeys(m map[string]int) {
for k, v := range m {
if n := lkGUIDKey(k); n != k {
delete(m, k)
m[n] = v
}
}
}
func lkLinkGUID(guid string, _ LKEndpoint, claimID int) {
lkLinksMu.Lock()
lkEnsureLinksLoaded()
lkLinks[guid] = claimID
lkLinks[lkGUIDKey(guid)] = claimID
if raw, err := json.Marshal(lkLinks); err == nil {
_ = os.WriteFile(lkLinksFile, raw, 0o600)
}
@@ -67,11 +81,12 @@ func lkEnsureLinksLoaded() {
if raw, err := os.ReadFile(lkLinksFile); err == nil {
_ = json.Unmarshal(raw, &lkLinks)
}
lkNormKeys(lkLinks)
}
// lkOrigin — guid → id заявки ЛК, ПОСТОЯННАЯ запись происхождения (для UI
// «путь заявки»). lkLinks чистится после финального PATCH, а origin остаётся.
const lkOriginFile = "/var/lib/bj/.bj/lk-origin.json"
var lkOriginFile = "/var/lib/bj/.bj/lk-origin.json"
var (
lkOriginMu sync.Mutex
@@ -84,6 +99,7 @@ func lkEnsureOriginLoaded() {
return
}
lkOriginLoaded = true
defer lkNormKeys(lkOrigin)
if raw, err := os.ReadFile(lkOriginFile); err == nil {
_ = json.Unmarshal(raw, &lkOrigin)
return
@@ -98,7 +114,7 @@ func lkRecordOrigin(guid string, claimID int) {
lkOriginMu.Lock()
defer lkOriginMu.Unlock()
lkEnsureOriginLoaded()
lkOrigin[guid] = claimID
lkOrigin[lkGUIDKey(guid)] = claimID
if raw, err := json.Marshal(lkOrigin); err == nil {
_ = os.WriteFile(lkOriginFile, raw, 0o600)
}
@@ -110,10 +126,19 @@ func LKClaimIDByGUID(guid string) (int, bool) {
lkOriginMu.Lock()
defer lkOriginMu.Unlock()
lkEnsureOriginLoaded()
id, ok := lkOrigin[guid]
id, ok := lkOrigin[lkGUIDKey(guid)]
return id, ok
}
// lkLinked — заявка ЛК ещё ждёт итогового статуса (связка не закрыта OnLKOutcome).
func lkLinked(guid string) bool {
lkLinksMu.Lock()
defer lkLinksMu.Unlock()
lkEnsureLinksLoaded()
_, ok := lkLinks[lkGUIDKey(guid)]
return ok
}
// lkSent — id заявок ЛК, уже отправленных в НРД (at-most-once защита от дублей
// переводов при повторных проходах поллера). Переживает рестарт.
const lkSentFile = "/var/lib/bj/.bj/lk-sent.json"
@@ -163,9 +188,9 @@ func lkMarkSent(id int) {
func OnLKOutcome(ctx context.Context, rc *RuntimeConfig, guid, status, reason string) {
lkLinksMu.Lock()
lkEnsureLinksLoaded()
id, ok := lkLinks[guid]
id, ok := lkLinks[lkGUIDKey(guid)]
if ok {
delete(lkLinks, guid)
delete(lkLinks, lkGUIDKey(guid))
if raw, err := json.Marshal(lkLinks); err == nil {
_ = os.WriteFile(lkLinksFile, raw, 0o600)
}
@@ -345,7 +370,7 @@ func PullLKClaims(ctx context.Context, rc *RuntimeConfig, svc *Service) (LKPullR
continue // чужой вид/статус — не наша заявка
}
id := toInt(c["id"])
if id == 0 || lkAlreadySent(id) {
if id == 0 || lkAlreadySent(id) || lkTestSkipped(id) {
continue
}
res.Fetched++
@@ -354,6 +379,10 @@ func PullLKClaims(ctx context.Context, rc *RuntimeConfig, svc *Service) (LKPullR
res.Errors = append(res.Errors, fmt.Sprintf("#%d: %v", id, err))
continue
}
if outcome == "skipped" {
res.Fetched--
continue
}
if outcome == "rejected" {
res.Rejected++
} else {
@@ -385,6 +414,13 @@ func pullOneLKClaim(ctx context.Context, rc *RuntimeConfig, svc *Service, ep LKE
if err != nil {
return "", err
}
if lkProdOnTestNSD(rc) && !realTestMatch(rc, claimReq.Investor.Document.Series, claimReq.Investor.Document.Number) {
// Заявка клиента — для боевого сервера: здесь её не трогаем (ни QUIK, ни DEPO, ни статус в ЛК).
lkTestSkip(id)
log.Printf("lk-gateway: ЛК #%d: заявка клиента не взята — боевой ЛК на сервере с тестовым контуром НРД (%s); берутся только заявки теста «боевой ЛК → тестовый НРД»",
id, rc.Snapshot().NSD.Profile)
return "skipped", nil
}
// Регистрируем клиента в справочнике (как для ЛК-подачи).
if ss, ok := svc.store.(*SeedStore); ok {
nsd := rc.Snapshot().NSD
@@ -427,8 +463,16 @@ func pullOneLKClaim(ctx context.Context, rc *RuntimeConfig, svc *Service, ep LKE
return "", fmt.Errorf("заявка #%d уже отправлялась в НРД — повторная отправка подавлена", id)
}
if _, err := svc.SendBuiltRequest(ctx, req, claimReq.Investor.ID, nil); err != nil {
// Отправка не удалась (напр. до реального Send в НРД) — НЕ помечаем
// отправленной, чтобы можно было повторить после устранения причины.
// Сделка уже заведена (напр. QUIK МНП не заблокировал бумаги) — заявку берём один раз:
// дальше перевод повторяет или отклоняет оператор в карточке, итог уйдёт в ЛК по связке.
// Иначе каждый проход заводил новую сделку с новым GUID и новым письмом (23.10.2026:
// 108 повторов заявки #71741 за 20 минут).
if d, gerr := svc.Repo().GetByGUID(ctx, req.Header.GUID); gerr == nil && d != nil {
lkMarkSent(id)
lkLinkGUID(string(req.Header.GUID), ep, id)
return "", fmt.Errorf("отправка в НРД: %w — перевод %s ждёт оператора в карточке, повторно заявка не берётся", err, shortGUID(string(req.Header.GUID)))
}
// Сделки нет (сбой до её заведения) — НЕ помечаем, повторим после устранения причины.
return "", fmt.Errorf("отправка в НРД: %w", err)
}
// Успешно ушло в НРД — фиксируем, чтобы повторный проход не создал дубль.
@@ -443,6 +487,36 @@ func pullOneLKClaim(ctx context.Context, rc *RuntimeConfig, svc *Service, ep LKE
return "sent", nil
}
// lkProdOnTestNSD — боевой ЛК на сервере с тестовым контуром НРД (23.10.2026: тестовый 28 с боевым ЛК
// брал заявки клиентов одновременно с боевым 9 и пытался заблокировать те же бумаги в МНП). Такой
// сервер берёт только заявки теста «боевой ЛК → тестовый НРД» и не заполняет портфели в ЛК.
func lkProdOnTestNSD(rc *RuntimeConfig) bool {
if rc == nil {
return false
}
s := rc.Snapshot()
return s.LK.ActiveEnv == "prod" && !nsdProfileProd(s.NSD.Profile)
}
// Заявки ЛК, не взятые сервером с тестовым контуром (lkProdOnTestNSD), — чтобы не запрашивать их
// каждые 10 секунд. В памяти: после перезапуска (и смены настроек) проверяются заново.
var lkTestSkips = struct {
sync.Mutex
m map[int]bool
}{m: map[int]bool{}}
func lkTestSkip(id int) {
lkTestSkips.Lock()
lkTestSkips.m[id] = true
lkTestSkips.Unlock()
}
func lkTestSkipped(id int) bool {
lkTestSkips.Lock()
defer lkTestSkips.Unlock()
return lkTestSkips.m[id]
}
// lkClaimToRequest маппит заявку ЕСИА Финанс → наш CreateClaimRequest.
func lkClaimToRequest(ctx context.Context, rc *RuntimeConfig, ep LKEndpoint, cl, data map[string]any) (CreateClaimRequest, error) {
var req CreateClaimRequest