feat(bj): модуль ЛК на PostgreSQL, обмен с DEPO, боевой ЛК с имитатором НРД, QUIK, НПР
- bj_exchange и «кран» Fancy убраны: данные модуля ЛК в PostgreSQL (схема lk) - трансферные таблицы FTV_M2M: создание при установке, события, справочник участников - сверка с DEPO: счёт, ФИО, дата рождения, ИИС по договору, квал-инвестор из опции DEPO - боевой ЛК ЕСИА Финанс при тестовом ИШ: журнал, отказ через DEPO, статусы в ЛК - онлайн-лицензирование в bj, план тестирования M2M для контрагента (интерактивный HTML) - модули QUIK МНП, экспорт QUIK, НПР/УДС; документы и тестовые материалы Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,612 @@
|
||||
package lkgateway
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"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 — фоновый опрос ЛК ЕСИА Финанс раз в минуту. Время ответов в
|
||||
// сервисе регламентировано, поэтому чаще опрашивать смысла нет. Ничего не
|
||||
// делает, если активное окружение ЛК не настроено (адрес/логин пустые).
|
||||
func (s *Server) pollLKClaims(ctx context.Context) {
|
||||
const interval = time.Minute
|
||||
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 // ЛК не настроен — тихо ждём
|
||||
}
|
||||
cctx, cancel := context.WithTimeout(ctx, 50*time.Second)
|
||||
res, err := PullLKClaims(cctx, s.rc, s.svc)
|
||||
cancel()
|
||||
if err != nil {
|
||||
log.Printf("lk-gateway: поллер ЛК: %v", err)
|
||||
continue
|
||||
}
|
||||
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 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 != "" {
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user