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:
zuevav
2026-09-15 19:41:12 +03:00
parent 59fc774f08
commit da12a9ec1c
132 changed files with 29969 additions and 1089 deletions
+410
View File
@@ -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 МНП: неизвестная операция")
}
})
}