Files
Bridge-and-Join-s/internal/lkgateway/depoevents.go
T
fontvielle 77b0ab1117 DEPO: справочник участников снова записывается на бою; GUID событий — NULL
На 9 (FTV_DE_DATA) FTV_M2M_EVENTS.GUID NOT NULL (создано bj раньше), событие
HANDBOOK без GUID падало и откатывало всю транзакцию — справочник в DEPO не
обновлялся (с 11:50 при каждом справочнике). Теперь справочник пишется в
своей транзакции, событие — отдельно (не записалось — предупреждение).
Структура: GUID в FTV_M2M_EVENTS — NULL (как на тесте с 12.10); для
существующих таблиц — ALTER COLUMN в наборе скриптов с правами
администратора (плашка обновления, скрипт .sql). Проверено на тесте
(BJ2_DEPO_DATA, транзакция с откатом).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q7eREaYwUt1i4fNLZcGnjn
2026-10-22 13:30:42 +03:00

1575 lines
70 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"
"errors"
"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)
}
}
}
}
}
// HandbookResult — результат обработки справочника участников джобой Fancy bj2depo
// (STATE/COMMENT последнего события HANDBOOK в FTV_M2M_EVENTS). bj только читает.
type HandbookResult struct {
Found bool
At time.Time
AtText string
State string
Comment string
Severity string // ok | warn | err
Err string
}
// handbookSeverity — важность результата обработки справочника по STATE/COMMENT.
func handbookSeverity(state, comment string) string {
s := strings.ToUpper(state)
u := strings.ToUpper(comment)
switch {
case strings.Contains(s, "ERROR") || strings.Contains(u, "ОШИБКА") || strings.Contains(u, "КРИТИЧ"):
return "err"
case strings.Contains(s, "WARN") || strings.Contains(u, "ПРЕДУПРЕЖД"):
return "warn"
default:
return "ok"
}
}
// depoHandbookResult читает результат последней обработки справочника участников: событие
// HANDBOOK, в COMMENT/STATE которого джоба Fancy bj2depo пишет итог сверки своего справочника
// юрлиц с данными НРД (расхождения, предупреждения). Только чтение, короткий таймаут.
func depoHandbookResult(rc *RuntimeConfig) HandbookResult {
var res HandbookResult
if rc == nil {
res.Err = "настройки недоступны"
return res
}
fs := rc.Snapshot().Fancy
if strings.TrimSpace(fs.Server) == "" {
res.Err = "подключение к DEPO (Обмен Fancy) не настроено"
return res
}
db, err := fancyOpen(fs, fs.DepoDB())
if err != nil {
res.Err = "DEPO недоступна: " + err.Error()
return res
}
defer db.Close()
ctx, cancel := context.WithTimeout(context.Background(), 4*time.Second)
defer cancel()
if !depoTableExists(ctx, db, "FTV_M2M_EVENTS") {
res.Err = "таблицы FTV_M2M_EVENTS нет"
return res
}
var at time.Time
var state, comment string
err = db.QueryRowContext(ctx, fsql.QEv11).Scan(&at, &state, &comment)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return res // событий HANDBOOK ещё нет
}
res.Err = err.Error()
return res
}
res.Found = true
res.At = at
res.AtText = at.Format("02.01.2006 15:04")
res.State = strings.TrimSpace(state)
res.Comment = strings.TrimSpace(comment)
res.Severity = handbookSeverity(res.State, res.Comment)
return res
}
// ---- справочник участников → 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++
}
if err := tx.Commit(); err != nil {
return err
}
depoHandbookWritten = time.Now()
// Сигнал Fancy, что справочник обновился: событие HANDBOOK в FTV_M2M_EVENTS — по нему джоба bj2depo
// переносит справочник в юрлица Fancy (FTV_M2M_UPDATE_HANDBOOK). Отдельно от записи справочника: не
// записалось (в старой таблице GUID NOT NULL) — справочник всё равно в DEPO, а событие ждёт скриптов.
if _, err := db.ExecContext(ctx, fsql.QEv10); err != nil {
log.Printf("depo-events: справочник записан в FTV_M2M_HANDBOOK (%d строк), но событие HANDBOOK — нет: %v — примените скрипты DEPO (плашка «Обновление bj требует применить скрипты»)", n, err)
return nil
}
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 != "<nil>" && !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 ""
}