Files
Bridge-and-Join-s/internal/lkgateway/quikhooks.go
T
fontvielle e51962c5bb QUIK МНП: входящий перевод зачисляется клиенту только после проведения в DEPO
Если по переводу есть поручение FTV_M2M, зачисление ждёт, пока события BJ_ORDER_ACCEPTED обработаны в DEPO
(SUCCESS); до того — состояние «ждёт проведения в DEPO» с причиной. Ошибка в DEPO — ждём, после возврата
в очередь и проведения зачисление проходит само. DEPO в переводе не участвует — как раньше, по ответу НРД.
22.10.2026, 895C0A32: бумаги ушли в QUIK, хотя поручение в DEPO не создалось.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q7eREaYwUt1i4fNLZcGnjn
2026-10-22 17:58:48 +03:00

431 lines
17 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 {
if req == nil {
return nil
}
q := quikSettingsFor(rc, string(req.Header.GUID)) // тест «боевой ЛК → TEST3» — боевая запись (realtest.go)
if !quikActive(q) || !q.AutoOut || manualQuikSkipped(ctx) { // ручной ввод: заблокировано сотрудником
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)
passExpenses := req.Header.CostInfo.Yes != nil // «передавать информацию о расходах» — как в заявке клиента
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, "", passExpenses); 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) {
if decision == nil {
return
}
guid := string(decision.Header.GUID)
q := quikSettingsFor(rc, guid)
if !quikActive(q) || !q.AutoOut {
return
}
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 := quikSettingsFor(rc, guid)
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) && !realTestAny()) || q.Server == "" { // тестовые заявки «боевой ЛК → TEST3» — всегда
return
}
// исходящие: наше поручение 16 принято НРД → подпись; отказ → снятие
if q.AutoOut && globalOutgoingStore != nil {
for _, od := range scanForm16Drafts(globalOutgoingStore) {
if !od.Submitted || (!quikActive(q) && realTestGet(od.GUID) == nil) {
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
}
// бумаги в QUIK — только после проведения в DEPO (если DEPO участвует в переводе)
if gate, posted, why := depoInPosted(ctx, rc, it.GUID); gate && !posted {
for _, sec := range it.Securities {
if sec.Decision == "confirmed" {
globalQuikLinks.update(it.GUID, sec.ISIN, func(l *QuikLink) {
if l.State == "" || l.State == "wait_depo" {
l.Direction, l.State, l.Err = "in", "wait_depo", why
}
})
}
}
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), "", deal.Request.Header.CostInfo.Yes != nil); 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 МНП: неизвестная операция")
}
})
}