e4de1ab4d8
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
699 lines
29 KiB
Go
699 lines
29 KiB
Go
package lkgateway
|
||
|
||
// manual.go — ручной ввод ИСХОДЯЩЕГО перевода M2M по бумажному поручению клиента.
|
||
//
|
||
// Клиент приносит поручение на бумаге, сотрудник депозитария вводит его в bj так, как клиент
|
||
// заполнил бы заявку в личном кабинете. Дальше перевод идёт тем же путём, что и заявка ЛК:
|
||
// пакет M2MTransferRequest в НРД → запись в DEPO (sentHook) → блокировка в QUIK (quikSendHook,
|
||
// если сотрудник не заблокировал вручную) → ответ контрагента → черновик формы 16 → поручение 16
|
||
// (вручную кнопкой или автоматическим режимом) → отчёт НРД MS010.
|
||
//
|
||
// Отправить нельзя, пока не заполнены реквизиты, не выполнена сверка с DEPO (если учётная система
|
||
// подключена) и не проставлены обязательные отметки сверки с учётной и торговой системами. Каждая
|
||
// отметка хранит, кто и когда её поставил. Поручения — в manual-orders.json.
|
||
|
||
import (
|
||
"context"
|
||
"crypto/sha256"
|
||
"encoding/hex"
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"os"
|
||
"regexp"
|
||
"sort"
|
||
"strconv"
|
||
"strings"
|
||
"sync"
|
||
"time"
|
||
|
||
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/m2m"
|
||
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/m2mcore"
|
||
)
|
||
|
||
const manualFile = "/var/lib/bj/.bj/manual-orders.json"
|
||
|
||
// ManualSec — бумага в поручении.
|
||
type ManualSec struct {
|
||
ISIN string `json:"isin"`
|
||
Code string `json:"code,omitempty"` // код НРД (необязательно; пусто — ISIN)
|
||
Name string `json:"name,omitempty"`
|
||
Qty string `json:"qty"`
|
||
}
|
||
|
||
// ManualMark — отметка сотрудника (кто и когда).
|
||
type ManualMark struct {
|
||
Done bool `json:"done"`
|
||
By string `json:"by,omitempty"`
|
||
At time.Time `json:"at,omitempty"`
|
||
}
|
||
|
||
// ManualDepoCheck — результат сверки с DEPO на момент Hash (данные поручения).
|
||
type ManualDepoCheck struct {
|
||
At time.Time `json:"at"`
|
||
By string `json:"by,omitempty"`
|
||
Hash string `json:"hash"`
|
||
Ran bool `json:"ran"`
|
||
Err string `json:"err,omitempty"`
|
||
Recommend string `json:"recommend,omitempty"`
|
||
Summary string `json:"summary,omitempty"`
|
||
Investor string `json:"investor,omitempty"`
|
||
Account string `json:"account,omitempty"`
|
||
Secs []depoSecResult `json:"secs,omitempty"`
|
||
}
|
||
|
||
// ManualOrder — поручение, введённое вручную.
|
||
type ManualOrder struct {
|
||
ID int `json:"id"`
|
||
CreatedAt time.Time `json:"created_at"`
|
||
CreatedBy string `json:"created_by,omitempty"`
|
||
UpdatedAt time.Time `json:"updated_at"`
|
||
|
||
// Основание
|
||
PaperNo string `json:"paper_no"`
|
||
PaperDate string `json:"paper_date"` // ГГГГ-ММ-ДД
|
||
ReceivedVia string `json:"received_via"`
|
||
|
||
// Клиент
|
||
LastName string `json:"last_name"`
|
||
FirstName string `json:"first_name"`
|
||
MiddleName string `json:"middle_name,omitempty"`
|
||
BirthDate string `json:"birth_date"` // ГГГГ-ММ-ДД
|
||
DocType string `json:"doc_type"`
|
||
DocSeries string `json:"doc_series,omitempty"`
|
||
DocNumber string `json:"doc_number"`
|
||
DocIssueDate string `json:"doc_issue_date,omitempty"`
|
||
DocIssuer string `json:"doc_issuer,omitempty"`
|
||
Contract string `json:"contract,omitempty"`
|
||
Account string `json:"account"`
|
||
QuikCode string `json:"quik_code,omitempty"`
|
||
Phone string `json:"phone,omitempty"`
|
||
|
||
// Принимающая сторона
|
||
RecvINN string `json:"recv_inn"`
|
||
RecvCode string `json:"recv_code,omitempty"`
|
||
RecvName string `json:"recv_name,omitempty"`
|
||
RecvAccount string `json:"recv_account,omitempty"` // счёт клиента у принимающего (для сведения)
|
||
SettlePlace string `json:"settle_place"` // nrd | spb
|
||
CostInfo bool `json:"cost_info"`
|
||
|
||
Securities []ManualSec `json:"securities"`
|
||
Comment string `json:"comment,omitempty"`
|
||
|
||
// Сверка и отметки до отправки
|
||
Depo *ManualDepoCheck `json:"depo,omitempty"`
|
||
Checks map[string]ManualMark `json:"checks,omitempty"`
|
||
QuikMode string `json:"quik_mode,omitempty"` // auto | manual | none
|
||
QuikInstr string `json:"quik_instr,omitempty"`
|
||
|
||
// Отправка
|
||
Status string `json:"status"` // draft | sent | declined
|
||
GUID string `json:"guid,omitempty"`
|
||
DealID string `json:"deal_id,omitempty"`
|
||
SentAt time.Time `json:"sent_at,omitempty"`
|
||
SentBy string `json:"sent_by,omitempty"`
|
||
SendErr string `json:"send_err,omitempty"`
|
||
DeclineReason string `json:"decline_reason,omitempty"`
|
||
DeclinedBy string `json:"declined_by,omitempty"`
|
||
DeclinedAt time.Time `json:"declined_at,omitempty"`
|
||
|
||
// Отметки после ответа
|
||
Post map[string]ManualMark `json:"post,omitempty"`
|
||
}
|
||
|
||
// FullName — ФИО клиента.
|
||
func (o *ManualOrder) FullName() string {
|
||
return strings.TrimSpace(strings.Join([]string{o.LastName, o.FirstName, o.MiddleName}, " "))
|
||
}
|
||
|
||
// Number — номер поручения в bj.
|
||
func (o *ManualOrder) Number() string { return fmt.Sprintf("Р-%05d", o.ID) }
|
||
|
||
// dataHash — отпечаток данных, от которых зависят сверка и отметки: изменили данные — сверку и
|
||
// отметки нужно пройти заново.
|
||
func (o *ManualOrder) dataHash() string {
|
||
b, _ := json.Marshal([]any{o.LastName, o.FirstName, o.MiddleName, o.BirthDate, o.DocType, o.DocSeries, o.DocNumber,
|
||
o.Account, o.RecvINN, o.SettlePlace, o.CostInfo, o.Securities})
|
||
sum := sha256.Sum256(b)
|
||
return hex.EncodeToString(sum[:8])
|
||
}
|
||
|
||
// manualCheckDef — обязательная отметка.
|
||
type manualCheckDef struct{ Key, Group, Title, Hint string }
|
||
|
||
// manualPreChecks — отметки перед отправкой (все обязательны).
|
||
var manualPreChecks = []manualCheckDef{
|
||
{"paper", "Поручение клиента", "Поручение оформлено правильно",
|
||
"подпись клиента или представителя, дата, реквизиты бумаг и принимающего депозитария; полномочия представителя (доверенность) проверены"},
|
||
{"client", "Учётная система депозитария", "Клиент идентифицирован",
|
||
"ФИО, документ, удостоверяющий личность, и дата рождения совпадают с анкетой депонента"},
|
||
{"account", "Учётная система депозитария", "Счёт депо открыт, принадлежит клиенту и не является ИИС",
|
||
"перевод с индивидуального инвестиционного счёта через M2M не выполняется"},
|
||
{"balance", "Учётная система депозитария", "Остаток достаточен, бумаги свободны",
|
||
"на торговом разделе не меньше количества в поручении; нет обременений, ареста и блокировок"},
|
||
{"security", "Учётная система депозитария", "Бумаги допускаются к переводу M2M",
|
||
"российские выпуски (ISIN начинается с RU), приняты на обслуживание"},
|
||
{"qual", "Учётная система депозитария", "Ограничения для квалифицированных инвесторов проверены",
|
||
"если бумага только для квалифицированных инвесторов — статус клиента проверен, клиент предупреждён о возможном отказе"},
|
||
{"fees", "Учётная система депозитария", "Задолженности по оплате услуг нет",
|
||
"или задолженность урегулирована по правилам депозитария"},
|
||
{"counterparty", "Принимающая сторона", "Принимающий депозитарий — участник M2M",
|
||
"ИНН и наименование сверены со справочником участников НРД"},
|
||
}
|
||
|
||
// manualPostDef — отметки после ответа: when — done (исполнено) | rejected (отказ); quik — только при
|
||
// ручной блокировке в торговой системе.
|
||
type manualPostDef struct {
|
||
Key, When, Title string
|
||
Quik bool
|
||
}
|
||
|
||
var manualPostChecks = []manualPostDef{
|
||
{"quik_debit", "done", "Списание в торговой системе проведено (лимит уменьшен окончательно)", true},
|
||
{"depo_done", "done", "Операция списания отражена в учётной системе депозитария", false},
|
||
{"client_done", "done", "Клиент уведомлён об исполнении", false},
|
||
{"paper_done", "done", "На поручении проставлена отметка об исполнении", false},
|
||
{"quik_unblock", "rejected", "Блокировка в торговой системе снята (лимит восстановлен)", true},
|
||
{"client_rejected", "rejected", "Клиент уведомлён об отказе и его причине", false},
|
||
{"paper_rejected", "rejected", "На поручении проставлена отметка об отказе", false},
|
||
}
|
||
|
||
// manualStore — поручения ручного ввода (переживают перезапуск).
|
||
type manualStore struct {
|
||
mu sync.Mutex
|
||
path string
|
||
items map[int]*ManualOrder
|
||
next int
|
||
}
|
||
|
||
var globalManual = loadManualStore(manualFile)
|
||
|
||
func loadManualStore(path string) *manualStore {
|
||
s := &manualStore{path: path, items: map[int]*ManualOrder{}, next: 1}
|
||
if b, err := os.ReadFile(path); err == nil {
|
||
var list []*ManualOrder
|
||
if json.Unmarshal(b, &list) == nil {
|
||
for _, o := range list {
|
||
if o.Status == "sending" { // отправка прервана перезапуском
|
||
o.Status, o.SendErr = "draft", "отправка прервана перезапуском сервиса — перед повтором проверьте журнал исходящих переводов"
|
||
}
|
||
s.items[o.ID] = o
|
||
if o.ID >= s.next {
|
||
s.next = o.ID + 1
|
||
}
|
||
}
|
||
}
|
||
}
|
||
return s
|
||
}
|
||
|
||
func (s *manualStore) saveLocked() error {
|
||
list := make([]*ManualOrder, 0, len(s.items))
|
||
for _, o := range s.items {
|
||
list = append(list, o)
|
||
}
|
||
sort.Slice(list, func(i, j int) bool { return list[i].ID < list[j].ID })
|
||
b, err := json.MarshalIndent(list, "", " ")
|
||
if err != nil {
|
||
return err
|
||
}
|
||
tmp := s.path + ".tmp"
|
||
if err := os.WriteFile(tmp, b, 0o600); err != nil {
|
||
return err
|
||
}
|
||
return os.Rename(tmp, s.path)
|
||
}
|
||
|
||
func (s *manualStore) Get(id int) (*ManualOrder, bool) {
|
||
s.mu.Lock()
|
||
defer s.mu.Unlock()
|
||
o, ok := s.items[id]
|
||
if !ok {
|
||
return nil, false
|
||
}
|
||
b, _ := json.Marshal(o) // глубокая копия
|
||
var cp ManualOrder
|
||
_ = json.Unmarshal(b, &cp)
|
||
return &cp, true
|
||
}
|
||
|
||
func (s *manualStore) List() []*ManualOrder {
|
||
s.mu.Lock()
|
||
ids := make([]int, 0, len(s.items))
|
||
for id := range s.items {
|
||
ids = append(ids, id)
|
||
}
|
||
s.mu.Unlock()
|
||
sort.Sort(sort.Reverse(sort.IntSlice(ids)))
|
||
out := make([]*ManualOrder, 0, len(ids))
|
||
for _, id := range ids {
|
||
if o, ok := s.Get(id); ok {
|
||
out = append(out, o)
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
// Create — новое поручение (ID присваивается).
|
||
func (s *manualStore) Create(o *ManualOrder) error {
|
||
s.mu.Lock()
|
||
defer s.mu.Unlock()
|
||
o.ID = s.next
|
||
s.next++
|
||
o.CreatedAt, o.UpdatedAt = time.Now(), time.Now()
|
||
s.items[o.ID] = o
|
||
return s.saveLocked()
|
||
}
|
||
|
||
// Update — изменение под замком: fn получает текущую запись.
|
||
func (s *manualStore) Update(id int, fn func(o *ManualOrder) error) error {
|
||
s.mu.Lock()
|
||
defer s.mu.Unlock()
|
||
o, ok := s.items[id]
|
||
if !ok {
|
||
return errors.New("поручение не найдено")
|
||
}
|
||
b, _ := json.Marshal(o)
|
||
var cp ManualOrder
|
||
_ = json.Unmarshal(b, &cp)
|
||
if err := fn(&cp); err != nil {
|
||
return err
|
||
}
|
||
cp.UpdatedAt = time.Now()
|
||
s.items[id] = &cp
|
||
return s.saveLocked()
|
||
}
|
||
|
||
var (
|
||
manualINNRe = regexp.MustCompile(`^\d{10}$`)
|
||
manualISINRe = regexp.MustCompile(`^[A-Z]{2}[A-Z0-9]{9}\d$`)
|
||
manualDateRe = regexp.MustCompile(`^\d{4}-\d{2}-\d{2}$`)
|
||
)
|
||
|
||
// manualQty — количество: целое или дробное (паи), > 0.
|
||
func manualQty(s string) (Quantity, bool) {
|
||
s = strings.ReplaceAll(strings.TrimSpace(s), ",", ".")
|
||
f, err := strconv.ParseFloat(s, 64)
|
||
if err != nil || f <= 0 {
|
||
return Quantity{}, false
|
||
}
|
||
if f == float64(uint64(f)) {
|
||
return Quantity{Whole: uint64(f)}, true
|
||
}
|
||
return Quantity{Fractional: strconv.FormatFloat(f, 'f', -1, 64)}, true
|
||
}
|
||
|
||
// manualDataProblems — что не заполнено или заполнено неверно.
|
||
func manualDataProblems(o *ManualOrder) []string {
|
||
var p []string
|
||
need := func(v, what string) {
|
||
if strings.TrimSpace(v) == "" {
|
||
p = append(p, "не заполнено: "+what)
|
||
}
|
||
}
|
||
need(o.PaperNo, "номер поручения клиента")
|
||
if !manualDateRe.MatchString(o.PaperDate) {
|
||
p = append(p, "не заполнена дата поручения клиента")
|
||
}
|
||
need(o.LastName, "фамилия клиента")
|
||
need(o.FirstName, "имя клиента")
|
||
if !manualDateRe.MatchString(o.BirthDate) {
|
||
p = append(p, "не заполнена дата рождения клиента")
|
||
}
|
||
need(o.DocType, "вид документа")
|
||
need(o.DocNumber, "номер документа")
|
||
if o.DocType == "21" && len(strings.ReplaceAll(o.DocSeries, " ", "")) != 4 {
|
||
p = append(p, "серия паспорта РФ — 4 цифры")
|
||
}
|
||
need(o.Account, "счёт депо клиента")
|
||
if !manualINNRe.MatchString(strings.TrimSpace(o.RecvINN)) {
|
||
p = append(p, "ИНН принимающего депозитария — 10 цифр")
|
||
}
|
||
if len(o.Securities) == 0 {
|
||
p = append(p, "нет ни одной бумаги")
|
||
}
|
||
for i, s := range o.Securities {
|
||
isin := strings.ToUpper(strings.TrimSpace(s.ISIN))
|
||
switch {
|
||
case !manualISINRe.MatchString(isin):
|
||
p = append(p, fmt.Sprintf("бумага %d: неверный ISIN «%s»", i+1, s.ISIN))
|
||
case !isRussianISIN(isin):
|
||
p = append(p, fmt.Sprintf("бумага %d: %s — иностранный выпуск, через M2M не переводится", i+1, isin))
|
||
}
|
||
if _, ok := manualQty(s.Qty); !ok {
|
||
p = append(p, fmt.Sprintf("бумага %d: количество должно быть больше нуля", i+1))
|
||
}
|
||
}
|
||
return p
|
||
}
|
||
|
||
// manualDepoConfigured — подключена ли учётная система (сверка обязательна).
|
||
func manualDepoConfigured(rc *RuntimeConfig) bool {
|
||
return rc != nil && strings.TrimSpace(rc.Snapshot().Fancy.Server) != ""
|
||
}
|
||
|
||
// manualQuikAutoAvailable — может ли bj сам заблокировать бумаги в торговой системе при отправке.
|
||
func manualQuikAutoAvailable(rc *RuntimeConfig) bool {
|
||
if rc == nil {
|
||
return false
|
||
}
|
||
q := rc.Snapshot().Quik
|
||
return quikActive(q) && q.AutoOut
|
||
}
|
||
|
||
// manualSendBlockers — почему поручение пока нельзя отправить (пусто — можно).
|
||
func manualSendBlockers(rc *RuntimeConfig, o *ManualOrder) []string {
|
||
if o.Status != "draft" {
|
||
return []string{"поручение уже " + manualStatusRu(o.Status)}
|
||
}
|
||
b := manualDataProblems(o)
|
||
if manualDepoConfigured(rc) {
|
||
switch {
|
||
case o.Depo == nil:
|
||
b = append(b, "не выполнена сверка с учётной системой (DEPO)")
|
||
case o.Depo.Hash != o.dataHash():
|
||
b = append(b, "данные изменены после сверки с DEPO — выполните сверку заново")
|
||
case !o.Depo.Ran:
|
||
b = append(b, "сверка с DEPO не выполнена: "+o.Depo.Err)
|
||
case o.Depo.Recommend != "confirm":
|
||
b = append(b, "сверка с DEPO не пройдена: "+o.Depo.Summary)
|
||
}
|
||
}
|
||
for _, c := range manualPreChecks {
|
||
if !o.Checks[c.Key].Done {
|
||
b = append(b, "нет отметки: "+c.Title)
|
||
}
|
||
}
|
||
switch o.QuikMode {
|
||
case "auto":
|
||
if !manualQuikAutoAvailable(rc) {
|
||
b = append(b, "автоматическая блокировка в торговой системе не включена — заблокируйте бумаги вручную")
|
||
}
|
||
case "manual":
|
||
if strings.TrimSpace(o.QuikInstr) == "" {
|
||
b = append(b, "укажите номер поручения на блокировку в торговой системе")
|
||
}
|
||
case "none":
|
||
default:
|
||
b = append(b, "не выбрано, как заблокированы бумаги в торговой системе")
|
||
}
|
||
return b
|
||
}
|
||
|
||
func manualStatusRu(s string) string {
|
||
switch s {
|
||
case "draft":
|
||
return "черновик"
|
||
case "sent":
|
||
return "отправлено в НРД"
|
||
case "sending":
|
||
return "отправляется"
|
||
case "declined":
|
||
return "отклонено депозитарием"
|
||
}
|
||
return s
|
||
}
|
||
|
||
// manualClaim — заявка в том же виде, что из личного кабинета.
|
||
func manualClaim(rc *RuntimeConfig, o *ManualOrder) (CreateClaimRequest, error) {
|
||
nsd := rc.Snapshot().NSD
|
||
var secs []ClaimSec
|
||
for _, s := range o.Securities {
|
||
isin := strings.ToUpper(strings.TrimSpace(s.ISIN))
|
||
q, ok := manualQty(s.Qty)
|
||
if !ok {
|
||
return CreateClaimRequest{}, fmt.Errorf("количество по %s", isin)
|
||
}
|
||
secs = append(secs, ClaimSec{
|
||
SecurityCode: pick(strings.TrimSpace(s.Code), isin),
|
||
SecurityDetails: SecurityDetails{ISIN: isin},
|
||
Quantity: q,
|
||
SettlementAccounts: []SettlementAccount{{
|
||
SettlementRequisitesINN: transferringINN(rc.Snapshot()),
|
||
SettlementLocation: SettlementLocation{DeponentCode: nsd.DeponentCode, AccountID: nsd.AccountID, SectionID: nsd.SectionID},
|
||
}},
|
||
})
|
||
}
|
||
cost := CostInfo{No: &struct{}{}}
|
||
if o.CostInfo {
|
||
cost = CostInfo{Yes: &CostInfoYes{Code: nsd.DeponentCode}}
|
||
}
|
||
ser := strings.ReplaceAll(strings.TrimSpace(o.DocSeries), " ", "")
|
||
return CreateClaimRequest{
|
||
Investor: Investor{
|
||
ID: lkInvestorUUID("manual:" + ser + strings.TrimSpace(o.DocNumber)),
|
||
LastName: strings.TrimSpace(o.LastName), FirstName: strings.TrimSpace(o.FirstName), MiddleName: strings.TrimSpace(o.MiddleName),
|
||
Document: Document{DocumentType: o.DocType, Series: ser, Number: strings.TrimSpace(o.DocNumber)},
|
||
BirthDate: o.BirthDate,
|
||
},
|
||
TransferringDepositoryINN: transferringINN(rc.Snapshot()),
|
||
ReceivingDepositoryINN: strings.TrimSpace(o.RecvINN),
|
||
CostInfo: cost,
|
||
Securities: secs,
|
||
SignatureFormat: "PAPER",
|
||
ClientAccount: strings.ToUpper(strings.TrimSpace(o.Account)),
|
||
}, nil
|
||
}
|
||
|
||
// manualDepoRun — сверка поручения с DEPO теми же проверками, что и заявка ЛК.
|
||
func manualDepoRun(rc *RuntimeConfig, o *ManualOrder) *ManualDepoCheck {
|
||
nsd := rc.Snapshot().NSD
|
||
var secs []depoOutSec
|
||
for i, s := range o.Securities {
|
||
secs = append(secs, depoOutSec{ReferenceID: strconv.Itoa(i + 1), ISIN: strings.ToUpper(strings.TrimSpace(s.ISIN)), Code: s.Code, Qty: s.Qty})
|
||
}
|
||
res := runDepoCheckOutAccount(o.LastName, o.FirstName, o.MiddleName, strings.ReplaceAll(o.DocSeries, " ", ""), strings.TrimSpace(o.DocNumber), o.BirthDate,
|
||
secs, nsd.AccountID, nsd.SectionID, strings.ToUpper(strings.TrimSpace(o.Account)))
|
||
return &ManualDepoCheck{At: time.Now(), Hash: o.dataHash(), Ran: res.Ran, Err: res.Err, Recommend: res.Recommend,
|
||
Summary: res.Summary, Investor: res.Investor, Account: res.Account, Secs: res.Secs}
|
||
}
|
||
|
||
// manualDepoClient — клиент по документу из DEPO: ФИО, дата рождения, открытый торговый счёт.
|
||
type manualDepoClient struct {
|
||
Found bool
|
||
Name, Birth, Account string
|
||
Err string
|
||
}
|
||
|
||
func manualDepoLookup(rc *RuntimeConfig, ser, num string) manualDepoClient {
|
||
var out manualDepoClient
|
||
if !manualDepoConfigured(rc) {
|
||
out.Err = "учётная система (DEPO) не подключена"
|
||
return out
|
||
}
|
||
fs := rc.Snapshot().Fancy
|
||
db, ctx, cancel, errS := depoOpen(fs)
|
||
if errS != "" {
|
||
out.Err = errS
|
||
return out
|
||
}
|
||
defer cancel()
|
||
defer db.Close()
|
||
p := depoResolveParty(ctx, db, fs, strings.ReplaceAll(ser, " ", ""), strings.TrimSpace(num), "")
|
||
if !p.faceOK {
|
||
out.Err = "клиент с таким документом в DEPO не найден"
|
||
return out
|
||
}
|
||
out.Found, out.Name, out.Account = true, p.faceName, p.accOpen
|
||
if p.birth.Valid {
|
||
out.Birth = p.birth.Time.Format("2006-01-02")
|
||
}
|
||
return out
|
||
}
|
||
|
||
// manualDepoSecNames — наименования бумаг из DEPO по ISIN (не найденные — без записи).
|
||
func manualDepoSecNames(rc *RuntimeConfig, isins []string) map[string]string {
|
||
out := map[string]string{}
|
||
if !manualDepoConfigured(rc) || len(isins) == 0 {
|
||
return out
|
||
}
|
||
fs := rc.Snapshot().Fancy
|
||
db, ctx, cancel, errS := depoOpen(fs)
|
||
if errS != "" {
|
||
return out
|
||
}
|
||
defer cancel()
|
||
defer db.Close()
|
||
for _, isin := range isins {
|
||
if _, name, found, _ := depoSecurity(ctx, db, isin); found {
|
||
out[isin] = name
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
// manualQuikSkipKey — в контексте отправки: бумаги в торговой системе заблокированы вручную
|
||
// (или торговая система не используется) — хук QUIK не вызывается.
|
||
type manualQuikSkipKey struct{}
|
||
|
||
func manualQuikSkipped(ctx context.Context) bool {
|
||
v, _ := ctx.Value(manualQuikSkipKey{}).(bool)
|
||
return v
|
||
}
|
||
|
||
// manualSend — отправка поручения в НРД тем же путём, что и заявка из личного кабинета.
|
||
func manualSend(ctx context.Context, rc *RuntimeConfig, svc *Service, o *ManualOrder) (guid, dealID string, err error) {
|
||
claim, err := manualClaim(rc, o)
|
||
if err != nil {
|
||
return "", "", err
|
||
}
|
||
if ss, ok := svc.store.(*SeedStore); ok {
|
||
nsd := rc.Snapshot().NSD
|
||
ss.EnsureLKClient(LKUser{
|
||
Login: "manual:" + o.Number(), Contract: claim.Investor.ID,
|
||
LastName: claim.Investor.LastName, FirstName: claim.Investor.FirstName, MiddleName: claim.Investor.MiddleName,
|
||
DocType: claim.Investor.Document.DocumentType, DocSeries: claim.Investor.Document.Series, DocNumber: claim.Investor.Document.Number,
|
||
Active: true,
|
||
}, nil, m2mcore.DepoAccount{
|
||
DeponentCode: nsd.DeponentCode,
|
||
AccountID: m2m.AccountID(nsd.AccountID),
|
||
SectionID: nsd.SectionID,
|
||
DepositoryINN: m2m.OrganizationINN(settlementPlaceINN(o.SettlePlace)),
|
||
})
|
||
}
|
||
req, err := svc.BuildRequest(ctx, claim)
|
||
if err != nil {
|
||
return "", "", fmt.Errorf("пакет не прошёл проверку формата НРД: %w", err)
|
||
}
|
||
sender := svc.sender
|
||
if emu, _ := lkEmulateNSD(rc); emu && lkEmuSender != nil {
|
||
sender = lkEmuSender
|
||
}
|
||
if o.QuikMode != "auto" {
|
||
ctx = context.WithValue(ctx, manualQuikSkipKey{}, true)
|
||
}
|
||
resp, err := svc.SendBuiltRequestVia(ctx, sender, req, claim.Investor.ID, nil)
|
||
if err != nil {
|
||
return string(req.Header.GUID), "", err
|
||
}
|
||
return string(req.Header.GUID), resp.ID, nil
|
||
}
|
||
|
||
// ---- ход исполнения (для карточки) ----
|
||
|
||
// manualStep — шаг исполнения: State — done | wait | todo | fail | skip.
|
||
type manualStep struct {
|
||
Title, Detail, State string
|
||
At time.Time
|
||
}
|
||
|
||
// manualProgress — ход исполнения отправленного поручения и итог: outcome — "" (в работе) |
|
||
// done | rejected.
|
||
type manualProgress struct {
|
||
Steps []manualStep
|
||
Outcome string
|
||
Claim *ClaimView
|
||
Depo *OutgoingDeal
|
||
Quik []*QuikLink
|
||
}
|
||
|
||
func manualProgressFor(ctx context.Context, svc *Service, o *ManualOrder) manualProgress {
|
||
var pr manualProgress
|
||
add := func(title, detail, state string, at time.Time) {
|
||
pr.Steps = append(pr.Steps, manualStep{Title: title, Detail: detail, State: state, At: at})
|
||
}
|
||
add("Поручение введено", o.CreatedBy, "done", o.CreatedAt)
|
||
if o.Depo != nil {
|
||
st := "done"
|
||
if !o.Depo.Ran || o.Depo.Recommend != "confirm" {
|
||
st = "fail"
|
||
}
|
||
add("Сверка с учётной системой", pick(o.Depo.Summary, o.Depo.Err), st, o.Depo.At)
|
||
}
|
||
if o.Status == "declined" {
|
||
add("Отклонено депозитарием", o.DeclineReason, "fail", o.DeclinedAt)
|
||
pr.Outcome = "rejected"
|
||
return pr
|
||
}
|
||
if o.Status != "sent" {
|
||
add("Отправка в НРД", "после сверки и отметок", "todo", time.Time{})
|
||
return pr
|
||
}
|
||
add("Пакет отправлен в НРД", "GUID "+o.GUID, "done", o.SentAt)
|
||
if svc != nil && o.DealID != "" {
|
||
if v, err := svc.GetClaim(ctx, o.DealID); err == nil {
|
||
pr.Claim = &v
|
||
}
|
||
}
|
||
if o.GUID != "" {
|
||
pr.Quik = globalQuikLinks.ForGUID(o.GUID)
|
||
if globalOutgoingStore != nil {
|
||
if d, ok := scanForm16Deal(globalOutgoingStore, o.GUID); ok {
|
||
pr.Depo = d
|
||
}
|
||
}
|
||
}
|
||
c := pr.Claim
|
||
switch {
|
||
case c == nil:
|
||
add("Ответ сервиса MOEX МОСТ", "нет данных о переводе", "wait", time.Time{})
|
||
case c.M2MResponse != nil && c.M2MResponse.StatusCode == "ERROR":
|
||
var codes []string
|
||
for _, r := range c.M2MResponse.Responses {
|
||
codes = append(codes, strings.TrimSpace(r.Code+" "+r.Text))
|
||
}
|
||
add("Ответ сервиса MOEX МОСТ: ошибка", strings.Join(codes, "; "), "fail", c.UpdatedAt)
|
||
pr.Outcome = "rejected"
|
||
return pr
|
||
case c.M2MResponse != nil:
|
||
add("Пакет принят сервисом MOEX МОСТ", c.M2MResponse.StatusCode, "done", time.Time{})
|
||
default:
|
||
add("Ответ сервиса MOEX МОСТ", "ждём квитанцию", "wait", time.Time{})
|
||
}
|
||
if c != nil && c.M2MDecision != nil {
|
||
rejected := false
|
||
var codes []string
|
||
for _, s := range c.M2MDecision.Securities {
|
||
if s.Outcome == "rejected" {
|
||
rejected = true
|
||
codes = append(codes, s.RejectCodes...)
|
||
}
|
||
}
|
||
if rejected {
|
||
add("Принимающий депозитарий отказал", rejectCodesLabel(codes), "fail", time.Time{})
|
||
pr.Outcome = "rejected"
|
||
return pr
|
||
}
|
||
add("Принимающий депозитарий согласился", "", "done", time.Time{})
|
||
} else if c != nil && (c.Status == "rejected" || c.Status == "timed_out") {
|
||
add("Перевод не состоялся", russianState(c.Status), "fail", c.UpdatedAt)
|
||
pr.Outcome = "rejected"
|
||
return pr
|
||
} else if pr.Depo == nil {
|
||
add("Решение принимающего депозитария", "ждём ответ контрагента", "wait", time.Time{})
|
||
}
|
||
d := pr.Depo
|
||
switch {
|
||
case d == nil:
|
||
add("Черновик поручения 16 от НРД", "придёт после согласия контрагента", "todo", time.Time{})
|
||
add("Поручение 16 подано", "", "todo", time.Time{})
|
||
add("Отчёт НРД об исполнении (MS010)", "", "todo", time.Time{})
|
||
return pr
|
||
case !d.Submitted:
|
||
add("Черновик поручения 16 получен", "", "done", d.DraftAt)
|
||
add("Поручение 16 подано", "подайте поручение кнопкой ниже (в автоматическом режиме bj подаёт сам)", "wait", time.Time{})
|
||
add("Отчёт НРД об исполнении (MS010)", "", "todo", time.Time{})
|
||
return pr
|
||
}
|
||
add("Черновик поручения 16 получен", "", "done", d.DraftAt)
|
||
add("Поручение 16 подано", "№ "+strings.Join(d.ContrDIDs, ", "), "done", d.OrderAt)
|
||
if d.Done {
|
||
add("Отчёт НРД об исполнении (MS010)", "бумаги списаны", "done", time.Time{})
|
||
pr.Outcome = "done"
|
||
} else {
|
||
add("Отчёт НРД об исполнении (MS010)", "ждём встречное поручение контрагента и отчёт НРД", "wait", time.Time{})
|
||
}
|
||
return pr
|
||
}
|
||
|
||
// manualDocTypes — виды документов (справочник НРД).
|
||
var manualDocTypes = []struct{ Code, Title string }{
|
||
{"21", "Паспорт гражданина РФ"},
|
||
{"03", "Свидетельство о рождении"},
|
||
{"10", "Паспорт иностранного гражданина"},
|
||
{"12", "Вид на жительство в РФ"},
|
||
{"22", "Заграничный паспорт гражданина РФ"},
|
||
{"07", "Военный билет"},
|
||
{"91", "Иной документ"},
|
||
}
|