Заполнение портфеля ДЕПО: кнопка включить/выключить, выбор клиента DEPO (поиск) — «Залить сейчас» только ему или добавить в постоянное; именование (префикс счёта, шаблон договора ЛК, раздел, хранение) — в настройках

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014FsEKyJrYF69Cj9ZXzFLRi
This commit is contained in:
zuevav
2026-09-29 17:32:32 +03:00
parent e47e6cfbdc
commit 1b0e1bee5f
4 changed files with 263 additions and 17 deletions
+164 -10
View File
@@ -71,6 +71,7 @@ func lkPaperType(cfi, name string) string {
// (пусто — все). 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)
@@ -106,7 +107,7 @@ func lkPortfolioLines(ctx context.Context, rc *RuntimeConfig, depoPort string, o
}
lines = append(lines, LKPortfolioLine{
ISIN: isin, Name: strings.TrimSpace(p.SecurityName), RegNumber: strings.TrimSpace(reg), Count: p.Quantity,
DepoAccount: p.AccountNum, Section: lkPortfolioSection, Storage: lkPortfolioStorage, PaperType: lkPaperType(cfi, p.SecurityName),
DepoAccount: p.AccountNum, Section: ps.section(), Storage: ps.storage(), PaperType: lkPaperType(cfi, p.SecurityName),
})
}
return lines, skipped, nil
@@ -167,6 +168,24 @@ 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"` // место хранения: «НРД»
}
func (s LKPortfolioSyncSettings) accountPrefix() string {
return pickStr(strings.TrimSpace(s.AccountPrefix), "ВТНКЦ")
}
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 }
@@ -263,24 +282,159 @@ func lkPortfolioStates() []LKPortfolioSyncState {
// 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
}
if r.FormValue("action") == "sync" {
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 != ""
case "off":
lk.PortfolioSync.Enabled = 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)
lkPortfolioPushPair(ctx, rc, pr)
cancel()
http.Redirect(w, r, "/admin/setup#lk-portfolio", http.StatusSeeOther)
return
}
if a := r.FormValue("action"); a == "sync" || a == "on" { // включили или «Заполнить сейчас» — сразу отправить
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Minute)
lkPortfolioSyncTick(ctx, rc, true)
cancel()
} else {
lk := rc.Snapshot().LK
lk.PortfolioSync.Enabled = r.FormValue("enabled") != ""
lk.PortfolioSync.Pairs = strings.TrimSpace(r.FormValue("pairs"))
if err := rc.UpdateLK(lk); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
}
http.Redirect(w, r, "/admin/setup#lk-portfolio", http.StatusSeeOther)
})
}
// 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)) < 2 {
return nil, 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 20 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()
var 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()
}
// lkPortfolioPushPair — залить портфель одного клиента сейчас (тест / «Залить сейчас»).
func lkPortfolioPushPair(ctx context.Context, rc *RuntimeConfig, pr lkPortfolioPair) {
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
lkPortfolioState.Unlock()
msg := ""
if err != nil {
msg = err.Error()
} 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))
}
}