feat(bj): модуль ЛК на PostgreSQL, обмен с DEPO, боевой ЛК с имитатором НРД, QUIK, НПР
- bj_exchange и «кран» Fancy убраны: данные модуля ЛК в PostgreSQL (схема lk) - трансферные таблицы FTV_M2M: создание при установке, события, справочник участников - сверка с DEPO: счёт, ФИО, дата рождения, ИИС по договору, квал-инвестор из опции DEPO - боевой ЛК ЕСИА Финанс при тестовом ИШ: журнал, отказ через DEPO, статусы в ЛК - онлайн-лицензирование в bj, план тестирования M2M для контрагента (интерактивный HTML) - модули QUIK МНП, экспорт QUIK, НПР/УДС; документы и тестовые материалы Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,319 @@
|
||||
package lkgateway
|
||||
|
||||
// Данные модуля личного кабинета клиента (bj-lk) в нашей PostgreSQL (база bj, схема lk):
|
||||
// пользователи, позиции клиентов, заявки на перевод и итоги по ним. До 15.09.2026 лежали
|
||||
// в базе обмена bj_exchange на MSSQL (схема «крана» Fancy); данные перенесены, «кран» убран.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/m2m"
|
||||
)
|
||||
|
||||
var (
|
||||
lkDBMu sync.Mutex
|
||||
lkDBDSN string
|
||||
lkDBConn *sql.DB
|
||||
)
|
||||
|
||||
// lkDB — общий пул подключений к PostgreSQL bj (Postgres.DSN). Закрывать не нужно.
|
||||
func lkDB(rc *RuntimeConfig) (*sql.DB, error) {
|
||||
dsn := strings.TrimSpace(rc.Snapshot().Postgres.DSN)
|
||||
if dsn == "" {
|
||||
return nil, errors.New("база PostgreSQL bj не настроена")
|
||||
}
|
||||
lkDBMu.Lock()
|
||||
defer lkDBMu.Unlock()
|
||||
if lkDBConn != nil && lkDBDSN == dsn {
|
||||
return lkDBConn, nil
|
||||
}
|
||||
if lkDBConn != nil {
|
||||
lkDBConn.Close()
|
||||
}
|
||||
db, err := sql.Open("pgx", dsn)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
db.SetMaxOpenConns(4)
|
||||
db.SetConnMaxLifetime(10 * time.Minute)
|
||||
lkDBConn, lkDBDSN = db, dsn
|
||||
return db, nil
|
||||
}
|
||||
|
||||
// lkDBReady — PostgreSQL bj доступна (для /api/lk/ping).
|
||||
func lkDBReady(rc *RuntimeConfig) bool {
|
||||
db, err := lkDB(rc)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cancel()
|
||||
return db.PingContext(ctx) == nil
|
||||
}
|
||||
|
||||
var lkSchemaDDL = []string{
|
||||
`CREATE SCHEMA IF NOT EXISTS lk`,
|
||||
`CREATE TABLE IF NOT EXISTS lk.users (
|
||||
id BIGSERIAL PRIMARY KEY, login VARCHAR(64) NOT NULL UNIQUE, password_hash VARCHAR(256) NOT NULL,
|
||||
contract_number VARCHAR(64) NOT NULL, full_name VARCHAR(128), last_name VARCHAR(64), first_name VARCHAR(64),
|
||||
middle_name VARCHAR(64), doc_type VARCHAR(8), doc_series VARCHAR(16), doc_number VARCHAR(32), email VARCHAR(128),
|
||||
phone VARCHAR(32), active BOOLEAN NOT NULL DEFAULT true, is_admin BOOLEAN NOT NULL DEFAULT false,
|
||||
can_transfer BOOLEAN NOT NULL DEFAULT false, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), last_login_at TIMESTAMPTZ)`,
|
||||
`CREATE TABLE IF NOT EXISTS lk.client_positions (
|
||||
id BIGSERIAL PRIMARY KEY, contract_number VARCHAR(64) NOT NULL, client_name VARCHAR(128), isin VARCHAR(20),
|
||||
security_code VARCHAR(40), security_name VARCHAR(256), quantity NUMERIC(28,8), blocked NUMERIC(28,8),
|
||||
available NUMERIC(28,8), deponent_code VARCHAR(16), account_id VARCHAR(40), section_id VARCHAR(40),
|
||||
settlement_inn VARCHAR(16), updated_at TIMESTAMPTZ NOT NULL DEFAULT now())`,
|
||||
`CREATE INDEX IF NOT EXISTS ix_lk_positions_contract ON lk.client_positions(contract_number)`,
|
||||
`CREATE TABLE IF NOT EXISTS lk.transfer_requests (
|
||||
id BIGSERIAL PRIMARY KEY, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), status VARCHAR(32) NOT NULL DEFAULT 'new',
|
||||
source VARCHAR(16), bj_claim_id VARCHAR(64), lk_request_id VARCHAR(64), contract_number VARCHAR(64),
|
||||
lk_investor_id VARCHAR(64), signed_document TEXT, signature_format VARCHAR(32), guid VARCHAR(40) NOT NULL,
|
||||
creation_timestamp VARCHAR(40), sender_code VARCHAR(16), receiver_code VARCHAR(16), cost_info VARCHAR(4),
|
||||
cost_code VARCHAR(16), investor_last VARCHAR(64), investor_first VARCHAR(64), investor_middle VARCHAR(64),
|
||||
doc_type VARCHAR(8), doc_series VARCHAR(16), doc_number VARCHAR(32), iia_agreement_type VARCHAR(8),
|
||||
iia_agreement_number VARCHAR(128), iia_agreement_date VARCHAR(16), iia_broker_inn VARCHAR(16),
|
||||
transferring_inn VARCHAR(16), receiving_inn VARCHAR(16), operation_type VARCHAR(8), request_xml TEXT, lk_payload TEXT)`,
|
||||
`CREATE INDEX IF NOT EXISTS ix_lk_requests_guid ON lk.transfer_requests(guid)`,
|
||||
`CREATE INDEX IF NOT EXISTS ix_lk_requests_contract ON lk.transfer_requests(contract_number)`,
|
||||
`CREATE TABLE IF NOT EXISTS lk.transfer_securities (
|
||||
id BIGSERIAL PRIMARY KEY, request_id BIGINT NOT NULL REFERENCES lk.transfer_requests(id), seq INT NOT NULL,
|
||||
reference_id VARCHAR(40), security_code VARCHAR(40), isin VARCHAR(20), sec_classification VARCHAR(32),
|
||||
sec_category VARCHAR(32), sec_type VARCHAR(64), sec_series VARCHAR(64), ident_reg_number VARCHAR(32),
|
||||
fund_reg_number VARCHAR(256), fund_class VARCHAR(128), quantity_whole BIGINT, quantity_fractional VARCHAR(40),
|
||||
settlement_inn VARCHAR(16), deponent_code VARCHAR(16), account_id VARCHAR(40), section_id VARCHAR(40),
|
||||
settlement_count INT, settlements_json TEXT, isolation_status VARCHAR(16))`,
|
||||
`CREATE INDEX IF NOT EXISTS ix_lk_securities_request ON lk.transfer_securities(request_id)`,
|
||||
`CREATE TABLE IF NOT EXISTS lk.transfer_responses (
|
||||
id BIGSERIAL PRIMARY KEY, request_id BIGINT, security_id BIGINT, guid VARCHAR(40),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(), verdict VARCHAR(32), reason VARCHAR(512), checked_by VARCHAR(64),
|
||||
check_source VARCHAR(64), securities_total INT, securities_ok INT, securities_failed INT, payload_json TEXT)`,
|
||||
`CREATE INDEX IF NOT EXISTS ix_lk_responses_request ON lk.transfer_responses(request_id)`,
|
||||
}
|
||||
|
||||
// ensureLKSchema — схема lk и таблицы модуля ЛК (идемпотентно), при старте bj.
|
||||
func ensureLKSchema(ctx context.Context, rc *RuntimeConfig) error {
|
||||
db, err := lkDB(rc)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cctx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
||||
defer cancel()
|
||||
for _, q := range lkSchemaDDL {
|
||||
if _, err := db.ExecContext(cctx, q); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ClientPosition — остаток бумаги на счёте клиента (для Личного кабинета).
|
||||
type ClientPosition struct {
|
||||
ISIN string `json:"isin"`
|
||||
SecurityCode string `json:"security_code"`
|
||||
SecurityName string `json:"security_name"`
|
||||
Quantity float64 `json:"quantity"`
|
||||
Blocked float64 `json:"blocked"`
|
||||
Available float64 `json:"available"`
|
||||
DeponentCode string `json:"deponent_code"`
|
||||
AccountID string `json:"account_id"`
|
||||
SectionID string `json:"section_id"`
|
||||
SettlementIN string `json:"settlement_inn"`
|
||||
ClientName string `json:"client_name"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
// Transferable — бумагу можно перевести через M2M (только российские выпуски).
|
||||
Transferable bool `json:"transferable"`
|
||||
Note string `json:"note"`
|
||||
}
|
||||
|
||||
// lkClientPositions — позиции клиента модуля ЛК по номеру договора (тестовые клиенты;
|
||||
// реальный портфель берётся из DEPO).
|
||||
func lkClientPositions(ctx context.Context, rc *RuntimeConfig, contract string) ([]ClientPosition, error) {
|
||||
db, err := lkDB(rc)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cctx, cancel := context.WithTimeout(ctx, 15*time.Second)
|
||||
defer cancel()
|
||||
rows, err := db.QueryContext(cctx, `SELECT COALESCE(isin,''), COALESCE(security_code,''), COALESCE(security_name,''),
|
||||
COALESCE(quantity,0), COALESCE(blocked,0), COALESCE(available,0),
|
||||
COALESCE(deponent_code,''), COALESCE(account_id,''), COALESCE(section_id,''),
|
||||
COALESCE(settlement_inn,''), COALESCE(client_name,''), to_char(updated_at,'DD.MM.YYYY HH24:MI')
|
||||
FROM lk.client_positions WHERE contract_number=$1 ORDER BY security_name, isin`, contract)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []ClientPosition
|
||||
for rows.Next() {
|
||||
var p ClientPosition
|
||||
if err := rows.Scan(&p.ISIN, &p.SecurityCode, &p.SecurityName, &p.Quantity, &p.Blocked, &p.Available,
|
||||
&p.DeponentCode, &p.AccountID, &p.SectionID, &p.SettlementIN, &p.ClientName, &p.UpdatedAt); err != nil {
|
||||
return out, err
|
||||
}
|
||||
out = append(out, p)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// LKRequestMeta — метаданные заявки модуля ЛК.
|
||||
type LKRequestMeta struct {
|
||||
Source string // 'lk'
|
||||
BJClaimID string // ID сделки bj
|
||||
LKInvestorID string // investor.id
|
||||
ContractNumber string // № договора клиента
|
||||
SignedDocument string // подпись клиента (base64)
|
||||
SignatureFormat string // формат подписи
|
||||
RequestXML string // M2MTransferRequest (UTF-8, аудит)
|
||||
}
|
||||
|
||||
// lkInsertRequest — заявка модуля ЛК (шапка и бумаги) в lk.transfer_requests / lk.transfer_securities.
|
||||
func lkInsertRequest(ctx context.Context, rc *RuntimeConfig, req *m2m.M2MTransferRequest, meta LKRequestMeta) (int64, error) {
|
||||
db, err := lkDB(rc)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
cctx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
||||
defer cancel()
|
||||
costInfo, costCode := "No", ""
|
||||
if req.Header.CostInfo.Yes != nil {
|
||||
costInfo, costCode = "Yes", string(req.Header.CostInfo.Yes.Code)
|
||||
}
|
||||
inv := req.Data.InvestorInformation
|
||||
doc := inv.IdentityDocument
|
||||
docSeries := ""
|
||||
if doc.DocumentSeries != nil {
|
||||
docSeries = string(*doc.DocumentSeries)
|
||||
}
|
||||
ourINN := transferringINN(Settings{})
|
||||
opType := ""
|
||||
switch ourINN {
|
||||
case string(req.Data.TransferringDepository.INN):
|
||||
opType = "debit"
|
||||
case string(req.Data.ReceivingDepository.INN):
|
||||
opType = "credit"
|
||||
}
|
||||
tx, err := db.BeginTx(cctx, nil)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
var id int64
|
||||
err = tx.QueryRowContext(cctx, `INSERT INTO lk.transfer_requests (source, bj_claim_id, lk_investor_id, signed_document, signature_format,
|
||||
guid, creation_timestamp, sender_code, receiver_code, cost_info, cost_code, investor_last, investor_first, investor_middle,
|
||||
doc_type, doc_series, doc_number, transferring_inn, receiving_inn, operation_type, contract_number, request_xml, status)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22,'new') RETURNING id`,
|
||||
meta.Source, meta.BJClaimID, meta.LKInvestorID, meta.SignedDocument, meta.SignatureFormat,
|
||||
string(req.Header.GUID), req.Header.CreationTimestamp.String(), string(req.Header.SenderCode), string(req.Header.ReceiverCode), costInfo, costCode,
|
||||
inv.LastName, inv.FirstName, inv.MiddleName, doc.DocumentType, docSeries, string(doc.DocumentNumber),
|
||||
string(req.Data.TransferringDepository.INN), string(req.Data.ReceivingDepository.INN), opType, meta.ContractNumber, meta.RequestXML).Scan(&id)
|
||||
if err != nil {
|
||||
tx.Rollback()
|
||||
return 0, fmt.Errorf("запись заявки: %w", err)
|
||||
}
|
||||
for i, sec := range req.Data.TransferredSecurities.Securities {
|
||||
isin := ""
|
||||
if sec.SecurityDetails.ISIN != nil {
|
||||
isin = string(*sec.SecurityDetails.ISIN)
|
||||
}
|
||||
var qWhole, qFrac any
|
||||
if sec.Quantity.Whole != nil {
|
||||
qWhole = int64(*sec.Quantity.Whole)
|
||||
} else if sec.Quantity.Fractional != nil {
|
||||
qFrac = string(*sec.Quantity.Fractional)
|
||||
}
|
||||
settINN, depCode, accID, sectID := "", "", "", ""
|
||||
if len(sec.SettlementAccount) > 0 {
|
||||
sa := sec.SettlementAccount[0]
|
||||
settINN, depCode, accID, sectID = string(sa.SettlementRequisites.INN), sa.SettlementLocation.DeponentCode, string(sa.SettlementLocation.AccountID), sa.SettlementLocation.SectionID
|
||||
}
|
||||
settJSON := ""
|
||||
if len(sec.SettlementAccount) > 1 {
|
||||
if b, e := json.Marshal(sec.SettlementAccount); e == nil {
|
||||
settJSON = string(b)
|
||||
}
|
||||
}
|
||||
if _, err := tx.ExecContext(cctx, `INSERT INTO lk.transfer_securities (request_id, seq, reference_id, security_code, isin,
|
||||
quantity_whole, quantity_fractional, settlement_inn, deponent_code, account_id, section_id, settlement_count, settlements_json, isolation_status)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14)`,
|
||||
id, i+1, string(sec.ReferenceID), string(sec.SecurityCode), isin, qWhole, qFrac, settINN, depCode, accID, sectID,
|
||||
len(sec.SettlementAccount), settJSON, string(sec.IsolationStatus)); err != nil {
|
||||
tx.Rollback()
|
||||
return 0, fmt.Errorf("запись бумаги %d: %w", i+1, err)
|
||||
}
|
||||
}
|
||||
return id, tx.Commit()
|
||||
}
|
||||
|
||||
// lkMarkStatus — статус заявки модуля ЛК (sent | error | ...).
|
||||
func lkMarkStatus(ctx context.Context, rc *RuntimeConfig, id int64, status string) error {
|
||||
db, err := lkDB(rc)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cctx, cancel := context.WithTimeout(ctx, 12*time.Second)
|
||||
defer cancel()
|
||||
_, err = db.ExecContext(cctx, "UPDATE lk.transfer_requests SET status=$1 WHERE id=$2", status, id)
|
||||
return err
|
||||
}
|
||||
|
||||
// lkFinalizeByGUID — итог НРД для заявки модуля ЛК: статус (done | declined) и причина,
|
||||
// чтобы клиент видел результат. Заявки не из модуля ЛК молча пропускаются.
|
||||
func lkFinalizeByGUID(ctx context.Context, rc *RuntimeConfig, guid, status, verdict, reason string) error {
|
||||
db, err := lkDB(rc)
|
||||
if err != nil {
|
||||
return nil // PostgreSQL не настроена — модуля ЛК нет
|
||||
}
|
||||
cctx, cancel := context.WithTimeout(ctx, 12*time.Second)
|
||||
defer cancel()
|
||||
var id int64
|
||||
if err := db.QueryRowContext(cctx, "SELECT id FROM lk.transfer_requests WHERE guid=$1", guid).Scan(&id); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
if _, err := db.ExecContext(cctx, "UPDATE lk.transfer_requests SET status=$1 WHERE id=$2", status, id); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = db.ExecContext(cctx, `INSERT INTO lk.transfer_responses (request_id, guid, verdict, reason, checked_by, check_source)
|
||||
VALUES ($1,$2,$3,$4,'НРД','MOEX МОСТ')`, id, guid, verdict, reason)
|
||||
return err
|
||||
}
|
||||
|
||||
// lkClaimsByContract — заявки клиента модуля ЛК по номеру договора.
|
||||
func lkClaimsByContract(ctx context.Context, rc *RuntimeConfig, contract string, limit int) ([]LKClaimRow, error) {
|
||||
db, err := lkDB(rc)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cctx, cancel := context.WithTimeout(ctx, 15*time.Second)
|
||||
defer cancel()
|
||||
if limit <= 0 || limit > 500 {
|
||||
limit = 100
|
||||
}
|
||||
rows, err := db.QueryContext(cctx, `SELECT r.id, r.guid, r.status, to_char(r.created_at,'DD.MM.YYYY HH24:MI'),
|
||||
(SELECT COUNT(*) FROM lk.transfer_securities x WHERE x.request_id=r.id),
|
||||
COALESCE((SELECT reason FROM lk.transfer_responses y WHERE y.request_id=r.id ORDER BY y.id DESC LIMIT 1),'')
|
||||
FROM lk.transfer_requests r WHERE r.contract_number=$1 ORDER BY r.id DESC LIMIT $2`, contract, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []LKClaimRow
|
||||
for rows.Next() {
|
||||
var c LKClaimRow
|
||||
if err := rows.Scan(&c.ID, &c.GUID, &c.Status, &c.CreatedAt, &c.Securit, &c.Reason); err != nil {
|
||||
return out, err
|
||||
}
|
||||
c.StatusRu = lkStatusRu(c.Status)
|
||||
out = append(out, c)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
Reference in New Issue
Block a user