Files

258 lines
11 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 && 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 {
_ = 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
}