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) }