da12a9ec1c
- 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>
1037 lines
45 KiB
Go
1037 lines
45 KiB
Go
package lkgateway
|
||
|
||
// Обмен с DEPO (Fancy) через трансферные таблицы в нашей копии базы
|
||
// (BJ_DEPO_DATA): FTV_M2M / FTV_M2M_LINES / FTV_M2M_EVENTS / FTV_M2M_HANDBOOK.
|
||
// Согласовано с программистом Fancy 09–10.09.2026 (страница /admin/help/fancy-events):
|
||
// - bj пишет поручения и события BJ_*; джоба bj2depo создаёт сущности DEPO и
|
||
// пишет в ответ только DEPO_ORDER_CREATED / DEPO_ERROR;
|
||
// - справочник участников — TRUNCATE + полная перезапись при каждом получении;
|
||
// - STATE: TODO при записи → SUCCESS/ERROR после обработки получателем.
|
||
|
||
import (
|
||
"context"
|
||
"database/sql"
|
||
"encoding/json"
|
||
"fmt"
|
||
"log"
|
||
"os"
|
||
"path/filepath"
|
||
"regexp"
|
||
"strconv"
|
||
"strings"
|
||
"sync"
|
||
"time"
|
||
|
||
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/m2m"
|
||
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/m2mcore"
|
||
)
|
||
|
||
const depoLinkFile = "/var/lib/bj/.bj/depo-events.json"
|
||
|
||
var uuidRe = regexp.MustCompile(`^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$`)
|
||
|
||
// DepoLink — что DEPO ответила по переводу (для карточек).
|
||
type DepoLink struct {
|
||
GUID string `json:"guid"`
|
||
DocNo string `json:"doc_no,omitempty"` // номер поручения в DEPO (из DEPO_ORDER_CREATED)
|
||
DocAt time.Time `json:"doc_at,omitempty"`
|
||
DocState string `json:"doc_state,omitempty"` // состояние документа в DEPO (OD_DOCS.STATE → название)
|
||
DocStateAt time.Time `json:"doc_state_at,omitempty"`
|
||
Errors []string `json:"errors,omitempty"` // DEPO_ERROR
|
||
LastEvent string `json:"last_event,omitempty"`
|
||
UpdatedAt time.Time `json:"updated_at"`
|
||
}
|
||
|
||
type depoLinkStore struct {
|
||
mu sync.Mutex
|
||
items map[string]*DepoLink
|
||
}
|
||
|
||
var globalDepoLinks = loadDepoLinks()
|
||
|
||
func loadDepoLinks() *depoLinkStore {
|
||
s := &depoLinkStore{items: map[string]*DepoLink{}}
|
||
if b, err := os.ReadFile(depoLinkFile); err == nil {
|
||
var list []*DepoLink
|
||
if json.Unmarshal(b, &list) == nil {
|
||
for _, l := range list {
|
||
s.items[strings.ToUpper(l.GUID)] = l
|
||
}
|
||
}
|
||
}
|
||
return s
|
||
}
|
||
|
||
func (s *depoLinkStore) Get(guid string) *DepoLink {
|
||
s.mu.Lock()
|
||
defer s.mu.Unlock()
|
||
if l, ok := s.items[strings.ToUpper(guid)]; ok {
|
||
cp := *l
|
||
return &cp
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func (s *depoLinkStore) update(guid string, fn func(l *DepoLink)) {
|
||
s.mu.Lock()
|
||
defer s.mu.Unlock()
|
||
k := strings.ToUpper(guid)
|
||
l, ok := s.items[k]
|
||
if !ok {
|
||
l = &DepoLink{GUID: k}
|
||
s.items[k] = l
|
||
}
|
||
fn(l)
|
||
l.UpdatedAt = time.Now()
|
||
list := make([]*DepoLink, 0, len(s.items))
|
||
for _, x := range s.items {
|
||
list = append(list, x)
|
||
}
|
||
b, _ := json.MarshalIndent(list, "", " ")
|
||
_ = os.MkdirAll(filepath.Dir(depoLinkFile), 0o755)
|
||
tmp := depoLinkFile + ".tmp"
|
||
if os.WriteFile(tmp, b, 0o640) == nil {
|
||
_ = os.Rename(tmp, depoLinkFile)
|
||
}
|
||
}
|
||
|
||
// ---- подключение и проверки ----
|
||
|
||
func depoEventsDB(rc *RuntimeConfig) (*sql.DB, error) {
|
||
if rc == nil {
|
||
return nil, fmt.Errorf("нет конфигурации")
|
||
}
|
||
fs := rc.Snapshot().Fancy
|
||
if strings.TrimSpace(fs.Server) == "" {
|
||
return nil, fmt.Errorf("сервер Fancy не настроен")
|
||
}
|
||
return fancyOpen(fs, fs.DepoDB())
|
||
}
|
||
|
||
func depoHasColumn(ctx context.Context, db *sql.DB, table, col string) bool {
|
||
var n int
|
||
_ = db.QueryRowContext(ctx, "SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME=@t AND COLUMN_NAME=@c", sql.Named("t", table), sql.Named("c", col)).Scan(&n)
|
||
return n > 0
|
||
}
|
||
|
||
func depoTableExists(ctx context.Context, db *sql.DB, name string) bool {
|
||
var id sql.NullInt64
|
||
if err := db.QueryRowContext(ctx, "SELECT OBJECT_ID('dbo."+name+"')").Scan(&id); err != nil {
|
||
return false
|
||
}
|
||
return id.Valid
|
||
}
|
||
|
||
var depoTablesWarned time.Time
|
||
|
||
// depoEvent — одна строка FTV_M2M_EVENTS (идемпотентно по GUID+REFERENCE+EVENT_TYPE), дата = сейчас.
|
||
func depoEvent(rc *RuntimeConfig, guid, ref, typ, codes, comment string) {
|
||
depoEventAt(rc, time.Time{}, guid, ref, typ, codes, comment)
|
||
}
|
||
|
||
// depoEventAt — то же с явной датой события (для выгрузки истории).
|
||
func depoEventAt(rc *RuntimeConfig, at time.Time, guid, ref, typ, codes, comment string) {
|
||
guid = strings.ToUpper(strings.TrimSpace(guid))
|
||
if !uuidRe.MatchString(guid) {
|
||
return // тестовые/служебные идентификаторы в DEPO не отдаём
|
||
}
|
||
db, err := depoEventsDB(rc)
|
||
if err != nil {
|
||
return
|
||
}
|
||
defer db.Close()
|
||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||
defer cancel()
|
||
if !depoTableExists(ctx, db, "FTV_M2M_EVENTS") {
|
||
if time.Since(depoTablesWarned) > time.Hour {
|
||
depoTablesWarned = time.Now()
|
||
log.Printf("depo-events: таблицы FTV_M2M_EVENTS нет в %s — события не пишутся", rc.Snapshot().Fancy.DepoDB())
|
||
}
|
||
return
|
||
}
|
||
if len([]rune(comment)) > 4000 {
|
||
comment = string([]rune(comment)[:4000])
|
||
}
|
||
var n int
|
||
_ = db.QueryRowContext(ctx, "SELECT COUNT(*) FROM dbo.FTV_M2M_EVENTS WHERE GUID=@g AND ISNULL(REFERENCE,'')=@r AND EVENT_TYPE=@t",
|
||
sql.Named("g", guid), sql.Named("r", ref), sql.Named("t", typ)).Scan(&n)
|
||
if n > 0 {
|
||
return
|
||
}
|
||
// Параметр даты — типизированный NULL: при нетипизированном nil драйвер шлёт nvarchar(1),
|
||
// ISNULL берёт тип первого аргумента и GETDATE() обрезается до одного символа.
|
||
evDate := sql.NullTime{Time: at, Valid: !at.IsZero()}
|
||
_, err = db.ExecContext(ctx, `INSERT INTO dbo.FTV_M2M_EVENTS (GUID, REFERENCE, STATE, EVENT_TYPE, EVENT_DATE, M2M_REJECT_CODE, COMMENT)
|
||
VALUES (@g, @r, 'TODO', @t, COALESCE(@at, GETDATE()), @c, @cm)`,
|
||
sql.Named("g", guid), sql.Named("r", nullStr(ref)), sql.Named("t", typ), sql.Named("at", evDate), sql.Named("c", nullStr(codes)), sql.Named("cm", nullStr(comment)))
|
||
if err != nil {
|
||
log.Printf("depo-events: %s %s/%s: %v", typ, shortGUID(guid), ref, err)
|
||
return
|
||
}
|
||
log.Printf("depo-events: %s %s %s %s", typ, shortGUID(guid), ref, codes)
|
||
}
|
||
|
||
type depoLine struct {
|
||
Ref string
|
||
ISIN string
|
||
Code string
|
||
Amount float64
|
||
Place string
|
||
}
|
||
|
||
// depoUpsertOrder — шапка FTV_M2M + строки FTV_M2M_LINES (одна транзакция, идемпотентно по GUID).
|
||
func depoUpsertOrder(rc *RuntimeConfig, guid, direction, cpCode, deponentName, deponentDoc string, ser, num string, lines []depoLine, comment string) bool {
|
||
guid = strings.ToUpper(strings.TrimSpace(guid))
|
||
if !uuidRe.MatchString(guid) {
|
||
return false
|
||
}
|
||
db, err := depoEventsDB(rc)
|
||
if err != nil {
|
||
return false
|
||
}
|
||
defer db.Close()
|
||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||
defer cancel()
|
||
if !depoTableExists(ctx, db, "FTV_M2M") || !depoTableExists(ctx, db, "FTV_M2M_LINES") {
|
||
return false
|
||
}
|
||
var n int
|
||
_ = db.QueryRowContext(ctx, "SELECT COUNT(*) FROM dbo.FTV_M2M WHERE GUID=@g", sql.Named("g", guid)).Scan(&n)
|
||
if n > 0 {
|
||
return true
|
||
}
|
||
// идентификаторы DEPO: лицо (SELF_ID) по паспорту, бумага (OD_VALUES.ID) по ISIN
|
||
var deponentID sql.NullInt64
|
||
serN := strings.ReplaceAll(strings.TrimSpace(ser), " ", "")
|
||
if serN != "" && strings.TrimSpace(num) != "" {
|
||
_ = db.QueryRowContext(ctx, "SELECT TOP 1 SELF_ID FROM OD_FACES WHERE REPLACE(DOC_SER,' ','')=@s AND DOC_NUM=@n ORDER BY ID", sql.Named("s", serN), sql.Named("n", strings.TrimSpace(num))).Scan(&deponentID)
|
||
}
|
||
hasDeponentID := depoHasColumn(ctx, db, "FTV_M2M", "DEPONENT_ID")
|
||
tx, err := db.BeginTx(ctx, nil)
|
||
if err != nil {
|
||
log.Printf("depo-events: FTV_M2M %s: %v", shortGUID(guid), err)
|
||
return false
|
||
}
|
||
head := `INSERT INTO dbo.FTV_M2M (GUID, DIRECTION, CONTRAGENT_M2M_CODE, DEPONENT_NAME, DEPONENT_DOC, COMMENT) VALUES (@g, @d, @cp, @nm, @doc, @cm)`
|
||
if hasDeponentID {
|
||
head = `INSERT INTO dbo.FTV_M2M (GUID, DIRECTION, CONTRAGENT_M2M_CODE, DEPONENT_NAME, DEPONENT_DOC, DEPONENT_ID, COMMENT) VALUES (@g, @d, @cp, @nm, @doc, @did, @cm)`
|
||
}
|
||
if _, err := tx.ExecContext(ctx, head,
|
||
sql.Named("g", guid), sql.Named("d", direction), sql.Named("cp", nullStr(cpCode)), sql.Named("nm", nullStr(deponentName)), sql.Named("doc", nullStr(deponentDoc)),
|
||
sql.Named("did", deponentID), sql.Named("cm", nullStr(comment))); err != nil {
|
||
tx.Rollback()
|
||
log.Printf("depo-events: FTV_M2M %s: %v", shortGUID(guid), err)
|
||
return false
|
||
}
|
||
// SHARE (ID бумаги в DEPO) — заполняем, если колонка есть (джоба может подставить сама)
|
||
hasShare := depoHasColumn(ctx, db, "FTV_M2M_LINES", "SHARE")
|
||
for _, ln := range lines {
|
||
var share sql.NullInt64
|
||
if ln.ISIN != "" && hasShare {
|
||
_ = db.QueryRowContext(ctx, "SELECT TOP 1 ID FROM OD_VALUES WHERE ISIN=@i ORDER BY ID", sql.Named("i", ln.ISIN)).Scan(&share)
|
||
}
|
||
q := `INSERT INTO dbo.FTV_M2M_LINES (GUID, REFERENCE, ISIN, SEC_CODE, AMOUNT, PLACE_NUMBER, LINK_NUMBER) VALUES (@g, @r, @i, @c, @a, @p, '')`
|
||
if hasShare {
|
||
q = `INSERT INTO dbo.FTV_M2M_LINES (GUID, REFERENCE, ISIN, SEC_CODE, SHARE, AMOUNT, PLACE_NUMBER, LINK_NUMBER) VALUES (@g, @r, @i, @c, @sh, @a, @p, '')`
|
||
}
|
||
if _, err := tx.ExecContext(ctx, q,
|
||
sql.Named("g", guid), sql.Named("r", ln.Ref), sql.Named("i", nullStr(ln.ISIN)), sql.Named("c", nullStr(ln.Code)), sql.Named("sh", share), sql.Named("a", ln.Amount), sql.Named("p", nullStr(ln.Place))); err != nil {
|
||
tx.Rollback()
|
||
log.Printf("depo-events: FTV_M2M_LINES %s/%s: %v", shortGUID(guid), ln.Ref, err)
|
||
return false
|
||
}
|
||
}
|
||
if err := tx.Commit(); err != nil {
|
||
log.Printf("depo-events: commit %s: %v", shortGUID(guid), err)
|
||
return false
|
||
}
|
||
log.Printf("depo-events: FTV_M2M %s %s создано (%d строк)", direction, shortGUID(guid), len(lines))
|
||
return true
|
||
}
|
||
|
||
// handbookCodeByINN — MC-код депозитария по ИНН из сохранённого справочника.
|
||
func handbookCodeByINN(inn string) string {
|
||
raw, err := os.ReadFile(handbookFile)
|
||
if err != nil {
|
||
return ""
|
||
}
|
||
var hb struct {
|
||
Participants []HandbookEntry `json:"participants"`
|
||
}
|
||
if json.Unmarshal(raw, &hb) != nil {
|
||
return ""
|
||
}
|
||
best := ""
|
||
for _, p := range hb.Participants {
|
||
if p.INN == strings.TrimSpace(inn) && p.Code != "" {
|
||
if p.Kind == "depo" {
|
||
return p.Code
|
||
}
|
||
if best == "" {
|
||
best = p.Code
|
||
}
|
||
}
|
||
}
|
||
return best
|
||
}
|
||
|
||
func parseQty(s string) float64 {
|
||
f, _ := strconv.ParseFloat(strings.ReplaceAll(strings.TrimSpace(s), ",", "."), 64)
|
||
return f
|
||
}
|
||
|
||
// ---- события по этапам ----
|
||
|
||
// входящий M2MTR получен → шапка IN + строки + BJ_RECEIVED
|
||
func depoOnIncomingReceived(rc *RuntimeConfig, it *IncomingTransfer) {
|
||
if rc == nil || it == nil {
|
||
return
|
||
}
|
||
place := rc.Snapshot().NSD.SectionID
|
||
var lines []depoLine
|
||
for _, s := range it.Securities {
|
||
lines = append(lines, depoLine{Ref: s.ReferenceID, ISIN: s.ISIN, Code: s.SecurityCode, Amount: parseQty(s.Quantity), Place: place})
|
||
}
|
||
name := strings.TrimSpace(strings.Join([]string{it.InvestorLast, it.InvestorFirst, it.InvestorMiddle}, " "))
|
||
doc := strings.TrimSpace(it.DocSeries + " " + it.DocNumber)
|
||
if depoUpsertOrder(rc, it.GUID, "IN", it.SenderCode, name, doc, it.DocSeries, it.DocNumber, lines, "входящий перевод M2M от "+it.SenderCode) {
|
||
depoEvent(rc, it.GUID, "", "BJ_RECEIVED", "", "входящий перевод получен, ждёт решения")
|
||
}
|
||
}
|
||
|
||
// наш M2MTR ушёл в НРД → шапка OUT + строки + BJ_SENT
|
||
func depoOnOutgoingSent(rc *RuntimeConfig, req *m2m.M2MTransferRequest) {
|
||
if rc == nil || req == nil {
|
||
return
|
||
}
|
||
inv := req.Data.InvestorInformation
|
||
ser := ""
|
||
if inv.IdentityDocument.DocumentSeries != nil {
|
||
ser = string(*inv.IdentityDocument.DocumentSeries)
|
||
}
|
||
num := string(inv.IdentityDocument.DocumentNumber)
|
||
place := rc.Snapshot().NSD.SectionID
|
||
var lines []depoLine
|
||
for _, s := range req.Data.TransferredSecurities.Securities {
|
||
lines = append(lines, depoLine{Ref: string(s.ReferenceID), ISIN: quikSecISIN(s), Code: string(s.SecurityCode), Amount: quikSecQty(s.Quantity), Place: place})
|
||
}
|
||
inn := string(req.Data.ReceivingDepository.INN)
|
||
cp := handbookCodeByINN(inn)
|
||
name := strings.TrimSpace(strings.Join([]string{inv.LastName, inv.FirstName, inv.MiddleName}, " "))
|
||
guid := string(req.Header.GUID)
|
||
if depoUpsertOrder(rc, guid, "OUT", cp, name, strings.TrimSpace(ser+" "+num), ser, num, lines, "исходящий перевод M2M, получатель ИНН "+inn) {
|
||
depoEvent(rc, guid, "", "BJ_SENT", "", "запрос на перевод отправлен в НРД")
|
||
}
|
||
}
|
||
|
||
// наше решение по входящему (M2MTD)
|
||
func depoOnIncomingDecision(rc *RuntimeConfig, it *IncomingTransfer, decisions []SecurityDecision) {
|
||
if rc == nil || it == nil {
|
||
return
|
||
}
|
||
for _, d := range decisions {
|
||
if d.Confirm {
|
||
depoEvent(rc, it.GUID, d.ReferenceID, "BJ_DECISION_CONFIRM", "", "приём бумаги подтверждён контрагенту")
|
||
} else {
|
||
depoEvent(rc, it.GUID, d.ReferenceID, "BJ_DECISION_REJECT", d.RejectCode, "отказ контрагенту: "+m2mCodeLabel(d.RejectCode))
|
||
}
|
||
}
|
||
}
|
||
|
||
// ответ контрагента по нашему исходящему
|
||
func depoOnCounterpartyDecision(rc *RuntimeConfig, decision *m2m.M2MTransferDecision) {
|
||
if rc == nil || decision == nil {
|
||
return
|
||
}
|
||
guid := string(decision.Header.GUID)
|
||
for _, sec := range decision.Data.Securities {
|
||
ref := string(sec.ReferenceID)
|
||
if sec.TransferDecision.Rejection != nil {
|
||
codes := strings.Join(sec.TransferDecision.Rejection.Codes, ";")
|
||
depoEvent(rc, guid, ref, "BJ_CP_REJECT", codes, "отказ принимающего депозитария: "+rejectCodesLabel(sec.TransferDecision.Rejection.Codes))
|
||
} else if sec.TransferDecision.Confirmation != nil {
|
||
depoEvent(rc, guid, ref, "BJ_CP_CONFIRM", "", "принимающий депозитарий подтвердил")
|
||
}
|
||
}
|
||
}
|
||
|
||
func depoOnServiceError(rc *RuntimeConfig, guid, codes, reason string) {
|
||
depoEvent(rc, guid, "", "BJ_SERVICE_ERROR", strings.ReplaceAll(strings.TrimSpace(codes), " ", ";"), "отклонено сервисом MOEX МОСТ: "+reason)
|
||
}
|
||
|
||
func depoOnOrderSent(rc *RuntimeConfig, guid string, contrDIDs []string, form string) {
|
||
depoEvent(rc, guid, "", "BJ_ORDER_SENT", "", "подано депо-поручение "+form+" в НРД, CONTR_D_ID "+strings.Join(contrDIDs, ", "))
|
||
}
|
||
|
||
// результат по депо-поручению: accepted | rejected | late | expired
|
||
func depoOnOrderResult(rc *RuntimeConfig, guid, kind, text string) {
|
||
switch kind {
|
||
case "accepted":
|
||
depoEvent(rc, guid, "", "BJ_ORDER_ACCEPTED", "", "НРД принял депо-поручение (отчёт MS010)")
|
||
depoEvent(rc, guid, "", "BJ_EXECUTED", "", "перевод исполнен (MS010)")
|
||
case "rejected":
|
||
depoEvent(rc, guid, "", "BJ_ORDER_REJECTED", "", "НРД отверг депо-поручение: "+text)
|
||
case "late":
|
||
depoEvent(rc, guid, "", "BJ_ORDER_LATE", "", "отчёт MS010 не получен за 5 минут")
|
||
case "expired":
|
||
depoEvent(rc, guid, "", "BJ_CANCELLED", "", "истёк срок 30 дней без исполнения — перевод считается отменённым")
|
||
}
|
||
}
|
||
|
||
func depoOnQuik(rc *RuntimeConfig, guid, isin, evt, instrNo string) {
|
||
typ := map[string]string{"register": "BJ_QUIK_BLOCKED", "deny": "BJ_QUIK_RELEASED", "sign": "BJ_QUIK_SIGNED", "deposit": "BJ_QUIK_DEPOSITED"}[evt]
|
||
if typ == "" {
|
||
return
|
||
}
|
||
depoEvent(rc, guid, "", typ, "", "QUIK МНП: поручение №"+instrNo+", "+isin)
|
||
}
|
||
|
||
// ---- чтение событий DEPO ----
|
||
|
||
func depoPollEvents(ctx context.Context, rc *RuntimeConfig) {
|
||
db, err := depoEventsDB(rc)
|
||
if err != nil {
|
||
return
|
||
}
|
||
defer db.Close()
|
||
cctx, cancel := context.WithTimeout(ctx, 20*time.Second)
|
||
defer cancel()
|
||
if !depoTableExists(cctx, db, "FTV_M2M_EVENTS") {
|
||
return
|
||
}
|
||
docCol := "NULL"
|
||
if depoHasColumn(cctx, db, "FTV_M2M", "DOC") {
|
||
docCol = "(SELECT TOP 1 CAST(m.DOC AS varchar(32)) FROM dbo.FTV_M2M m WHERE m.GUID=e.GUID)"
|
||
} else if depoHasColumn(cctx, db, "FTV_M2M", "FANSY_DOC") {
|
||
docCol = "(SELECT TOP 1 CAST(m.FANSY_DOC AS varchar(32)) FROM dbo.FTV_M2M m WHERE m.GUID=e.GUID)"
|
||
}
|
||
depoPollOrders(cctx, db, rc)
|
||
depoAlertEventFailures(cctx, db, rc)
|
||
rows, err := db.QueryContext(cctx, `SELECT TOP 100 e.ID, CAST(e.GUID AS varchar(36)), ISNULL(e.REFERENCE,''), e.EVENT_TYPE, ISNULL(e.COMMENT,''), ISNULL(e.M2M_REJECT_CODE,''),
|
||
ISNULL(`+docCol+`,'')
|
||
FROM dbo.FTV_M2M_EVENTS e WHERE e.EVENT_TYPE LIKE 'DEPO[_]%' AND ISNULL(e.STATE,'TODO')='TODO' ORDER BY e.ID`)
|
||
if err != nil {
|
||
log.Printf("depo-events: чтение: %v", err)
|
||
return
|
||
}
|
||
type ev struct {
|
||
id int64
|
||
guid, ref, typ, comment, codes, doc string
|
||
}
|
||
var list []ev
|
||
for rows.Next() {
|
||
var e ev
|
||
if rows.Scan(&e.id, &e.guid, &e.ref, &e.typ, &e.comment, &e.codes, &e.doc) == nil {
|
||
list = append(list, e)
|
||
}
|
||
}
|
||
rows.Close()
|
||
for _, e := range list {
|
||
state, note := "SUCCESS", "применено в bj"
|
||
switch e.typ {
|
||
case "DEPO_ORDER_CREATED":
|
||
doc := strings.TrimSpace(e.comment)
|
||
if e.doc != "" && e.doc != "0" {
|
||
doc = e.doc
|
||
}
|
||
globalDepoLinks.update(e.guid, func(l *DepoLink) { l.DocNo, l.DocAt, l.LastEvent = doc, time.Now(), e.typ })
|
||
log.Printf("depo-events: DEPO создала поручение по %s: %s", shortGUID(e.guid), doc)
|
||
case "DEPO_ERROR":
|
||
globalDepoLinks.update(e.guid, func(l *DepoLink) {
|
||
l.Errors = append(l.Errors, time.Now().Format("02.01 15:04")+" "+e.comment)
|
||
l.LastEvent = e.typ
|
||
})
|
||
raiseSystemAlert(rc, "DEPO: ошибка обработки перевода "+shortGUID(e.guid), e.comment)
|
||
default:
|
||
state, note = "ERROR", "тип события не поддерживается bj"
|
||
}
|
||
_, _ = db.ExecContext(cctx, "UPDATE dbo.FTV_M2M_EVENTS SET STATE=@s, TimeBeg=ISNULL(TimeBeg,GETDATE()), TimeEnd=GETDATE(), COMMENT=ISNULL(COMMENT,'')+@n WHERE ID=@id",
|
||
sql.Named("s", state), sql.Named("n", " | bj: "+note), sql.Named("id", e.id))
|
||
}
|
||
}
|
||
|
||
// depoPollOrders — джоба bj2depo ставит номер поручения DEPO прямо в FTV_M2M.DOC (и DOL по строкам),
|
||
// а ошибки — STATE='ERROR' + COMMENT. Забираем это в связки без отдельных событий.
|
||
func depoPollOrders(ctx context.Context, db *sql.DB, rc *RuntimeConfig) {
|
||
if !depoTableExists(ctx, db, "FTV_M2M") {
|
||
return
|
||
}
|
||
docCol := "NULL"
|
||
if depoHasColumn(ctx, db, "FTV_M2M", "DOC") {
|
||
docCol = "CAST(m.DOC AS varchar(32))"
|
||
} else if depoHasColumn(ctx, db, "FTV_M2M", "FANSY_DOC") {
|
||
docCol = "CAST(m.FANSY_DOC AS varchar(32))"
|
||
}
|
||
rows, err := db.QueryContext(ctx, `SELECT TOP 200 CAST(m.GUID AS varchar(36)), ISNULL(m.STATE,''), ISNULL(`+docCol+`,''), ISNULL(m.COMMENT,''),
|
||
ISNULL((SELECT TOP 1 ISNULL(l.COMMENT,'') FROM dbo.FTV_M2M_LINES l WHERE l.GUID=m.GUID AND ISNULL(l.STATE,'')='ERROR'),''),
|
||
ISNULL((SELECT TOP 1 ISNULL(st.NAME,'') FROM OD_DOCS d LEFT JOIN OD_DOC_STATES st ON st.ID=d.STATE AND st.D_CAT=0 WHERE CAST(d.ID AS varchar(32))=`+docCol+`),''),
|
||
(SELECT TOP 1 d.PROC_DATE FROM OD_DOCS d WHERE CAST(d.ID AS varchar(32))=`+docCol+`)
|
||
FROM dbo.FTV_M2M m WHERE m.TimeNew > DATEADD(day,-45,GETDATE()) ORDER BY m.ID DESC`)
|
||
if err != nil {
|
||
log.Printf("depo-events: чтение FTV_M2M: %v", err)
|
||
return
|
||
}
|
||
defer rows.Close()
|
||
for rows.Next() {
|
||
var guid, state, doc, comment, lineErr, docState string
|
||
var procAt sql.NullTime
|
||
if rows.Scan(&guid, &state, &doc, &comment, &lineErr, &docState, &procAt) != nil {
|
||
continue
|
||
}
|
||
l := globalDepoLinks.Get(guid)
|
||
// состояние документа в DEPO (ручные действия депозитария: отмена, архив)
|
||
if docState != "" && (l == nil || l.DocState != docState) {
|
||
globalDepoLinks.update(guid, func(x *DepoLink) {
|
||
x.DocState = docState
|
||
if procAt.Valid {
|
||
x.DocStateAt = procAt.Time
|
||
}
|
||
})
|
||
if l != nil && l.DocState != "" && docState != l.DocState {
|
||
log.Printf("depo-events: документ DEPO %s по %s: состояние «%s»", doc, shortGUID(guid), docState)
|
||
if strings.Contains(strings.ToLower(docState), "отмен") {
|
||
raiseSystemAlert(rc, "DEPO: депозитарий отменил документ по переводу "+shortGUID(guid), "Документ №"+doc+": «"+docState+"». Проверьте перевод и позицию клиента в QUIK.")
|
||
}
|
||
}
|
||
}
|
||
if doc != "" && doc != "0" && (l == nil || l.DocNo != doc) {
|
||
globalDepoLinks.update(guid, func(x *DepoLink) { x.DocNo, x.DocAt, x.LastEvent = doc, time.Now(), "FTV_M2M.DOC" })
|
||
log.Printf("depo-events: DEPO создала поручение по %s: %s", shortGUID(guid), doc)
|
||
}
|
||
if state == "ERROR" || lineErr != "" {
|
||
msg := strings.TrimSpace(comment + " " + lineErr)
|
||
if l == nil || len(l.Errors) == 0 || !strings.HasSuffix(l.Errors[len(l.Errors)-1], msg) {
|
||
globalDepoLinks.update(guid, func(x *DepoLink) {
|
||
x.Errors = append(x.Errors, time.Now().Format("02.01 15:04")+" "+msg)
|
||
x.LastEvent = "FTV_M2M.STATE=ERROR"
|
||
})
|
||
raiseSystemAlert(rc, "DEPO: джоба не обработала перевод "+shortGUID(guid), msg)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// ---- справочник участников → FTV_M2M_HANDBOOK (полная перезапись) ----
|
||
|
||
var depoHandbookWritten time.Time
|
||
|
||
func depoWriteHandbook(rc *RuntimeConfig) error {
|
||
raw, err := os.ReadFile(handbookFile)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
var hb struct {
|
||
At string `json:"at"`
|
||
Participants []HandbookEntry `json:"participants"`
|
||
Places []HandbookEntry `json:"settlement_places"`
|
||
}
|
||
if err := json.Unmarshal(raw, &hb); err != nil {
|
||
return err
|
||
}
|
||
db, err := depoEventsDB(rc)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
defer db.Close()
|
||
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||
defer cancel()
|
||
if !depoTableExists(ctx, db, "FTV_M2M_HANDBOOK") {
|
||
if _, err := db.ExecContext(ctx, depoTableSpecByName("FTV_M2M_HANDBOOK").createSQL(false)); err != nil {
|
||
return fmt.Errorf("создание FTV_M2M_HANDBOOK: %w — создайте таблицы: Настройка → Обмен Fancy → «Трансферные таблицы DEPO»", err)
|
||
}
|
||
log.Printf("depo-events: таблица FTV_M2M_HANDBOOK создана в %s", rc.Snapshot().Fancy.DepoDB())
|
||
}
|
||
hbAt, _ := time.Parse(time.RFC3339, hb.At)
|
||
tx, err := db.BeginTx(ctx, nil)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
// полная перезапись (согласовано с программистом); DELETE — у логина bj нет права на TRUNCATE
|
||
if _, err := tx.ExecContext(ctx, "DELETE FROM dbo.FTV_M2M_HANDBOOK"); err != nil {
|
||
tx.Rollback()
|
||
return err
|
||
}
|
||
ins := func(e HandbookEntry, kind string) error {
|
||
short := e.Name
|
||
if short == "" {
|
||
short = e.DisplayName
|
||
}
|
||
_, err := tx.ExecContext(ctx, `INSERT INTO dbo.FTV_M2M_HANDBOOK (INN, PARTICIPANT_CODE, KIND, NAME_FULL, NAME_SHORT, NAME_DISPLAY, NAME_ENG, HANDBOOK_AT, UPDATED_AT, ACTIVE)
|
||
VALUES (@inn, @code, @kind, @full, @short, @disp, @eng, @at, GETDATE(), 1)`,
|
||
sql.Named("inn", e.INN), sql.Named("code", nullStr(e.Code)), sql.Named("kind", kind), sql.Named("full", nullStr(e.FullName)), sql.Named("short", nullStr(short)),
|
||
sql.Named("disp", nullStr(e.DisplayName)), sql.Named("eng", nullStr(e.EngName)), sql.Named("at", hbAt))
|
||
return err
|
||
}
|
||
n := 0
|
||
for _, p := range hb.Participants {
|
||
kind := "DEPO"
|
||
if p.Kind == "broker" {
|
||
kind = "BROKER"
|
||
}
|
||
if err := ins(p, kind); err != nil {
|
||
tx.Rollback()
|
||
return err
|
||
}
|
||
n++
|
||
}
|
||
for _, p := range hb.Places {
|
||
if err := ins(p, "PLACE"); err != nil {
|
||
tx.Rollback()
|
||
return err
|
||
}
|
||
n++
|
||
}
|
||
if err := tx.Commit(); err != nil {
|
||
return err
|
||
}
|
||
depoHandbookWritten = time.Now()
|
||
log.Printf("depo-events: справочник участников записан в FTV_M2M_HANDBOOK: %d строк", n)
|
||
return nil
|
||
}
|
||
|
||
// depoEventsLoop — чтение событий DEPO раз в минуту; справочник — при старте и раз в 6 часов
|
||
// (плюс сразу после получения нового от НРД — вызов из saveHandbook).
|
||
func (s *Server) depoEventsLoop(ctx context.Context) {
|
||
ticker := time.NewTicker(time.Minute)
|
||
defer ticker.Stop()
|
||
first := true
|
||
for {
|
||
if !first {
|
||
select {
|
||
case <-ctx.Done():
|
||
return
|
||
case <-ticker.C:
|
||
}
|
||
}
|
||
first = false
|
||
func() {
|
||
defer func() {
|
||
if r := recover(); r != nil {
|
||
log.Printf("depo-events: panic: %v", r)
|
||
}
|
||
}()
|
||
if time.Since(depoHandbookWritten) > 6*time.Hour {
|
||
if err := depoWriteHandbook(s.rc); err != nil {
|
||
log.Printf("depo-events: справочник: %v", err)
|
||
depoHandbookWritten = time.Now().Add(-5 * time.Hour) // повтор через час
|
||
}
|
||
}
|
||
depoPollEvents(ctx, s.rc)
|
||
}()
|
||
healthBeat("depo-events")
|
||
}
|
||
}
|
||
|
||
// ---- обработка трансферных таблиц силами bj (замена джобы SQL Agent bj2depo) ----
|
||
|
||
func depoJobProcName(f FancyMSSQLSettings) string {
|
||
if p := strings.TrimSpace(f.DepoJobProc); p != "" {
|
||
return p
|
||
}
|
||
return "dbo.FTV_M2M_JOB"
|
||
}
|
||
|
||
func depoJobEvery(f FancyMSSQLSettings) int {
|
||
if f.DepoJobEverySec >= 5 {
|
||
return f.DepoJobEverySec
|
||
}
|
||
return 10
|
||
}
|
||
|
||
var depoJobErrAlerted time.Time
|
||
|
||
// depoJobOnce — один вызов процедуры обработки. Итог сохраняется в настройках (для карточки).
|
||
func depoJobOnce(rc *RuntimeConfig) {
|
||
f := rc.Snapshot().Fancy
|
||
db, err := depoEventsDB(rc)
|
||
if err != nil {
|
||
return
|
||
}
|
||
defer db.Close()
|
||
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||
defer cancel()
|
||
proc := depoJobProcName(f)
|
||
if !regexp.MustCompile(`^[A-Za-z0-9_.\[\]]+$`).MatchString(proc) {
|
||
return
|
||
}
|
||
start := time.Now()
|
||
_, err = db.ExecContext(ctx, "exec "+proc)
|
||
res := fmt.Sprintf("%s: OK за %s", start.Format("02.01 15:04:05"), time.Since(start).Round(time.Millisecond))
|
||
if err != nil {
|
||
res = fmt.Sprintf("%s: ОШИБКА %s", start.Format("02.01 15:04:05"), quikErrorDetail(err))
|
||
if time.Since(depoJobErrAlerted) > time.Hour {
|
||
depoJobErrAlerted = time.Now()
|
||
hint := ""
|
||
if strings.Contains(strings.ToLower(err.Error()), "не удалось найти") || strings.Contains(strings.ToLower(err.Error()), "could not find") || strings.Contains(strings.ToLower(err.Error()), "разрешение") || strings.Contains(strings.ToLower(err.Error()), "permission") {
|
||
hint = " Скорее всего, у логина bj нет права на процедуру: GRANT EXECUTE ON " + proc + " TO " + f.User + ";"
|
||
}
|
||
raiseSystemAlert(rc, "DEPO: bj не может запустить обработку трансферных таблиц", err.Error()+hint)
|
||
log.Printf("depo-job: %v", err)
|
||
}
|
||
}
|
||
cur := rc.Snapshot().Fancy
|
||
if cur.DepoJobLast != res {
|
||
cur.DepoJobLast, cur.DepoJobLastAt = res, time.Now()
|
||
_ = rc.UpdateFancy(cur)
|
||
}
|
||
}
|
||
|
||
// depoJobLoop — вызывает процедуру обработки, пока включён режим Fancy.DepoJobRun.
|
||
func (s *Server) depoJobLoop(ctx context.Context) {
|
||
for {
|
||
f := s.rc.Snapshot().Fancy
|
||
wait := time.Duration(depoJobEvery(f)) * time.Second
|
||
if f.DepoJobRun && strings.TrimSpace(f.Server) != "" {
|
||
func() {
|
||
defer func() {
|
||
if r := recover(); r != nil {
|
||
log.Printf("depo-job: panic: %v", r)
|
||
}
|
||
}()
|
||
depoJobOnce(s.rc)
|
||
}()
|
||
} else {
|
||
wait = 15 * time.Second
|
||
}
|
||
healthBeat("depo-job")
|
||
select {
|
||
case <-ctx.Done():
|
||
return
|
||
case <-time.After(wait):
|
||
}
|
||
}
|
||
}
|
||
|
||
// ---- выбор способа обработки при настройке: проверки и создание джобы ----
|
||
|
||
const depoJobName = "bj2depo"
|
||
|
||
// depoJobAgentScript — скрипт для администратора SQL (ручной вариант).
|
||
func depoJobAgentScript(f FancyMSSQLSettings) string {
|
||
return fmt.Sprintf(`USE msdb;
|
||
EXEC dbo.sp_add_job @job_name = N'%[1]s', @enabled = 1, @description = N'bj2depo: обработка трансферных таблиц M2M (bj)';
|
||
EXEC dbo.sp_add_jobstep @job_name = N'%[1]s', @step_name = N'%[1]s', @subsystem = N'TSQL', @database_name = N'%[2]s', @command = N'exec %[3]s;';
|
||
EXEC dbo.sp_add_jobschedule @job_name = N'%[1]s', @name = N'%[1]s', @enabled = 1, @freq_type = 4, @freq_interval = 1, @freq_subday_type = 2, @freq_subday_interval = %[4]d, @active_start_time = 0;
|
||
EXEC dbo.sp_add_jobserver @job_name = N'%[1]s';`, depoJobName, f.DepoDB(), depoJobProcName(f), depoJobEvery(f))
|
||
}
|
||
|
||
// depoJobGrantScript — права для варианта «внутри bj».
|
||
func depoJobGrantScript(f FancyMSSQLSettings) string {
|
||
return fmt.Sprintf("USE %s;\nGRANT EXECUTE ON %s TO %s;", f.DepoDB(), depoJobProcName(f), f.User)
|
||
}
|
||
|
||
// depoJobAgentRightsScript — права для варианта «bj создаёт джобу сам».
|
||
func depoJobAgentRightsScript(f FancyMSSQLSettings) string {
|
||
return fmt.Sprintf(`USE msdb;
|
||
CREATE USER %[1]s FOR LOGIN %[1]s;
|
||
ALTER ROLE SQLAgentOperatorRole ADD MEMBER %[1]s;
|
||
-- служебная учётная запись SQL Agent должна иметь право выполнять %[2]s в %[3]s`, f.User, depoJobProcName(f), f.DepoDB())
|
||
}
|
||
|
||
func depoJobPing(ctx context.Context, db *sql.DB, wait time.Duration) (bool, string) {
|
||
guid := strings.ToUpper(fmt.Sprintf("%08x-0000-4000-8000-%012x", time.Now().Unix()&0xFFFFFFFF, time.Now().UnixNano()&0xFFFFFFFFFFFF))
|
||
if _, err := db.ExecContext(ctx, "INSERT INTO dbo.FTV_M2M_EVENTS (GUID, REFERENCE, STATE, EVENT_TYPE, EVENT_DATE, COMMENT) VALUES (@g, NULL, 'TODO', 'BJ_PING', GETDATE(), N'проверка обработчика из bj')", sql.Named("g", guid)); err != nil {
|
||
return false, "не удалось записать проверочное событие: " + quikErrorDetail(err)
|
||
}
|
||
defer db.ExecContext(context.Background(), "DELETE FROM dbo.FTV_M2M_EVENTS WHERE GUID=@g", sql.Named("g", guid))
|
||
deadline := time.Now().Add(wait)
|
||
for time.Now().Before(deadline) {
|
||
var st string
|
||
if err := db.QueryRowContext(ctx, "SELECT ISNULL(STATE,'') FROM dbo.FTV_M2M_EVENTS WHERE GUID=@g", sql.Named("g", guid)).Scan(&st); err == nil && st != "TODO" && st != "" {
|
||
return true, fmt.Sprintf("проверочное событие BJ_PING обработано за %s (STATE=%s)", time.Since(deadline.Add(-wait)).Round(time.Second), st)
|
||
}
|
||
time.Sleep(3 * time.Second)
|
||
}
|
||
return false, fmt.Sprintf("проверочное событие BJ_PING не обработано за %s — обработчик не запущен (или процедура не знает BJ_PING)", wait)
|
||
}
|
||
|
||
// depoJobCheck — проверка готовности выбранного способа обработки.
|
||
func depoJobCheck(ctx context.Context, rc *RuntimeConfig) (string, bool) {
|
||
f := rc.Snapshot().Fancy
|
||
db, err := depoEventsDB(rc)
|
||
if err != nil {
|
||
return "подключение к DEPO: " + err.Error(), false
|
||
}
|
||
defer db.Close()
|
||
if !depoTableExists(ctx, db, "FTV_M2M_EVENTS") {
|
||
return "в базе " + f.DepoDB() + " нет таблиц FTV_M2M* — создайте их: Настройка → Обмен Fancy → «Трансферные таблицы DEPO»", false
|
||
}
|
||
if ok, msg := depoRunAsCheck(ctx, db, f); !ok {
|
||
return msg, false
|
||
}
|
||
if fails, err := depoJobFailures(ctx, db, 1, 5); err == nil && len(fails) > 0 {
|
||
return fmt.Sprintf("обработчик за последние сутки не смог обработать записей: %d (последняя: %s %s — %s). Устраните причину и верните их в очередь: Обмен с DEPO → «Вернуть в очередь»",
|
||
len(fails), fails[0].TableRu(), fails[0].Type, fails[0].Comment), false
|
||
}
|
||
switch f.DepoJobMode {
|
||
case "bj":
|
||
var n int
|
||
_ = db.QueryRowContext(ctx, "SELECT COUNT(*) FROM fn_my_permissions(@p,'OBJECT') WHERE permission_name='EXECUTE'", sql.Named("p", depoJobProcName(f))).Scan(&n)
|
||
if n == 0 {
|
||
return "у логина " + f.User + " нет права EXECUTE на " + depoJobProcName(f) + " — выполните на сервере: " + strings.ReplaceAll(depoJobGrantScript(f), "\n", " "), false
|
||
}
|
||
start := time.Now()
|
||
if _, err := db.ExecContext(ctx, "exec "+depoJobProcName(f)); err != nil {
|
||
return "вызов " + depoJobProcName(f) + ": " + quikErrorDetail(err), false
|
||
}
|
||
return fmt.Sprintf("право есть, процедура %s выполнена за %s; bj будет вызывать её каждые %d с", depoJobProcName(f), time.Since(start).Round(time.Millisecond), depoJobEvery(f)), true
|
||
case "sql-auto", "sql-manual":
|
||
// 1) джоба видна через msdb?
|
||
var seen string
|
||
if mdb, err := fancyOpen(f, "msdb"); err == nil {
|
||
var enabled int
|
||
if e := mdb.QueryRowContext(ctx, "SELECT enabled FROM msdb.dbo.sysjobs WHERE name=@n", sql.Named("n", depoJobName)).Scan(&enabled); e == nil {
|
||
if enabled == 1 {
|
||
seen = "джоба " + depoJobName + " есть и включена; "
|
||
} else {
|
||
seen = "джоба " + depoJobName + " есть, но ВЫКЛЮЧЕНА; "
|
||
}
|
||
}
|
||
mdb.Close()
|
||
}
|
||
// 2) реально ли обрабатывает: BJ_PING
|
||
ok, msg := depoJobPing(ctx, db, 40*time.Second)
|
||
return seen + msg, ok
|
||
}
|
||
return "способ обработки не выбран", false
|
||
}
|
||
|
||
// depoJobCreateAgent — создать джобу SQL Agent от имени логина bj (нужна роль SQLAgentOperatorRole).
|
||
func depoJobCreateAgent(ctx context.Context, rc *RuntimeConfig) error {
|
||
f := rc.Snapshot().Fancy
|
||
mdb, err := fancyOpen(f, "msdb")
|
||
if err != nil {
|
||
return fmt.Errorf("подключение к msdb: %w", err)
|
||
}
|
||
defer mdb.Close()
|
||
var n int
|
||
if err := mdb.QueryRowContext(ctx, "SELECT COUNT(*) FROM msdb.dbo.sysjobs WHERE name=@n", sql.Named("n", depoJobName)).Scan(&n); err != nil {
|
||
return fmt.Errorf("нет доступа к msdb (%s). Нужны права: %s", quikErrorDetail(err), strings.ReplaceAll(depoJobAgentRightsScript(f), "\n", " "))
|
||
}
|
||
if n > 0 {
|
||
return fmt.Errorf("джоба %s уже существует", depoJobName)
|
||
}
|
||
for _, stmt := range strings.Split(depoJobAgentScript(f), "\n") {
|
||
stmt = strings.TrimSpace(stmt)
|
||
if stmt == "" || strings.HasPrefix(stmt, "USE ") {
|
||
continue
|
||
}
|
||
if _, err := mdb.ExecContext(ctx, stmt); err != nil {
|
||
return fmt.Errorf("%s → %s. Нужны права: %s", stmt[:min(40, len(stmt))], quikErrorDetail(err), strings.ReplaceAll(depoJobAgentRightsScript(f), "\n", " "))
|
||
}
|
||
}
|
||
log.Printf("depo-job: джоба SQL Agent %s создана", depoJobName)
|
||
return nil
|
||
}
|
||
|
||
// DepoJobView — данные карточки «Где запускается обработка».
|
||
type DepoJobView struct {
|
||
Name, Proc, GrantScript, AgentRights, AgentScript string
|
||
Every int
|
||
}
|
||
|
||
func depoJobView(f FancyMSSQLSettings) DepoJobView {
|
||
return DepoJobView{Name: depoJobName, Proc: depoJobProcName(f), Every: depoJobEvery(f), GrantScript: depoJobGrantScript(f), AgentRights: depoJobAgentRightsScript(f), AgentScript: depoJobAgentScript(f)}
|
||
}
|
||
|
||
// ---- выгрузка истории тестов (TEST3, банк Зенит / робот) в трансферные таблицы ----
|
||
|
||
func orderRespAt(resps []orderResponse) time.Time {
|
||
for _, r := range resps {
|
||
if r.Done {
|
||
if t, err := time.ParseInLocation("02.01.2006 15:04:05", r.Time, time.Local); err == nil {
|
||
return t
|
||
}
|
||
}
|
||
}
|
||
return time.Time{}
|
||
}
|
||
|
||
// depoReplayHistory — переписывает в FTV_M2M* все сохранённые в bj переводы с их фактической
|
||
// историей (получен/отправлен, решения с кодами, поручения 16/16-1, MS010). Идемпотентно:
|
||
// шапки и события по ключам не дублируются. Возвращает отчёт для оператора.
|
||
func depoReplayHistory(ctx context.Context, rc *RuntimeConfig, svc *Service, onlyCP string) string {
|
||
var sb strings.Builder
|
||
cpOK := func(code string) bool {
|
||
if strings.TrimSpace(onlyCP) == "" {
|
||
return true
|
||
}
|
||
for _, c := range strings.Split(onlyCP, ",") {
|
||
if strings.EqualFold(strings.TrimSpace(c), strings.TrimSpace(code)) {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
place := rc.Snapshot().NSD.SectionID
|
||
nIn, nOut, nEv := 0, 0, 0
|
||
ev := func(at time.Time, guid, ref, typ, codes, comment string) {
|
||
depoEventAt(rc, at, guid, ref, typ, codes, comment)
|
||
nEv++
|
||
}
|
||
// входящие (мы зачисляли)
|
||
if globalIncomingStore != nil {
|
||
for _, it := range globalIncomingStore.List() {
|
||
if !uuidRe.MatchString(strings.ToUpper(it.GUID)) || !cpOK(it.SenderCode) {
|
||
continue
|
||
}
|
||
var lines []depoLine
|
||
for _, s := range it.Securities {
|
||
lines = append(lines, depoLine{Ref: s.ReferenceID, ISIN: s.ISIN, Code: s.SecurityCode, Amount: parseQty(s.Quantity), Place: place})
|
||
}
|
||
name := strings.TrimSpace(strings.Join([]string{it.InvestorLast, it.InvestorFirst, it.InvestorMiddle}, " "))
|
||
cp := handbookNameByCode(it.SenderCode)
|
||
depoUpsertOrder(rc, it.GUID, "IN", it.SenderCode, name, strings.TrimSpace(it.DocSeries+" "+it.DocNumber), it.DocSeries, it.DocNumber, lines, "TEST3: входящий от "+it.SenderCode+" "+cp)
|
||
nIn++
|
||
ev(it.ReceivedAt, it.GUID, "", "BJ_RECEIVED", "", "входящий перевод получен от "+it.SenderCode+" "+cp)
|
||
if it.Status == "answered" {
|
||
for _, s := range it.Securities {
|
||
switch s.Decision {
|
||
case "confirmed":
|
||
ev(it.AnsweredAt, it.GUID, s.ReferenceID, "BJ_DECISION_CONFIRM", "", "приём подтверждён контрагенту")
|
||
case "rejected":
|
||
ev(it.AnsweredAt, it.GUID, s.ReferenceID, "BJ_DECISION_REJECT", s.RejectCode, "отказ контрагенту: "+m2mCodeLabel(s.RejectCode))
|
||
}
|
||
}
|
||
}
|
||
if it.OrderSubmitted {
|
||
ev(it.OrderAt, it.GUID, "", "BJ_ORDER_SENT", "", "подано депо-поручение 16/1 (зачисление), CONTR_D_ID "+it.OrderContrDID)
|
||
resps := findOrderResponses(it.OrderContrDID)
|
||
if done, rejected, text := orderRespVerdict(resps); done {
|
||
at := orderRespAt(resps)
|
||
if rejected {
|
||
ev(at, it.GUID, "", "BJ_ORDER_REJECTED", "", "НРД отверг депо-поручение: "+text)
|
||
} else {
|
||
ev(at, it.GUID, "", "BJ_ORDER_ACCEPTED", "", "НРД принял депо-поручение (отчёт MS010)")
|
||
ev(at, it.GUID, "", "BJ_EXECUTED", "", "перевод исполнен (MS010)")
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
// исходящие (мы списывали)
|
||
if svc != nil {
|
||
deals, err := svc.Repo().List(ctx, m2mcore.Filter{Limit: 500})
|
||
if err != nil {
|
||
fmt.Fprintf(&sb, "исходящие: ошибка чтения заявок: %v\n", err)
|
||
}
|
||
for _, d := range deals {
|
||
if d == nil || d.Request == nil || !uuidRe.MatchString(strings.ToUpper(string(d.GUID))) {
|
||
continue
|
||
}
|
||
guid := string(d.GUID)
|
||
req := d.Request
|
||
inv := req.Data.InvestorInformation
|
||
ser := ""
|
||
if inv.IdentityDocument.DocumentSeries != nil {
|
||
ser = string(*inv.IdentityDocument.DocumentSeries)
|
||
}
|
||
num := string(inv.IdentityDocument.DocumentNumber)
|
||
var lines []depoLine
|
||
refISIN := map[string]string{}
|
||
for _, s := range req.Data.TransferredSecurities.Securities {
|
||
isin := quikSecISIN(s)
|
||
refISIN[string(s.ReferenceID)] = isin
|
||
lines = append(lines, depoLine{Ref: string(s.ReferenceID), ISIN: isin, Code: string(s.SecurityCode), Amount: quikSecQty(s.Quantity), Place: place})
|
||
}
|
||
inn := string(req.Data.ReceivingDepository.INN)
|
||
cp := handbookCodeByINN(inn)
|
||
if !cpOK(cp) {
|
||
continue
|
||
}
|
||
name := strings.TrimSpace(strings.Join([]string{inv.LastName, inv.FirstName, inv.MiddleName}, " "))
|
||
depoUpsertOrder(rc, guid, "OUT", cp, name, strings.TrimSpace(ser+" "+num), ser, num, lines, "TEST3: исходящий, получатель "+cp+" "+handbookNameByCode(cp))
|
||
nOut++
|
||
ev(d.CreatedAt, guid, "", "BJ_SENT", "", "запрос на перевод отправлен в НРД")
|
||
if d.Decision != nil {
|
||
for _, sec := range d.Decision.Data.Securities {
|
||
ref := string(sec.ReferenceID)
|
||
if sec.TransferDecision.Rejection != nil {
|
||
ev(d.UpdatedAt, guid, ref, "BJ_CP_REJECT", strings.Join(sec.TransferDecision.Rejection.Codes, ";"), "отказ принимающего депозитария: "+rejectCodesLabel(sec.TransferDecision.Rejection.Codes))
|
||
} else if sec.TransferDecision.Confirmation != nil {
|
||
ev(d.UpdatedAt, guid, ref, "BJ_CP_CONFIRM", "", "принимающий депозитарий подтвердил")
|
||
}
|
||
}
|
||
}
|
||
if d.Response != nil && strings.EqualFold(string(d.Response.StatusCode), "ERROR") {
|
||
var codes []string
|
||
for _, rr := range d.Response.Responses {
|
||
codes = append(codes, string(rr.Code))
|
||
}
|
||
ev(d.UpdatedAt, guid, "", "BJ_SERVICE_ERROR", strings.Join(codes, ";"), "отклонено сервисом MOEX МОСТ: "+rejectCodesLabel(codes))
|
||
}
|
||
if globalOutgoingStore != nil {
|
||
if od, ok := scanForm16Deal(globalOutgoingStore, guid); ok && od.Submitted {
|
||
ev(od.OrderAt, guid, "", "BJ_ORDER_SENT", "", "подано депо-поручение 16 (списание), CONTR_D_ID "+strings.Join(od.ContrDIDs, ", "))
|
||
resps, _ := outgoingResponses(od.ContrDIDs)
|
||
if done, rejected, text := orderRespVerdict(resps); done {
|
||
at := orderRespAt(resps)
|
||
if rejected {
|
||
ev(at, guid, "", "BJ_ORDER_REJECTED", "", "НРД отверг депо-поручение: "+text)
|
||
} else {
|
||
ev(at, guid, "", "BJ_ORDER_ACCEPTED", "", "НРД принял депо-поручение (отчёт MS010)")
|
||
ev(at, guid, "", "BJ_EXECUTED", "", "перевод исполнен (MS010)")
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
fmt.Fprintf(&sb, "выгружено: входящих %d, исходящих %d, событий записано (с учётом уже имевшихся) %d", nIn, nOut, nEv)
|
||
return sb.String()
|
||
}
|
||
|
||
// ---- тестовый прогон входящего в DEPO с реальным клиентом ----
|
||
|
||
// depoSimulateIncoming — копия входящего перевода (бумаги и решения как в оригинале) на реального
|
||
// клиента DEPO по паспорту: новый GUID, шапка IN + строки + события полного сценария приёма.
|
||
// В НРД ничего не отправляется — только трансферные таблицы DEPO.
|
||
func depoSimulateIncoming(ctx context.Context, rc *RuntimeConfig, it *IncomingTransfer, ser, num string) (string, error) {
|
||
if it == nil {
|
||
return "", fmt.Errorf("входящий не найден")
|
||
}
|
||
db, err := depoEventsDB(rc)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
defer db.Close()
|
||
serN := strings.ReplaceAll(strings.TrimSpace(ser), " ", "")
|
||
numN := strings.TrimSpace(num)
|
||
var name string
|
||
if err := db.QueryRowContext(ctx, `SELECT TOP 1 f.NAME FROM OD_FACES f JOIN OD_CLIENTS c ON c.FACE=f.ID
|
||
WHERE REPLACE(f.DOC_SER,' ','')=@s AND f.DOC_NUM=@n AND c.NUMBER LIKE N'ВТНКЦ%' AND c.STATE=0`, sql.Named("s", serN), sql.Named("n", numN)).Scan(&name); err != nil {
|
||
return "", fmt.Errorf("клиент с паспортом %s %s и открытым торговым счётом в DEPO не найден", ser, num)
|
||
}
|
||
guid := strings.ToUpper(fmt.Sprintf("%08x-%04x-4000-8000-%012x", time.Now().Unix()&0xFFFFFFFF, time.Now().UnixNano()&0xFFFF, time.Now().UnixNano()&0xFFFFFFFFFFFF))
|
||
place := rc.Snapshot().NSD.SectionID
|
||
var lines []depoLine
|
||
for _, s := range it.Securities {
|
||
lines = append(lines, depoLine{Ref: s.ReferenceID, ISIN: s.ISIN, Code: s.SecurityCode, Amount: parseQty(s.Quantity), Place: place})
|
||
}
|
||
cp := handbookNameByCode(it.SenderCode)
|
||
if !depoUpsertOrder(rc, guid, "IN", it.SenderCode, name, strings.TrimSpace(ser+" "+num), ser, num, lines, "ТЕСТ по образцу "+shortGUID(it.GUID)+": входящий от "+it.SenderCode+" "+cp+", реальный клиент") {
|
||
return "", fmt.Errorf("не удалось записать шапку в FTV_M2M")
|
||
}
|
||
now := time.Now()
|
||
depoEventAt(rc, now, guid, "", "BJ_RECEIVED", "", "входящий перевод получен от "+it.SenderCode+" "+cp+" (тест по образцу "+shortGUID(it.GUID)+")")
|
||
anyConfirmed := false
|
||
for _, s := range it.Securities {
|
||
switch s.Decision {
|
||
case "confirmed":
|
||
anyConfirmed = true
|
||
depoEventAt(rc, now.Add(time.Minute), guid, s.ReferenceID, "BJ_DECISION_CONFIRM", "", "приём подтверждён контрагенту")
|
||
case "rejected":
|
||
depoEventAt(rc, now.Add(time.Minute), guid, s.ReferenceID, "BJ_DECISION_REJECT", s.RejectCode, "отказ контрагенту: "+m2mCodeLabel(s.RejectCode))
|
||
default:
|
||
anyConfirmed = true
|
||
depoEventAt(rc, now.Add(time.Minute), guid, s.ReferenceID, "BJ_DECISION_CONFIRM", "", "приём подтверждён контрагенту (тест)")
|
||
}
|
||
}
|
||
if anyConfirmed {
|
||
depoEventAt(rc, now.Add(2*time.Minute), guid, "", "BJ_ORDER_SENT", "", "подано депо-поручение 16/1 (зачисление) в НРД (тест)")
|
||
depoEventAt(rc, now.Add(3*time.Minute), guid, "", "BJ_ORDER_ACCEPTED", "", "НРД принял депо-поручение (отчёт MS010) (тест)")
|
||
depoEventAt(rc, now.Add(3*time.Minute), guid, "", "BJ_EXECUTED", "", "перевод исполнен (MS010) (тест)")
|
||
}
|
||
return guid, nil
|
||
}
|