Files
Bridge-and-Join-s/internal/lkgateway/lkportfolio.go
T
fontvielle c6f83e212e Портфель ЛК: под карточкой только последняя ручная заливка
Каждая «Залить сейчас» оставляла строку навсегда (до перезапуска). Теперь
ручная заливка заменяет прежнюю ручную; клиенты постоянного заполнения
остаются. Пропущенные бумаги — свёрнутым списком «пропущено бумаг: N».

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q7eREaYwUt1i4fNLZcGnjn
2026-10-22 12:27:37 +03:00

576 lines
24 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
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, &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, 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)
}