c6f83e212e
Каждая «Залить сейчас» оставляла строку навсегда (до перезапуска). Теперь ручная заливка заменяет прежнюю ручную; клиенты постоянного заполнения остаются. Пропущенные бумаги — свёрнутым списком «пропущено бумаг: N». Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q7eREaYwUt1i4fNLZcGnjn
576 lines
24 KiB
Go
576 lines
24 KiB
Go
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
|
||
ps := rc.Snapshot().LK.PortfolioSync
|
||
pos, err := depoClientPositions(ctx, fs, depoPort)
|
||
if err != nil {
|
||
return nil, nil, fmt.Errorf("DEPO: %w", err)
|
||
}
|
||
db, err := fancyOpen(fs, fs.DepoDB())
|
||
if err != nil {
|
||
return nil, nil, fmt.Errorf("DEPO: %w", err)
|
||
}
|
||
defer db.Close()
|
||
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, ®)
|
||
if strings.TrimSpace(reg) == "" && qerr == nil {
|
||
_ = qe.QueryRow(ctx, `SELECT "RegNumber" FROM "Securities" WHERE "ISINCode"=$1 AND COALESCE("RegNumber",'')<>'' LIMIT 1`, isin).Scan(®)
|
||
}
|
||
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, nil
|
||
}
|
||
|
||
// 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
|
||
hash string
|
||
manual bool // ручная заливка («Залить сейчас»), не постоянное заполнение
|
||
}
|
||
|
||
var lkPortfolioState = struct {
|
||
sync.Mutex
|
||
m map[string]*LKPortfolioSyncState
|
||
}{m: map[string]*LKPortfolioSyncState{}}
|
||
|
||
// lkPortfolioSyncTick — сверка портфелей с DEPO; в ЛК уходит только изменившийся (или при force).
|
||
func lkPortfolioSyncTick(ctx context.Context, rc *RuntimeConfig, force bool) {
|
||
st := rc.Snapshot().LK.PortfolioSync
|
||
if !st.Enabled {
|
||
return
|
||
}
|
||
prs := st.pairs()
|
||
if st.AllClients {
|
||
all, err := lkPortfolioAllPairs(ctx, rc)
|
||
if err != nil {
|
||
log.Printf("lk-portfolio: список клиентов (все): %v", err)
|
||
return
|
||
}
|
||
prs = all
|
||
}
|
||
for _, pr := range prs {
|
||
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.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
|
||
lkPortfolioState.Unlock()
|
||
if same && !force {
|
||
continue
|
||
}
|
||
if len(lines) == 0 { // ЛК отвечает 500 на пустой импорт — не шлём
|
||
lkPortfolioState.Lock()
|
||
s.Skipped, s.Err, s.hash = skipped, lkPortfolioNothing(skipped), h
|
||
lkPortfolioState.Unlock()
|
||
continue
|
||
}
|
||
code, resp, perr := lkPortfolioPush(ctx, rc, pr.LK, lines)
|
||
lkPortfolioState.Lock()
|
||
s.Skipped = 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()
|
||
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))
|
||
}
|
||
}
|
||
}
|
||
|
||
// 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
|
||
}
|
||
|
||
// 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) ([]lkPortfolioPair, error) {
|
||
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, 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 := ""
|
||
if err != nil {
|
||
msg = err.Error()
|
||
} else if len(lines) == 0 {
|
||
msg = lkPortfolioNothing(skipped)
|
||
} 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 = msg
|
||
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)
|
||
}
|