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:
zuevav
2026-09-15 19:41:12 +03:00
parent 59fc774f08
commit da12a9ec1c
132 changed files with 29969 additions and 1089 deletions
+256
View File
@@ -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
}