Files
Bridge-and-Join-s/internal/lkgateway/depoevents.go
T

1052 lines
46 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
// Обмен с 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)
}
// счёт клиента для DEPO_NUMBER строк: открытый торговый ВТНКЦ…, при нескольких договорах — самый
// ранний (то же правило, что при зачислении в QUIK); джоба по номеру находит OD_CLIENTS.ID (DEPO).
var clientAcc sql.NullString
if serN != "" && strings.TrimSpace(num) != "" {
_ = db.QueryRowContext(ctx, `SELECT TOP 1 c.NUMBER FROM OD_FACES f
JOIN OD_CLIENTS c ON c.FACE=f.ID LEFT JOIN OD_CLIENTS_PORTS p ON p.CLIENT=c.ID
WHERE REPLACE(f.DOC_SER,' ','')=@s AND f.DOC_NUM=@n AND c.NUMBER LIKE N'ВТНКЦ%' AND c.STATE=0 AND c.D_CLOSE IS NULL
ORDER BY ISNULL(p.PORT_DATE,'2099-01-01'), ISNULL(c.D_OPEN,'2099-01-01'), c.NUMBER`,
sql.Named("s", serN), sql.Named("n", strings.TrimSpace(num))).Scan(&clientAcc)
}
hasDepoNumber := depoHasColumn(ctx, db, "FTV_M2M_LINES", "DEPO_NUMBER")
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)
}
cols, vals := "GUID, REFERENCE, ISIN, SEC_CODE, AMOUNT, PLACE_NUMBER, LINK_NUMBER", "@g, @r, @i, @c, @a, @p, ''"
if hasShare {
cols, vals = cols+", SHARE", vals+", @sh"
}
if hasDepoNumber {
cols, vals = cols+", DEPO_NUMBER", vals+", @acc"
}
q := `INSERT INTO dbo.FTV_M2M_LINES (` + cols + `) VALUES (` + vals + `)`
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)), sql.Named("acc", clientAcc)); 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 строк, счёт клиента %s)", direction, shortGUID(guid), len(lines), pickStr(clientAcc.String, "не найден"))
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
}