10305a095e
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014FsEKyJrYF69Cj9ZXzFLRi
636 lines
25 KiB
Go
636 lines
25 KiB
Go
package lkgateway
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"log"
|
|
"net/http"
|
|
"os"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/m2m"
|
|
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/m2mcore"
|
|
)
|
|
|
|
// settlementPlaceINN возвращает ИНН места расчётов по коду площадки из ЛК:
|
|
// spb → СПБ Банк (7831000034), иначе (nrd/пусто) → НРД (7702165310). Это два
|
|
// единственных места расчётов из справочника M2M.
|
|
func settlementPlaceINN(place string) string {
|
|
if strings.EqualFold(strings.TrimSpace(place), "spb") {
|
|
return "7831000034"
|
|
}
|
|
return "7702165310"
|
|
}
|
|
|
|
// lkClaimInvestorKey — стабильный ключ инвестора для lkInvestorUUID: ЛК user_id
|
|
// (или id заявки, если пользователь не задан). Один инвестор → один UUID сделки.
|
|
func lkClaimInvestorKey(cl map[string]any) string {
|
|
if u := str(cl["user_id"]); u != "" && u != "0" {
|
|
return "esia-user:" + u
|
|
}
|
|
return "esia-claim:" + str(cl["id"])
|
|
}
|
|
|
|
// lkClaimLinks — guid отправленного в НРД запроса → id заявки в ЛК, чтобы по
|
|
// ответу НРД PATCH-нуть статус на их стороне. Переживает рестарт.
|
|
const lkLinksFile = "/var/lib/bj/.bj/lk-claim-links.json"
|
|
|
|
var (
|
|
lkLinksMu sync.Mutex
|
|
lkLinks = map[string]int{}
|
|
lkLoaded bool
|
|
)
|
|
|
|
func lkLinkGUID(guid string, _ LKEndpoint, claimID int) {
|
|
lkLinksMu.Lock()
|
|
lkEnsureLinksLoaded()
|
|
lkLinks[guid] = claimID
|
|
if raw, err := json.Marshal(lkLinks); err == nil {
|
|
_ = os.WriteFile(lkLinksFile, raw, 0o600)
|
|
}
|
|
lkLinksMu.Unlock()
|
|
// Персистентная запись происхождения — для отображения «путь из ЛК» в
|
|
// карточке. В отличие от lkLinks, НЕ удаляется после финального статуса.
|
|
lkRecordOrigin(guid, claimID)
|
|
}
|
|
func lkEnsureLinksLoaded() {
|
|
if lkLoaded {
|
|
return
|
|
}
|
|
lkLoaded = true
|
|
if raw, err := os.ReadFile(lkLinksFile); err == nil {
|
|
_ = json.Unmarshal(raw, &lkLinks)
|
|
}
|
|
}
|
|
|
|
// lkOrigin — guid → id заявки ЛК, ПОСТОЯННАЯ запись происхождения (для UI
|
|
// «путь заявки»). lkLinks чистится после финального PATCH, а origin остаётся.
|
|
const lkOriginFile = "/var/lib/bj/.bj/lk-origin.json"
|
|
|
|
var (
|
|
lkOriginMu sync.Mutex
|
|
lkOrigin = map[string]int{}
|
|
lkOriginLoaded bool
|
|
)
|
|
|
|
func lkEnsureOriginLoaded() {
|
|
if lkOriginLoaded {
|
|
return
|
|
}
|
|
lkOriginLoaded = true
|
|
if raw, err := os.ReadFile(lkOriginFile); err == nil {
|
|
_ = json.Unmarshal(raw, &lkOrigin)
|
|
return
|
|
}
|
|
// Бэкофилл: если постоянного файла ещё нет — засеваем из активных связок
|
|
// (уже отправленные, но не финализированные ЛК-заявки тоже покажут путь).
|
|
if raw, err := os.ReadFile(lkLinksFile); err == nil {
|
|
_ = json.Unmarshal(raw, &lkOrigin)
|
|
}
|
|
}
|
|
func lkRecordOrigin(guid string, claimID int) {
|
|
lkOriginMu.Lock()
|
|
defer lkOriginMu.Unlock()
|
|
lkEnsureOriginLoaded()
|
|
lkOrigin[guid] = claimID
|
|
if raw, err := json.Marshal(lkOrigin); err == nil {
|
|
_ = os.WriteFile(lkOriginFile, raw, 0o600)
|
|
}
|
|
}
|
|
|
|
// LKClaimIDByGUID возвращает id заявки ЛК по guid отправленного запроса и true,
|
|
// если заявка происходит из ЛК ЕСИА Финанс (для отображения пути в карточке).
|
|
func LKClaimIDByGUID(guid string) (int, bool) {
|
|
lkOriginMu.Lock()
|
|
defer lkOriginMu.Unlock()
|
|
lkEnsureOriginLoaded()
|
|
id, ok := lkOrigin[guid]
|
|
return id, ok
|
|
}
|
|
|
|
// lkSent — id заявок ЛК, уже отправленных в НРД (at-most-once защита от дублей
|
|
// переводов при повторных проходах поллера). Переживает рестарт.
|
|
const lkSentFile = "/var/lib/bj/.bj/lk-sent.json"
|
|
|
|
var (
|
|
lkSentMu sync.Mutex
|
|
lkSent = map[int]bool{}
|
|
lkSentLoaded bool
|
|
)
|
|
|
|
func lkEnsureSentLoaded() {
|
|
if lkSentLoaded {
|
|
return
|
|
}
|
|
lkSentLoaded = true
|
|
if raw, err := os.ReadFile(lkSentFile); err == nil {
|
|
var ids []int
|
|
if json.Unmarshal(raw, &ids) == nil {
|
|
for _, id := range ids {
|
|
lkSent[id] = true
|
|
}
|
|
}
|
|
}
|
|
}
|
|
func lkAlreadySent(id int) bool {
|
|
lkSentMu.Lock()
|
|
defer lkSentMu.Unlock()
|
|
lkEnsureSentLoaded()
|
|
return lkSent[id]
|
|
}
|
|
func lkMarkSent(id int) {
|
|
lkSentMu.Lock()
|
|
defer lkSentMu.Unlock()
|
|
lkEnsureSentLoaded()
|
|
lkSent[id] = true
|
|
ids := make([]int, 0, len(lkSent))
|
|
for k := range lkSent {
|
|
ids = append(ids, k)
|
|
}
|
|
if raw, err := json.Marshal(ids); err == nil {
|
|
_ = os.WriteFile(lkSentFile, raw, 0o600)
|
|
}
|
|
}
|
|
|
|
// OnLKOutcome вызывается nsdpoller по ответу НРД: PATCH статуса заявки в ЛК.
|
|
// status: "done" (исполнена) или "rejected" (отклонена). reason — в client_notation.
|
|
func OnLKOutcome(ctx context.Context, rc *RuntimeConfig, guid, status, reason string) {
|
|
lkLinksMu.Lock()
|
|
lkEnsureLinksLoaded()
|
|
id, ok := lkLinks[guid]
|
|
if ok {
|
|
delete(lkLinks, guid)
|
|
if raw, err := json.Marshal(lkLinks); err == nil {
|
|
_ = os.WriteFile(lkLinksFile, raw, 0o600)
|
|
}
|
|
}
|
|
lkLinksMu.Unlock()
|
|
if !ok {
|
|
return // не заявка из ЛК-коннектора
|
|
}
|
|
ep, okEp := lkActiveEndpoint(rc)
|
|
if !okEp {
|
|
return
|
|
}
|
|
fs := "done"
|
|
if status != "done" {
|
|
fs = "rejected"
|
|
}
|
|
body, _ := json.Marshal(map[string]any{"claim": map[string]any{"status": fs, "client_notation": reason}})
|
|
pc, pb := lkPatch(ctx, ep, "/claims/"+strconv.Itoa(id), body)
|
|
title, detail := "Статус в ЛК: «Исполнена»", reason
|
|
if fs == "rejected" {
|
|
title = "Статус в ЛК: «Отклонена»"
|
|
}
|
|
if pc >= 400 || pc == 0 {
|
|
title, detail = title+" — не записан", fmt.Sprintf("HTTP %d %s", pc, pb)
|
|
}
|
|
globalLKFlows.stepIfExists(guid, "response", title, detail)
|
|
}
|
|
|
|
// Коннектор к API ЕСИА Финанс (back_office). bj — КЛИЕНТ: забирает новые заявки
|
|
// depo_m2m, строит M2MTransferRequest, отправляет в НРД (робот на тесте) и
|
|
// возвращает статус через PATCH. Структуры — по swagger_backoffice_4-*.yaml.
|
|
//
|
|
// Поток (ручной триггер «Забрать заявки из ЛК» / позже — фоновый поллер):
|
|
// GET /claims?kind=depo_m2m&status=in_queue — новые заявки
|
|
// GET /users/{user_id} — ФИО/ДУЛ инвестора
|
|
// → маппинг → BuildRequest → SendBuiltRequest (в НРД)
|
|
// PATCH /claims/{id} {"claim":{"status":"in_process"}} — «В работе»
|
|
// Ответ НРД (Decision/ERROR) ловит nsdpoller и через OnLKOutcome PATCH-ит
|
|
// «Исполнена»/«Отклонена».
|
|
|
|
// lkHTTP — общий клиент к API ЛК с Basic активного окружения.
|
|
func lkActiveEndpoint(rc *RuntimeConfig) (LKEndpoint, bool) {
|
|
s := rc.Snapshot().LK
|
|
ep := s.Test
|
|
if s.ActiveEnv == "prod" {
|
|
ep = s.Prod
|
|
}
|
|
return ep, ep.BaseURL != "" && ep.Login != ""
|
|
}
|
|
|
|
func lkGet(ctx context.Context, ep LKEndpoint, path string) (int, []byte, error) {
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, lkAPIBase(ep.BaseURL)+path, nil)
|
|
if err != nil {
|
|
return 0, nil, err
|
|
}
|
|
req.Header.Set("Accept", "application/json")
|
|
req.SetBasicAuth(ep.Login, ep.Password)
|
|
resp, err := (&http.Client{Timeout: 20 * time.Second}).Do(req)
|
|
if err != nil {
|
|
return 0, nil, err
|
|
}
|
|
defer resp.Body.Close()
|
|
b, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
|
return resp.StatusCode, b, nil
|
|
}
|
|
|
|
func lkPatch(ctx context.Context, ep LKEndpoint, path string, body []byte) (int, string) {
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPatch, lkAPIBase(ep.BaseURL)+path, strings.NewReader(string(body)))
|
|
if err != nil {
|
|
return 0, err.Error()
|
|
}
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req.Header.Set("Accept", "application/json")
|
|
req.SetBasicAuth(ep.Login, ep.Password)
|
|
resp, err := (&http.Client{Timeout: 20 * time.Second}).Do(req)
|
|
if err != nil {
|
|
return 0, err.Error()
|
|
}
|
|
defer resp.Body.Close()
|
|
b, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
|
|
return resp.StatusCode, strings.TrimSpace(string(b))
|
|
}
|
|
|
|
// LKPullResult — итог одного прохода «забрать заявки».
|
|
type LKPullResult struct {
|
|
Fetched int
|
|
Sent int // отправлено в НРД (или имитатору)
|
|
Rejected int // отклонено сверкой до отправки
|
|
Emulated bool
|
|
Errors []string
|
|
}
|
|
|
|
// pollLKClaims — фоновый опрос ЛК ЕСИА Финанс раз в 10 секунд: подписанная клиентом заявка
|
|
// уходит в работу почти сразу (29.09.2026: минута — долго). Ничего не делает, если активное
|
|
// окружение ЛК не настроено (адрес/логин пустые). Одинаковая ошибка пишется в журнал не чаще раза в 5 мин.
|
|
func (s *Server) pollLKClaims(ctx context.Context) {
|
|
const interval = 10 * time.Second
|
|
var lastErr string
|
|
var lastErrAt time.Time
|
|
ticker := time.NewTicker(interval)
|
|
defer ticker.Stop()
|
|
log.Printf("lk-gateway: поллер заявок ЛК ЕСИА Финанс запущен (интервал %s)", interval)
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case <-ticker.C:
|
|
healthBeat("poll-lk")
|
|
if _, ok := lkActiveEndpoint(s.rc); !ok {
|
|
continue // ЛК не настроен — тихо ждём
|
|
}
|
|
if !moduleAllowed(s.rc, "esia-finance") {
|
|
continue // ЕСИА Финанс — отдельный модуль лицензии
|
|
}
|
|
// до 4 мин: блокировка в QUIK МНП может ждать, пока модуль подхватит нового получателя
|
|
cctx, cancel := context.WithTimeout(ctx, 4*time.Minute)
|
|
res, err := PullLKClaims(cctx, s.rc, s.svc)
|
|
cancel()
|
|
if err != nil {
|
|
if err.Error() != lastErr || time.Since(lastErrAt) > 5*time.Minute {
|
|
log.Printf("lk-gateway: поллер ЛК: %v", err)
|
|
lastErr, lastErrAt = err.Error(), time.Now()
|
|
}
|
|
continue
|
|
}
|
|
if lastErr != "" {
|
|
log.Printf("lk-gateway: поллер ЛК: связь с ЛК восстановлена")
|
|
lastErr = ""
|
|
}
|
|
// тот же проход: портфели ДЕПО из DEPO в ЛК (только изменившиеся, lkportfolio.go)
|
|
pctx, pcancel := context.WithTimeout(ctx, time.Minute)
|
|
lkPortfolioSyncTick(pctx, s.rc, false)
|
|
pcancel()
|
|
if res.Fetched > 0 || len(res.Errors) > 0 {
|
|
log.Printf("lk-gateway: поллер ЛК: получено %d, отправлено %d, отклонено %d, ошибок %d (имитация НРД: %v)",
|
|
res.Fetched, res.Sent, res.Rejected, len(res.Errors), res.Emulated)
|
|
for i, e := range res.Errors {
|
|
if i >= 5 {
|
|
log.Printf("lk-gateway: поллер ЛК: …ещё %d ошибок", len(res.Errors)-5)
|
|
break
|
|
}
|
|
log.Printf("lk-gateway: поллер ЛК: ошибка %s", e)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// PullLKClaims забирает новые depo_m2m заявки из ЛК и отправляет в НРД.
|
|
func PullLKClaims(ctx context.Context, rc *RuntimeConfig, svc *Service) (LKPullResult, error) {
|
|
var res LKPullResult
|
|
ep, ok := lkActiveEndpoint(rc)
|
|
if !ok {
|
|
return res, fmt.Errorf("ЛК: не настроено активное окружение (адрес/логин)")
|
|
}
|
|
res.Emulated, _ = lkEmulateNSD(rc)
|
|
// ВАЖНО: back_office фильтрует ТОЛЬКО по filter[...] (Rails strong params);
|
|
// плоские kind=/status= молча игнорируются, а список отдаётся id по
|
|
// возрастанию (старейшие) — свежая заявка на первую страницу не попадёт.
|
|
// order[]=-id — новые первыми. Статус фильтруем у себя: на тесте подписанная
|
|
// заявка встаёт «В очереди» (in_queue), в бою сразу «В работе» (in_process).
|
|
code, body, err := lkGet(ctx, ep, "/claims?filter%5Bkind%5D=depo_m2m&order%5B%5D=-id&per_page=50")
|
|
if err != nil {
|
|
return res, fmt.Errorf("GET /claims: %w", err)
|
|
}
|
|
if code != 200 {
|
|
return res, fmt.Errorf("GET /claims → HTTP %d: %s", code, snippet(body))
|
|
}
|
|
var list struct {
|
|
Claims []map[string]any `json:"claims"`
|
|
}
|
|
if json.Unmarshal(body, &list) != nil {
|
|
return res, fmt.Errorf("разбор списка заявок не удался")
|
|
}
|
|
// ВАЖНО: API back_office может игнорировать query-фильтры — фильтруем строго на
|
|
// нашей стороне: depo_m2m в статусе in_queue/in_process, которые bj ещё не
|
|
// обрабатывал (после обработки bj сам ставит «В работе» — такие пропускаем молча).
|
|
for _, c := range list.Claims {
|
|
if st := str(c["status"]); str(c["kind"]) != "depo_m2m" || (st != "in_queue" && st != "in_process") {
|
|
continue // чужой вид/статус — не наша заявка
|
|
}
|
|
id := toInt(c["id"])
|
|
if id == 0 || lkAlreadySent(id) {
|
|
continue
|
|
}
|
|
res.Fetched++
|
|
outcome, err := pullOneLKClaim(ctx, rc, svc, ep, id)
|
|
if err != nil {
|
|
res.Errors = append(res.Errors, fmt.Sprintf("#%d: %v", id, err))
|
|
continue
|
|
}
|
|
if outcome == "rejected" {
|
|
res.Rejected++
|
|
} else {
|
|
res.Sent++
|
|
}
|
|
}
|
|
return res, nil
|
|
}
|
|
|
|
// pullOneLKClaim: полная заявка → инвестор → M2M → НРД (или имитатор со сверкой DEPO,
|
|
// lkemulate.go) → PATCH «В работе». Возвращает "sent" или "rejected".
|
|
func pullOneLKClaim(ctx context.Context, rc *RuntimeConfig, svc *Service, ep LKEndpoint, id int) (string, error) {
|
|
code, body, err := lkGet(ctx, ep, "/claims/"+strconv.Itoa(id))
|
|
if err != nil || code != 200 {
|
|
return "", fmt.Errorf("GET /claims/%d → %d %v", id, code, err)
|
|
}
|
|
var wrap struct {
|
|
Claim map[string]any `json:"claim"`
|
|
}
|
|
if json.Unmarshal(body, &wrap) != nil || wrap.Claim == nil {
|
|
return "", fmt.Errorf("разбор заявки")
|
|
}
|
|
cl := wrap.Claim
|
|
data, _ := cl["data"].(map[string]any)
|
|
if data == nil {
|
|
return "", fmt.Errorf("нет data в заявке")
|
|
}
|
|
claimReq, err := lkClaimToRequest(ctx, rc, ep, cl, data)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
// Регистрируем клиента в справочнике (как для ЛК-подачи).
|
|
if ss, ok := svc.store.(*SeedStore); ok {
|
|
nsd := rc.Snapshot().NSD
|
|
settle := m2mcore.DepoAccount{
|
|
DeponentCode: nsd.DeponentCode,
|
|
AccountID: m2m.AccountID(nsd.AccountID),
|
|
SectionID: nsd.SectionID,
|
|
// SettlementRequisites.INN — это ИНН МЕСТА РАСЧЁТОВ (центральный
|
|
// депозитарий), а НЕ наш собственный ИНН: НРД 7702165310 либо СПБ
|
|
// Банк 7831000034 по place_of_payment. Иначе M2M19 «недопустимое
|
|
// место расчётов» (наш ИНН отсутствует в списке мест расчётов).
|
|
DepositoryINN: m2m.OrganizationINN(settlementPlaceINN(str(data["contragent_place_of_payment"]))),
|
|
}
|
|
ss.EnsureLKClient(lkUserFromClaim(cl, claimReq), nil, settle)
|
|
}
|
|
if emu, _ := lkEmulateNSD(rc); emu {
|
|
return lkProcessEmulated(ctx, rc, svc, ep, id, claimReq)
|
|
}
|
|
for _, sec := range claimReq.Securities {
|
|
if isin := sec.SecurityDetails.ISIN; isin != "" && !isRussianISIN(isin) {
|
|
lkRejectInvalid(ctx, rc, ep, id, &lkDataError{Code: "M2M01", Text: fmt.Sprintf("иностранная бумага %s — перевод M2M недоступен", isin)})
|
|
return "rejected", nil
|
|
}
|
|
}
|
|
req, err := svc.BuildRequest(ctx, claimReq)
|
|
if err != nil {
|
|
if errors.Is(err, errReceiverUnknown) { // справочник есть, а депозитария в нём нет — повтор не поможет
|
|
lkRejectInvalid(ctx, rc, ep, id, &lkDataError{Code: "M2M15", Text: "депозитарий получателя не найден в справочнике участников M2M НРД — проверьте ИНН депозитария получателя"})
|
|
return "rejected", nil
|
|
}
|
|
if strings.Contains(err.Error(), "невалиден") { // данные не проходят формат НРД — повтор не поможет
|
|
lkRejectInvalid(ctx, rc, ep, id, &lkDataError{Text: "данные заявки не прошли проверку формата НРД: " + err.Error()})
|
|
return "rejected", nil
|
|
}
|
|
return "", fmt.Errorf("BuildRequest: %w", err)
|
|
}
|
|
// At-most-once: если заявка уже отправлялась в НРД — не шлём повторно
|
|
// (иначе поллер каждую минуту плодил бы дубли переводов при ошибке PATCH).
|
|
if lkAlreadySent(id) {
|
|
return "", fmt.Errorf("заявка #%d уже отправлялась в НРД — повторная отправка подавлена", id)
|
|
}
|
|
if _, err := svc.SendBuiltRequest(ctx, req, claimReq.Investor.ID, nil); err != nil {
|
|
// Отправка не удалась (напр. до реального Send в НРД) — НЕ помечаем
|
|
// отправленной, чтобы можно было повторить после устранения причины.
|
|
return "", fmt.Errorf("отправка в НРД: %w", err)
|
|
}
|
|
// Успешно ушло в НРД — фиксируем, чтобы повторный проход не создал дубль.
|
|
lkMarkSent(id)
|
|
// Связка guid → id заявки ЛК для обратного PATCH статуса.
|
|
lkLinkGUID(string(req.Header.GUID), ep, id)
|
|
// «В работе».
|
|
pc, pb := lkPatch(ctx, ep, "/claims/"+strconv.Itoa(id), []byte(`{"claim":{"status":"in_process"}}`))
|
|
if pc >= 400 {
|
|
return "", fmt.Errorf("отправлено в НРД, но PATCH статуса не прошёл: HTTP %d %s", pc, pb)
|
|
}
|
|
return "sent", nil
|
|
}
|
|
|
|
// lkClaimToRequest маппит заявку ЕСИА Финанс → наш CreateClaimRequest.
|
|
func lkClaimToRequest(ctx context.Context, rc *RuntimeConfig, ep LKEndpoint, cl, data map[string]any) (CreateClaimRequest, error) {
|
|
var req CreateClaimRequest
|
|
nsd := rc.Snapshot().NSD
|
|
|
|
// Бумага (paper вложен; paper_count плоско).
|
|
paper, _ := data["paper"].(map[string]any)
|
|
// Данные заявки здесь не браковать: заявка должна попасть в журнал и пройти проверку
|
|
// шлюза, которая и ответит отказом (lkemulate.go / lkflow.go).
|
|
isin := strings.ToUpper(strings.TrimSpace(str(paper["isin"])))
|
|
var qty Quantity
|
|
pc := toFloat(data["paper_count"])
|
|
switch {
|
|
case pc <= 0:
|
|
qty.Fractional = "0" // пакет соберётся без проверки формата, отказ даст сверка
|
|
case pc == float64(uint64(pc)):
|
|
qty.Whole = uint64(pc)
|
|
default:
|
|
qty.Fractional = strconv.FormatFloat(pc, 'f', -1, 64)
|
|
}
|
|
sd := SecurityDetails{}
|
|
code := isin
|
|
if isin != "" {
|
|
sd.ISIN = isin
|
|
} else {
|
|
code = str(paper["registration_number"])
|
|
sd.SecurityInfo = &SecurityInfo{IdentificationDetails: IdentificationDetails{RegNumber: str(paper["registration_number"])}}
|
|
}
|
|
sec := ClaimSec{
|
|
SecurityCode: code,
|
|
SecurityDetails: sd,
|
|
Quantity: qty,
|
|
SettlementAccounts: []SettlementAccount{{
|
|
SettlementRequisitesINN: transferringINN(rc.Snapshot()),
|
|
SettlementLocation: SettlementLocation{DeponentCode: nsd.DeponentCode, AccountID: nsd.AccountID, SectionID: nsd.SectionID},
|
|
}},
|
|
}
|
|
|
|
// Инвестор — из GET /users/{user_id}. ID инвестора хранится в uuid-колонке
|
|
// журнала сделок, поэтому строим детерминированный UUIDv5 из ЛК-идентификатора
|
|
// (один инвестор ЛК → один UUID во всех его заявках).
|
|
inv := Investor{ID: lkInvestorUUID(lkClaimInvestorKey(cl))}
|
|
if uid := toInt(cl["user_id"]); uid != 0 {
|
|
if _, ub, err := lkGet(ctx, ep, "/users/"+strconv.Itoa(uid)); err == nil {
|
|
var uw map[string]any
|
|
if json.Unmarshal(ub, &uw) == nil {
|
|
u := pickMap(uw, "user", "data")
|
|
if u == nil {
|
|
u = uw
|
|
}
|
|
inv.LastName = str(u["surname"])
|
|
inv.FirstName = str(u["name"])
|
|
inv.MiddleName = str(u["patronymic"])
|
|
inv.BirthDate = firstNonEmpty(u, "birthday", "birth_date", "date_of_birth")
|
|
inv.Document = Document{
|
|
DocumentType: mapDocType(str(u["document_type"])),
|
|
// На стенде поля паспорта: pass_series / pass_number.
|
|
Series: firstNonEmpty(u, "pass_series", "document_series", "series", "passport_series"),
|
|
Number: firstNonEmpty(u, "pass_number", "document_number", "number", "passport_number"),
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
cost := CostInfo{No: &struct{}{}}
|
|
if b, _ := data["expense_information"].(bool); b {
|
|
cost = CostInfo{Yes: &CostInfoYes{Code: nsd.DeponentCode}}
|
|
}
|
|
req = CreateClaimRequest{
|
|
Investor: inv,
|
|
TransferringDepositoryINN: transferringINN(rc.Snapshot()),
|
|
ReceivingDepositoryINN: str(data["contragent_inn"]),
|
|
CostInfo: cost,
|
|
Securities: []ClaimSec{sec},
|
|
SignatureFormat: "ESIA",
|
|
ClientAccount: strings.TrimSpace(str(data["account"])),
|
|
}
|
|
// ТЕСТ-МАРШРУТ К РОБОТУ НРД. Если наименование контрагента содержит маркер
|
|
// «РОБОТ <сценарий>» (1111/2001/2002/3333) — заявка адресуется роботу
|
|
// MC0012500000, а серия/номер ДУЛ переопределяются на сценарий (робот
|
|
// читает сценарий из DocumentSeries). Прод-заявки этим маркером не
|
|
// затрагиваются. Позволяет прогонять все сценарии через путь ЛК.
|
|
// Только в режиме разработки: у клиентов маршрута к роботу нет.
|
|
if sc := lkDetectRobotScenario(str(data["contragent_name"])); sc != "" && devMode(rc) {
|
|
doc := Document{DocumentType: "21", Series: sc, Number: robotDocumentNumbers[sc]}
|
|
req.Investor.Document = doc
|
|
req.InvestorDocumentOverride = &doc
|
|
req.ReceiverCodeOverride = NSDRobotCode
|
|
// Робот проверяет реквизиты по своему эталонному набору: конкретные ИНН
|
|
// передающего/зачисляющего депозитариев и бумаги с кодом НРД (не ISIN),
|
|
// иначе МОСТ отклоняет (M2M18/M2M30). Подставляем ровно как в эталонном
|
|
// buildRobotClaimRequest — ЛК-заявка служит только триггером сценария.
|
|
req.TransferringDepositoryINN = "7703807489"
|
|
req.ReceivingDepositoryINN = "7722061076"
|
|
req.CostInfo = CostInfo{Yes: &CostInfoYes{Code: nsd.DeponentCode}}
|
|
req.Securities = buildRobotSecurities(nsd.DeponentCode, nsd.AccountID, nsd.SectionID, 0)
|
|
}
|
|
return req, nil
|
|
}
|
|
|
|
// lkDetectRobotScenario извлекает код сценария робота из наименования
|
|
// контрагента ЛК-заявки (маркер вида «РОБОТ 2001» / «robot-1111»). Возвращает
|
|
// "" если маркера нет — тогда заявка идёт обычным путём.
|
|
func lkDetectRobotScenario(contragentName string) string {
|
|
n := strings.ToUpper(contragentName)
|
|
if !strings.Contains(n, "РОБОТ") && !strings.Contains(n, "ROBOT") {
|
|
return ""
|
|
}
|
|
for _, sc := range []string{"1111", "2001", "2002", "3333"} {
|
|
if strings.Contains(n, sc) {
|
|
return sc
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// --- вспомогательное ---
|
|
|
|
func lkUserFromClaim(cl map[string]any, req CreateClaimRequest) LKUser {
|
|
return LKUser{
|
|
Login: "lk:" + str(cl["id"]), Contract: req.Investor.ID,
|
|
LastName: req.Investor.LastName, FirstName: req.Investor.FirstName, MiddleName: req.Investor.MiddleName,
|
|
DocType: req.Investor.Document.DocumentType, DocSeries: req.Investor.Document.Series, DocNumber: req.Investor.Document.Number,
|
|
Active: true,
|
|
}
|
|
}
|
|
|
|
func mapDocType(t string) string {
|
|
if t == "passport" || t == "" {
|
|
return "21" // паспорт РФ по справочнику НРД
|
|
}
|
|
return t
|
|
}
|
|
|
|
func firstNonEmpty(m map[string]any, keys ...string) string {
|
|
for _, k := range keys {
|
|
if v := str(m[k]); v != "" {
|
|
return v
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
func pickMap(m map[string]any, keys ...string) map[string]any {
|
|
for _, k := range keys {
|
|
if v, ok := m[k].(map[string]any); ok {
|
|
return v
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
func str(v any) string {
|
|
if v == nil {
|
|
return ""
|
|
}
|
|
if s, ok := v.(string); ok {
|
|
return s
|
|
}
|
|
return fmt.Sprintf("%v", v)
|
|
}
|
|
func toInt(v any) int {
|
|
switch n := v.(type) {
|
|
case float64:
|
|
return int(n)
|
|
case int:
|
|
return n
|
|
case string:
|
|
i, _ := strconv.Atoi(n)
|
|
return i
|
|
}
|
|
return 0
|
|
}
|
|
func toFloat(v any) float64 {
|
|
switch n := v.(type) {
|
|
case float64:
|
|
return n
|
|
case string:
|
|
f, _ := strconv.ParseFloat(strings.ReplaceAll(n, ",", "."), 64)
|
|
return f
|
|
}
|
|
return 0
|
|
}
|
|
func snippet(b []byte) string {
|
|
if len(b) > 200 {
|
|
return string(b[:200]) + "…"
|
|
}
|
|
return string(b)
|
|
}
|