Files
Bridge-and-Join-s/internal/lkgateway/lkportfolio.go
T
fontvielle 32d88fc42a ЛК: итог «Исполнена» по 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
2026-10-23 16:04:44 +03:00

746 lines
31 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package lkgateway
// Синхронизация депозитарного портфеля клиента из DEPO в ЛК ЕСИА Финанс (29.09.2026): метод back_office
// POST /portfolio/import?import_type=depo_portfolio. Форма заявки M2M в ЛК берёт бумаги клиента из этого
// портфеля (по счёту депо и разделу). Источники полей:
// - остатки, счёт депо, ISIN, наименование — DEPO (OD_RESTS / OD_VALUES);
// - вид бумаги (paper_type) — код CFI в DEPO: ED — депозитарная расписка, E — акция, D — облигация, C — фонд;
// - номер гос. регистрации — экспорт QUIK (Securities.RegNumber); в DEPO его нет, а ЛК без него не принимает;
// - раздел счёта — «Основной» (решение 29.09.2026: в DEPO и в ЛК разделов нет, ЛК требует непустой),
// место хранения — «НРД».
// Бумаги без номера гос. регистрации не отправляются (ЛК отвергает весь импорт) — они в отчёте.
import (
"context"
"crypto/sha256"
"database/sql"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"sort"
"strings"
"sync"
"time"
)
const (
lkPortfolioSection = "Основной"
lkPortfolioStorage = "НРД"
)
// LKPortfolioLine — строка портфеля ДЕПО для ЛК.
type LKPortfolioLine struct {
ISIN string `json:"isin"`
Name string `json:"name"`
RegNumber string `json:"registration_number"`
Count float64 `json:"count"`
DepoAccount string `json:"depo_account"`
Section string `json:"depo_account_section"`
Storage string `json:"storage"`
PaperType string `json:"paper_type"`
}
// lkPaperType — вид бумаги по коду CFI (ISO 10962).
func lkPaperType(cfi, name string) string {
cfi = strings.ToUpper(strings.TrimSpace(cfi))
switch {
case strings.HasPrefix(cfi, "ED"):
return "depositary_receipt"
case strings.HasPrefix(cfi, "E"):
return "share"
case strings.HasPrefix(cfi, "D"):
return "bond"
case strings.HasPrefix(cfi, "C"):
return "fund"
}
low := strings.ToLower(name)
switch {
case strings.Contains(low, "облиг") || strings.Contains(low, "офз") || strings.Contains(low, " обл"):
return "bond"
case strings.Contains(low, "пиф") || strings.Contains(low, "пай") || strings.Contains(low, "фонд"):
return "fund"
case strings.Contains(low, "расписк"):
return "depositary_receipt"
}
return "share"
}
// lkPortfolioLines — портфель клиента (портфель DEPO, напр. «БР-859») для ЛК; onlyISIN — только эти бумаги
// (пусто — все). skipped — бумаги, которые не отправляются, с причиной.
func lkPortfolioLines(ctx context.Context, rc *RuntimeConfig, depoPort string, onlyISIN map[string]bool) (lines []LKPortfolioLine, skipped []string, err error) {
fs := rc.Snapshot().Fancy
db, err := fancyOpen(fs, fs.DepoDB())
if err != nil {
return nil, nil, fmt.Errorf("DEPO: %w", err)
}
defer db.Close()
pos, err := depoClientPositionsDB(ctx, db, depoPort)
if err != nil {
return nil, nil, fmt.Errorf("DEPO: %w", err)
}
lines, skipped = lkPortfolioLinesFrom(ctx, rc, db, pos, onlyISIN)
return lines, skipped, nil
}
// lkPortfolioLinesFrom — строки для ЛК из уже прочитанных остатков; db — открытая база DEPO (вид бумаги
// и номер гос. регистрации, кэш на час).
func lkPortfolioLinesFrom(ctx context.Context, rc *RuntimeConfig, db *sql.DB, pos []DepoPosition, onlyISIN map[string]bool) (lines []LKPortfolioLine, skipped []string) {
ps := rc.Snapshot().LK.PortfolioSync
qe, qerr := nprDB(rc) // экспорт QUIK (только чтение) — номер гос. регистрации
for _, p := range pos {
isin := strings.ToUpper(strings.TrimSpace(p.ISIN))
if isin == "" || (len(onlyISIN) > 0 && !onlyISIN[isin]) || p.Quantity <= 0 {
continue
}
cfi, reg, cached := lkSecInfoCached(isin)
if !cached {
// Номер гос. регистрации — из DEPO (OD_SHARES.NUM_REG — выпуск бумаги, OD_SHARES.ID = OD_VALUES.ID),
// запасной — экспорт QUIK. Проверено на DEPO 28: у Сбербанка 10301481B, у ОФЗ 26222RMFS.
_ = db.QueryRowContext(ctx, `SELECT TOP 1 ISNULL(v.CFI,''), ISNULL(s.NUM_REG,'') FROM OD_VALUES v LEFT JOIN OD_SHARES s ON s.ID=v.ID
WHERE v.ISIN=@i ORDER BY CASE WHEN ISNULL(s.NUM_REG,'')<>'' THEN 0 ELSE 1 END, v.ID`, sql.Named("i", isin)).Scan(&cfi, &reg)
if strings.TrimSpace(reg) == "" && qerr == nil {
_ = qe.QueryRow(ctx, `SELECT "RegNumber" FROM "Securities" WHERE "ISINCode"=$1 AND COALESCE("RegNumber",'')<>'' LIMIT 1`, isin).Scan(&reg)
}
if strings.TrimSpace(reg) != "" || qerr == nil {
lkSecInfoPut(isin, cfi, reg)
}
}
if strings.TrimSpace(reg) == "" {
why := "нет номера гос. регистрации ни в DEPO, ни в экспорте QUIK (иностранные бумаги, КСУ)"
if qerr != nil {
why = "нет номера гос. регистрации в DEPO (экспорт QUIK не подключён)"
}
skipped = append(skipped, fmt.Sprintf("%s %s (%s): %s", isin, p.SecurityName, p.AccountNum, why))
continue
}
lines = append(lines, LKPortfolioLine{
ISIN: isin, Name: strings.TrimSpace(p.SecurityName), RegNumber: strings.TrimSpace(reg), Count: p.Quantity,
DepoAccount: p.AccountNum, Section: ps.section(), Storage: ps.storage(), PaperType: lkPaperType(cfi, p.SecurityName),
})
}
return lines, skipped
}
// lkPortfolioByPort — остатки из общего запроса (depoAllPositions) по портфелям пар, как в Dp01: портфель
// совпадает или начинается с него и пробела («БР-1001» → «БР-1001 Гравс Геннадий»). Порядок строк — по
// бумаге, счёту, ISIN: от него зависит отпечаток портфеля, ЛК повторно не получает тот же портфель.
func lkPortfolioByPort(all map[string][]DepoPosition, prs []lkPortfolioPair) map[string][]DepoPosition {
want := map[string]bool{}
for _, pr := range prs {
want[pr.Depo] = true
}
out := map[string][]DepoPosition{}
for port, pos := range all {
for i := 0; i <= len(port); i++ {
if i == len(port) || port[i] == ' ' {
if k := port[:i]; want[k] {
out[k] = append(out[k], pos...)
}
}
}
}
for _, pos := range out {
sort.SliceStable(pos, func(i, j int) bool {
a, b := pos[i], pos[j]
if a.SecurityName != b.SecurityName {
return a.SecurityName < b.SecurityName
}
if a.AccountNum != b.AccountNum {
return a.AccountNum < b.AccountNum
}
return a.ISIN < b.ISIN
})
}
return out
}
// lkPortfolioPush — отправить портфель ДЕПО договора ЛК (номер договора депо в ЛК, напр. «ДД-816»).
func lkPortfolioPush(ctx context.Context, rc *RuntimeConfig, lkContract string, lines []LKPortfolioLine) (int, string, error) {
ep, ok := lkActiveEndpoint(rc)
if !ok {
return 0, "", fmt.Errorf("ЛК не настроен")
}
body, _ := json.Marshal(map[string]any{"data": map[string]any{lkContract: lines}})
req, err := http.NewRequestWithContext(ctx, http.MethodPost, lkAPIBase(ep.BaseURL)+"/portfolio/import?import_type=depo_portfolio", strings.NewReader(string(body)))
if err != nil {
return 0, "", err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
req.SetBasicAuth(ep.Login, ep.Password)
resp, err := (&http.Client{Timeout: 60 * time.Second}).Do(req)
if err != nil {
return 0, "", err
}
defer resp.Body.Close()
b, _ := io.ReadAll(io.LimitReader(resp.Body, 64<<10))
return resp.StatusCode, strings.TrimSpace(string(b)), nil
}
// Кэш вида бумаги (CFI) и номера гос. регистрации на час: портфель сверяется каждые 10 секунд.
var lkSecInfo = struct {
sync.Mutex
m map[string]lkSecInfoEntry
}{m: map[string]lkSecInfoEntry{}}
type lkSecInfoEntry struct {
cfi, reg string
at time.Time
}
func lkSecInfoCached(isin string) (cfi, reg string, ok bool) {
lkSecInfo.Lock()
defer lkSecInfo.Unlock()
e, ok := lkSecInfo.m[isin]
if !ok || time.Since(e.at) > time.Hour {
return "", "", false
}
return e.cfi, e.reg, true
}
func lkSecInfoPut(isin, cfi, reg string) {
lkSecInfo.Lock()
lkSecInfo.m[isin] = lkSecInfoEntry{cfi: cfi, reg: reg, at: time.Now()}
lkSecInfo.Unlock()
}
// LKPortfolioSyncSettings — какие договоры ЛК синхронизировать и с какими портфелями DEPO.
type LKPortfolioSyncSettings struct {
Enabled bool `json:"enabled"`
// Pairs — «договор депо в ЛК = портфель DEPO» через «;», напр. «ДД-816=БР-859».
Pairs string `json:"pairs,omitempty"`
// Именование у депозитариев разное — всё настраивается; пусто — как у Фонтвьель.
AccountPrefix string `json:"account_prefix,omitempty"` // префикс торгового счёта в DEPO: «ВТНКЦ»
ContractTemplate string `json:"contract_template,omitempty"` // номер договора депо в ЛК: «ДД-{n}», {n} — номер счёта без префикса и ведущих нулей
Section string `json:"section,omitempty"` // раздел счёта депо в ЛК: «Основной»
Storage string `json:"storage,omitempty"` // место хранения: «НРД»
// AllClients — заполнять портфель ВСЕМ клиентам: все открытые торговые счета DEPO
// (по префиксу), договор ЛК выводится из счёта по шаблону. Иначе — только Pairs.
AllClients bool `json:"all_clients,omitempty"`
}
func (s LKPortfolioSyncSettings) accountPrefix() string {
return pickStr(strings.TrimSpace(s.AccountPrefix), depoAccPrefix())
}
func (s LKPortfolioSyncSettings) contractTemplate() string {
return pickStr(strings.TrimSpace(s.ContractTemplate), "ДД-{n}")
}
func (s LKPortfolioSyncSettings) section() string {
return pickStr(strings.TrimSpace(s.Section), lkPortfolioSection)
}
func (s LKPortfolioSyncSettings) storage() string {
return pickStr(strings.TrimSpace(s.Storage), lkPortfolioStorage)
}
type lkPortfolioPair struct{ LK, Depo string }
func (s LKPortfolioSyncSettings) pairs() []lkPortfolioPair {
var out []lkPortfolioPair
for _, p := range strings.Split(s.Pairs, ";") {
a, b, ok := strings.Cut(p, "=")
if a, b = strings.TrimSpace(a), strings.TrimSpace(b); ok && a != "" && b != "" {
out = append(out, lkPortfolioPair{LK: a, Depo: b})
}
}
return out
}
// LKPortfolioSyncState — итог по договору (для настроек).
type LKPortfolioSyncState struct {
LK, Depo string
CheckedAt time.Time // последняя сверка с DEPO
PushedAt time.Time // последняя отправка в ЛК
Lines int
Skipped []string
Err string
Nothing string // отправлять нечего (нет остатков или все бумаги без номера гос. регистрации) — не ошибка
hash string
manual bool // ручная заливка («Залить сейчас»), не постоянное заполнение
}
var lkPortfolioState = struct {
sync.Mutex
m map[string]*LKPortfolioSyncState
}{m: map[string]*LKPortfolioSyncState{}}
// lkPortfolioRun — проходы по очереди: фоновый и «Заполнить сейчас» не отправляют один портфель дважды
// (23.10.2026 при включении «для всех» 481 договор ушёл дважды).
var lkPortfolioRun sync.Mutex
var lkPortfolioTestOnce sync.Once
// lkPortfolioSyncTick — сверка портфелей с DEPO; в ЛК уходит только изменившийся (или при force — кроме
// отправленных в последнюю минуту). Одно соединение с DEPO на проход; «для всех клиентов» — остатки одним
// запросом (23.10.2026: по два новых соединения и запросу на каждого из ~600 клиентов — ~50 входов в DEPO в секунду).
func lkPortfolioSyncTick(ctx context.Context, rc *RuntimeConfig, force bool) {
st := rc.Snapshot().LK.PortfolioSync
if !st.Enabled {
return
}
if lkProdOnTestNSD(rc) { // тестовая DEPO не пишет портфели клиентов в боевой ЛК (lkconnector.go)
lkPortfolioTestOnce.Do(func() {
log.Printf("lk-portfolio: не заполняется — боевой ЛК на сервере с тестовым контуром НРД (%s)", rc.Snapshot().NSD.Profile)
})
return
}
lkPortfolioRun.Lock()
defer lkPortfolioRun.Unlock()
fs := rc.Snapshot().Fancy
db, err := fancyOpen(fs, fs.DepoDB())
if err != nil {
log.Printf("lk-portfolio: DEPO: %v", err)
return
}
defer db.Close()
prs := st.pairs()
var byPort map[string][]DepoPosition
if st.AllClients {
all, err := lkPortfolioAllPairs(ctx, rc, db)
if err != nil {
log.Printf("lk-portfolio: список клиентов (все): %v", err)
return
}
pos, err := depoAllPositions(ctx, db)
if err != nil {
log.Printf("lk-portfolio: остатки DEPO (все клиенты): %v", err)
return
}
prs, byPort = all, lkPortfolioByPort(pos, all)
}
for _, pr := range prs {
var lines []LKPortfolioLine
var skipped []string
var err error
if byPort != nil {
lines, skipped = lkPortfolioLinesFrom(ctx, rc, db, byPort[pr.Depo], nil)
} else if pos, perr := depoClientPositionsDB(ctx, db, pr.Depo); perr != nil {
err = fmt.Errorf("DEPO: %w", perr)
} else {
lines, skipped = lkPortfolioLinesFrom(ctx, rc, db, pos, nil)
}
lkPortfolioState.Lock()
s := lkPortfolioState.m[pr.LK]
if s == nil {
s = &LKPortfolioSyncState{LK: pr.LK, Depo: pr.Depo}
lkPortfolioState.m[pr.LK] = s
}
s.Depo, s.CheckedAt, s.manual = pr.Depo, time.Now(), false
if err != nil {
if s.Err != err.Error() {
log.Printf("lk-portfolio: %s ← %s: %v", pr.LK, pr.Depo, err)
}
s.Err = err.Error()
lkPortfolioState.Unlock()
continue
}
raw, _ := json.Marshal(lines)
sum := sha256.Sum256(raw)
h := hex.EncodeToString(sum[:])
same := h == s.hash // отпечаток ставится только после принятой отправки или «нечего отправлять»
if same {
s.Err = "" // DEPO снова читается, портфель тот же — прежняя ошибка чтения снята
}
recent := !s.PushedAt.IsZero() && time.Since(s.PushedAt) < time.Minute
lkPortfolioState.Unlock()
if same && (!force || recent) {
continue
}
if len(lines) == 0 { // ЛК отвечает 500 на пустой импорт — не шлём
lkPortfolioState.Lock()
s.Skipped, s.Err, s.Nothing, s.hash = skipped, "", lkPortfolioNothing(skipped), h
lkPortfolioState.Unlock()
continue
}
code, resp, perr := lkPortfolioPush(ctx, rc, pr.LK, lines)
lkPortfolioState.Lock()
prev := s.Err
s.Skipped, s.Nothing = skipped, ""
switch {
case perr != nil:
s.Err = perr.Error()
case code != 200 || !strings.Contains(resp, `"success":true`):
s.Err = fmt.Sprintf("HTTP %d: %s", code, resp)
default:
s.Err, s.hash, s.PushedAt, s.Lines = "", h, time.Now(), len(lines)
}
msg := s.Err
lkPortfolioState.Unlock()
switch {
case perr != nil: // ЛК не отвечает — остальным договорам этого прохода тоже не уйти
log.Printf("lk-portfolio: %s ← %s: отправка не прошла, проход прерван: %s", pr.LK, pr.Depo, msg)
return
case msg != "" && msg != prev: // та же ошибка в следующих проходах — без повтора в журнале
log.Printf("lk-portfolio: %s ← %s: отправка не прошла: %s", pr.LK, pr.Depo, msg)
case msg == "":
log.Printf("lk-portfolio: %s ← %s: портфель ДЕПО обновлён в ЛК (%d бумаг, пропущено %d)", pr.LK, pr.Depo, len(lines), len(skipped))
}
}
}
// lkPortfolioAllEvery — как часто сверять портфели «для всех клиентов» (по списку — каждые 10 секунд).
const lkPortfolioAllEvery = 5 * time.Minute
// lkPortfolioLoop — заполнение портфеля ДЕПО в ЛК, отдельно от опроса заявок ЛК (23.10.2026: проход по всем
// клиентам задерживал приём заявок). По списку — каждые 10 секунд, для всех клиентов — раз в 5 минут.
func (s *Server) lkPortfolioLoop(ctx context.Context) {
ticker := time.NewTicker(10 * time.Second)
defer ticker.Stop()
var lastAll time.Time
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
healthBeat("lk-portfolio")
ps := s.rc.Snapshot().LK.PortfolioSync
if !ps.Enabled {
continue
}
if _, ok := lkActiveEndpoint(s.rc); !ok || !moduleAllowed(s.rc, "esia-finance") {
continue
}
if ps.AllClients {
if time.Since(lastAll) < lkPortfolioAllEvery {
continue
}
lastAll = time.Now()
}
pctx, cancel := context.WithTimeout(ctx, 4*time.Minute)
lkPortfolioSyncTick(pctx, s.rc, false)
cancel()
}
}
}
// lkPortfolioStates — состояние для страницы настроек: последняя ручная заливка сверху, затем клиенты
// постоянного заполнения.
func lkPortfolioStates() []LKPortfolioSyncState {
lkPortfolioState.Lock()
defer lkPortfolioState.Unlock()
var out []LKPortfolioSyncState
for _, s := range lkPortfolioState.m {
out = append(out, *s)
}
sort.Slice(out, func(i, j int) bool {
if out[i].manual != out[j].manual {
return out[i].manual
}
return out[i].LK < out[j].LK
})
return out
}
// lkPortfolioRecentN — сколько последних отправок видно в настройках без раскрытия списка.
const lkPortfolioRecentN = 5
// LKPortfolioSummary — итог заполнения для настроек (23.10.2026: «для всех» — сотни договоров, список
// целиком на странице мешает): счётчики, последние отправки; ошибки и все договоры — под раскрытием.
type LKPortfolioSummary struct {
Total, Sent, Nothing, Errors, Skipped int
LastPush, LastCheck time.Time
Manual *LKPortfolioSyncState // последняя ручная заливка («Залить сейчас»)
Recent, Failed, All []LKPortfolioSyncState
}
func lkPortfolioSummary() LKPortfolioSummary {
var sm LKPortfolioSummary
for _, s := range lkPortfolioStates() {
if s.manual {
m := s
sm.Manual = &m
continue
}
sm.Total++
sm.Skipped += len(s.Skipped)
switch {
case s.Err != "":
sm.Errors++
sm.Failed = append(sm.Failed, s)
case s.Nothing != "":
sm.Nothing++
case !s.PushedAt.IsZero():
sm.Sent++
}
if s.PushedAt.After(sm.LastPush) {
sm.LastPush = s.PushedAt
}
if s.CheckedAt.After(sm.LastCheck) {
sm.LastCheck = s.CheckedAt
}
sm.All = append(sm.All, s)
if !s.PushedAt.IsZero() {
sm.Recent = append(sm.Recent, s)
}
}
sort.SliceStable(sm.Recent, func(i, j int) bool { return sm.Recent[i].PushedAt.After(sm.Recent[j].PushedAt) })
if len(sm.Recent) > lkPortfolioRecentN {
sm.Recent = sm.Recent[:lkPortfolioRecentN]
}
return sm
}
// lkPortfolioInSync — договор в постоянном заполнении (по списку или «для всех»).
func lkPortfolioInSync(rc *RuntimeConfig, lk string) bool {
ps := rc.Snapshot().LK.PortfolioSync
if ps.Enabled && ps.AllClients {
return true
}
for _, p := range ps.pairs() {
if p.LK == lk {
return true
}
}
return false
}
// registerLKPortfolioSync — POST /admin/setup/lk-portfolio: настройки и «синхронизировать сейчас».
func registerLKPortfolioSync(mux *http.ServeMux, rc *RuntimeConfig) {
// GET /admin/setup/lk-portfolio/clients?q= — поиск клиента DEPO для заливки портфеля.
mux.HandleFunc("/admin/setup/lk-portfolio/clients", func(w http.ResponseWriter, r *http.Request) {
list, err := lkPortfolioClients(r.Context(), rc, r.URL.Query().Get("q"))
w.Header().Set("Content-Type", "application/json; charset=utf-8")
if err != nil {
_ = json.NewEncoder(w).Encode(map[string]string{"error": err.Error()})
return
}
_ = json.NewEncoder(w).Encode(list)
})
mux.HandleFunc("/admin/setup/lk-portfolio", func(w http.ResponseWriter, r *http.Request) {
_ = r.ParseForm()
if r.Method != http.MethodPost {
http.Error(w, "method", http.StatusMethodNotAllowed)
return
}
lk := rc.Snapshot().LK
if r.Form.Has("pairs") {
lk.PortfolioSync.Pairs = strings.TrimSpace(r.FormValue("pairs"))
}
if r.Form.Has("naming") { // именование у депозитария
lk.PortfolioSync.AccountPrefix = strings.TrimSpace(r.FormValue("account_prefix"))
lk.PortfolioSync.ContractTemplate = strings.TrimSpace(r.FormValue("contract_template"))
lk.PortfolioSync.Section = strings.TrimSpace(r.FormValue("section"))
lk.PortfolioSync.Storage = strings.TrimSpace(r.FormValue("storage"))
}
switch r.FormValue("action") {
case "on":
lk.PortfolioSync.Enabled = lk.PortfolioSync.Pairs != "" || lk.PortfolioSync.AllClients
case "on_all":
lk.PortfolioSync.AllClients, lk.PortfolioSync.Enabled = true, true
case "off":
lk.PortfolioSync.Enabled, lk.PortfolioSync.AllClients = false, false
}
if err := rc.UpdateLK(lk); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
// выбранный клиент: «Залить сейчас» (только его, для теста) или «Добавить в постоянное заполнение»
if port, lkc := strings.TrimSpace(r.FormValue("client_port")), strings.TrimSpace(r.FormValue("client_lk")); port != "" && lkc != "" {
pr := lkPortfolioPair{LK: lkc, Depo: port}
if r.FormValue("action") == "client_add" {
have := false
for _, x := range lk.PortfolioSync.pairs() {
if x.LK == lkc {
have = true
}
}
if !have {
lk.PortfolioSync.Pairs = strings.Trim(lk.PortfolioSync.Pairs+"; "+lkc+"="+port, "; ")
_ = rc.UpdateLK(lk)
}
}
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Minute)
n, skipped, perr := lkPortfolioPushPair(ctx, rc, pr)
cancel()
msg := fmt.Sprintf("Портфель залит в ЛК: договор %s ← портфель DEPO %s, бумаг %d", lkc, port, n)
if skipped > 0 {
msg += fmt.Sprintf(" (пропущено %d — без номера гос. регистрации)", skipped)
}
if perr != "" {
msg = "Портфель не залит в ЛК (" + lkc + " ← " + port + "): " + perr
}
if r.FormValue("action") == "client_add" {
msg += ". Клиент добавлен в постоянное заполнение."
}
lkPortfolioDone(w, r, msg)
return
}
msg := "Настройки заполнения портфеля сохранены"
switch a := r.FormValue("action"); a {
case "sync", "on", "on_all": // включили или «Заполнить сейчас» — сразу отправить
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Minute)
lkPortfolioSyncTick(ctx, rc, true)
cancel()
ok, bad := 0, 0
for _, st := range lkPortfolioStates() {
if st.Err != "" {
bad++
} else if !st.PushedAt.IsZero() {
ok++
}
}
msg = map[string]string{"on": "Заполнение по списку включено", "on_all": "Заполнение для всех клиентов включено", "sync": "Портфели сверены"}[a] +
fmt.Sprintf(": залито договоров %d, с ошибкой %d — подробности под карточкой", ok, bad)
case "off":
msg = "Заполнение портфеля выключено"
}
lkPortfolioDone(w, r, msg)
})
}
// LKPortfolioClient — клиент DEPO для выбора в настройках: портфель, ФИО, торговый счёт и договор ЛК.
type LKPortfolioClient struct {
Port string `json:"port"`
Name string `json:"name"`
Account string `json:"account"`
LK string `json:"lk"`
}
// lkContractByAccount — договор депо в ЛК по торговому счёту DEPO по шаблону: у Фонтвьель «ВТНКЦ00000816» →
// «ДД-816» (проверено 29.09.2026 на ДД-816 и ДД-1515). Префикс и шаблон — в настройках.
func lkContractByAccount(ps LKPortfolioSyncSettings, acc string) string {
acc = strings.TrimSpace(acc)
pre := ps.accountPrefix()
if !strings.HasPrefix(acc, pre) {
return ""
}
n := strings.TrimLeft(strings.TrimPrefix(acc, pre), "0")
if n == "" {
return ""
}
return strings.ReplaceAll(ps.contractTemplate(), "{n}", n)
}
// lkPortfolioClients — клиенты DEPO по ФИО, портфелю или счёту (открытые торговые счета); пустой
// запрос — первые по алфавиту, чтобы выбрать из списка без ввода.
func lkPortfolioClients(ctx context.Context, rc *RuntimeConfig, q string) ([]LKPortfolioClient, error) {
q = strings.TrimSpace(q)
if len([]rune(q)) == 1 {
return []LKPortfolioClient{}, nil
}
fs := rc.Snapshot().Fancy
ps := rc.Snapshot().LK.PortfolioSync
db, err := fancyOpen(fs, fs.DepoDB())
if err != nil {
return nil, err
}
defer db.Close()
cctx, cancel := context.WithTimeout(ctx, 15*time.Second)
defer cancel()
rows, err := db.QueryContext(cctx, `SELECT TOP 50 ISNULL(p.PORT_NUM,''), ISNULL(f.NAME,''), c.NUMBER
FROM OD_CLIENTS c JOIN OD_FACES f ON f.ID=c.FACE LEFT JOIN OD_CLIENTS_PORTS p ON p.CLIENT=c.ID
WHERE c.NUMBER LIKE @pre AND c.STATE=0 AND c.D_CLOSE IS NULL AND (f.NAME LIKE @q OR p.PORT_NUM LIKE @q OR c.NUMBER LIKE @q)
ORDER BY f.NAME`, sql.Named("q", "%"+likeEscape(q)+"%"), sql.Named("pre", likeEscape(ps.accountPrefix())+"%"))
if err != nil {
return nil, err
}
defer rows.Close()
out := []LKPortfolioClient{}
for rows.Next() {
var c LKPortfolioClient
if rows.Scan(&c.Port, &c.Name, &c.Account) == nil && c.Port != "" {
c.LK = lkContractByAccount(ps, c.Account)
out = append(out, c)
}
}
return out, rows.Err()
}
// lkPortfolioAllPairs — все открытые торговые счета DEPO с портфелем (режим «для всех
// клиентов»): фильтр по префиксу счёта, договор ЛК выводится из счёта по шаблону,
// портфель — PORT_NUM. Только MSSQL-база DEPO (как и поиск одного клиента).
func lkPortfolioAllPairs(ctx context.Context, rc *RuntimeConfig, db *sql.DB) ([]lkPortfolioPair, error) {
ps := rc.Snapshot().LK.PortfolioSync
cctx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
rows, err := db.QueryContext(cctx, `SELECT ISNULL(p.PORT_NUM,''), c.NUMBER
FROM OD_CLIENTS c LEFT JOIN OD_CLIENTS_PORTS p ON p.CLIENT=c.ID
WHERE c.NUMBER LIKE @pre AND c.STATE=0 AND c.D_CLOSE IS NULL AND p.PORT_NUM IS NOT NULL`,
sql.Named("pre", likeEscape(ps.accountPrefix())+"%"))
if err != nil {
return nil, err
}
defer rows.Close()
var out []lkPortfolioPair
seen := map[string]bool{}
for rows.Next() {
var port, acc string
if rows.Scan(&port, &acc) != nil || strings.TrimSpace(port) == "" {
continue
}
lk := lkContractByAccount(ps, acc)
if lk == "" || seen[lk] {
continue
}
seen[lk] = true
out = append(out, lkPortfolioPair{LK: lk, Depo: strings.TrimSpace(port)})
}
return out, rows.Err()
}
// lkPortfolioPushPair — залить портфель одного клиента сейчас (тест / «Залить сейчас»): сколько бумаг
// ушло, сколько пропущено, ошибка (пусто — залито).
func lkPortfolioPushPair(ctx context.Context, rc *RuntimeConfig, pr lkPortfolioPair) (int, int, string) {
lines, skipped, err := lkPortfolioLines(ctx, rc, pr.Depo, nil)
lkPortfolioState.Lock()
s := lkPortfolioState.m[pr.LK]
if s == nil {
s = &LKPortfolioSyncState{LK: pr.LK, Depo: pr.Depo}
lkPortfolioState.m[pr.LK] = s
}
s.Depo, s.CheckedAt, s.Skipped = pr.Depo, time.Now(), skipped
if !lkPortfolioInSync(rc, pr.LK) {
s.manual = true
for k, o := range lkPortfolioState.m { // от прежних ручных заливок — только последняя
if o.manual && k != pr.LK {
delete(lkPortfolioState.m, k)
}
}
}
lkPortfolioState.Unlock()
msg, nothing := "", ""
if err != nil {
msg = err.Error()
} else if len(lines) == 0 {
nothing = lkPortfolioNothing(skipped)
msg = nothing
} else if code, resp, perr := lkPortfolioPush(ctx, rc, pr.LK, lines); perr != nil {
msg = perr.Error()
} else if code != 200 || !strings.Contains(resp, `"success":true`) {
msg = fmt.Sprintf("HTTP %d: %s", code, resp)
}
lkPortfolioState.Lock()
s.Err, s.Nothing = msg, nothing
if nothing != "" {
s.Err = ""
}
if msg == "" {
raw, _ := json.Marshal(lines)
sum := sha256.Sum256(raw)
s.hash, s.PushedAt, s.Lines = hex.EncodeToString(sum[:]), time.Now(), len(lines)
}
lkPortfolioState.Unlock()
if msg != "" {
log.Printf("lk-portfolio: %s ← %s: заливка не прошла: %s", pr.LK, pr.Depo, msg)
} else {
log.Printf("lk-portfolio: %s ← %s: портфель ДЕПО залит в ЛК (%d бумаг, пропущено %d)", pr.LK, pr.Depo, len(lines), len(skipped))
}
return len(lines), len(skipped), msg
}
// lkPortfolioNothing — почему портфель не отправлен: отправлять нечего.
func lkPortfolioNothing(skipped []string) string {
if len(skipped) == 0 {
return "отправлять нечего: в DEPO у клиента нет бумаг на остатке"
}
return fmt.Sprintf("отправлять нечего: у всех бумаг (%d) нет номера гос. регистрации, без него ЛК не принимает портфель", len(skipped))
}
// lkPortfolioDone — вернуться к карточке «Заполнение портфеля» в том же разделе настроек с сообщением.
func lkPortfolioDone(w http.ResponseWriter, r *http.Request, msg string) {
r.Form.Set("section", "lk-portfolio")
setupFlash(w, r, msg)
}