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,256 @@
|
||||
package lkgateway
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha1"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/m2m"
|
||||
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/m2mcore"
|
||||
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/nsdxml"
|
||||
)
|
||||
|
||||
// lkInvestorUUID — стабильный UUID инвестора, выведенный из номера договора.
|
||||
// Нужен, потому что заявка (Deal) хранится в Postgres с investor_id типа UUID,
|
||||
// а в ЛК клиент идентифицируется договором (BR200) и логином.
|
||||
func lkInvestorUUID(contract string) string {
|
||||
sum := sha1.Sum([]byte("bj-lk:" + strings.ToUpper(strings.TrimSpace(contract))))
|
||||
h := hex.EncodeToString(sum[:])
|
||||
// оформляем как UUID v5-подобный (8-4-4-4-12)
|
||||
return fmt.Sprintf("%s-%s-5%s-a%s-%s", h[0:8], h[8:12], h[13:16], h[17:20], h[20:32])
|
||||
}
|
||||
|
||||
// Заявка на перевод бумаг, поданная клиентом из Личного кабинета.
|
||||
// Путь: ЛК → bj-server → запись в PostgreSQL (lk.transfer_requests) → M2M в НРД
|
||||
// (в тестовом режиме — имитатор НРД) → итог по ответу НРД (lkFinalizeByGUID).
|
||||
|
||||
// LKTransferSecurity — одна бумага в заявке клиента.
|
||||
type LKTransferSecurity struct {
|
||||
ISIN string `json:"isin"`
|
||||
Quantity float64 `json:"quantity"`
|
||||
}
|
||||
|
||||
// LKTransferRequest — запрос на перевод из личного кабинета.
|
||||
type LKTransferRequest struct {
|
||||
ReceivingINN string `json:"receiving_inn"` // ИНН принимающего депозитария (куда переводим)
|
||||
ReceivingName string `json:"receiving_name"` // наименование (для журнала)
|
||||
AccountAtRecv string `json:"account_at_recv"` // счёт депо клиента у принимающего брокера (если известен)
|
||||
Securities []LKTransferSecurity `json:"securities"`
|
||||
Comment string `json:"comment"`
|
||||
}
|
||||
|
||||
// LKTransferResult — ответ клиенту.
|
||||
type LKTransferResult struct {
|
||||
OK bool `json:"ok"`
|
||||
RequestID int64 `json:"request_id"`
|
||||
GUID string `json:"guid"`
|
||||
Status string `json:"status"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// LKClaimRow — заявка клиента для отображения в ЛК.
|
||||
type LKClaimRow struct {
|
||||
ID int64 `json:"id"`
|
||||
GUID string `json:"guid"`
|
||||
Status string `json:"status"`
|
||||
StatusRu string `json:"status_ru"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
Securit int `json:"securities"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
|
||||
// lkStatusRu — человекочитаемый статус заявки для клиента.
|
||||
func lkStatusRu(s string) string {
|
||||
switch s {
|
||||
case "new":
|
||||
return "Принята, ожидает проверки"
|
||||
case "checking":
|
||||
return "На проверке"
|
||||
case "ready":
|
||||
return "Проверена, отправляется в НРД"
|
||||
case "sent":
|
||||
return "Отправлена в НРД"
|
||||
case "rejected":
|
||||
return "Отклонена"
|
||||
case "declined":
|
||||
return "Отклонена НРД"
|
||||
case "done":
|
||||
return "Завершена"
|
||||
case "error":
|
||||
return "Ошибка обработки"
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// CreateLKTransfer формирует заявку клиента, сохраняет её и отправляет в НРД.
|
||||
// Проверяет, что бумаги есть в портфеле клиента и количество доступно.
|
||||
func (s *Service) CreateLKTransfer(ctx context.Context, rc *RuntimeConfig, u LKUser, in LKTransferRequest) (LKTransferResult, error) {
|
||||
var res LKTransferResult
|
||||
fancy := rc.Snapshot().Fancy
|
||||
if _, err := lkDB(rc); err != nil {
|
||||
return res, errors.New("сервис временно недоступен (БД не настроена)")
|
||||
}
|
||||
if strings.TrimSpace(in.ReceivingINN) == "" {
|
||||
return res, errors.New("укажите принимающий депозитарий")
|
||||
}
|
||||
if len(in.Securities) == 0 {
|
||||
return res, errors.New("выберите хотя бы одну бумагу")
|
||||
}
|
||||
// Подача разрешена только клиентам с флагом can_transfer (тестовые);
|
||||
// реальные депозитарные клиенты работают в режиме просмотра.
|
||||
if !u.CanTransfer {
|
||||
return res, errors.New("для этого договора подача заявок недоступна (режим просмотра)")
|
||||
}
|
||||
// Портфель клиента — источник реквизитов и контроль доступного остатка.
|
||||
// Сначала реальный депозитарный учёт (тот же источник, что видит клиент в ЛК),
|
||||
// иначе — позиции модуля ЛК (lk.client_positions).
|
||||
var pos []ClientPosition
|
||||
if dp, derr := depoClientPositions(ctx, fancy, u.Contract); derr == nil && len(dp) > 0 {
|
||||
pos = depoPositionsAsClient(dp)
|
||||
} else {
|
||||
p2, err := lkClientPositions(ctx, rc, u.Contract)
|
||||
if err != nil {
|
||||
return res, fmt.Errorf("не удалось получить портфель: %w", err)
|
||||
}
|
||||
pos = p2
|
||||
}
|
||||
pos = markTransferable(pos)
|
||||
byISIN := map[string]ClientPosition{}
|
||||
for _, p := range pos {
|
||||
byISIN[strings.ToUpper(p.ISIN)] = p
|
||||
}
|
||||
|
||||
nsd := rc.Snapshot().NSD
|
||||
var secs []ClaimSec
|
||||
for _, want := range in.Securities {
|
||||
key := strings.ToUpper(strings.TrimSpace(want.ISIN))
|
||||
p, ok := byISIN[key]
|
||||
if !ok {
|
||||
return res, fmt.Errorf("бумага %s не найдена в вашем портфеле", want.ISIN)
|
||||
}
|
||||
if !p.Transferable {
|
||||
return res, fmt.Errorf("бумага %s (%s) — иностранный выпуск, перевод через M2M недоступен: "+
|
||||
"в MOEX МОСТ переводятся только российские ценные бумаги", p.ISIN, p.SecurityName)
|
||||
}
|
||||
if want.Quantity <= 0 {
|
||||
return res, fmt.Errorf("укажите количество для %s", want.ISIN)
|
||||
}
|
||||
if want.Quantity > p.Available {
|
||||
return res, fmt.Errorf("по %s доступно к переводу %.0f, запрошено %.0f", want.ISIN, p.Available, want.Quantity)
|
||||
}
|
||||
dep, acc, sect := p.DeponentCode, p.AccountID, p.SectionID
|
||||
if dep == "" {
|
||||
dep = nsd.DeponentCode
|
||||
}
|
||||
if acc == "" {
|
||||
acc = nsd.AccountID
|
||||
}
|
||||
if sect == "" {
|
||||
sect = nsd.SectionID
|
||||
}
|
||||
code := p.SecurityCode
|
||||
if code == "" {
|
||||
code = p.ISIN
|
||||
}
|
||||
// ИНН реквизитов расчётов есть только в таблице обмена; у депозитарных
|
||||
// позиций его нет — подставляем ИНН нашего депозитария (место расчётов НРД).
|
||||
setlINN := p.SettlementIN
|
||||
if setlINN == "" {
|
||||
setlINN = transferringINN(rc.Snapshot())
|
||||
}
|
||||
secs = append(secs, ClaimSec{
|
||||
SecurityCode: code,
|
||||
SecurityDetails: SecurityDetails{ISIN: p.ISIN},
|
||||
Quantity: Quantity{Whole: uint64(want.Quantity)},
|
||||
SettlementAccounts: []SettlementAccount{{
|
||||
SettlementRequisitesINN: setlINN,
|
||||
SettlementLocation: SettlementLocation{
|
||||
DeponentCode: dep, AccountID: acc, SectionID: sect,
|
||||
},
|
||||
}},
|
||||
})
|
||||
}
|
||||
|
||||
// ФИО/документ клиента — для M2MTransferRequest.
|
||||
last, first, middle := u.LastName, u.FirstName, u.MiddleName
|
||||
if last == "" && u.FullName != "" {
|
||||
parts := strings.Fields(u.FullName)
|
||||
if len(parts) > 0 {
|
||||
last = parts[0]
|
||||
}
|
||||
if len(parts) > 1 {
|
||||
first = parts[1]
|
||||
}
|
||||
if len(parts) > 2 {
|
||||
middle = parts[2]
|
||||
}
|
||||
}
|
||||
doc := Document{DocumentType: u.DocType, Series: u.DocSeries, Number: u.DocNumber}
|
||||
claim := CreateClaimRequest{
|
||||
Investor: Investor{
|
||||
ID: u.Contract,
|
||||
LastName: last, FirstName: first, MiddleName: middle,
|
||||
Document: doc,
|
||||
},
|
||||
TransferringDepositoryINN: transferringINN(rc.Snapshot()),
|
||||
ReceivingDepositoryINN: strings.TrimSpace(in.ReceivingINN),
|
||||
CostInfo: CostInfo{Yes: &CostInfoYes{Code: nsd.DeponentCode}},
|
||||
Securities: secs,
|
||||
SignatureFormat: "LK",
|
||||
}
|
||||
// ТЕСТОВЫЙ КЛИЕНТ: если серия ДУЛ совпадает с кодом сценария робота НРД
|
||||
// (1111/2001/2002/3333), заявка адресуется роботу-тестировщику, а серия/номер
|
||||
// документа задают сценарий. Так из ЛК можно прогнать полный цикл с НРД.
|
||||
if _, isRobot := robotScenarios[u.DocSeries]; isRobot {
|
||||
claim.ReceiverCodeOverride = NSDRobotCode
|
||||
claim.InvestorDocumentOverride = &doc
|
||||
}
|
||||
// Регистрируем клиента ЛК в справочнике (анкета + депо-счета из портфеля),
|
||||
// иначе EnrichRequest не найдёт его данные. Депозитарным позициям (без своих
|
||||
// НРД-реквизитов) подставляем фиксированное место расчётов нашего депозитария.
|
||||
if ss, ok := s.store.(*SeedStore); ok {
|
||||
ss.EnsureLKClient(u, pos, m2mcore.DepoAccount{
|
||||
DeponentCode: nsd.DeponentCode,
|
||||
AccountID: m2m.AccountID(nsd.AccountID),
|
||||
SectionID: nsd.SectionID,
|
||||
DepositoryINN: m2m.OrganizationINN(transferringINN(rc.Snapshot())),
|
||||
})
|
||||
}
|
||||
req, err := s.BuildRequest(ctx, claim)
|
||||
if err != nil {
|
||||
return res, fmt.Errorf("не удалось сформировать поручение: %w", err)
|
||||
}
|
||||
xmlBytes, err := nsdxml.Marshal(req)
|
||||
if err != nil {
|
||||
return res, err
|
||||
}
|
||||
meta := LKRequestMeta{
|
||||
Source: "lk",
|
||||
BJClaimID: string(req.Header.GUID),
|
||||
LKInvestorID: lkInvestorUUID(u.Contract), // UUID — его ждёт хранилище заявок
|
||||
ContractNumber: u.Contract,
|
||||
SignatureFormat: "LK",
|
||||
RequestXML: string(nsdxml.DecodeWindows1251(xmlBytes)),
|
||||
}
|
||||
id, err := lkInsertRequest(ctx, rc, req, meta)
|
||||
if err != nil {
|
||||
return res, fmt.Errorf("не удалось сохранить заявку: %w", err)
|
||||
}
|
||||
// Заявка сразу уходит в НРД; в тестовом режиме (боевой ЛК при тестовом ИШ) — имитатору НРД.
|
||||
sender := s.sender
|
||||
if emu, _ := lkEmulateNSD(rc); emu && lkEmuSender != nil {
|
||||
sender = lkEmuSender
|
||||
}
|
||||
if _, err := s.SendBuiltRequestVia(ctx, sender, req, lkInvestorUUID(u.Contract), nil); err != nil {
|
||||
_ = lkMarkStatus(ctx, rc, id, "error")
|
||||
return res, fmt.Errorf("заявка сохранена, но не отправлена: %w", err)
|
||||
}
|
||||
_ = lkMarkStatus(ctx, rc, id, "sent")
|
||||
return LKTransferResult{
|
||||
OK: true, RequestID: id, GUID: string(req.Header.GUID), Status: "sent",
|
||||
Message: "Заявка принята и отправлена в НРД. Итог появится в разделе «Мои заявки».",
|
||||
}, nil
|
||||
}
|
||||
Reference in New Issue
Block a user