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, accSQL(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 { return depoWriteHandbookCtx(context.Background(), rc) } // depoWriteHandbookCtx — то же с контекстом вызывающего: после подготовки DEPO (действие человека) вход идёт и во // время паузы после прежнего неудачного входа — иначе справочник ждал бы своего расписания («таблица не создана»). func depoWriteHandbookCtx(pctx context.Context, 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(pctx, 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++ } // Сигнал Fancy, что справочник обновился: событие HANDBOOK в FTV_M2M_EVENTS (в той же // транзакции, что и перезапись таблицы) — по нему джоба bj2depo может перечитать справочник. if _, err := tx.ExecContext(ctx, fsql.QEv10); err != nil { tx.Rollback() return err } if err := tx.Commit(); err != nil { return err } depoHandbookWritten = time.Now() log.Printf("depo-events: справочник участников записан в FTV_M2M_HANDBOOK: %d строк + событие HANDBOOK", 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): } } } // ---- выбор способа обработки при настройке: проверки и создание джобы ---- // depoJobNameLegacy — имя задания у установок до 06.10.2026 (одно на сервер). const depoJobNameLegacy = "bj2depo" // depoJobNameRe — допустимое имя задания SQL Server Agent (подставляется в скрипты). var depoJobNameRe = regexp.MustCompile(`^[A-Za-z0-9_.\-]{1,100}$`) // depoJobName — имя задания SQL Server Agent этой установки. С 06.10.2026 задание называется по базе // (depoJobNameFor): на одном сервере бывает несколько баз DEPO (боевая и тестовая), и у каждой своё // задание. Пусто в настройках — установка старше, у неё задание bj2depo. func depoJobName(f FancyMSSQLSettings) string { if n := strings.TrimSpace(f.DepoJobName); n != "" { return n } return depoJobNameLegacy } // depoJobNameFor — имя задания по умолчанию для базы db. func depoJobNameFor(db string) string { return "bj2depo_" + db } // 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), 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 EXISTS (SELECT 1 FROM sys.database_principals WHERE name = N'%[5]s' AND type = 'S' AND sid <> SUSER_SID(N'%[5]s')) ALTER USER [%[5]s] WITH LOGIN = [%[5]s]; 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), 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() // Таблицы msdb (sysjobs, sysjobhistory…) роли SQLAgentReaderRole закрыты — только представление // sysjobs_view и процедуры sp_help_*: ими и читаем, чтобы проверка работала логином bj. var ( jobEnabled, schedEnabled, subType, subInt sql.NullInt64 runDate, runTime, runStatus sql.NullInt64 runMsg sql.NullString now time.Time ) err = mdb.QueryRowContext(ctx, "SELECT enabled, GETDATE() FROM msdb.dbo.sysjobs_view WHERE name = @n", sql.Named("n", depoJobName(f))).Scan(&jobEnabled, &now) if err == sql.ErrNoRows { return "джобы " + depoJobName(f) + " на сервере нет — создайте её (вариант «создаёт bj» с логином администратора или скрипт для администратора)", false } if err != nil { return "список джоб недоступен логину " + conn.User + " (" + quikErrorDetail(err) + "). Чтобы bj мог проверять джобу, администратору SQL — один раз: " + strings.ReplaceAll(depoJobReaderGrant(f), "\n", " "), false } // задание с этим именем — той ли базы (на одном сервере у боевой и тестовой баз DEPO свои задания) if rows, err := mssqlRowMaps(ctx, mdb, "EXEC msdb.dbo.sp_help_jobstep @job_name = @n", sql.Named("n", depoJobName(f))); err == nil { for _, r := range rows { if dbn := strings.TrimSpace(fmt.Sprint(r["database_name"])); dbn != "" && dbn != "" && !strings.EqualFold(dbn, f.DepoDB()) { return fmt.Sprintf("задание %s обрабатывает базу %s, а bj работает с базой %s — это задание другой базы. Задайте для этой базы своё имя задания (мастер, «Учёт DEPO», пункт 3) и подготовьте базу заново", depoJobName(f), dbn, f.DepoDB()), false } } } if rows, err := mssqlRowMaps(ctx, mdb, "EXEC msdb.dbo.sp_help_jobschedule @job_name = @n", sql.Named("n", depoJobName(f))); err == nil { for _, r := range rows { if mapInt(r, "enabled") == 1 || !schedEnabled.Valid { schedEnabled = sql.NullInt64{Int64: mapInt(r, "enabled"), Valid: true} subType = sql.NullInt64{Int64: mapInt(r, "freq_subday_type"), Valid: true} subInt = sql.NullInt64{Int64: mapInt(r, "freq_subday_interval"), Valid: true} } } } if rows, err := mssqlRowMaps(ctx, mdb, "EXEC msdb.dbo.sp_help_job @job_name = @n, @job_aspect = N'JOB'", sql.Named("n", depoJobName(f))); err == nil && len(rows) > 0 { runDate = sql.NullInt64{Int64: mapInt(rows[0], "last_run_date"), Valid: true} runTime = sql.NullInt64{Int64: mapInt(rows[0], "last_run_time"), Valid: true} runStatus = sql.NullInt64{Int64: mapInt(rows[0], "last_run_outcome"), Valid: true} } if runStatus.Valid && runStatus.Int64 == 0 && runDate.Int64 != 0 { // причина — в сообщении последнего выполненного шага (история идёт от новых к старым) if rows, err := mssqlRowMaps(ctx, mdb, "EXEC msdb.dbo.sp_help_jobhistory @job_name = @n, @mode = N'FULL'", sql.Named("n", depoJobName(f))); err == nil { for _, r := range rows { if mapInt(r, "step_id") > 0 { runMsg = sql.NullString{String: fmt.Sprint(r["message"]), Valid: true} break } } } } if jobEnabled.Int64 != 1 { return "джоба " + depoJobName(f) + " есть, но ВЫКЛЮЧЕНА — включите её в SQL Server Agent", false } if !schedEnabled.Valid || schedEnabled.Int64 != 1 { return "у джобы " + depoJobName(f) + " нет включённого расписания — она не будет запускаться", 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(f), 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 EXISTS (SELECT 1 FROM sys.database_principals WHERE name = N'%[1]s' AND type = 'S' AND sid <> SUSER_SID(N'%[1]s')) ALTER USER [%[1]s] WITH LOGIN = [%[1]s];\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 msg := depoObjectsMissing(ctx, db, f); msg != "" { return msg, 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_view WHERE name=@n", sql.Named("n", depoJobName(f))).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(f)) } 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(f)) 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(f), 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, accSQL(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, accSQL(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 } // mssqlRowMaps — строки результата (в т.ч. процедуры sp_help_*) как «колонка в нижнем регистре → значение». func mssqlRowMaps(ctx context.Context, db *sql.DB, q string, args ...any) ([]map[string]any, error) { rows, err := db.QueryContext(ctx, q, args...) if err != nil { return nil, err } defer rows.Close() cols, err := rows.Columns() if err != nil { return nil, err } var out []map[string]any for rows.Next() { v := make([]any, len(cols)) p := make([]any, len(cols)) for i := range v { p[i] = &v[i] } if err := rows.Scan(p...); err != nil { return nil, err } m := make(map[string]any, len(cols)) for i, c := range cols { m[strings.ToLower(c)] = v[i] } out = append(out, m) } return out, rows.Err() } func mapInt(m map[string]any, k string) int64 { switch v := m[k].(type) { case int64: return v case int32: return int64(v) case int: return int64(v) case int16: return int64(v) case uint8: return int64(v) case bool: if v { return 1 } case []byte: n, _ := strconv.ParseInt(string(v), 10, 64) return n case string: n, _ := strconv.ParseInt(v, 10, 64) return n } return 0 } // Объекты в базе DEPO, без которых обработка событий не работает: таблицы Fancy с поддержкой M2M и // процедуры расширения bj2depo (deploy/depo/fancy-m2m). Проверяются по имени сразу — без 40-секундного // ожидания BJ_PING и без общего «не обработано». var ( depoFancyM2MTables = []string{"D_CLISER_M2M", "D_CLISER_M2M_DOL", "OD_M2M_ACCS", "OD_ONLINE_M2M_DEPO"} depoBj2depoProcs = []string{"FTV_M2M_JOB", "FTV_M2M_PROCESS_CREATE", "FTV_M2M_PROCESS_EVENTS", "FTV_M2M_PROCESS_OPERATION", "FTV_M2M_PROCESS_WIRING"} ) func depoObjectsMissing(ctx context.Context, db *sql.DB, f FancyMSSQLSettings) string { if msg := depoTablesMissing(ctx, db, f); msg != "" { return msg } return depoProcsMissing(ctx, db, f) } // depoMissing — какие объекты из списка (тип U — таблица, P — процедура) отсутствуют в базе DEPO. func depoMissing(ctx context.Context, db *sql.DB, names []string, typ string) []string { var out []string for _, n := range names { var ok int if err := db.QueryRowContext(ctx, "SELECT CASE WHEN OBJECT_ID(@n, @t) IS NULL THEN 0 ELSE 1 END", sql.Named("n", "dbo."+n), sql.Named("t", typ)).Scan(&ok); err == nil && ok == 0 { out = append(out, n) } } return out } // depoTablesMissing — нет таблиц Fancy DEPO с поддержкой M2M: без них обмен невозможен. func depoTablesMissing(ctx context.Context, db *sql.DB, f FancyMSSQLSettings) string { if f.IsPostgres() { return "" } if m := depoMissing(ctx, db, depoFancyM2MTables, "U"); len(m) > 0 { return "в базе " + f.DepoDB() + " нет таблиц Fancy DEPO с поддержкой M2M (" + strings.Join(m, ", ") + "): нужна версия Fancy DEPO с M2M — обратитесь к разработчику Fancy" } return "" } // depoProcsMissingText — начало сообщения «нет процедур bj2depo» (по нему отличается от других отказов обработки). const depoProcsMissingText = "нет процедур обработки bj2depo" // depoProcsMissing — нет процедур обработки bj2depo. С 06.10.2026 это не блокирует запуск: формат обмена с учётной // системой ещё готовится, процедуры могут поставляться отдельно (в том числе сообщениями в саму учётную систему) — // проверка готовности напоминает «не забудьте доустановить». func depoProcsMissing(ctx context.Context, db *sql.DB, f FancyMSSQLSettings) string { if f.IsPostgres() { return "" } procs := depoBj2depoProcs if p := strings.TrimSpace(f.DepoJobProc); p != "" && !strings.EqualFold(strings.TrimPrefix(p, "dbo."), "FTV_M2M_JOB") { procs = []string{strings.TrimPrefix(p, "dbo.")} // своя процедура обработки — проверяем только её } if m := depoMissing(ctx, db, procs, "P"); len(m) > 0 { return "в базе " + f.DepoDB() + " " + depoProcsMissingText + ": " + strings.Join(m, ", ") + ". Их нужно установить в базу DEPO (порядок: WIRING → OPERATION → EVENTS → CREATE → JOB), затем — «Перепроверить всё»" } return "" }