Files

411 lines
16 KiB
Go
Raw Permalink 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 {
q := rc.Snapshot().Quik
if !quikActive(q) || !q.AutoOut || req == nil || 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)
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 МНП: неизвестная операция")
}
})
}