d47229c9a5
Автоматический режим (Настройка → НРД, выключен по умолчанию): входящие переводы — решение по свежей сверке с DEPO без оператора (пакет только целиком; бумага для квал. инвесторов при неполной или непрочитанной квалификации — отказ M2M08), поручение 16/1 — как только пришёл черновик MF010, поручение 16 по исходящим — по черновику. Нет сверки или ИШ — повтор каждые 3 минуты и оповещение, без сверки ничего не принимается. Обрабатывается только появившееся не раньше суток до включения (старые черновики в INBOX не подаются). Решение не отправляется повторно по уже отвеченному переводу. Имитация QUIK МНП (Настройка → QUIK МНП → цикл): регистрация, подпись, снятие и зачисление проходят как выполненные с пометкой «имитация», в МНП ничего не пишется; события в DEPO — как обычно. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
411 lines
16 KiB
Go
411 lines
16 KiB
Go
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 !quikActive(q) || !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 !quikActive(q) || !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 !quikActive(q) || !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 !quikActive(q) || 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 !quikActive(q) {
|
||
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 МНП: неизвестная операция")
|
||
}
|
||
})
|
||
}
|