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 МНП: неизвестная операция") } }) }