4cbefa41d5
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014FsEKyJrYF69Cj9ZXzFLRi
271 lines
12 KiB
Go
271 lines
12 KiB
Go
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/nsdadapter/igw"
|
||
"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 "queued":
|
||
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 && devMode(rc) {
|
||
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 {
|
||
var nd *igw.NotDeliveredError
|
||
if errors.As(err, &nd) {
|
||
// НРД сейчас не принимает пакеты: заявка сохранена и уйдёт сама, когда связь восстановится
|
||
// (nsdhealth.go, автоповтор) — клиенту это не ошибка.
|
||
_ = lkMarkStatus(ctx, rc, id, "queued")
|
||
return LKTransferResult{
|
||
OK: true, RequestID: id, GUID: string(req.Header.GUID), Status: "queued",
|
||
Message: "Заявка принята. Обмен с НРД сейчас временно недоступен — заявка будет отправлена автоматически, как только связь восстановится. Статус — в разделе «Мои заявки».",
|
||
}, 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
|
||
}
|