Files
zuevav 059206348f DEPO: события по ответу программиста bj2depo — без COMMENT/STATE, параметры в PARAMETERS, время события из bj
COMMENT и STATE в трансферных таблицах заполняет только bj2depo: bj их больше не пишет (шапка,
события, BJ_PING). Параметры событий — в PARAMETERS (CONTR_D_ID поручения НРД, номер поручения МНП,
текст причины отказа), EVENT_DATE — фактическое время события в bj. Событий от bj2depo нет: bj не
читает и не помечает DEPO_*, номер документа берёт из FTV_M2M.DOC. Страница «bj ↔ bj2depo» обновлена:
поля, правила, FTV_M2M_PROCESS_WIRING по BJ_ORDER_ACCEPTED/BJ_EXECUTED, примеры SQL, закрытые и
оставшиеся вопросы.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 12:54:50 +03:00

1015 lines
42 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package lkgateway
// Обмен с DEPO (Fancy) через трансферные таблицы в нашей копии базы
// (BJ_DEPO_DATA): FTV_M2M / FTV_M2M_LINES / FTV_M2M_EVENTS / FTV_M2M_HANDBOOK.
// Согласовано с программистом Fancy (страница /admin/help/fancy-events), уточнено 17.09.2026:
// - bj — мастер: пишет поручения и события BJ_*; джоба bj2depo создаёт сущности DEPO, событий от
// себя не пишет — результат bj читает в FTV_M2M.DOC и в STATE/COMMENT своих событий;
// - COMMENT и STATE заполняет только bj2depo (STATE по умолчанию TODO); параметры события bj пишет
// в PARAMETERS; EVENT_DATE — фактическое время события в bj;
// - справочник участников — TRUNCATE + полная перезапись при каждом получении.
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"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, "SELECT COUNT(*) FROM dbo.FTV_M2M_EVENTS WHERE GUID=@g AND ISNULL(REFERENCE,'')=@r AND EVENT_TYPE=@t",
sql.Named("g", guid), sql.Named("r", ref), sql.Named("t", typ)).Scan(&n)
if n > 0 {
return
}
q := `INSERT INTO dbo.FTV_M2M_EVENTS (GUID, REFERENCE, EVENT_TYPE, EVENT_DATE, M2M_REJECT_CODE) VALUES (@g, @r, @t, @at, @c)`
if depoHasColumn(ctx, db, "FTV_M2M_EVENTS", "PARAMETERS") {
q = `INSERT INTO dbo.FTV_M2M_EVENTS (GUID, REFERENCE, EVENT_TYPE, EVENT_DATE, M2M_REJECT_CODE, PARAMETERS) VALUES (@g, @r, @t, @at, @c, @p)`
}
_, 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, "SELECT COUNT(*) FROM dbo.FTV_M2M WHERE GUID=@g", sql.Named("g", guid)).Scan(&n)
if n > 0 {
return true
}
// идентификаторы DEPO: лицо (SELF_ID) по паспорту, бумага (OD_VALUES.ID) по ISIN
var deponentID sql.NullInt64
serN := strings.ReplaceAll(strings.TrimSpace(ser), " ", "")
if serN != "" && strings.TrimSpace(num) != "" {
_ = db.QueryRowContext(ctx, "SELECT TOP 1 SELF_ID FROM OD_FACES WHERE REPLACE(DOC_SER,' ','')=@s AND DOC_NUM=@n ORDER BY ID", sql.Named("s", serN), sql.Named("n", strings.TrimSpace(num))).Scan(&deponentID)
}
// счёт клиента для DEPO_NUMBER строк: открытый торговый ВТНКЦ…, при нескольких договорах — самый
// ранний (то же правило, что при зачислении в QUIK); джоба по номеру находит OD_CLIENTS.ID (DEPO).
var clientAcc sql.NullString
if serN != "" && strings.TrimSpace(num) != "" {
_ = db.QueryRowContext(ctx, `SELECT TOP 1 c.NUMBER FROM OD_FACES f
JOIN OD_CLIENTS c ON c.FACE=f.ID LEFT JOIN OD_CLIENTS_PORTS p ON p.CLIENT=c.ID
WHERE REPLACE(f.DOC_SER,' ','')=@s AND f.DOC_NUM=@n AND c.NUMBER LIKE N'ВТНКЦ%' AND c.STATE=0 AND c.D_CLOSE IS NULL
ORDER BY ISNULL(p.PORT_DATE,'2099-01-01'), ISNULL(c.D_OPEN,'2099-01-01'), c.NUMBER`,
sql.Named("s", serN), sql.Named("n", strings.TrimSpace(num))).Scan(&clientAcc)
}
hasDepoNumber := depoHasColumn(ctx, db, "FTV_M2M_LINES", "DEPO_NUMBER")
hasDeponentID := depoHasColumn(ctx, db, "FTV_M2M", "DEPONENT_ID")
tx, err := db.BeginTx(ctx, nil)
if err != nil {
log.Printf("depo-events: FTV_M2M %s: %v", shortGUID(guid), err)
return false
}
// COMMENT не пишем — его заполняет bj2depo (17.09.2026); comment остаётся только для журнала bj.
head := `INSERT INTO dbo.FTV_M2M (GUID, DIRECTION, CONTRAGENT_M2M_CODE, DEPONENT_NAME, DEPONENT_DOC) VALUES (@g, @d, @cp, @nm, @doc)`
if hasDeponentID {
head = `INSERT INTO dbo.FTV_M2M (GUID, DIRECTION, CONTRAGENT_M2M_CODE, DEPONENT_NAME, DEPONENT_DOC, DEPONENT_ID) VALUES (@g, @d, @cp, @nm, @doc, @did)`
}
if _, err := tx.ExecContext(ctx, head,
sql.Named("g", guid), sql.Named("d", direction), sql.Named("cp", nullStr(cpCode)), sql.Named("nm", nullStr(deponentName)), sql.Named("doc", nullStr(deponentDoc)),
sql.Named("did", deponentID), sql.Named("cm", nullStr(comment))); err != nil {
tx.Rollback()
log.Printf("depo-events: FTV_M2M %s: %v", shortGUID(guid), err)
return false
}
// SHARE (ID бумаги в DEPO) — заполняем, если колонка есть (джоба может подставить сама)
hasShare := depoHasColumn(ctx, db, "FTV_M2M_LINES", "SHARE")
for _, ln := range lines {
var share sql.NullInt64
if ln.ISIN != "" && hasShare {
_ = db.QueryRowContext(ctx, "SELECT TOP 1 ID FROM OD_VALUES WHERE ISIN=@i ORDER BY ID", sql.Named("i", ln.ISIN)).Scan(&share)
}
cols, vals := "GUID, REFERENCE, ISIN, SEC_CODE, AMOUNT, PLACE_NUMBER, LINK_NUMBER", "@g, @r, @i, @c, @a, @p, ''"
if hasShare {
cols, vals = cols+", SHARE", vals+", @sh"
}
if hasDepoNumber {
cols, vals = cols+", DEPO_NUMBER", vals+", @acc"
}
q := `INSERT INTO dbo.FTV_M2M_LINES (` + cols + `) VALUES (` + vals + `)`
if _, err := tx.ExecContext(ctx, q,
sql.Named("g", guid), sql.Named("r", ln.Ref), sql.Named("i", nullStr(ln.ISIN)), sql.Named("c", nullStr(ln.Code)), sql.Named("sh", share), sql.Named("a", ln.Amount), sql.Named("p", nullStr(ln.Place)), sql.Named("acc", clientAcc)); err != nil {
tx.Rollback()
log.Printf("depo-events: FTV_M2M_LINES %s/%s: %v", shortGUID(guid), ln.Ref, err)
return false
}
}
if err := tx.Commit(); err != nil {
log.Printf("depo-events: commit %s: %v", shortGUID(guid), err)
return false
}
log.Printf("depo-events: FTV_M2M %s %s создано (%d строк, счёт клиента %s)", direction, shortGUID(guid), len(lines), pickStr(clientAcc.String, "не найден"))
return true
}
// handbookCodeByINN — MC-код депозитария по ИНН из сохранённого справочника.
func handbookCodeByINN(inn string) string {
raw, err := os.ReadFile(handbookFile)
if err != nil {
return ""
}
var hb struct {
Participants []HandbookEntry `json:"participants"`
}
if json.Unmarshal(raw, &hb) != nil {
return ""
}
best := ""
for _, p := range hb.Participants {
if p.INN == strings.TrimSpace(inn) && p.Code != "" {
if p.Kind == "depo" {
return p.Code
}
if best == "" {
best = p.Code
}
}
}
return best
}
func parseQty(s string) float64 {
f, _ := strconv.ParseFloat(strings.ReplaceAll(strings.TrimSpace(s), ",", "."), 64)
return f
}
// ---- события по этапам ----
// входящий M2MTR получен → шапка IN + строки + BJ_RECEIVED
func depoOnIncomingReceived(rc *RuntimeConfig, it *IncomingTransfer) {
if rc == nil || it == nil {
return
}
place := rc.Snapshot().NSD.SectionID
var lines []depoLine
for _, s := range it.Securities {
lines = append(lines, depoLine{Ref: s.ReferenceID, ISIN: s.ISIN, Code: s.SecurityCode, Amount: parseQty(s.Quantity), Place: place})
}
name := strings.TrimSpace(strings.Join([]string{it.InvestorLast, it.InvestorFirst, it.InvestorMiddle}, " "))
doc := strings.TrimSpace(it.DocSeries + " " + it.DocNumber)
if depoUpsertOrder(rc, it.GUID, "IN", it.SenderCode, name, doc, it.DocSeries, it.DocNumber, lines, "входящий перевод M2M от "+it.SenderCode) {
depoEvent(rc, it.GUID, "", "BJ_RECEIVED", "", "")
}
}
// наш M2MTR ушёл в НРД → шапка OUT + строки + BJ_SENT
func depoOnOutgoingSent(rc *RuntimeConfig, req *m2m.M2MTransferRequest) {
if rc == nil || req == nil {
return
}
inv := req.Data.InvestorInformation
ser := ""
if inv.IdentityDocument.DocumentSeries != nil {
ser = string(*inv.IdentityDocument.DocumentSeries)
}
num := string(inv.IdentityDocument.DocumentNumber)
place := rc.Snapshot().NSD.SectionID
var lines []depoLine
for _, s := range req.Data.TransferredSecurities.Securities {
lines = append(lines, depoLine{Ref: string(s.ReferenceID), ISIN: quikSecISIN(s), Code: string(s.SecurityCode), Amount: quikSecQty(s.Quantity), Place: place})
}
inn := string(req.Data.ReceivingDepository.INN)
cp := handbookCodeByINN(inn)
name := strings.TrimSpace(strings.Join([]string{inv.LastName, inv.FirstName, inv.MiddleName}, " "))
guid := string(req.Header.GUID)
if depoUpsertOrder(rc, guid, "OUT", cp, name, strings.TrimSpace(ser+" "+num), ser, num, lines, "исходящий перевод M2M, получатель ИНН "+inn) {
depoEvent(rc, guid, "", "BJ_SENT", "", "")
}
}
// наше решение по входящему (M2MTD)
func depoOnIncomingDecision(rc *RuntimeConfig, it *IncomingTransfer, decisions []SecurityDecision) {
if rc == nil || it == nil {
return
}
for _, d := range decisions {
if d.Confirm {
depoEvent(rc, it.GUID, d.ReferenceID, "BJ_DECISION_CONFIRM", "", "")
} else {
depoEvent(rc, it.GUID, d.ReferenceID, "BJ_DECISION_REJECT", d.RejectCode, m2mCodeLabel(d.RejectCode))
}
}
}
// ответ контрагента по нашему исходящему
func depoOnCounterpartyDecision(rc *RuntimeConfig, decision *m2m.M2MTransferDecision) {
if rc == nil || decision == nil {
return
}
guid := string(decision.Header.GUID)
for _, sec := range decision.Data.Securities {
ref := string(sec.ReferenceID)
if sec.TransferDecision.Rejection != nil {
codes := strings.Join(sec.TransferDecision.Rejection.Codes, ";")
depoEvent(rc, guid, ref, "BJ_CP_REJECT", codes, rejectCodesLabel(sec.TransferDecision.Rejection.Codes))
} else if sec.TransferDecision.Confirmation != nil {
depoEvent(rc, guid, ref, "BJ_CP_CONFIRM", "", "")
}
}
}
func depoOnServiceError(rc *RuntimeConfig, guid, codes, reason string) {
depoEvent(rc, guid, "", "BJ_SERVICE_ERROR", strings.ReplaceAll(strings.TrimSpace(codes), " ", ";"), reason)
}
func depoOnOrderSent(rc *RuntimeConfig, guid string, contrDIDs []string, form string) {
depoEvent(rc, guid, "", "BJ_ORDER_SENT", "", strings.Join(contrDIDs, ";"))
}
// результат по депо-поручению: accepted | rejected | late | expired
func depoOnOrderResult(rc *RuntimeConfig, guid, kind, text string) {
switch kind {
case "accepted":
depoEvent(rc, guid, "", "BJ_ORDER_ACCEPTED", "", "")
depoEvent(rc, guid, "", "BJ_EXECUTED", "", "")
case "rejected":
depoEvent(rc, guid, "", "BJ_ORDER_REJECTED", "", text)
case "late":
depoEvent(rc, guid, "", "BJ_ORDER_LATE", "", "")
case "expired":
depoEvent(rc, guid, "", "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
}
depoEvent(rc, guid, "", 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, `SELECT TOP 200 CAST(m.GUID AS varchar(36)), ISNULL(m.STATE,''), ISNULL(`+docCol+`,''), ISNULL(m.COMMENT,''),
ISNULL((SELECT TOP 1 ISNULL(l.COMMENT,'') FROM dbo.FTV_M2M_LINES l WHERE l.GUID=m.GUID AND ISNULL(l.STATE,'')='ERROR'),''),
ISNULL((SELECT TOP 1 ISNULL(st.NAME,'') FROM OD_DOCS d LEFT JOIN OD_DOC_STATES st ON st.ID=d.STATE AND st.D_CAT=0 WHERE CAST(d.ID AS varchar(32))=`+docCol+`),''),
(SELECT TOP 1 d.PROC_DATE FROM OD_DOCS d WHERE CAST(d.ID AS varchar(32))=`+docCol+`)
FROM dbo.FTV_M2M m WHERE m.TimeNew > DATEADD(day,-45,GETDATE()) ORDER BY m.ID DESC`)
if err != nil {
log.Printf("depo-events: чтение FTV_M2M: %v", err)
return
}
defer rows.Close()
for rows.Next() {
var guid, state, doc, comment, lineErr, docState string
var procAt sql.NullTime
if rows.Scan(&guid, &state, &doc, &comment, &lineErr, &docState, &procAt) != nil {
continue
}
l := globalDepoLinks.Get(guid)
// состояние документа в DEPO (ручные действия депозитария: отмена, архив)
if docState != "" && (l == nil || l.DocState != docState) {
globalDepoLinks.update(guid, func(x *DepoLink) {
x.DocState = docState
if procAt.Valid {
x.DocStateAt = procAt.Time
}
})
if l != nil && l.DocState != "" && docState != l.DocState {
log.Printf("depo-events: документ DEPO %s по %s: состояние «%s»", doc, shortGUID(guid), docState)
if strings.Contains(strings.ToLower(docState), "отмен") {
raiseSystemAlert(rc, "DEPO: депозитарий отменил документ по переводу "+shortGUID(guid), "Документ №"+doc+": «"+docState+"». Проверьте перевод и позицию клиента в QUIK.")
}
}
}
if doc != "" && doc != "0" && (l == nil || l.DocNo != doc) {
globalDepoLinks.update(guid, func(x *DepoLink) { x.DocNo, x.DocAt, x.LastEvent = doc, time.Now(), "FTV_M2M.DOC" })
log.Printf("depo-events: DEPO создала поручение по %s: %s", shortGUID(guid), doc)
}
if state == "ERROR" || lineErr != "" {
msg := strings.TrimSpace(comment + " " + lineErr)
if l == nil || len(l.Errors) == 0 || !strings.HasSuffix(l.Errors[len(l.Errors)-1], msg) {
globalDepoLinks.update(guid, func(x *DepoLink) {
x.Errors = append(x.Errors, time.Now().Format("02.01 15:04")+" "+msg)
x.LastEvent = "FTV_M2M.STATE=ERROR"
})
raiseSystemAlert(rc, "DEPO: джоба не обработала перевод "+shortGUID(guid), msg)
}
}
}
}
// ---- справочник участников → FTV_M2M_HANDBOOK (полная перезапись) ----
var depoHandbookWritten time.Time
func depoWriteHandbook(rc *RuntimeConfig) error {
raw, err := os.ReadFile(handbookFile)
if err != nil {
return err
}
var hb struct {
At string `json:"at"`
Participants []HandbookEntry `json:"participants"`
Places []HandbookEntry `json:"settlement_places"`
}
if err := json.Unmarshal(raw, &hb); err != nil {
return err
}
db, err := depoEventsDB(rc)
if err != nil {
return err
}
defer db.Close()
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
if !depoTableExists(ctx, db, "FTV_M2M_HANDBOOK") {
if _, err := db.ExecContext(ctx, depoTableSpecByName("FTV_M2M_HANDBOOK").createSQL(false)); err != nil {
return fmt.Errorf("создание FTV_M2M_HANDBOOK: %w — создайте таблицы: Настройка → Обмен Fancy → «Трансферные таблицы DEPO»", err)
}
log.Printf("depo-events: таблица FTV_M2M_HANDBOOK создана в %s", rc.Snapshot().Fancy.DepoDB())
}
hbAt, _ := time.Parse(time.RFC3339, hb.At)
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return err
}
// полная перезапись (согласовано с программистом); DELETE — у логина bj нет права на TRUNCATE
if _, err := tx.ExecContext(ctx, "DELETE FROM dbo.FTV_M2M_HANDBOOK"); err != nil {
tx.Rollback()
return err
}
ins := func(e HandbookEntry, kind string) error {
short := e.Name
if short == "" {
short = e.DisplayName
}
_, err := tx.ExecContext(ctx, `INSERT INTO dbo.FTV_M2M_HANDBOOK (INN, PARTICIPANT_CODE, KIND, NAME_FULL, NAME_SHORT, NAME_DISPLAY, NAME_ENG, HANDBOOK_AT, UPDATED_AT, ACTIVE)
VALUES (@inn, @code, @kind, @full, @short, @disp, @eng, @at, GETDATE(), 1)`,
sql.Named("inn", e.INN), sql.Named("code", nullStr(e.Code)), sql.Named("kind", kind), sql.Named("full", nullStr(e.FullName)), sql.Named("short", nullStr(short)),
sql.Named("disp", nullStr(e.DisplayName)), sql.Named("eng", nullStr(e.EngName)), sql.Named("at", hbAt))
return err
}
n := 0
for _, p := range hb.Participants {
kind := "DEPO"
if p.Kind == "broker" {
kind = "BROKER"
}
if err := ins(p, kind); err != nil {
tx.Rollback()
return err
}
n++
}
for _, p := range hb.Places {
if err := ins(p, "PLACE"); err != nil {
tx.Rollback()
return err
}
n++
}
if err := tx.Commit(); err != nil {
return err
}
depoHandbookWritten = time.Now()
log.Printf("depo-events: справочник участников записан в FTV_M2M_HANDBOOK: %d строк", n)
return nil
}
// depoEventsLoop — чтение событий DEPO раз в минуту; справочник — при старте и раз в 6 часов
// (плюс сразу после получения нового от НРД — вызов из saveHandbook).
func (s *Server) depoEventsLoop(ctx context.Context) {
ticker := time.NewTicker(time.Minute)
defer ticker.Stop()
first := true
for {
if !first {
select {
case <-ctx.Done():
return
case <-ticker.C:
}
}
first = false
func() {
defer func() {
if r := recover(); r != nil {
log.Printf("depo-events: panic: %v", r)
}
}()
if time.Since(depoHandbookWritten) > 6*time.Hour {
if err := depoWriteHandbook(s.rc); err != nil {
log.Printf("depo-events: справочник: %v", err)
depoHandbookWritten = time.Now().Add(-5 * time.Hour) // повтор через час
}
}
depoPollEvents(ctx, s.rc)
}()
healthBeat("depo-events")
}
}
// ---- обработка трансферных таблиц силами bj (замена джобы SQL Agent bj2depo) ----
func depoJobProcName(f FancyMSSQLSettings) string {
if p := strings.TrimSpace(f.DepoJobProc); p != "" {
return p
}
return "dbo.FTV_M2M_JOB"
}
func depoJobEvery(f FancyMSSQLSettings) int {
if f.DepoJobEverySec >= 5 {
return f.DepoJobEverySec
}
return 10
}
var depoJobErrAlerted time.Time
// depoJobOnce — один вызов процедуры обработки. Итог сохраняется в настройках (для карточки).
func depoJobOnce(rc *RuntimeConfig) {
f := rc.Snapshot().Fancy
db, err := depoEventsDB(rc)
if err != nil {
return
}
defer db.Close()
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
proc := depoJobProcName(f)
if !regexp.MustCompile(`^[A-Za-z0-9_.\[\]]+$`).MatchString(proc) {
return
}
start := time.Now()
_, err = db.ExecContext(ctx, "exec "+proc)
res := fmt.Sprintf("%s: OK за %s", start.Format("02.01 15:04:05"), time.Since(start).Round(time.Millisecond))
if err != nil {
res = fmt.Sprintf("%s: ОШИБКА %s", start.Format("02.01 15:04:05"), quikErrorDetail(err))
if time.Since(depoJobErrAlerted) > time.Hour {
depoJobErrAlerted = time.Now()
hint := ""
if strings.Contains(strings.ToLower(err.Error()), "не удалось найти") || strings.Contains(strings.ToLower(err.Error()), "could not find") || strings.Contains(strings.ToLower(err.Error()), "разрешение") || strings.Contains(strings.ToLower(err.Error()), "permission") {
hint = " Скорее всего, у логина bj нет права на процедуру: GRANT EXECUTE ON " + proc + " TO " + f.User + ";"
}
raiseSystemAlert(rc, "DEPO: bj не может запустить обработку трансферных таблиц", err.Error()+hint)
log.Printf("depo-job: %v", err)
}
}
cur := rc.Snapshot().Fancy
if cur.DepoJobLast != res {
cur.DepoJobLast, cur.DepoJobLastAt = res, time.Now()
_ = rc.UpdateFancy(cur)
}
}
// depoJobLoop — вызывает процедуру обработки, пока включён режим Fancy.DepoJobRun.
func (s *Server) depoJobLoop(ctx context.Context) {
for {
f := s.rc.Snapshot().Fancy
wait := time.Duration(depoJobEvery(f)) * time.Second
if f.DepoJobRun && strings.TrimSpace(f.Server) != "" {
func() {
defer func() {
if r := recover(); r != nil {
log.Printf("depo-job: panic: %v", r)
}
}()
depoJobOnce(s.rc)
}()
} else {
wait = 15 * time.Second
}
healthBeat("depo-job")
select {
case <-ctx.Done():
return
case <-time.After(wait):
}
}
}
// ---- выбор способа обработки при настройке: проверки и создание джобы ----
const depoJobName = "bj2depo"
// depoJobAgentScript — скрипт для администратора SQL (ручной вариант).
func depoJobAgentScript(f FancyMSSQLSettings) string {
return fmt.Sprintf(`USE msdb;
EXEC dbo.sp_add_job @job_name = N'%[1]s', @enabled = 1, @description = N'bj2depo: обработка трансферных таблиц M2M (bj)';
EXEC dbo.sp_add_jobstep @job_name = N'%[1]s', @step_name = N'%[1]s', @subsystem = N'TSQL', @database_name = N'%[2]s', @command = N'exec %[3]s;';
EXEC dbo.sp_add_jobschedule @job_name = N'%[1]s', @name = N'%[1]s', @enabled = 1, @freq_type = 4, @freq_interval = 1, @freq_subday_type = 2, @freq_subday_interval = %[4]d, @active_start_time = 0;
EXEC dbo.sp_add_jobserver @job_name = N'%[1]s';`, depoJobName, f.DepoDB(), depoJobProcName(f), depoJobEvery(f))
}
// depoJobGrantScript — права для варианта «внутри bj».
func depoJobGrantScript(f FancyMSSQLSettings) string {
return fmt.Sprintf("USE %s;\nGRANT EXECUTE ON %s TO %s;", f.DepoDB(), depoJobProcName(f), f.User)
}
// depoJobAgentRightsScript — права для варианта «bj создаёт джобу сам».
func depoJobAgentRightsScript(f FancyMSSQLSettings) string {
return fmt.Sprintf(`USE msdb;
CREATE USER %[1]s FOR LOGIN %[1]s;
ALTER ROLE SQLAgentOperatorRole ADD MEMBER %[1]s;
-- служебная учётная запись SQL Agent должна иметь право выполнять %[2]s в %[3]s`, f.User, depoJobProcName(f), f.DepoDB())
}
func depoJobPing(ctx context.Context, db *sql.DB, wait time.Duration) (bool, string) {
guid := strings.ToUpper(fmt.Sprintf("%08x-0000-4000-8000-%012x", time.Now().Unix()&0xFFFFFFFF, time.Now().UnixNano()&0xFFFFFFFFFFFF))
if _, err := db.ExecContext(ctx, "INSERT INTO dbo.FTV_M2M_EVENTS (GUID, REFERENCE, EVENT_TYPE, EVENT_DATE) VALUES (@g, NULL, 'BJ_PING', GETDATE())", sql.Named("g", guid)); err != nil {
return false, "не удалось записать проверочное событие: " + quikErrorDetail(err)
}
defer db.ExecContext(context.Background(), "DELETE FROM dbo.FTV_M2M_EVENTS WHERE GUID=@g", sql.Named("g", guid))
deadline := time.Now().Add(wait)
for time.Now().Before(deadline) {
var st string
if err := db.QueryRowContext(ctx, "SELECT ISNULL(STATE,'') FROM dbo.FTV_M2M_EVENTS WHERE GUID=@g", sql.Named("g", guid)).Scan(&st); err == nil && st != "TODO" && st != "" {
return true, fmt.Sprintf("проверочное событие BJ_PING обработано за %s (STATE=%s)", time.Since(deadline.Add(-wait)).Round(time.Second), st)
}
time.Sleep(3 * time.Second)
}
return false, fmt.Sprintf("проверочное событие BJ_PING не обработано за %s — обработчик не запущен (или процедура не знает BJ_PING)", wait)
}
// depoJobCheck — проверка готовности выбранного способа обработки.
func depoJobCheck(ctx context.Context, rc *RuntimeConfig) (string, bool) {
f := rc.Snapshot().Fancy
db, err := depoEventsDB(rc)
if err != nil {
return "подключение к DEPO: " + err.Error(), false
}
defer db.Close()
if !depoTableExists(ctx, db, "FTV_M2M_EVENTS") {
return "в базе " + f.DepoDB() + " нет таблиц FTV_M2M* — создайте их: Настройка → Обмен Fancy → «Трансферные таблицы DEPO»", false
}
if ok, msg := depoRunAsCheck(ctx, db, f); !ok {
return msg, false
}
if fails, err := depoJobFailures(ctx, db, 1, 5); err == nil && len(fails) > 0 {
return fmt.Sprintf("обработчик за последние сутки не смог обработать записей: %d (последняя: %s %s — %s). Устраните причину и верните их в очередь: Обмен с DEPO → «Вернуть в очередь»",
len(fails), fails[0].TableRu(), fails[0].Type, fails[0].Comment), false
}
switch f.DepoJobMode {
case "bj":
var n int
_ = db.QueryRowContext(ctx, "SELECT COUNT(*) FROM fn_my_permissions(@p,'OBJECT') WHERE permission_name='EXECUTE'", sql.Named("p", depoJobProcName(f))).Scan(&n)
if n == 0 {
return "у логина " + f.User + " нет права EXECUTE на " + depoJobProcName(f) + " — выполните на сервере: " + strings.ReplaceAll(depoJobGrantScript(f), "\n", " "), false
}
start := time.Now()
if _, err := db.ExecContext(ctx, "exec "+depoJobProcName(f)); err != nil {
return "вызов " + depoJobProcName(f) + ": " + quikErrorDetail(err), false
}
return fmt.Sprintf("право есть, процедура %s выполнена за %s; bj будет вызывать её каждые %d с", depoJobProcName(f), time.Since(start).Round(time.Millisecond), depoJobEvery(f)), true
case "sql-auto", "sql-manual":
// 1) джоба видна через msdb?
var seen string
if mdb, err := fancyOpen(f, "msdb"); err == nil {
var enabled int
if e := mdb.QueryRowContext(ctx, "SELECT enabled FROM msdb.dbo.sysjobs WHERE name=@n", sql.Named("n", depoJobName)).Scan(&enabled); e == nil {
if enabled == 1 {
seen = "джоба " + depoJobName + " есть и включена; "
} else {
seen = "джоба " + depoJobName + " есть, но ВЫКЛЮЧЕНА; "
}
}
mdb.Close()
}
// 2) реально ли обрабатывает: BJ_PING
ok, msg := depoJobPing(ctx, db, 40*time.Second)
return seen + msg, ok
}
return "способ обработки не выбран", false
}
// depoJobCreateAgent — создать джобу SQL Agent от имени логина bj (нужна роль SQLAgentOperatorRole).
func depoJobCreateAgent(ctx context.Context, rc *RuntimeConfig) error {
f := rc.Snapshot().Fancy
mdb, err := fancyOpen(f, "msdb")
if err != nil {
return fmt.Errorf("подключение к msdb: %w", err)
}
defer mdb.Close()
var n int
if err := mdb.QueryRowContext(ctx, "SELECT COUNT(*) FROM msdb.dbo.sysjobs WHERE name=@n", sql.Named("n", depoJobName)).Scan(&n); err != nil {
return fmt.Errorf("нет доступа к msdb (%s). Нужны права: %s", quikErrorDetail(err), strings.ReplaceAll(depoJobAgentRightsScript(f), "\n", " "))
}
if n > 0 {
return fmt.Errorf("джоба %s уже существует", depoJobName)
}
for _, stmt := range strings.Split(depoJobAgentScript(f), "\n") {
stmt = strings.TrimSpace(stmt)
if stmt == "" || strings.HasPrefix(stmt, "USE ") {
continue
}
if _, err := mdb.ExecContext(ctx, stmt); err != nil {
return fmt.Errorf("%s → %s. Нужны права: %s", stmt[:min(40, len(stmt))], quikErrorDetail(err), strings.ReplaceAll(depoJobAgentRightsScript(f), "\n", " "))
}
}
log.Printf("depo-job: джоба SQL Agent %s создана", depoJobName)
return nil
}
// DepoJobView — данные карточки «Где запускается обработка».
type DepoJobView struct {
Name, Proc, GrantScript, AgentRights, AgentScript string
Every int
}
func depoJobView(f FancyMSSQLSettings) DepoJobView {
return DepoJobView{Name: depoJobName, Proc: depoJobProcName(f), Every: depoJobEvery(f), GrantScript: depoJobGrantScript(f), AgentRights: depoJobAgentRightsScript(f), AgentScript: depoJobAgentScript(f)}
}
// ---- выгрузка истории тестов (TEST3, банк Зенит / робот) в трансферные таблицы ----
func orderRespAt(resps []orderResponse) time.Time {
for _, r := range resps {
if r.Done {
if t, err := time.ParseInLocation("02.01.2006 15:04:05", r.Time, time.Local); err == nil {
return t
}
}
}
return time.Time{}
}
// depoReplayHistory — переписывает в FTV_M2M* все сохранённые в bj переводы с их фактической
// историей (получен/отправлен, решения с кодами, поручения 16/16-1, MS010). Идемпотентно:
// шапки и события по ключам не дублируются. Возвращает отчёт для оператора.
func depoReplayHistory(ctx context.Context, rc *RuntimeConfig, svc *Service, onlyCP string) string {
var sb strings.Builder
cpOK := func(code string) bool {
if strings.TrimSpace(onlyCP) == "" {
return true
}
for _, c := range strings.Split(onlyCP, ",") {
if strings.EqualFold(strings.TrimSpace(c), strings.TrimSpace(code)) {
return true
}
}
return false
}
place := rc.Snapshot().NSD.SectionID
nIn, nOut, nEv := 0, 0, 0
ev := func(at time.Time, guid, ref, typ, codes, comment string) {
depoEventAt(rc, at, guid, ref, typ, codes, comment)
nEv++
}
// входящие (мы зачисляли)
if globalIncomingStore != nil {
for _, it := range globalIncomingStore.List() {
if !uuidRe.MatchString(strings.ToUpper(it.GUID)) || !cpOK(it.SenderCode) {
continue
}
var lines []depoLine
for _, s := range it.Securities {
lines = append(lines, depoLine{Ref: s.ReferenceID, ISIN: s.ISIN, Code: s.SecurityCode, Amount: parseQty(s.Quantity), Place: place})
}
name := strings.TrimSpace(strings.Join([]string{it.InvestorLast, it.InvestorFirst, it.InvestorMiddle}, " "))
cp := handbookNameByCode(it.SenderCode)
depoUpsertOrder(rc, it.GUID, "IN", it.SenderCode, name, strings.TrimSpace(it.DocSeries+" "+it.DocNumber), it.DocSeries, it.DocNumber, lines, "TEST3: входящий от "+it.SenderCode+" "+cp)
nIn++
ev(it.ReceivedAt, it.GUID, "", "BJ_RECEIVED", "", "")
if it.Status == "answered" {
for _, s := range it.Securities {
switch s.Decision {
case "confirmed":
ev(it.AnsweredAt, it.GUID, s.ReferenceID, "BJ_DECISION_CONFIRM", "", "")
case "rejected":
ev(it.AnsweredAt, it.GUID, s.ReferenceID, "BJ_DECISION_REJECT", s.RejectCode, m2mCodeLabel(s.RejectCode))
}
}
}
if it.OrderSubmitted {
ev(it.OrderAt, it.GUID, "", "BJ_ORDER_SENT", "", it.OrderContrDID)
resps := findOrderResponses(it.OrderContrDID)
if done, rejected, text := orderRespVerdict(resps); done {
at := orderRespAt(resps)
if rejected {
ev(at, it.GUID, "", "BJ_ORDER_REJECTED", "", text)
} else {
ev(at, it.GUID, "", "BJ_ORDER_ACCEPTED", "", "")
ev(at, it.GUID, "", "BJ_EXECUTED", "", "")
}
}
}
}
}
// исходящие (мы списывали)
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 {
ev(od.OrderAt, guid, "", "BJ_ORDER_SENT", "", strings.Join(od.ContrDIDs, ";"))
resps, _ := outgoingResponses(od.ContrDIDs)
if done, rejected, text := orderRespVerdict(resps); done {
at := orderRespAt(resps)
if rejected {
ev(at, guid, "", "BJ_ORDER_REJECTED", "", text)
} else {
ev(at, guid, "", "BJ_ORDER_ACCEPTED", "", "")
ev(at, guid, "", "BJ_EXECUTED", "", "")
}
}
}
}
}
}
fmt.Fprintf(&sb, "выгружено: входящих %d, исходящих %d, событий записано (с учётом уже имевшихся) %d", nIn, nOut, nEv)
return sb.String()
}
// ---- тестовый прогон входящего в DEPO с реальным клиентом ----
// depoSimulateIncoming — копия входящего перевода (бумаги и решения как в оригинале) на реального
// клиента DEPO по паспорту: новый GUID, шапка IN + строки + события полного сценария приёма.
// В НРД ничего не отправляется — только трансферные таблицы DEPO.
func depoSimulateIncoming(ctx context.Context, rc *RuntimeConfig, it *IncomingTransfer, ser, num string) (string, error) {
if it == nil {
return "", fmt.Errorf("входящий не найден")
}
db, err := depoEventsDB(rc)
if err != nil {
return "", err
}
defer db.Close()
serN := strings.ReplaceAll(strings.TrimSpace(ser), " ", "")
numN := strings.TrimSpace(num)
var name string
if err := db.QueryRowContext(ctx, `SELECT TOP 1 f.NAME FROM OD_FACES f JOIN OD_CLIENTS c ON c.FACE=f.ID
WHERE REPLACE(f.DOC_SER,' ','')=@s AND f.DOC_NUM=@n AND c.NUMBER LIKE N'ВТНКЦ%' AND c.STATE=0`, sql.Named("s", serN), sql.Named("n", numN)).Scan(&name); err != nil {
return "", fmt.Errorf("клиент с паспортом %s %s и открытым торговым счётом в DEPO не найден", ser, num)
}
guid := strings.ToUpper(fmt.Sprintf("%08x-%04x-4000-8000-%012x", time.Now().Unix()&0xFFFFFFFF, time.Now().UnixNano()&0xFFFF, time.Now().UnixNano()&0xFFFFFFFFFFFF))
place := rc.Snapshot().NSD.SectionID
var lines []depoLine
for _, s := range it.Securities {
lines = append(lines, depoLine{Ref: s.ReferenceID, ISIN: s.ISIN, Code: s.SecurityCode, Amount: parseQty(s.Quantity), Place: place})
}
cp := handbookNameByCode(it.SenderCode)
if !depoUpsertOrder(rc, guid, "IN", it.SenderCode, name, strings.TrimSpace(ser+" "+num), ser, num, lines, "ТЕСТ по образцу "+shortGUID(it.GUID)+": входящий от "+it.SenderCode+" "+cp+", реальный клиент") {
return "", fmt.Errorf("не удалось записать шапку в FTV_M2M")
}
now := time.Now()
depoEventAt(rc, now, guid, "", "BJ_RECEIVED", "", "")
anyConfirmed := false
for _, s := range it.Securities {
switch s.Decision {
case "confirmed":
anyConfirmed = true
depoEventAt(rc, now.Add(time.Minute), guid, s.ReferenceID, "BJ_DECISION_CONFIRM", "", "")
case "rejected":
depoEventAt(rc, now.Add(time.Minute), guid, s.ReferenceID, "BJ_DECISION_REJECT", s.RejectCode, m2mCodeLabel(s.RejectCode))
default:
anyConfirmed = true
depoEventAt(rc, now.Add(time.Minute), guid, s.ReferenceID, "BJ_DECISION_CONFIRM", "", "")
}
}
if anyConfirmed {
depoEventAt(rc, now.Add(2*time.Minute), guid, "", "BJ_ORDER_SENT", "", "")
depoEventAt(rc, now.Add(3*time.Minute), guid, "", "BJ_ORDER_ACCEPTED", "", "")
depoEventAt(rc, now.Add(3*time.Minute), guid, "", "BJ_EXECUTED", "", "")
}
return guid, nil
}