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:
@@ -0,0 +1,410 @@
|
||||
package lkgateway
|
||||
|
||||
// Точки встраивания цикла QUIK МНП в жизненный цикл переводов M2M.
|
||||
//
|
||||
// исходящий: SendBuiltRequest → quikOutgoingBeforeSend (регистрация вывода, блокировка)
|
||||
// applyIncoming(M2MTD с отказом) → quikOnDecision (снятие по отказанным бумагам)
|
||||
// applyIncoming(M2MER ERROR) → quikOnServiceError (снятие всех)
|
||||
// quikCycleLoop: MS010 по нашему поручению 16 → подпись (исполнение)
|
||||
// входящий: quikCycleLoop: MS010 по нашему поручению 16/1 → «Ввод ЦБ (M2M)» (зачисление)
|
||||
// терминал: quikCycleLoop (PollTerminal): новые «Вывод ЦБ (M2M)» из QUIK → связка + оповещение
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/m2m"
|
||||
)
|
||||
|
||||
func quikSecQty(q m2m.Quantity) float64 {
|
||||
if q.Whole != nil {
|
||||
return float64(*q.Whole)
|
||||
}
|
||||
if q.Fractional != nil {
|
||||
f, _ := strconv.ParseFloat(strings.ReplaceAll(string(*q.Fractional), ",", "."), 64)
|
||||
return f
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func quikSecISIN(s m2m.RequestSecurity) string {
|
||||
if s.SecurityDetails.ISIN != nil {
|
||||
return strings.TrimSpace(string(*s.SecurityDetails.ISIN))
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// quikOutgoingBeforeSend — перед отправкой M2MTR регистрируем «Вывод ЦБ (M2M)»:
|
||||
// QUIK проверяет позицию и блокирует бумаги. Если клиент не найден в DEPO —
|
||||
// отправку не блокируем (оповещаем); если QUIK отверг регистрацию — отправку
|
||||
// ОТМЕНЯЕМ (нельзя переводить то, что не заблокировано).
|
||||
func quikOutgoingBeforeSend(ctx context.Context, rc *RuntimeConfig, req *m2m.M2MTransferRequest) error {
|
||||
q := rc.Snapshot().Quik
|
||||
if !q.WritesEnabled || !q.AutoOut || req == nil {
|
||||
return nil
|
||||
}
|
||||
guid := string(req.Header.GUID)
|
||||
inv := req.Data.InvestorInformation
|
||||
ser := ""
|
||||
if inv.IdentityDocument.DocumentSeries != nil {
|
||||
ser = string(*inv.IdentityDocument.DocumentSeries)
|
||||
}
|
||||
num := string(inv.IdentityDocument.DocumentNumber)
|
||||
code, contract, err := quikResolveClient(ctx, rc, ser, num)
|
||||
if err != nil {
|
||||
for _, sec := range req.Data.TransferredSecurities.Securities {
|
||||
if isin := quikSecISIN(sec); isin != "" {
|
||||
globalQuikLinks.update(guid, isin, func(l *QuikLink) {
|
||||
l.Direction, l.Qty, l.State, l.Err = "out", quikSecQty(sec.Quantity), "error", "клиент QUIK не определён: "+err.Error()
|
||||
})
|
||||
}
|
||||
}
|
||||
raiseSystemAlert(rc, "QUIK МНП: клиент не найден в DEPO, бумаги не заблокированы",
|
||||
fmt.Sprintf("Перевод %s (%s %s): %s. Перевод отправлен в НРД без блокировки в QUIK — заблокируйте вручную.", shortGUID(guid), inv.LastName, inv.FirstName, err))
|
||||
return nil
|
||||
}
|
||||
inn := string(req.Data.ReceivingDepository.INN)
|
||||
var done []string
|
||||
for _, sec := range req.Data.TransferredSecurities.Securities {
|
||||
isin, qty := quikSecISIN(sec), quikSecQty(sec.Quantity)
|
||||
if isin == "" || qty <= 0 {
|
||||
continue
|
||||
}
|
||||
if _, err := QuikOutRegister(ctx, rc, guid, code, contract, isin, qty, inn, "", true); err != nil {
|
||||
for _, d := range done {
|
||||
_, _ = QuikOutDeny(ctx, rc, guid, d, "Отмена: не удалось заблокировать другую бумагу перевода")
|
||||
}
|
||||
return fmt.Errorf("QUIK МНП не заблокировал %s × %v: %w", isin, qty, err)
|
||||
}
|
||||
done = append(done, isin)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// quikOnDecision — ответ контрагента: по отказанным бумагам снимаем поручения.
|
||||
func quikOnDecision(ctx context.Context, rc *RuntimeConfig, svc *Service, decision *m2m.M2MTransferDecision) {
|
||||
q := rc.Snapshot().Quik
|
||||
if !q.WritesEnabled || !q.AutoOut || decision == nil {
|
||||
return
|
||||
}
|
||||
guid := string(decision.Header.GUID)
|
||||
links := globalQuikLinks.ForGUID(guid)
|
||||
if len(links) == 0 {
|
||||
return
|
||||
}
|
||||
refISIN := map[string]string{}
|
||||
if deal, err := svc.Repo().GetByGUID(ctx, decision.Header.GUID); err == nil && deal != nil && deal.Request != nil {
|
||||
for _, sec := range deal.Request.Data.TransferredSecurities.Securities {
|
||||
refISIN[string(sec.ReferenceID)] = quikSecISIN(sec)
|
||||
}
|
||||
}
|
||||
for _, sec := range decision.Data.Securities {
|
||||
if sec.TransferDecision.Rejection == nil {
|
||||
continue
|
||||
}
|
||||
isin := refISIN[string(sec.ReferenceID)]
|
||||
if isin == "" && len(links) == 1 {
|
||||
isin = links[0].ISIN
|
||||
}
|
||||
if l := globalQuikLinks.Get(guid, isin); l != nil && l.State == "registered" {
|
||||
_, _ = QuikOutDeny(ctx, rc, guid, isin, "Отказ принимающего депозитария: "+rejectCodesLabel(sec.TransferDecision.Rejection.Codes))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// quikOnServiceError — сервис МОСТ отверг перевод целиком: снимаем все поручения.
|
||||
func quikOnServiceError(ctx context.Context, rc *RuntimeConfig, guid, reason string) {
|
||||
q := rc.Snapshot().Quik
|
||||
if !q.WritesEnabled || !q.AutoOut {
|
||||
return
|
||||
}
|
||||
for _, l := range globalQuikLinks.ForGUID(guid) {
|
||||
if l.State == "registered" {
|
||||
_, _ = QuikOutDeny(ctx, rc, guid, l.ISIN, "Отклонено сервисом MOEX МОСТ: "+reason)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// orderRespVerdict — есть ли по поручению отчёт MS010 и не отказ ли это.
|
||||
func orderRespVerdict(resps []orderResponse) (done, rejected bool, text string) {
|
||||
for _, r := range resps {
|
||||
if !r.Done {
|
||||
continue
|
||||
}
|
||||
done = true
|
||||
low := strings.ToLower(r.Text)
|
||||
if strings.Contains(low, "отказ") || strings.Contains(low, "reject") || strings.Contains(low, "не исполнен") {
|
||||
return true, true, orderRespSummary(r.Text)
|
||||
}
|
||||
}
|
||||
return done, false, ""
|
||||
}
|
||||
|
||||
// quikCycleTick — один проход опросчика.
|
||||
func quikCycleTick(ctx context.Context, rc *RuntimeConfig) {
|
||||
q := rc.Snapshot().Quik
|
||||
if !q.WritesEnabled || q.Server == "" {
|
||||
return
|
||||
}
|
||||
// исходящие: наше поручение 16 принято НРД → подпись; отказ → снятие
|
||||
if q.AutoOut && globalOutgoingStore != nil {
|
||||
for _, od := range scanForm16Drafts(globalOutgoingStore) {
|
||||
if !od.Submitted {
|
||||
continue
|
||||
}
|
||||
links := globalQuikLinks.ForGUID(od.GUID)
|
||||
pending := false
|
||||
for _, l := range links {
|
||||
if l.State == "registered" {
|
||||
pending = true
|
||||
}
|
||||
}
|
||||
if !pending {
|
||||
continue
|
||||
}
|
||||
resps, _ := outgoingResponses(od.ContrDIDs)
|
||||
done, rejected, text := orderRespVerdict(resps)
|
||||
if !done {
|
||||
continue
|
||||
}
|
||||
for _, l := range links {
|
||||
if l.State != "registered" {
|
||||
continue
|
||||
}
|
||||
if rejected {
|
||||
_, _ = QuikOutDeny(ctx, rc, l.GUID, l.ISIN, "Отказ НРД по поручению депо: "+text)
|
||||
} else {
|
||||
_, _ = QuikOutSign(ctx, rc, l.GUID, l.ISIN, "Перевод исполнен НРД (MS010), GUID "+shortGUID(l.GUID))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// входящие: наше поручение 16/1 принято НРД → зачисление в QUIK
|
||||
if q.AutoIn && globalIncomingStore != nil {
|
||||
for _, it := range globalIncomingStore.List() {
|
||||
if !it.OrderSubmitted {
|
||||
continue
|
||||
}
|
||||
need := false
|
||||
for _, sec := range it.Securities {
|
||||
if sec.Decision == "confirmed" {
|
||||
if l := globalQuikLinks.Get(it.GUID, sec.ISIN); l == nil || (l.State != "deposited" && l.State != "error") {
|
||||
need = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if !need {
|
||||
continue
|
||||
}
|
||||
done, rejected, _ := orderRespVerdict(findOrderResponses(it.OrderContrDID))
|
||||
if !done || rejected {
|
||||
continue
|
||||
}
|
||||
code, contract, err := quikResolveClient(ctx, rc, it.DocSeries, it.DocNumber)
|
||||
if err != nil {
|
||||
for _, sec := range it.Securities {
|
||||
if sec.Decision == "confirmed" {
|
||||
globalQuikLinks.update(it.GUID, sec.ISIN, func(l *QuikLink) {
|
||||
l.Direction, l.State, l.Err = "in", "error", "клиент QUIK не определён: "+err.Error()
|
||||
})
|
||||
}
|
||||
}
|
||||
raiseSystemAlert(rc, "QUIK МНП: входящий перевод не зачислен — клиент не найден в DEPO",
|
||||
fmt.Sprintf("Перевод %s (%s %s): %s. Зачислите бумаги в QUIK вручную.", shortGUID(it.GUID), it.InvestorLast, it.InvestorFirst, err))
|
||||
continue
|
||||
}
|
||||
for _, sec := range it.Securities {
|
||||
if sec.Decision != "confirmed" {
|
||||
continue
|
||||
}
|
||||
if l := globalQuikLinks.Get(it.GUID, sec.ISIN); l != nil && (l.State == "deposited" || l.State == "error") {
|
||||
continue
|
||||
}
|
||||
qty, _ := strconv.ParseFloat(strings.ReplaceAll(strings.TrimSpace(sec.Quantity), ",", "."), 64)
|
||||
if qty <= 0 {
|
||||
continue
|
||||
}
|
||||
_, _ = QuikInDeposit(ctx, rc, it.GUID, code, contract, sec.ISIN, qty, it.SenderINN, "Депозитарий "+it.SenderCode)
|
||||
}
|
||||
}
|
||||
}
|
||||
// поручения, поданные клиентами из QUIK: связка + оповещение оператора
|
||||
if q.PollTerminal {
|
||||
items, err := quikListM2MInstructions(ctx, q, 100, true)
|
||||
if err != nil {
|
||||
log.Printf("quik: опрос Export: %v", err)
|
||||
return
|
||||
}
|
||||
for _, it := range items {
|
||||
if it.Number == "" || globalQuikLinks.ByInstrNo(it.Number) != nil {
|
||||
continue
|
||||
}
|
||||
key := "terminal-" + it.Number
|
||||
globalQuikLinks.update(key, it.ISIN, func(l *QuikLink) {
|
||||
l.Direction, l.ClientID, l.Contract, l.InstrNo, l.Qty, l.State = "out", it.ClientID, it.AgreeServ, it.Number, it.Volume, "manual"
|
||||
l.Err = "подано клиентом из QUIK — заявка в bj не создана"
|
||||
l.Steps = append(l.Steps, QuikStep{At: time.Now(), Action: "terminal", Reply: "поручение " + it.Number + " зарегистрировано клиентом из QUIK", OK: true})
|
||||
})
|
||||
raiseSystemAlert(rc, "QUIK: клиент подал «Вывод ЦБ (M2M)» из терминала",
|
||||
fmt.Sprintf("Поручение №%s: клиент %s, %s × %v, депозитарий получателя ИНН %s. Бумаги заблокированы в QUIK. Создайте заявку на перевод и привяжите поручение в карточке заявки (страница QUIK МНП).", it.Number, it.ClientID, it.ISIN, it.Volume, it.RecipINN))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// quikCycleLoop — фоновый опросчик (30 с).
|
||||
func (s *Server) quikCycleLoop(ctx context.Context) {
|
||||
ticker := time.NewTicker(30 * time.Second)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
}
|
||||
func() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
log.Printf("quik: panic в опросчике: %v", r)
|
||||
}
|
||||
}()
|
||||
quikCycleTick(ctx, s.rc)
|
||||
}()
|
||||
healthBeat("poll-quik")
|
||||
}
|
||||
}
|
||||
|
||||
// ---- ручные операции из карточек ----
|
||||
|
||||
// registerQuikOps — POST /admin/quik/op: guid, isin, op=register|sign|deny|deposit|link, reason, instr_no.
|
||||
func registerQuikOps(mux *http.ServeMux, rc *RuntimeConfig, svc *Service) {
|
||||
mux.HandleFunc("/admin/quik/op", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
back := r.FormValue("back")
|
||||
if back == "" || !strings.HasPrefix(back, "/admin") {
|
||||
back = r.Header.Get("Referer")
|
||||
if back == "" {
|
||||
back = "/admin/quik"
|
||||
}
|
||||
}
|
||||
flash := func(msg string) {
|
||||
sep := "?"
|
||||
if strings.Contains(back, "?") {
|
||||
sep = "&"
|
||||
}
|
||||
http.Redirect(w, r, back+sep+"flash="+urlQ(msg), http.StatusSeeOther)
|
||||
}
|
||||
guid, isin, op := strings.TrimSpace(r.FormValue("guid")), strings.TrimSpace(r.FormValue("isin")), r.FormValue("op")
|
||||
q := rc.Snapshot().Quik
|
||||
if !q.WritesEnabled {
|
||||
flash("QUIK МНП: записи в МНП выключены (Настройка → QUIK МНП → Автоматический цикл)")
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 3*time.Minute)
|
||||
defer cancel()
|
||||
switch op {
|
||||
case "link": // привязать поручение, поданное из терминала, к заявке
|
||||
no := strings.TrimSpace(r.FormValue("instr_no"))
|
||||
t := globalQuikLinks.ByInstrNo(no)
|
||||
if t == nil {
|
||||
flash("QUIK МНП: поручение №" + no + " не найдено среди подхваченных")
|
||||
return
|
||||
}
|
||||
globalQuikLinks.update(guid, isin, func(l *QuikLink) {
|
||||
l.Direction, l.ClientID, l.Contract, l.InstrNo, l.Qty, l.State, l.Err = "out", t.ClientID, t.Contract, t.InstrNo, t.Qty, "registered", ""
|
||||
l.Steps = append(l.Steps, QuikStep{At: time.Now(), Action: "link", Reply: "привязано поручение " + no + " из терминала", OK: true})
|
||||
})
|
||||
flash("QUIK МНП: поручение №" + no + " привязано к переводу")
|
||||
case "register":
|
||||
deal, err := svc.Repo().GetByGUID(ctx, m2m.UUID(guid))
|
||||
if err != nil || deal == nil || deal.Request == nil {
|
||||
flash("QUIK МНП: заявка не найдена")
|
||||
return
|
||||
}
|
||||
inv := deal.Request.Data.InvestorInformation
|
||||
ser := ""
|
||||
if inv.IdentityDocument.DocumentSeries != nil {
|
||||
ser = string(*inv.IdentityDocument.DocumentSeries)
|
||||
}
|
||||
code, contract, err := quikResolveClient(ctx, rc, ser, string(inv.IdentityDocument.DocumentNumber))
|
||||
if err != nil {
|
||||
flash("QUIK МНП: " + err.Error())
|
||||
return
|
||||
}
|
||||
var qty float64
|
||||
for _, sec := range deal.Request.Data.TransferredSecurities.Securities {
|
||||
if quikSecISIN(sec) == isin {
|
||||
qty = quikSecQty(sec.Quantity)
|
||||
}
|
||||
}
|
||||
if qty <= 0 {
|
||||
flash("QUIK МНП: бумага " + isin + " не найдена в заявке")
|
||||
return
|
||||
}
|
||||
if _, err := QuikOutRegister(ctx, rc, guid, code, contract, isin, qty, string(deal.Request.Data.ReceivingDepository.INN), "", true); err != nil {
|
||||
flash("QUIK МНП: " + err.Error())
|
||||
return
|
||||
}
|
||||
flash("QUIK МНП: поручение зарегистрировано, бумаги заблокированы (" + code + ", " + isin + ")")
|
||||
case "sign":
|
||||
if _, err := QuikOutSign(ctx, rc, guid, isin, "Подписано оператором bj"); err != nil {
|
||||
flash("QUIK МНП: " + err.Error())
|
||||
return
|
||||
}
|
||||
flash("QUIK МНП: поручение подписано и исполнено, бумаги списаны")
|
||||
case "deny":
|
||||
reason := strings.TrimSpace(r.FormValue("reason"))
|
||||
if reason == "" {
|
||||
reason = "Снято оператором депозитария"
|
||||
}
|
||||
if _, err := QuikOutDeny(ctx, rc, guid, isin, reason); err != nil {
|
||||
flash("QUIK МНП: " + err.Error())
|
||||
return
|
||||
}
|
||||
flash("QUIK МНП: поручение снято, блокировка снята")
|
||||
case "deposit":
|
||||
it, ok := globalIncomingStore.Get(guid)
|
||||
if !ok {
|
||||
// GUID мог быть передан вместо ID
|
||||
for _, x := range globalIncomingStore.List() {
|
||||
if strings.EqualFold(x.GUID, guid) {
|
||||
it, ok = x, true
|
||||
}
|
||||
}
|
||||
}
|
||||
if !ok {
|
||||
flash("QUIK МНП: входящий перевод не найден")
|
||||
return
|
||||
}
|
||||
code, contract, err := quikResolveClient(ctx, rc, it.DocSeries, it.DocNumber)
|
||||
if err != nil {
|
||||
flash("QUIK МНП: " + err.Error())
|
||||
return
|
||||
}
|
||||
var qty float64
|
||||
for _, sec := range it.Securities {
|
||||
if sec.ISIN == isin {
|
||||
qty, _ = strconv.ParseFloat(strings.ReplaceAll(strings.TrimSpace(sec.Quantity), ",", "."), 64)
|
||||
}
|
||||
}
|
||||
if qty <= 0 {
|
||||
flash("QUIK МНП: бумага " + isin + " не найдена во входящем переводе")
|
||||
return
|
||||
}
|
||||
if _, err := QuikInDeposit(ctx, rc, it.GUID, code, contract, isin, qty, it.SenderINN, "Депозитарий "+it.SenderCode); err != nil {
|
||||
flash("QUIK МНП: " + err.Error())
|
||||
return
|
||||
}
|
||||
flash("QUIK МНП: бумаги зачислены клиенту " + code)
|
||||
default:
|
||||
flash("QUIK МНП: неизвестная операция")
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user