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

1325 lines
58 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 (страница /admin/help/fancy-events), уточнено 17.09.2026:
// - bj — мастер: пишет поручения и события BJ_*; джоба bj2depo создаёт сущности DEPO, событий от
// себя не пишет — результат bj читает в FTV_M2M.DOC и в STATE/COMMENT своих событий;
// - COMMENT и STATE заполняет только bj2depo (STATE по умолчанию TODO); параметры события bj пишет
// в PARAMETERS; EVENT_DATE — фактическое время события в bj;
// - справочник участников — TRUNCATE + полная перезапись при каждом получении.
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/lkgateway/fsql"
"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), время события — сейчас.
// params — параметры события для bj2depo (PARAMETERS): номер поручения НРД, номер поручения МНП, текст причины отказа.
func depoEvent(rc *RuntimeConfig, guid, ref, typ, codes, params string) {
depoEventAt(rc, time.Now(), guid, ref, typ, codes, params)
}
// depoEventAt — то же с явным временем события (выгрузка истории). COMMENT и STATE не пишем:
// их заполняет bj2depo (STATE по умолчанию TODO).
func depoEventAt(rc *RuntimeConfig, at time.Time, guid, ref, typ, codes, params 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 at.IsZero() {
at = time.Now()
}
if len([]rune(params)) > 4000 {
params = string([]rune(params)[:4000])
}
var n int
_ = db.QueryRowContext(ctx, fsql.QEv01,
sql.Named("g", guid), sql.Named("r", ref), sql.Named("t", typ)).Scan(&n)
if n > 0 {
return
}
q := fsql.Ev01
if depoHasColumn(ctx, db, "FTV_M2M_EVENTS", "PARAMETERS") {
q = fsql.Ev02
}
_, err = db.ExecContext(ctx, q,
sql.Named("g", guid), sql.Named("r", nullStr(ref)), sql.Named("t", typ), sql.Named("at", at.In(time.FixedZone("МСК", 3*3600)).Format("2006-01-02T15:04:05.000")),
sql.Named("c", nullStr(codes)), sql.Named("p", nullStr(params)))
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 %s", typ, shortGUID(guid), ref, codes, params)
}
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, fsql.QEv02, 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, fsql.QEv03, 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, fsql.Ev03,
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
}
// COMMENT не пишем — его заполняет bj2depo (17.09.2026); comment остаётся только для журнала bj.
head := fsql.Ev04
if hasDeponentID {
head = fsql.Ev05
}
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")
hasLineTimeNew := depoHasColumn(ctx, db, "FTV_M2M_LINES", "TimeNew")
for _, ln := range lines {
var share sql.NullInt64
if ln.ISIN != "" && hasShare {
_ = db.QueryRowContext(ctx, fsql.QEv04, sql.Named("i", ln.ISIN)).Scan(&share)
}
// LINK_NUMBER — наш депозитарный счёт в НРД: по нему джоба bj2depo находит регистр аналитики (В. Кокин, 22.09.2026)
cols, vals := "GUID, REFERENCE, ISIN, SEC_CODE, AMOUNT, PLACE_NUMBER, LINK_NUMBER", "@g, @r, @i, @c, @a, @p, @lnk"
if hasShare {
cols, vals = cols+", SHARE", vals+", @sh"
}
if hasDepoNumber {
cols, vals = cols+", DEPO_NUMBER", vals+", @acc"
}
// TimeNew — явно: у FTV_M2M_LINES в боевой DEPO нет умолчания GETDATE() (29.09.2026), время было пустым
if hasLineTimeNew {
now := "GETDATE()"
if rc.Snapshot().Fancy.IsPostgres() {
now = "LOCALTIMESTAMP"
}
cols, vals = cols+", TimeNew", vals+", "+now
}
q := fsql.Ev06 + 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("lnk", strings.TrimSpace(rc.Snapshot().NSD.AccountID)), 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 := readHandbook()
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) {
// по строкам: джоба bj2depo ищет строку по GUID+REFERENCE для любого события (22.09.2026)
for _, s := range it.Securities {
depoEvent(rc, it.GUID, s.ReferenceID, "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) {
for _, s := range req.Data.TransferredSecurities.Securities {
depoEvent(rc, guid, string(s.ReferenceID), "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) {
// по строкам: джоба bj2depo ищет строку по GUID+REFERENCE для любого события
for _, ref := range depoLineRefs(rc, guid, "") {
depoEvent(rc, guid, ref, "BJ_SERVICE_ERROR", strings.ReplaceAll(strings.TrimSpace(codes), " ", ";"), reason)
}
}
// depoLineRefs — REFERENCE строк перевода из FTV_M2M_LINES (по ISIN, если задан; иначе все строки).
func depoLineRefs(rc *RuntimeConfig, guid, isin string) []string {
db, err := depoEventsDB(rc)
if err != nil {
return nil
}
defer db.Close()
ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second)
defer cancel()
rows, err := db.QueryContext(ctx, fsql.QEv09, sql.Named("g", strings.ToUpper(guid)), sql.Named("i", strings.ToUpper(strings.TrimSpace(isin))))
if err != nil {
return nil
}
defer rows.Close()
var refs []string
for rows.Next() {
var r string
if rows.Scan(&r) == nil && r != "" {
refs = append(refs, r)
}
}
if len(refs) == 0 && isin != "" {
return depoLineRefs(rc, guid, "")
}
return refs
}
// СТРОЧНЫЕ события по депо-поручениям (договорённость с Fancy 18.09.2026, ответы
// В. Кокина): одно депо-поручение = одна бумага пакета, поэтому подача и исход
// пишутся в DEPO ПО СТРОКАМ с REFERENCE и сразу по MS010 этой строки, не дожидаясь
// остальных — пакет может исполниться частично. Связка contr_d_id↔REFERENCE джобе
// bj2depo не нужна (по REFERENCE она находит всё); contr_d_id кладём в COMMENT для
// разбора. Событие на весь GUID (пустой REFERENCE) больше не пишем: переход на
// строчные — одновременно на bj и bj2depo.
// depoOnOrderLineSent — подано депо-поручение по строке (REFERENCE), contr_d_id — в комментарии.
func depoOnOrderLineSent(rc *RuntimeConfig, guid, ref, contrDID string) {
depoEvent(rc, guid, ref, "BJ_ORDER_SENT", "", contrDID)
}
// depoOnOrderLineResult — исход по строке: accepted | rejected | late | expired.
// accepted → BJ_ORDER_ACCEPTED + BJ_EXECUTED; rejected → BJ_ORDER_REJECTED;
// late → BJ_ORDER_LATE (5 мин без MS010); expired → BJ_CANCELLED (30 дней по бумаге).
// nsdNo — номер ответа НРД (отчёт MS010, BizMsgIdr): PARAMETERS событий по исполнению (29.09.2026).
func depoOnOrderLineResult(rc *RuntimeConfig, guid, ref, kind, text, nsdNo string) {
switch kind {
case "accepted":
depoEvent(rc, guid, ref, "BJ_ORDER_ACCEPTED", "", nsdNo)
depoEvent(rc, guid, ref, "BJ_EXECUTED", "", nsdNo)
case "rejected":
params := nsdNo
if params == "" {
params = text
}
depoEvent(rc, guid, ref, "BJ_ORDER_REJECTED", "", params)
case "late":
depoEvent(rc, guid, ref, "BJ_ORDER_LATE", "", "")
case "expired":
depoEvent(rc, guid, ref, "BJ_CANCELLED", "", "")
}
}
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
}
for _, ref := range depoLineRefs(rc, guid, isin) {
depoEvent(rc, guid, ref, typ, "", instrNo)
}
}
// ---- чтение событий 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
}
// Событий от bj2depo нет (17.09.2026: мастер — bj): номер документа — FTV_M2M.DOC, результат
// обработки — STATE/COMMENT наших событий и шапки.
depoPollOrders(cctx, db, rc)
depoAlertEventFailures(cctx, db, rc)
}
// 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, fsql.Ev07+docCol+fsql.Ev08+docCol+fsql.Ev09+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), "отмен") {
if !isDepoTestGUID(guid) {
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"
})
if !isDepoTestGUID(guid) {
raiseSystemAlert(rc, "DEPO: джоба не обработала перевод "+shortGUID(guid), msg)
}
}
}
}
}
// ---- справочник участников → FTV_M2M_HANDBOOK (полная перезапись) ----
var depoHandbookWritten time.Time
func depoWriteHandbook(rc *RuntimeConfig) error {
raw, err := readHandbook()
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, fsql.QEv05); 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, fsql.Ev10,
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 = execProc(ctx, db, 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))
}
// depoJobAgentAdminScript — тот же набор для администратора SQL (вариант «создаёт администратор по
// скрипту»): с пояснениями и защитой от повторного запуска (джоба уже есть — ничего не делает).
// depoJobAgentScript остаётся построчным: его выполняет сам bj в варианте «джобу создаёт bj».
func depoJobAgentAdminScript(f FancyMSSQLSettings) string {
return fmt.Sprintf(`-- Bridge&Join: джоба SQL Server Agent «%[1]s» — обработка таблиц обмена M2M в базе %[2]s.
-- Каждые %[4]d с вызывает процедуру %[3]s: записи bj превращаются в документы и операции учёта DEPO.
-- Выполнить в SSMS или sqlcmd под логином с правами sysadmin (или SQLAgentOperatorRole в msdb).
-- Служба SQL Server Agent должна быть запущена. Скрипт можно запускать повторно: если джоба уже есть, он её не трогает.
USE msdb;
GO
IF NOT EXISTS (SELECT 1 FROM msdb.dbo.sysjobs WHERE name = N'%[1]s')
BEGIN
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';
PRINT N'Джоба %[1]s создана.';
END
ELSE
PRINT N'Джоба %[1]s уже есть — не изменялась.';
GO
-- Логину bj — чтение списка джоб и их истории: bj проверяет, что джоба запускается по расписанию.
IF USER_ID(N'%[5]s') IS NULL CREATE USER [%[5]s] FOR LOGIN [%[5]s];
IF IS_ROLEMEMBER(N'SQLAgentReaderRole', N'%[5]s') = 0 ALTER ROLE SQLAgentReaderRole ADD MEMBER [%[5]s];
GO
`, depoJobName, f.DepoDB(), depoJobProcName(f), depoJobEvery(f), pickStr(strings.TrimSpace(f.User), "bj_user"))
}
// 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, fsql.QEv06, sql.Named("g", guid)); err != nil {
return false, "не удалось записать проверочное событие: " + quikErrorDetail(err)
}
defer db.ExecContext(context.Background(), fsql.QEv07, sql.Named("g", guid))
deadline := time.Now().Add(wait)
for time.Now().Before(deadline) {
var st string
if err := db.QueryRowContext(ctx, fsql.QEv08, 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 — процедуры обработки не отработали. Проверьте: 1) в базе DEPO есть процедуры FTV_M2M_JOB и FTV_M2M_PROCESS_*; 2) пользователь, от имени которого они работают (SYSDBA), есть в базе; 3) ошибку последнего запуска джобы — в её истории в SSMS", wait)
}
// depoJobCheck — проверка готовности выбранного способа обработки.
func depoJobCheck(ctx context.Context, rc *RuntimeConfig) (string, bool) {
return depoJobRunnerCheck(ctx, rc, "", "")
}
// depoJobRunnerCheck — запускалка обработки (05.10.2026: шаг «Обработка» проверяет только её, а не процедуры):
// SQL Agent — джоба есть, включена, есть расписание и она запускалась недавно; «bj сам» — право EXECUTE.
// adminUser/adminPass — разовый логин администратора (если введён), иначе — логин bj (нужен SQLAgentReaderRole в msdb).
func depoJobRunnerCheck(ctx context.Context, rc *RuntimeConfig, adminUser, adminPass string) (string, bool) {
f := rc.Snapshot().Fancy
switch f.DepoJobMode {
case "bj":
db, err := depoEventsDB(rc)
if err != nil {
return "подключение к DEPO: " + err.Error(), false
}
defer db.Close()
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
}
return fmt.Sprintf("право на %s есть; bj будет вызывать её каждые %d с", depoJobProcName(f), depoJobEvery(f)), true
case "sql-auto", "sql-manual":
return depoAgentJobStatus(ctx, f, adminUser, adminPass)
}
return "способ обработки не выбран", false
}
// depoAgentJobStatus — состояние джобы SQL Agent по msdb.
func depoAgentJobStatus(ctx context.Context, f FancyMSSQLSettings, adminUser, adminPass string) (string, bool) {
conn := f
if strings.TrimSpace(adminUser) != "" {
conn.User, conn.Password = strings.TrimSpace(adminUser), adminPass
}
mdb, err := fancyOpen(conn, "msdb")
if err != nil {
return "подключение к msdb: " + err.Error(), false
}
defer mdb.Close()
var (
jobEnabled, schedEnabled, subType, subInt sql.NullInt64
runDate, runTime, runStatus sql.NullInt64
runMsg sql.NullString
now time.Time
)
err = mdb.QueryRowContext(ctx, `SELECT j.enabled, s.enabled, s.freq_subday_type, s.freq_subday_interval,
h.run_date, h.run_time, h.run_status, h.message, GETDATE()
FROM msdb.dbo.sysjobs j
LEFT JOIN msdb.dbo.sysjobschedules js ON js.job_id = j.job_id
LEFT JOIN msdb.dbo.sysschedules s ON s.schedule_id = js.schedule_id
OUTER APPLY (SELECT TOP 1 run_date, run_time, run_status, message FROM msdb.dbo.sysjobhistory
WHERE job_id = j.job_id AND step_id = 0 ORDER BY instance_id DESC) h
WHERE j.name = @n`, sql.Named("n", depoJobName)).Scan(&jobEnabled, &schedEnabled, &subType, &subInt, &runDate, &runTime, &runStatus, &runMsg, &now)
if err == sql.ErrNoRows {
return "джобы " + depoJobName + " на сервере нет — создайте её (вариант «создаёт bj» с логином администратора или скрипт для администратора)", false
}
if err != nil {
return "список джоб недоступен логину " + conn.User + " (" + quikErrorDetail(err) + "). Чтобы bj мог проверять джобу, администратору SQL — один раз: " + strings.ReplaceAll(depoJobReaderGrant(f), "\n", " "), false
}
if jobEnabled.Int64 != 1 {
return "джоба " + depoJobName + " есть, но ВЫКЛЮЧЕНА — включите её в SQL Server Agent", false
}
if !schedEnabled.Valid || schedEnabled.Int64 != 1 {
return "у джобы " + depoJobName + " нет включённого расписания — она не будет запускаться", false
}
every := time.Duration(depoJobEvery(f)) * time.Second
if subType.Int64 == 2 && subInt.Int64 > 0 { // каждые N секунд
every = time.Duration(subInt.Int64) * time.Second
} else if subType.Int64 == 4 && subInt.Int64 > 0 { // каждые N минут
every = time.Duration(subInt.Int64) * time.Minute
}
sched := fmt.Sprintf("джоба %s включена, запускается каждые %s", depoJobName, humanEvery(every))
if !runDate.Valid || runDate.Int64 == 0 {
return sched + ", но ещё ни разу не запускалась — запущена ли служба SQL Server Agent?", false
}
d, t := runDate.Int64, runTime.Int64
last := time.Date(int(d/10000), time.Month(d/100%100), int(d%100), int(t/10000), int(t/100%100), int(t%100), 0, now.Location())
age := now.Sub(last)
limit := 3 * every
if limit < time.Minute {
limit = time.Minute
}
if age > limit {
return fmt.Sprintf("%s, но последний запуск был %s (%s назад) — запущена ли служба SQL Server Agent?", sched, last.Format("02.01 15:04:05"), humanDur(age)), false
}
msg := fmt.Sprintf("%s; последний запуск %s (%d с назад)", sched, last.Format("15:04:05"), int(age.Seconds()))
if runStatus.Int64 == 0 { // запуск есть, но обработка внутри упала — это не про джобу
txt := strings.TrimSpace(runMsg.String)
if r := []rune(txt); len(r) > 300 {
txt = string(r[:300]) + "…"
}
msg += ". Запуск завершился ошибкой обработки — это уже не про джобу, а про процедуры (см. «Обработка событий»): " + txt
}
return msg, true
}
func humanEvery(d time.Duration) string {
if d < time.Minute {
return fmt.Sprintf("%d с", int(d.Seconds()))
}
return fmt.Sprintf("%d мин", int(d.Minutes()))
}
// depoJobReaderGrant — право логину bj видеть джобы и их историю (для проверки джобы из bj).
func depoJobReaderGrant(f FancyMSSQLSettings) string {
return fmt.Sprintf("USE msdb;\nIF USER_ID(N'%[1]s') IS NULL CREATE USER [%[1]s] FOR LOGIN [%[1]s];\nIF IS_ROLEMEMBER(N'SQLAgentReaderRole', N'%[1]s') = 0 ALTER ROLE SQLAgentReaderRole ADD MEMBER [%[1]s];", f.User)
}
// depoProcCheck — работает ли сама обработка событий (процедуры FTV_M2M_*): таблицы, пользователь
// процедур, ошибки обработчика, проверочное событие BJ_PING. Отдельно от запускалки (джобы).
func depoProcCheck(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*", 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
}
if f.DepoJobMode == "bj" {
if _, err := execProc(ctx, db, depoJobProcName(f)); err != nil {
return "вызов " + depoJobProcName(f) + ": " + quikErrorDetail(err), false
}
}
ok, msg := depoJobPing(ctx, db, 40*time.Second)
return msg, ok
}
// depoJobCreateAgent — создать джобу SQL Agent от имени логина bj (нужна роль SQLAgentOperatorRole).
// depoJobCreateAgent — создать джобу SQL Agent. adminUser/adminPass — логин администратора SQL
// (sysadmin или SQLAgentOperatorRole в msdb): используется один раз и не сохраняется; пусто — логином bj.
func depoJobCreateAgent(ctx context.Context, rc *RuntimeConfig, adminUser, adminPass string) error {
f := rc.Snapshot().Fancy
conn := f
if strings.TrimSpace(adminUser) != "" {
conn.User, conn.Password = strings.TrimSpace(adminUser), adminPass
}
mdb, err := fancyOpen(conn, "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 {
depoGrantJobReader(ctx, mdb, f) // джоба осталась (msdb не восстанавливали) — права на проверку всё равно выдаём
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)
depoGrantJobReader(ctx, mdb, f)
return nil
}
// depoGrantJobReader — пока есть логин администратора: логину bj — чтение списка джоб (для проверки).
func depoGrantJobReader(ctx context.Context, mdb *sql.DB, f FancyMSSQLSettings) {
if !depoLoginRe.MatchString(f.User) {
return
}
for _, q := range strings.Split(depoJobReaderGrant(f), "\n") {
if strings.HasPrefix(q, "USE ") {
continue
}
if _, err := mdb.ExecContext(ctx, q); err != nil {
log.Printf("depo-job: право SQLAgentReaderRole логину %s: %s", f.User, quikErrorDetail(err))
return
}
}
}
// DepoJobView — данные карточки «Где запускается обработка».
type DepoJobView struct {
Name, Proc, GrantScript, AgentRights, AgentScript string
AdminScript 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), AdminScript: depoJobAgentAdminScript(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", "", "")
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 {
// по строкам: у каждой бумаги своё поручение 16/1 и свой MS010
for _, ln := range incomingOrderLines(it) {
ev(it.OrderAt, it.GUID, ln.Ref, "BJ_ORDER_SENT", "", ln.ContrDID)
resps := findOrderResponses(ln.ContrDID)
if done, rejected, text := orderRespVerdict(resps); done {
at := orderRespAt(resps)
if rejected {
ev(at, it.GUID, ln.Ref, "BJ_ORDER_REJECTED", "", pickStr(orderRespNo(resps), text))
} else {
ev(at, it.GUID, ln.Ref, "BJ_ORDER_ACCEPTED", "", orderRespNo(resps))
ev(at, it.GUID, ln.Ref, "BJ_EXECUTED", "", orderRespNo(resps))
}
}
}
}
}
}
// исходящие (мы списывали)
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, ";"), rejectCodesLabel(codes))
}
if globalOutgoingStore != nil {
if od, ok := scanForm16Deal(globalOutgoingStore, guid); ok && od.Submitted {
// по строкам: один черновик MF010 = одна бумага; REFERENCE — по ISIN из M2MTR
isinRef := map[string]string{}
for ref, isin := range refISIN {
isinRef[strings.ToUpper(isin)] = ref
}
for _, sec := range od.Secs {
if sec.ContrDID == "" {
continue
}
ref := isinRef[strings.ToUpper(sec.ISIN)]
ev(od.OrderAt, guid, ref, "BJ_ORDER_SENT", "", sec.ContrDID)
resps := findOrderResponses(sec.ContrDID)
if done, rejected, text := orderRespVerdict(resps); done {
at := orderRespAt(resps)
if rejected {
ev(at, guid, ref, "BJ_ORDER_REJECTED", "", pickStr(orderRespNo(resps), text))
} else {
ev(at, guid, ref, "BJ_ORDER_ACCEPTED", "", orderRespNo(resps))
ev(at, guid, ref, "BJ_EXECUTED", "", orderRespNo(resps))
}
}
}
}
}
}
}
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, fsql.Ev11, 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
// у копии — свои REFERENCE: иначе в DEPO появляются M2M-поручения с одинаковым номером строки (задвоение)
ref := depoSimRefs(guid, it.Securities)
var lines []depoLine
for _, s := range it.Securities {
lines = append(lines, depoLine{Ref: 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()
for _, s := range it.Securities {
depoEventAt(rc, now, guid, ref[s.ReferenceID], "BJ_RECEIVED", "", "")
}
anyConfirmed := false
for _, s := range it.Securities {
switch s.Decision {
case "confirmed":
anyConfirmed = true
depoEventAt(rc, now.Add(time.Minute), guid, ref[s.ReferenceID], "BJ_DECISION_CONFIRM", "", "")
case "rejected":
depoEventAt(rc, now.Add(time.Minute), guid, ref[s.ReferenceID], "BJ_DECISION_REJECT", s.RejectCode, m2mCodeLabel(s.RejectCode))
default:
anyConfirmed = true
depoEventAt(rc, now.Add(time.Minute), guid, ref[s.ReferenceID], "BJ_DECISION_CONFIRM", "", "")
}
}
// Исходы — по строкам (REFERENCE), как в бою: по каждой принятой бумаге своё поручение.
if anyConfirmed {
for _, s := range it.Securities {
if s.Decision == "rejected" {
continue
}
depoEventAt(rc, now.Add(2*time.Minute), guid, ref[s.ReferenceID], "BJ_ORDER_SENT", "", "")
depoEventAt(rc, now.Add(3*time.Minute), guid, ref[s.ReferenceID], "BJ_ORDER_ACCEPTED", "", "")
depoEventAt(rc, now.Add(3*time.Minute), guid, ref[s.ReferenceID], "BJ_EXECUTED", "", "")
}
}
return guid, nil
}
// depoSimulateOutgoing — копия исходящего перевода (бумаги и решение контрагента как в оригинале)
// на реального клиента DEPO по паспорту: новый GUID, шапка OUT + строки + события полного сценария
// списания (форма 16 и MS010 имитируются). В НРД ничего не отправляется — только трансферные таблицы DEPO.
func depoSimulateOutgoing(ctx context.Context, rc *RuntimeConfig, svc *Service, srcGUID, ser, num string) (string, error) {
if svc == nil {
return "", fmt.Errorf("сервис сделок не запущен")
}
d, err := svc.Repo().GetByGUID(ctx, m2m.UUID(strings.ToLower(strings.TrimSpace(srcGUID))))
if err != nil || d == nil || d.Request == nil {
if d2, e2 := svc.Repo().GetByGUID(ctx, m2m.UUID(strings.ToUpper(strings.TrimSpace(srcGUID)))); e2 == nil && d2 != nil && d2.Request != nil {
d = d2
} else {
return "", fmt.Errorf("исходящая сделка %s не найдена", shortGUID(srcGUID))
}
}
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, fsql.Ev11, sql.Named("s", serN), sql.Named("n", numN)).Scan(&name); err != nil {
return "", fmt.Errorf("клиент с паспортом %s %s и открытым торговым счётом в DEPO не найден", ser, num)
}
req := d.Request
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
newRef := map[string]string{}
for k, s := range req.Data.TransferredSecurities.Securities {
newRef[string(s.ReferenceID)] = fmt.Sprintf("M2MS%s%02d", guid[:8], k+1)
}
var lines []depoLine
for _, s := range req.Data.TransferredSecurities.Securities {
lines = append(lines, depoLine{Ref: newRef[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)
if !depoUpsertOrder(rc, guid, "OUT", cp, name, strings.TrimSpace(ser+" "+num), ser, num, lines, "ТЕСТ по образцу "+shortGUID(string(d.GUID))+": исходящий, получатель "+cp+" "+handbookNameByCode(cp)) {
return "", fmt.Errorf("не удалось записать шапку в FTV_M2M")
}
now := time.Now()
// решение контрагента по строкам: как в оригинале; без решения — считаем подтверждённым
rejected := map[string]bool{}
if d.Decision != nil {
for _, sec := range d.Decision.Data.Securities {
if sec.TransferDecision.Rejection != nil {
rejected[string(sec.ReferenceID)] = true
}
}
}
for _, s := range req.Data.TransferredSecurities.Securities {
ref := newRef[string(s.ReferenceID)]
orig := string(s.ReferenceID)
depoEventAt(rc, now, guid, ref, "BJ_SENT", "", "")
if rejected[orig] {
var codes []string
for _, sec := range d.Decision.Data.Securities {
if string(sec.ReferenceID) == orig && sec.TransferDecision.Rejection != nil {
codes = sec.TransferDecision.Rejection.Codes
}
}
depoEventAt(rc, now.Add(time.Minute), guid, ref, "BJ_CP_REJECT", strings.Join(codes, ";"), rejectCodesLabel(codes))
continue
}
depoEventAt(rc, now.Add(time.Minute), guid, ref, "BJ_CP_CONFIRM", "", "")
// форма 16 по бумаге и её MS010 — имитация
depoEventAt(rc, now.Add(2*time.Minute), guid, ref, "BJ_ORDER_SENT", "", "")
depoEventAt(rc, now.Add(3*time.Minute), guid, ref, "BJ_ORDER_ACCEPTED", "", "")
depoEventAt(rc, now.Add(3*time.Minute), guid, ref, "BJ_EXECUTED", "", "")
}
return guid, nil
}
// depoSimRefs — новые REFERENCE для копии входящего (уникальны в DEPO: M2MS + начало GUID + №).
func depoSimRefs(guid string, secs []IncomingSecurity) map[string]string {
m := map[string]string{}
for k, s := range secs {
m[s.ReferenceID] = fmt.Sprintf("M2MS%s%02d", guid[:8], k+1)
}
return m
}