Раздел «Ручной ввод M2M» по бумажным поручениям; тестовые тексты и имитации убраны из клиентского интерфейса
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,698 @@
|
||||
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", "Иной документ"},
|
||||
}
|
||||
Reference in New Issue
Block a user