2a7442c935
Данные - корпоративные действия по бумагам клиента (U_CA_ACTS), готовые выписки (U_OP_INF_EXTRACT), заявки клиента на документ (lk.doc_requests) с оповещением сотрудникам; страница «Документы» - дистрибутив кабинета из настроек ядра: архив с программой, службой, настройками, установщиком и инструкцией; ключ доступа на странице показан замаскированным Связь без публичного адреса у ядра - режим ожидания (BJ_MODE=agent): кабинет копит запросы, ядро само подключается к нему, выполняет их обработчиком /api/lk/* и возвращает ответы; 4 параллельных подключения, тот же ключ модуля в обе стороны, состояние канала в настройках - сквозной тест: настоящий кабинет + настоящий цикл ядра Защита (по итогам аудита) - вход: лимит попыток по адресу и логину, одинаковое время ответа для несуществующего логина, пароль от 10 символов - сессии ядра: на диске отпечаток токена и ссылка на учётную запись, без персональных данных; карточка клиента читается из БД на каждый запрос (блокировка действует сразу); абсолютный срок 24 ч; привязка к браузеру, к сети — по настройке - вход сотрудника в кабинет клиента пишется в lk.audit и поднимает оповещение - кабинет: CSRF на всех формах, /impersonate только POST, куки HttpOnly+Secure+SameSite=Strict, выход гасит все сессии, заголовки CSP/X-Frame-Options/nosniff/HSTS, no-store, таймауты сервера, ограничение тела, recover; сообщения через куку вместо адреса; /health наружу без подробностей - шрифты вшиты в программу: страница не обращается к сторонним сайтам - экранирование LIKE в выборках по номеру договора; ключ и токен без суррогата при отказе rand - исправлено: ошибка входа не показывалась клиенту (шаблон ждал Error, приходил Flash) Тесты: lksecurity_test, lkagent_test, lkdist_test, modules/lk (CSRF, заголовки, режим ожидания), живые lkcoverage/lkunpriced, scripts/lk-sec-smoke.sh — проверка работающего кабинета снаружи. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
279 lines
11 KiB
Go
279 lines
11 KiB
Go
package lkgateway
|
|
|
|
// lkdocs.go — корпоративные действия по бумагам клиента и документы депозитария:
|
|
// готовые выписки из учётной системы (U_OP_INF_EXTRACT) плюс заявки клиента на
|
|
// документ, которые кабинет кладёт в схему lk и показывает сотрудникам.
|
|
//
|
|
// Кабинет не создаёт документы в учётной системе: заявка — это поручение сотруднику,
|
|
// он готовит документ у себя и выкладывает клиенту.
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// LKCorpAction — корпоративное действие по бумаге клиента.
|
|
type LKCorpAction struct {
|
|
FixDate string `json:"fix_date,omitempty"` // дата фиксации
|
|
OperDate string `json:"oper_date,omitempty"` // дата операции
|
|
Kind string `json:"kind"` // расшифровка вида
|
|
Code string `json:"code,omitempty"` // код вида в учёте
|
|
ISIN string `json:"isin,omitempty"`
|
|
Security string `json:"security,omitempty"`
|
|
Note string `json:"note,omitempty"`
|
|
}
|
|
|
|
// caKindRu — вид корпоративного действия по коду учёта (коды ISO 20022).
|
|
func caKindRu(code string) string {
|
|
switch strings.ToUpper(strings.TrimSpace(code)) {
|
|
case "DVCA":
|
|
return "дивиденды"
|
|
case "INTR":
|
|
return "выплата купона"
|
|
case "REDM":
|
|
return "погашение"
|
|
case "MCAL":
|
|
return "досрочное погашение"
|
|
case "PCAL":
|
|
return "частичное погашение"
|
|
case "BPUT":
|
|
return "оферта: предъявление к выкупу"
|
|
case "TEND":
|
|
return "добровольное предложение о выкупе"
|
|
case "MEET", "XMET", "OMET":
|
|
return "собрание владельцев"
|
|
case "CONV":
|
|
return "конвертация"
|
|
case "SPLF", "SPLR":
|
|
return "дробление или консолидация"
|
|
case "MRGR":
|
|
return "реорганизация"
|
|
case "EXOF":
|
|
return "обмен бумаг"
|
|
case "DRCA":
|
|
return "выплата по депозитарной расписке"
|
|
}
|
|
return "корпоративное действие"
|
|
}
|
|
|
|
// lkCorpActions — ближайшие корпоративные действия по бумагам клиента.
|
|
func lkCorpActions(ctx context.Context, s FancyMSSQLSettings, isins []string) ([]LKCorpAction, error) {
|
|
if strings.TrimSpace(s.Server) == "" || len(isins) == 0 {
|
|
return nil, nil
|
|
}
|
|
conn, err := fancyOpen(s, s.DepoDB())
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer conn.Close()
|
|
cctx, cancel := context.WithTimeout(ctx, 25*time.Second)
|
|
defer cancel()
|
|
// Список ISIN подставляем параметрами: значения приходят из портфеля клиента.
|
|
names := make([]string, 0, len(isins))
|
|
args := make([]any, 0, len(isins))
|
|
for i, isin := range isins {
|
|
names = append(names, fmt.Sprintf("@p%d", i+1))
|
|
args = append(args, sql.Named(fmt.Sprintf("p%d", i+1), strings.ToUpper(strings.TrimSpace(isin))))
|
|
}
|
|
q := fmt.Sprintf(`SELECT TOP 40 ISNULL(a.KD_TYPE,''), ISNULL(v.ISIN,''), ISNULL(v.NAME,''), a.FIX_DATE, a.OPER_DATE, ISNULL(a.OFFER_NAME,'')
|
|
FROM U_CA_ACTS a JOIN OD_VALUES v ON v.ID = a.SHARE
|
|
WHERE UPPER(v.ISIN) IN (%s)
|
|
AND ISNULL(a.FIX_DATE, a.OPER_DATE) >= DATEADD(month, -1, GETDATE())
|
|
ORDER BY ISNULL(a.FIX_DATE, a.OPER_DATE)`, strings.Join(names, ","))
|
|
rows, err := conn.QueryContext(cctx, q, args...)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
var out []LKCorpAction
|
|
for rows.Next() {
|
|
var code, isin, name, offer string
|
|
var fix, oper sql.NullTime
|
|
if rows.Scan(&code, &isin, &name, &fix, &oper, &offer) != nil {
|
|
continue
|
|
}
|
|
a := LKCorpAction{Code: strings.TrimSpace(code), Kind: caKindRu(code), ISIN: isin, Security: name, Note: strings.TrimSpace(offer)}
|
|
if fix.Valid && fix.Time.Year() < 9000 {
|
|
a.FixDate = fix.Time.Format("02.01.2006")
|
|
}
|
|
if oper.Valid && oper.Time.Year() > 2000 && oper.Time.Year() < 9000 {
|
|
a.OperDate = oper.Time.Format("02.01.2006")
|
|
}
|
|
out = append(out, a)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
// LKDocument — документ депозитария, готовый или заказанный клиентом.
|
|
type LKDocument struct {
|
|
ID int64 `json:"id,omitempty"`
|
|
Date string `json:"date"` // дата готовности или заказа
|
|
Title string `json:"title"` // что за документ
|
|
Period string `json:"period,omitempty"` // за какой период
|
|
Account string `json:"account,omitempty"`
|
|
State string `json:"state"` // готов | в работе | отклонён
|
|
Note string `json:"note,omitempty"` // комментарий депозитария
|
|
Ordered bool `json:"ordered"` // заявка клиента (а не готовый документ учёта)
|
|
Kind string `json:"kind,omitempty"` // вид заявки
|
|
}
|
|
|
|
// lkDocSchema — таблица заявок на документы (создаётся вместе со схемой lk).
|
|
const lkDocSchema = `
|
|
CREATE TABLE IF NOT EXISTS lk.doc_requests (
|
|
id bigserial PRIMARY KEY,
|
|
created_at timestamptz NOT NULL DEFAULT now(),
|
|
contract_number text NOT NULL,
|
|
client_name text,
|
|
kind text NOT NULL,
|
|
period_from date,
|
|
period_to date,
|
|
account text,
|
|
comment text,
|
|
status text NOT NULL DEFAULT 'new',
|
|
staff_note text,
|
|
done_at timestamptz
|
|
);
|
|
CREATE INDEX IF NOT EXISTS ix_lk_doc_requests_contract ON lk.doc_requests (contract_number, created_at DESC);
|
|
`
|
|
|
|
// lkDocKinds — виды документов, которые клиент может заказать в кабинете.
|
|
var lkDocKinds = []string{
|
|
"Выписка по счёту депо",
|
|
"Справка об остатках на дату",
|
|
"Отчёт об операциях за период",
|
|
"Справка о доходах",
|
|
}
|
|
|
|
// lkDocKindAllowed — вид заявки из списка (чужие значения не принимаем).
|
|
func lkDocKindAllowed(kind string) bool {
|
|
for _, k := range lkDocKinds {
|
|
if k == kind {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// lkDocRequestAdd — заявка клиента на документ.
|
|
func lkDocRequestAdd(ctx context.Context, rc *RuntimeConfig, contract, clientName, kind, from, to, account, comment string) error {
|
|
if !lkDocKindAllowed(kind) {
|
|
return fmt.Errorf("неизвестный вид документа")
|
|
}
|
|
db, err := lkDB(rc)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
var fromP, toP any
|
|
if t, err := time.Parse("2006-01-02", strings.TrimSpace(from)); err == nil {
|
|
fromP = t
|
|
}
|
|
if t, err := time.Parse("2006-01-02", strings.TrimSpace(to)); err == nil {
|
|
toP = t
|
|
}
|
|
_, err = db.ExecContext(ctx, `INSERT INTO lk.doc_requests (contract_number, client_name, kind, period_from, period_to, account, comment)
|
|
VALUES ($1,$2,$3,$4,$5,$6,$7)`, contract, clientName, kind, fromP, toP, strings.TrimSpace(account), strings.TrimSpace(comment))
|
|
return err
|
|
}
|
|
|
|
// lkDocRequests — заявки клиента на документы.
|
|
func lkDocRequests(ctx context.Context, rc *RuntimeConfig, contract string) ([]LKDocument, error) {
|
|
db, err := lkDB(rc)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
rows, err := db.QueryContext(ctx, `SELECT id, created_at, kind, period_from, period_to, COALESCE(account,''), status, COALESCE(staff_note,'')
|
|
FROM lk.doc_requests WHERE contract_number=$1 ORDER BY created_at DESC LIMIT 50`, contract)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
var out []LKDocument
|
|
for rows.Next() {
|
|
var d LKDocument
|
|
var created time.Time
|
|
var from, to *time.Time
|
|
var status string
|
|
if err := rows.Scan(&d.ID, &created, &d.Kind, &from, &to, &d.Account, &status, &d.Note); err != nil {
|
|
continue
|
|
}
|
|
d.Ordered, d.Title, d.Date = true, d.Kind, created.Format("02.01.2006")
|
|
switch status {
|
|
case "done":
|
|
d.State = "готов"
|
|
case "rejected":
|
|
d.State = "отклонён"
|
|
default:
|
|
d.State = "в работе"
|
|
}
|
|
if from != nil && to != nil {
|
|
d.Period = from.Format("02.01.2006") + " — " + to.Format("02.01.2006")
|
|
}
|
|
out = append(out, d)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
// lkReadyDocuments — готовые выписки по счетам клиента из учётной системы.
|
|
func lkReadyDocuments(ctx context.Context, s FancyMSSQLSettings, contract string) ([]LKDocument, error) {
|
|
if strings.TrimSpace(s.Server) == "" {
|
|
return nil, nil
|
|
}
|
|
ids, _, accounts, err := depoClientIDs(ctx, s, contract)
|
|
if err != nil || len(ids) == 0 {
|
|
return nil, err
|
|
}
|
|
conn, err := fancyOpen(s, s.DepoDB())
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer conn.Close()
|
|
cctx, cancel := context.WithTimeout(ctx, 25*time.Second)
|
|
defer cancel()
|
|
q := fmt.Sprintf(`SELECT TOP 30 d.R_DATE, ISNULL(d.NUM,''), e.B_DATE, e.E_DATE, e.DEPO, d.STATE, ISNULL(e.IS_TURNS,0)
|
|
FROM U_OP_INF_EXTRACT e JOIN OD_DOCS d ON d.ID = e.DOC
|
|
WHERE e.DEPO IN (%s) ORDER BY d.R_DATE DESC`, inClause(ids))
|
|
rows, err := conn.QueryContext(cctx, q)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
var out []LKDocument
|
|
for rows.Next() {
|
|
var rd, bd, ed sql.NullTime
|
|
var num string
|
|
var acc int64
|
|
var state, turns sql.NullInt64
|
|
if rows.Scan(&rd, &num, &bd, &ed, &acc, &state, &turns) != nil {
|
|
continue
|
|
}
|
|
d := LKDocument{Title: "Выписка по счёту депо", Account: accounts[acc], State: docStateRu(state, sql.NullTime{}), Note: num}
|
|
if turns.Valid && turns.Int64 == 1 {
|
|
d.Title = "Выписка по счёту депо с оборотами"
|
|
}
|
|
if rd.Valid {
|
|
d.Date = rd.Time.Format("02.01.2006")
|
|
}
|
|
if bd.Valid && ed.Valid {
|
|
d.Period = bd.Time.Format("02.01.2006") + " — " + ed.Time.Format("02.01.2006")
|
|
} else if ed.Valid {
|
|
d.Period = "на " + ed.Time.Format("02.01.2006")
|
|
}
|
|
out = append(out, d)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
// notifyDocRequest — сообщить сотрудникам депозитария, что клиент заказал документ.
|
|
func notifyDocRequest(rc *RuntimeConfig, u LKUser, kind, from, to string) {
|
|
period := ""
|
|
if from != "" || to != "" {
|
|
period = fmt.Sprintf(" за период %s — %s", pick(from, "…"), pick(to, "…"))
|
|
}
|
|
go raiseSystemAlert(rc, "Кабинет: заказан документ",
|
|
fmt.Sprintf("Клиент %s (договор %s) заказал документ «%s»%s. Заявка видна в кабинете клиента и в журнале lk.doc_requests.",
|
|
pick(u.FullName, u.Login), u.Contract, kind, period))
|
|
}
|