diff --git a/internal/lkgateway/depoevents.go b/internal/lkgateway/depoevents.go index 4119d67..e66d39d 100644 --- a/internal/lkgateway/depoevents.go +++ b/internal/lkgateway/depoevents.go @@ -2,11 +2,12 @@ package lkgateway // Обмен с DEPO (Fancy) через трансферные таблицы в нашей копии базы // (BJ_DEPO_DATA): FTV_M2M / FTV_M2M_LINES / FTV_M2M_EVENTS / FTV_M2M_HANDBOOK. -// Согласовано с программистом Fancy 09–10.09.2026 (страница /admin/help/fancy-events): -// - bj пишет поручения и события BJ_*; джоба bj2depo создаёт сущности DEPO и -// пишет в ответ только DEPO_ORDER_CREATED / DEPO_ERROR; -// - справочник участников — TRUNCATE + полная перезапись при каждом получении; -// - STATE: TODO при записи → SUCCESS/ERROR после обработки получателем. +// Согласовано с программистом 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" @@ -124,13 +125,15 @@ func depoTableExists(ctx context.Context, db *sql.DB, name string) bool { var depoTablesWarned time.Time -// depoEvent — одна строка FTV_M2M_EVENTS (идемпотентно по GUID+REFERENCE+EVENT_TYPE), дата = сейчас. -func depoEvent(rc *RuntimeConfig, guid, ref, typ, codes, comment string) { - depoEventAt(rc, time.Time{}, guid, ref, typ, codes, comment) +// 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 — то же с явной датой события (для выгрузки истории). -func depoEventAt(rc *RuntimeConfig, at time.Time, guid, ref, typ, codes, comment string) { +// 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 не отдаём @@ -149,8 +152,11 @@ func depoEventAt(rc *RuntimeConfig, at time.Time, guid, ref, typ, codes, comment } return } - if len([]rune(comment)) > 4000 { - comment = string([]rune(comment)[:4000]) + 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", @@ -158,17 +164,18 @@ func depoEventAt(rc *RuntimeConfig, at time.Time, guid, ref, typ, codes, comment if n > 0 { return } - // Параметр даты — типизированный NULL: при нетипизированном nil драйвер шлёт nvarchar(1), - // ISNULL берёт тип первого аргумента и GETDATE() обрезается до одного символа. - evDate := sql.NullTime{Time: at, Valid: !at.IsZero()} - _, err = db.ExecContext(ctx, `INSERT INTO dbo.FTV_M2M_EVENTS (GUID, REFERENCE, STATE, EVENT_TYPE, EVENT_DATE, M2M_REJECT_CODE, COMMENT) - VALUES (@g, @r, 'TODO', @t, COALESCE(@at, GETDATE()), @c, @cm)`, - sql.Named("g", guid), sql.Named("r", nullStr(ref)), sql.Named("t", typ), sql.Named("at", evDate), sql.Named("c", nullStr(codes)), sql.Named("cm", nullStr(comment))) + 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", typ, shortGUID(guid), ref, codes) + log.Printf("depo-events: %s %s %s %s %s", typ, shortGUID(guid), ref, codes, params) } type depoLine struct { @@ -223,9 +230,10 @@ func depoUpsertOrder(rc *RuntimeConfig, guid, direction, cpCode, deponentName, d log.Printf("depo-events: FTV_M2M %s: %v", shortGUID(guid), err) return false } - head := `INSERT INTO dbo.FTV_M2M (GUID, DIRECTION, CONTRAGENT_M2M_CODE, DEPONENT_NAME, DEPONENT_DOC, COMMENT) VALUES (@g, @d, @cp, @nm, @doc, @cm)` + // 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, COMMENT) VALUES (@g, @d, @cp, @nm, @doc, @did, @cm)` + 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)), @@ -310,7 +318,7 @@ func depoOnIncomingReceived(rc *RuntimeConfig, it *IncomingTransfer) { 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", "", "входящий перевод получен, ждёт решения") + depoEvent(rc, it.GUID, "", "BJ_RECEIVED", "", "") } } @@ -335,7 +343,7 @@ func depoOnOutgoingSent(rc *RuntimeConfig, req *m2m.M2MTransferRequest) { 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", "", "запрос на перевод отправлен в НРД") + depoEvent(rc, guid, "", "BJ_SENT", "", "") } } @@ -346,9 +354,9 @@ func depoOnIncomingDecision(rc *RuntimeConfig, it *IncomingTransfer, decisions [ } for _, d := range decisions { if d.Confirm { - depoEvent(rc, it.GUID, d.ReferenceID, "BJ_DECISION_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)) + depoEvent(rc, it.GUID, d.ReferenceID, "BJ_DECISION_REJECT", d.RejectCode, m2mCodeLabel(d.RejectCode)) } } } @@ -363,33 +371,33 @@ func depoOnCounterpartyDecision(rc *RuntimeConfig, decision *m2m.M2MTransferDeci 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)) + 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", "", "принимающий депозитарий подтвердил") + depoEvent(rc, guid, ref, "BJ_CP_CONFIRM", "", "") } } } func depoOnServiceError(rc *RuntimeConfig, guid, codes, reason string) { - depoEvent(rc, guid, "", "BJ_SERVICE_ERROR", strings.ReplaceAll(strings.TrimSpace(codes), " ", ";"), "отклонено сервисом MOEX МОСТ: "+reason) + 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", "", "подано депо-поручение "+form+" в НРД, CONTR_D_ID "+strings.Join(contrDIDs, ", ")) + 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", "", "НРД принял депо-поручение (отчёт MS010)") - depoEvent(rc, guid, "", "BJ_EXECUTED", "", "перевод исполнен (MS010)") + depoEvent(rc, guid, "", "BJ_ORDER_ACCEPTED", "", "") + depoEvent(rc, guid, "", "BJ_EXECUTED", "", "") case "rejected": - depoEvent(rc, guid, "", "BJ_ORDER_REJECTED", "", "НРД отверг депо-поручение: "+text) + depoEvent(rc, guid, "", "BJ_ORDER_REJECTED", "", text) case "late": - depoEvent(rc, guid, "", "BJ_ORDER_LATE", "", "отчёт MS010 не получен за 5 минут") + depoEvent(rc, guid, "", "BJ_ORDER_LATE", "", "") case "expired": - depoEvent(rc, guid, "", "BJ_CANCELLED", "", "истёк срок 30 дней без исполнения — перевод считается отменённым") + depoEvent(rc, guid, "", "BJ_CANCELLED", "", "") } } @@ -398,7 +406,7 @@ func depoOnQuik(rc *RuntimeConfig, guid, isin, evt, instrNo string) { if typ == "" { return } - depoEvent(rc, guid, "", typ, "", "QUIK МНП: поручение №"+instrNo+", "+isin) + depoEvent(rc, guid, "", typ, "", instrNo) } // ---- чтение событий DEPO ---- @@ -414,55 +422,10 @@ func depoPollEvents(ctx context.Context, rc *RuntimeConfig) { if !depoTableExists(cctx, db, "FTV_M2M_EVENTS") { return } - docCol := "NULL" - if depoHasColumn(cctx, db, "FTV_M2M", "DOC") { - docCol = "(SELECT TOP 1 CAST(m.DOC AS varchar(32)) FROM dbo.FTV_M2M m WHERE m.GUID=e.GUID)" - } else if depoHasColumn(cctx, db, "FTV_M2M", "FANSY_DOC") { - docCol = "(SELECT TOP 1 CAST(m.FANSY_DOC AS varchar(32)) FROM dbo.FTV_M2M m WHERE m.GUID=e.GUID)" - } + // Событий от bj2depo нет (17.09.2026: мастер — bj): номер документа — FTV_M2M.DOC, результат + // обработки — STATE/COMMENT наших событий и шапки. depoPollOrders(cctx, db, rc) depoAlertEventFailures(cctx, db, rc) - rows, err := db.QueryContext(cctx, `SELECT TOP 100 e.ID, CAST(e.GUID AS varchar(36)), ISNULL(e.REFERENCE,''), e.EVENT_TYPE, ISNULL(e.COMMENT,''), ISNULL(e.M2M_REJECT_CODE,''), - ISNULL(`+docCol+`,'') - FROM dbo.FTV_M2M_EVENTS e WHERE e.EVENT_TYPE LIKE 'DEPO[_]%' AND ISNULL(e.STATE,'TODO')='TODO' ORDER BY e.ID`) - if err != nil { - log.Printf("depo-events: чтение: %v", err) - return - } - type ev struct { - id int64 - guid, ref, typ, comment, codes, doc string - } - var list []ev - for rows.Next() { - var e ev - if rows.Scan(&e.id, &e.guid, &e.ref, &e.typ, &e.comment, &e.codes, &e.doc) == nil { - list = append(list, e) - } - } - rows.Close() - for _, e := range list { - state, note := "SUCCESS", "применено в bj" - switch e.typ { - case "DEPO_ORDER_CREATED": - doc := strings.TrimSpace(e.comment) - if e.doc != "" && e.doc != "0" { - doc = e.doc - } - globalDepoLinks.update(e.guid, func(l *DepoLink) { l.DocNo, l.DocAt, l.LastEvent = doc, time.Now(), e.typ }) - log.Printf("depo-events: DEPO создала поручение по %s: %s", shortGUID(e.guid), doc) - case "DEPO_ERROR": - globalDepoLinks.update(e.guid, func(l *DepoLink) { - l.Errors = append(l.Errors, time.Now().Format("02.01 15:04")+" "+e.comment) - l.LastEvent = e.typ - }) - raiseSystemAlert(rc, "DEPO: ошибка обработки перевода "+shortGUID(e.guid), e.comment) - default: - state, note = "ERROR", "тип события не поддерживается bj" - } - _, _ = db.ExecContext(cctx, "UPDATE dbo.FTV_M2M_EVENTS SET STATE=@s, TimeBeg=ISNULL(TimeBeg,GETDATE()), TimeEnd=GETDATE(), COMMENT=ISNULL(COMMENT,'')+@n WHERE ID=@id", - sql.Named("s", state), sql.Named("n", " | bj: "+note), sql.Named("id", e.id)) - } } // depoPollOrders — джоба bj2depo ставит номер поручения DEPO прямо в FTV_M2M.DOC (и DOL по строкам), @@ -745,7 +708,7 @@ ALTER ROLE SQLAgentOperatorRole ADD MEMBER %[1]s; func depoJobPing(ctx context.Context, db *sql.DB, wait time.Duration) (bool, string) { guid := strings.ToUpper(fmt.Sprintf("%08x-0000-4000-8000-%012x", time.Now().Unix()&0xFFFFFFFF, time.Now().UnixNano()&0xFFFFFFFFFFFF)) - if _, err := db.ExecContext(ctx, "INSERT INTO dbo.FTV_M2M_EVENTS (GUID, REFERENCE, STATE, EVENT_TYPE, EVENT_DATE, COMMENT) VALUES (@g, NULL, 'TODO', 'BJ_PING', GETDATE(), N'проверка обработчика из bj')", sql.Named("g", guid)); err != nil { + 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)) @@ -898,27 +861,27 @@ func depoReplayHistory(ctx context.Context, rc *RuntimeConfig, svc *Service, onl cp := handbookNameByCode(it.SenderCode) depoUpsertOrder(rc, it.GUID, "IN", it.SenderCode, name, strings.TrimSpace(it.DocSeries+" "+it.DocNumber), it.DocSeries, it.DocNumber, lines, "TEST3: входящий от "+it.SenderCode+" "+cp) nIn++ - ev(it.ReceivedAt, it.GUID, "", "BJ_RECEIVED", "", "входящий перевод получен от "+it.SenderCode+" "+cp) + 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", "", "приём подтверждён контрагенту") + 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)) + ev(it.AnsweredAt, it.GUID, s.ReferenceID, "BJ_DECISION_REJECT", s.RejectCode, m2mCodeLabel(s.RejectCode)) } } } if it.OrderSubmitted { - ev(it.OrderAt, it.GUID, "", "BJ_ORDER_SENT", "", "подано депо-поручение 16/1 (зачисление), CONTR_D_ID "+it.OrderContrDID) + 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) + ev(at, it.GUID, "", "BJ_ORDER_REJECTED", "", text) } else { - ev(at, it.GUID, "", "BJ_ORDER_ACCEPTED", "", "НРД принял депо-поручение (отчёт MS010)") - ev(at, it.GUID, "", "BJ_EXECUTED", "", "перевод исполнен (MS010)") + ev(at, it.GUID, "", "BJ_ORDER_ACCEPTED", "", "") + ev(at, it.GUID, "", "BJ_EXECUTED", "", "") } } } @@ -957,14 +920,14 @@ func depoReplayHistory(ctx context.Context, rc *RuntimeConfig, svc *Service, onl 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", "", "запрос на перевод отправлен в НРД") + 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)) + 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", "", "принимающий депозитарий подтвердил") + ev(d.UpdatedAt, guid, ref, "BJ_CP_CONFIRM", "", "") } } } @@ -973,19 +936,19 @@ func depoReplayHistory(ctx context.Context, rc *RuntimeConfig, svc *Service, onl for _, rr := range d.Response.Responses { codes = append(codes, string(rr.Code)) } - ev(d.UpdatedAt, guid, "", "BJ_SERVICE_ERROR", strings.Join(codes, ";"), "отклонено сервисом MOEX МОСТ: "+rejectCodesLabel(codes)) + 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", "", "подано депо-поручение 16 (списание), CONTR_D_ID "+strings.Join(od.ContrDIDs, ", ")) + 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) + ev(at, guid, "", "BJ_ORDER_REJECTED", "", text) } else { - ev(at, guid, "", "BJ_ORDER_ACCEPTED", "", "НРД принял депо-поручение (отчёт MS010)") - ev(at, guid, "", "BJ_EXECUTED", "", "перевод исполнен (MS010)") + ev(at, guid, "", "BJ_ORDER_ACCEPTED", "", "") + ev(at, guid, "", "BJ_EXECUTED", "", "") } } } @@ -1028,24 +991,24 @@ func depoSimulateIncoming(ctx context.Context, rc *RuntimeConfig, it *IncomingTr return "", fmt.Errorf("не удалось записать шапку в FTV_M2M") } now := time.Now() - depoEventAt(rc, now, guid, "", "BJ_RECEIVED", "", "входящий перевод получен от "+it.SenderCode+" "+cp+" (тест по образцу "+shortGUID(it.GUID)+")") + 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", "", "приём подтверждён контрагенту") + 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)) + 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", "", "приём подтверждён контрагенту (тест)") + depoEventAt(rc, now.Add(time.Minute), guid, s.ReferenceID, "BJ_DECISION_CONFIRM", "", "") } } if anyConfirmed { - depoEventAt(rc, now.Add(2*time.Minute), guid, "", "BJ_ORDER_SENT", "", "подано депо-поручение 16/1 (зачисление) в НРД (тест)") - depoEventAt(rc, now.Add(3*time.Minute), guid, "", "BJ_ORDER_ACCEPTED", "", "НРД принял депо-поручение (отчёт MS010) (тест)") - depoEventAt(rc, now.Add(3*time.Minute), guid, "", "BJ_EXECUTED", "", "перевод исполнен (MS010) (тест)") + 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 } diff --git a/internal/lkgateway/web/templates/admin_help_fancy_events.html b/internal/lkgateway/web/templates/admin_help_fancy_events.html index f712d3b..23cd703 100644 --- a/internal/lkgateway/web/templates/admin_help_fancy_events.html +++ b/internal/lkgateway/web/templates/admin_help_fancy_events.html @@ -45,7 +45,7 @@
SBER/03); bj находит OD_VALUES.ID по ISIN (IS_IN=1 = принята на обслуживание).ISIN, SEC_CODE и SHARE = OD_VALUES.ID.ВТНКЦ… (OD_CLIENTS, STATE=0). При нескольких договорах берётся самый ранний (правило депозитария).OD_CLIENTS.ID и NUMBER.HL171004001C + раздел 36MC04136…. В DEPO это запись OD_CLIENTS с NUMBER HL171004001C/253400M18U5TB02TW421/253400_X1A47/<раздел> (например ID 79 для раздела 36MC0413600000F00). OD_CLIENTS_REC.LINK_NUMBER у нас пустой везде.PLACE_NUMBER = раздел (или ID записи OD_CLIENTS, если удобнее).M2M_REJECT_CODE через ;, текст — в COMMENT.M2M_REJECT_CODE через ;, текст — в PARAMETERS.GUID перевода (из M2MTR) и REFERENCE строки (референс бумаги из M2MTR, M2M…).| Таблица | Поля от bj |
|---|---|
FTV_M2M | GUID, DIRECTION (IN — к нам зачисляют, OUT — мы списываем), CONTRAGENT_M2M_CODE, DEPONENT_NAME, DEPONENT_DOC, DEPONENT_ID (если нашли), COMMENT. Вставка вместе со строками в одной транзакции. |
FTV_M2M | GUID, DIRECTION (IN — к нам зачисляют, OUT — мы списываем), CONTRAGENT_M2M_CODE, DEPONENT_NAME, DEPONENT_DOC, DEPONENT_ID (если нашли). Вставка вместе со строками в одной транзакции. |
FTV_M2M_LINES | GUID, REFERENCE, ISIN, SEC_CODE, SHARE (если нашли), AMOUNT (штуки, дробные для паёв), PLACE_NUMBER, DEPO_NUMBER — номер торгового счёта клиента ВТНКЦ… (джоба находит по нему DEPO). |
FTV_M2M_EVENTS | GUID, REFERENCE (пусто = по поручению целиком), EVENT_TYPE, EVENT_DATE, M2M_REJECT_CODE, COMMENT, STATE (см. правила). |
FTV_M2M_EVENTS | GUID, REFERENCE (пусто = по поручению целиком), EVENT_TYPE, EVENT_DATE (фактическое время события в bj), M2M_REJECT_CODE, PARAMETERS (параметры события, см. таблицу событий). |
COMMENT и STATE во всех таблицах заполняет только bj2depo (17.09.2026): bj их не пишет, STATE при вставке получает значение по умолчанию TODO. Номер документа DEPO bj2depo ставит в FTV_M2M.DOC (строки — DOL).
Поручение создаётся в DEPO на самом раннем этапе: сразу после записи в FTV_M2M (для входящих — как только пришёл M2MTR; для исходящих — как только наш M2MTR ушёл в НРД).
GUID + REFERENCE + EVENT_TYPE; повтор не пишется.BJ_* пишет bj и читает bj2depo; DEPO_* пишет bj2depo и читает bj. Если удобнее, вместо префикса — колонка SOURCE.STATE — состояние обработки получателем: пишущая сторона ставит TODO; получатель после обработки — SUCCESS или ERROR, в COMMENT — что сделано или почему не вышло; TimeBeg/TimeEnd — время обработки.BJ_*); bj2depo событий не пишет, результат обработки отражает в STATE (SUCCESS / ERROR), COMMENT, TimeBeg/TimeEnd события и в FTV_M2M.DOC.STATE и COMMENT пишет только bj2depo; bj при вставке их не заполняет.PARAMETERS: номер депо-поручения НРД (CONTR_D_ID), номер поручения МНП, текст причины отказа.EVENT_DATE — фактическое время события в bj (МСК), не время вставки.ID. Событие по строке (REFERENCE задан) относится к одной бумаге; без REFERENCE — ко всему поручению.M2M_REJECT_CODE через ;, как их даёт НРД (M2M01…M2M10), человекочитаемый текст — в COMMENT.M2M_REJECT_CODE через ;, как их даёт НРД (M2M01…M2M10); человекочитаемый текст — в PARAMETERS.BJ_DECISION_CONFIRM / BJ_DECISION_REJECTBJ_CP_CONFIRM / BJ_CP_REJECTBJ_SERVICE_ERRORBJ_ORDER_SENTBJ_ORDER_SENT;)BJ_ORDER_ACCEPTED / BJ_ORDER_REJECTEDBJ_ORDER_LATEBJ_EXECUTEDBJ_CANCELLEDBJ_GATEWAY_REJECTBJ_GATEWAY_REJECTBJ_PINGBJ_QUIK_BLOCKED / BJ_QUIK_RELEASED / BJ_QUIK_SIGNED / BJ_QUIK_DEPOSITEDBJ_QUIK_BLOCKED / BJ_QUIK_RELEASED / BJ_QUIK_SIGNED / BJ_QUIK_DEPOSITEDСогласовано: bj2depo пишет только DEPO_ORDER_CREATED и DEPO_ERROR. Остальные типы из таблицы ниже (проверка, исполнение, отмена) остаются в резерве на случай, если понадобятся позже; bj их пока не ждёт.
| EVENT_TYPE | Когда bj2depo пишет | Уровень | Что делает bj |
|---|---|---|---|
DEPO_ORDER_CREATED | поручение в DEPO создано; номер документа в COMMENT (и/или FANSY_DOC в FTV_M2M) | поручение | показывает номер в карточке перевода, далее ссылается на него |
DEPO_ERROR | джоба не смогла обработать событие или поручение (текст) | любой | оповещение оператору bj |
Согласовано с программистом Fancy: от bj2depo достаточно двух событий, DEPO_ORDER_CREATED и DEPO_ERROR. Проверку строк по регламенту делает bj (сверка с DEPO), состояние документа bj читает сам (раздел «Обратная связь в bj»), поэтому отдельные события о проверке, исполнении и отмене не нужны.
Не используются (17.09.2026, программист Fancy: «мастером является bj»). Прежние DEPO_ORDER_CREATED / DEPO_ERROR отменены. bj узнаёт результат так:
FTV_M2M.DOC, строки — FTV_M2M_LINES.DOL; состояние документа bj читает в OD_DOCS.STATE;STATE = SUCCESS / ERROR и COMMENT у самого события BJ_*; по ERROR оператор bj получает оповещение.| Входящий перевод (мы зачисляем) | Исходящий перевод (мы списываем) |
|---|---|
1. bj: FTV_M2M + LINES, BJ_RECEIVED | 1. bj: QUIK блокирует бумаги (BJ_QUIK_BLOCKED), M2MTR в НРД, FTV_M2M + LINES, BJ_SENT |
2. bj2depo: DEPO_ORDER_CREATED; bj сверяет строки с DEPO | 2. bj2depo: DEPO_ORDER_CREATED |
2. bj2depo: документ DEPO (FTV_M2M.DOC); bj сверяет строки с DEPO | 2. bj2depo: документ DEPO (FTV_M2M.DOC) |
3. bj: ответ контрагенту — BJ_DECISION_CONFIRM / BJ_DECISION_REJECT | 3. bj: ответ контрагента — BJ_CP_CONFIRM / BJ_CP_REJECT (при отказе QUIK снимает блокировку: BJ_QUIK_RELEASED) |
4. bj: подача 16/1 — BJ_ORDER_SENT | 4. bj: подача 16 — BJ_ORDER_SENT |
5. bj: BJ_ORDER_ACCEPTED (MS010) или BJ_ORDER_REJECTED; QUIK зачисляет — BJ_QUIK_DEPOSITED | 5. bj: BJ_ORDER_ACCEPTED / BJ_ORDER_REJECTED; QUIK списывает — BJ_QUIK_SIGNED |
6. bj2depo: движение по счёту депо по BJ_ORDER_ACCEPTED / BJ_EXECUTED, документ в «Конец обработки» | 6. bj2depo: то же, списание |
6. bj2depo: по BJ_ORDER_ACCEPTED / BJ_EXECUTED — FTV_M2M_PROCESS_WIRING, документ «Обработано (ожидание формирования отчёта)»; 7. «Формирование отчёта» — сотрудник депозитария (по рассылке) или автомат документооборота | 6–7. то же для списания |
Отклонения: BJ_GATEWAY_REJECT (шлюз bj отказал до отправки в НРД; статус в ЛК — после обработки джобой), BJ_SERVICE_ERROR, BJ_ORDER_LATE (5 мин без MS010), BJ_CANCELLED (30 дней без исполнения), DEPO_ERROR. | |
BJ_RECEIVED, BJ_SENTBJ_DECISION_REJECT (входящий: мы отказали), BJ_CP_REJECT (исходящий: отказал контрагент)D_CLISER_M2M_DOL строки записать код из M2M_REJECT_CODE в M2M_STATUS/M2M_TEXT (текст из COMMENT). Если отказаны все строки документа — выполнить шаг «Отмена» (тип шага 0→REJ), как это делает оператор вручнуюBJ_DECISION_REJECT (входящий: мы отказали), BJ_CP_REJECT (исходящий: отказал контрагент)D_CLISER_M2M_DOL строки записать код из M2M_REJECT_CODE в M2M_STATUS/M2M_TEXT (текст из PARAMETERS). Если отказаны все строки документа — выполнить шаг «Отмена» (тип шага 0→REJ), как это делает оператор вручнуюBJ_DECISION_CONFIRM, BJ_CP_CONFIRMM2M_STATUS «принято»); движения по счёту пока нет — ждём поручение НРДBJ_ORDER_SENTBJ_ORDER_ACCEPTED + BJ_EXECUTEDBJ_GATEWAY_REJECTD_CLISER_M2M_DOL строки записать код из M2M_REJECT_CODE и причину из COMMENT; если отказаны все строки — шаг «Отмена». Документ мог не создаться (например, бумаги с таким ISIN нет в OD_VALUES) — тогда достаточно пометить событие ERROR с текстом причиныBJ_ORDER_REJECTED, BJ_SERVICE_ERROR, BJ_CANCELLEDBJ_ORDER_SENTBJ_ORDER_ACCEPTED + BJ_EXECUTED[dbo].[FTV_M2M_PROCESS_WIRING] @OPER_ID (ID документа) — директива «Обработать» для списания / зачисления; bj процедуру не вызывает. Дальше директива «Формирование отчёта»: сотрудники депозитария по почтовой рассылке или автомат документооборота (нужна лицензия) — средствами SQL отчёт не формируетсяBJ_GATEWAY_REJECTD_CLISER_M2M_DOL строки записать код из M2M_REJECT_CODE и причину из PARAMETERS; если отказаны все строки — шаг «Отмена». Документ мог не создаться (например, бумаги с таким ISIN нет в OD_VALUES) — тогда достаточно пометить событие ERROR с текстом причиныBJ_ORDER_REJECTED, BJ_SERVICE_ERROR, BJ_CANCELLEDBJ_ORDER_LATEBJ_QUIK_*, BJ_PINGНазвания и коды состояний — из справочника OD_DOC_STATES (D_CAT=0): 0 «Начальный ввод», 1 «Начало обработки (зарегистрирован)», 2 «Конец обработки (в архиве)», 3 «Отмена обработки», 10 «Ожидание доставки (ответа)», 127 «Ожидание исполнения операции». Шаг отмены, который выполнял оператор: OD_STEP_TYPES ID 75069472 «Отмена» (SYS_NAME 0->REJ, OUT_STATE 3).
Запрос, которым джоба находит отказы вместе с документом и строкой:
-SELECT e.ID, e.GUID, e.REFERENCE, e.M2M_REJECT_CODE, e.COMMENT, m.DOC, l.DOL +SELECT e.ID, e.GUID, e.REFERENCE, e.M2M_REJECT_CODE, e.PARAMETERS, m.DOC, l.DOL FROM dbo.FTV_M2M_EVENTS e JOIN dbo.FTV_M2M m ON m.GUID = e.GUID JOIN dbo.FTV_M2M_LINES l ON l.GUID = e.GUID AND l.REFERENCE = e.REFERENCE WHERE e.STATE = 'TODO' AND e.EVENT_TYPE IN ('BJ_DECISION_REJECT', 'BJ_CP_REJECT') ORDER BY e.ID;Для событий по документу целиком (REFERENCE пуст) джойн со строками не нужен:
-SELECT e.ID, e.GUID, e.EVENT_TYPE, e.M2M_REJECT_CODE, e.COMMENT, m.DOC ++SELECT e.ID, e.GUID, e.EVENT_TYPE, e.M2M_REJECT_CODE, e.PARAMETERS, m.DOC FROM dbo.FTV_M2M_EVENTS e JOIN dbo.FTV_M2M m ON m.GUID = e.GUID WHERE e.STATE = 'TODO' AND e.REFERENCE IS NULL @@ -197,20 +196,19 @@ ORDER BY e.ID;Примеры SQL
Так bj заведёт входящий перевод (одна транзакция):
BEGIN TRAN; -INSERT INTO dbo.FTV_M2M (GUID, DIRECTION, CONTRAGENT_M2M_CODE, DEPONENT_NAME, DEPONENT_DOC, DEPONENT_ID, COMMENT) -VALUES ('5325B860-3265-49B6-986D-FA84FB41FE27', 'IN', 'MC0034900000', N'Погонщикова Виктория Олеговна', '4500 123456', 11773497, N'M2MTR от MC0034900000'); -INSERT INTO dbo.FTV_M2M_LINES (GUID, REFERENCE, ISIN, SEC_CODE, SHARE, AMOUNT, PLACE_NUMBER) -VALUES ('5325B860-3265-49B6-986D-FA84FB41FE27', 'M2M0000002675550', 'RU000A107UL4', 'RU000A107UL4', 18824, 1, '36MC0413600000F00'); -INSERT INTO dbo.FTV_M2M_EVENTS (GUID, REFERENCE, STATE, EVENT_TYPE, EVENT_DATE, COMMENT) -VALUES ('5325B860-3265-49B6-986D-FA84FB41FE27', NULL, 'TODO', 'BJ_RECEIVED', GETDATE(), N'входящий перевод получен'); -COMMIT;-Так bj2depo сообщает, что документ в DEPO создан:
-INSERT INTO dbo.FTV_M2M_EVENTS (GUID, REFERENCE, STATE, EVENT_TYPE, EVENT_DATE, COMMENT) -VALUES ('5325B860-3265-49B6-986D-FA84FB41FE27', NULL, 'TODO', 'DEPO_ORDER_CREATED', GETDATE(), N'75070271');-Так bj забирает свои события и подтверждает обработку:
-SELECT ID, GUID, REFERENCE, EVENT_TYPE, M2M_REJECT_CODE, COMMENT -FROM dbo.FTV_M2M_EVENTS WHERE EVENT_TYPE LIKE 'DEPO_%' AND STATE = 'TODO' ORDER BY ID; -UPDATE dbo.FTV_M2M_EVENTS SET STATE = 'SUCCESS', TimeBeg = GETDATE(), TimeEnd = GETDATE(), COMMENT = N'применено в bj' WHERE ID = @id;+INSERT INTO dbo.FTV_M2M (GUID, DIRECTION, CONTRAGENT_M2M_CODE, DEPONENT_NAME, DEPONENT_DOC) +VALUES ('5325B860-3265-49B6-986D-FA84FB41FE27', 'IN', 'MC0034900000', N'Погонщикова Виктория Олеговна', '4500 123456'); +INSERT INTO dbo.FTV_M2M_LINES (GUID, REFERENCE, ISIN, SEC_CODE, SHARE, AMOUNT, PLACE_NUMBER, DEPO_NUMBER) +VALUES ('5325B860-3265-49B6-986D-FA84FB41FE27', 'M2M0000002675550', 'RU000A107UL4', 'RU000A107UL4', 18824, 1, '36MC0413600000F00', N'ВТНКЦ00000816'); +INSERT INTO dbo.FTV_M2M_EVENTS (GUID, REFERENCE, EVENT_TYPE, EVENT_DATE) +VALUES ('5325B860-3265-49B6-986D-FA84FB41FE27', NULL, 'BJ_RECEIVED', '2026-09-17T09:21:26.000'); -- время получения M2MTR в bj +COMMIT; +-- параметры события — в PARAMETERS, например подача поручения 16/1: +INSERT INTO dbo.FTV_M2M_EVENTS (GUID, REFERENCE, EVENT_TYPE, EVENT_DATE, PARAMETERS) +VALUES ('5325B860-3265-49B6-986D-FA84FB41FE27', NULL, 'BJ_ORDER_SENT', '2026-09-17T09:22:24.000', N'20608');Так bj читает результат обработки (событий от bj2depo нет):
+SELECT CAST(GUID AS varchar(36)) GUID, STATE, COMMENT, DOC FROM dbo.FTV_M2M WHERE GUID = @guid; +SELECT ID, EVENT_TYPE, REFERENCE, STATE, COMMENT, TimeBeg, TimeEnd FROM dbo.FTV_M2M_EVENTS WHERE GUID = @guid ORDER BY ID;Структура таблицы справочника и то, как bj её перезаписывает:
CREATE TABLE dbo.FTV_M2M_HANDBOOK ( @@ -225,18 +223,22 @@ VALUES ('7707025725', 'MC0005600000', 'DEPO', N'…', N'АО АКБ "Центр COMMIT;Открытые вопросы
-Закрыто 15.09.2026:
+Закрыто 17.09.2026 (ответ программиста Fancy):
DEPO_ORDER_CREATED и DEPO_ERROR; DEPO_EXECUTED не нужен, bj его не ждёт;INN / PARTICIPANT_CODE / NAME_SHORT, история не нужна, перезапись целиком; таблицу создаёт наша сторона;REFERENCE для всех исходящих переводов, включая созданные из терминала QUIK, присваивает bj при формировании M2MTR (формат M2M…);bj2depo на SQL002 / BJ_DEPO_DATA работает (EXECUTE AS SYSDBA), логин bj — читатель базы и запись в трансферные таблицы;BJ_QUIK_* bj пишет для истории; джоба помечает их SUCCESS без действий.FTV_M2M_HANDBOOK создана и заполняется bj; обработка справочника джобой — на последнем этапе проекта (директива вендора переносится с Fancy-скриптов на SQL);BJ_ORDER_ACCEPTED / BJ_EXECUTED — процедура FTV_M2M_PROCESS_WIRING (вызывает bj2depo), документ «Обработано (ожидание формирования отчёта)»;COMMENT и STATE заполняет только bj2depo; параметры — в PARAMETERS; EVENT_DATE — фактическое время события (в bj сделано 17.09);DEPO_ORDER_CREATED / DEPO_ERROR отменены);FTV_M2M_LINES.DEPO_NUMBER (bj пишет номер ВТНКЦ… с 16.09).Закрыто 15.09.2026: REFERENCE для всех исходящих присваивает bj (M2M…); джоба bj2depo на SQL002 / BJ_DEPO_DATA работает; BJ_QUIK_* — для истории, без действий.
Остаётся:
FTV_M2M_HANDBOOK в BJ_DEPO_DATA по скрипту из раздела «Справочник участников».BJ_ORDER_ACCEPTED / BJ_EXECUTED, запись отказов в строки и шаг «Отмена».REFERENCE (по документу целиком: BJ_RECEIVED, BJ_SENT, BJ_ORDER_SENT, BJ_ORDER_ACCEPTED, BJ_EXECUTED, BJ_QUIK_*) джоба помечает ERROR «Не найдена строка M2M-поручение по GUID + REFERENCE // FTV_M2M_PROCESS_EVENTS // 69» — на 17.09 (перевод D744A410) так же. Для них строку искать не нужно: документ — по GUID.PLACE по PLACE_NUMBER = 36MC0413600000F00 не проставляется; в DEPO запись называется HL171004001C/…/36MC0413600000F00 — в каком виде передавать?