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

1189 lines
50 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package lkgateway
// Обмен с DEPO (Fancy) через трансферные таблицы в нашей копии базы
// (BJ_DEPO_DATA): FTV_M2M / FTV_M2M_LINES / FTV_M2M_EVENTS / FTV_M2M_HANDBOOK.
// Согласовано с программистом Fancy (страница /admin/help/fancy-events), уточнено 17.09.2026:
// - bj — мастер: пишет поручения и события BJ_*; джоба bj2depo создаёт сущности DEPO, событий от
// себя не пишет — результат bj читает в FTV_M2M.DOC и в STATE/COMMENT своих событий;
// - COMMENT и STATE заполняет только bj2depo (STATE по умолчанию TODO); параметры события bj пишет
// в PARAMETERS; EVENT_DATE — фактическое время события в bj;
// - справочник участников — TRUNCATE + полная перезапись при каждом получении.
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/lkgateway/fsql"
"log"
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
"sync"
"time"
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/m2m"
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/m2mcore"
)
const depoLinkFile = "/var/lib/bj/.bj/depo-events.json"
var uuidRe = regexp.MustCompile(`^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$`)
// DepoLink — что DEPO ответила по переводу (для карточек).
type DepoLink struct {
GUID string `json:"guid"`
DocNo string `json:"doc_no,omitempty"` // номер поручения в DEPO (из DEPO_ORDER_CREATED)
DocAt time.Time `json:"doc_at,omitempty"`
DocState string `json:"doc_state,omitempty"` // состояние документа в DEPO (OD_DOCS.STATE → название)
DocStateAt time.Time `json:"doc_state_at,omitempty"`
Errors []string `json:"errors,omitempty"` // DEPO_ERROR
LastEvent string `json:"last_event,omitempty"`
UpdatedAt time.Time `json:"updated_at"`
}
type depoLinkStore struct {
mu sync.Mutex
items map[string]*DepoLink
}
var globalDepoLinks = loadDepoLinks()
func loadDepoLinks() *depoLinkStore {
s := &depoLinkStore{items: map[string]*DepoLink{}}
if b, err := os.ReadFile(depoLinkFile); err == nil {
var list []*DepoLink
if json.Unmarshal(b, &list) == nil {
for _, l := range list {
s.items[strings.ToUpper(l.GUID)] = l
}
}
}
return s
}
func (s *depoLinkStore) Get(guid string) *DepoLink {
s.mu.Lock()
defer s.mu.Unlock()
if l, ok := s.items[strings.ToUpper(guid)]; ok {
cp := *l
return &cp
}
return nil
}
func (s *depoLinkStore) update(guid string, fn func(l *DepoLink)) {
s.mu.Lock()
defer s.mu.Unlock()
k := strings.ToUpper(guid)
l, ok := s.items[k]
if !ok {
l = &DepoLink{GUID: k}
s.items[k] = l
}
fn(l)
l.UpdatedAt = time.Now()
list := make([]*DepoLink, 0, len(s.items))
for _, x := range s.items {
list = append(list, x)
}
b, _ := json.MarshalIndent(list, "", " ")
_ = os.MkdirAll(filepath.Dir(depoLinkFile), 0o755)
tmp := depoLinkFile + ".tmp"
if os.WriteFile(tmp, b, 0o640) == nil {
_ = os.Rename(tmp, depoLinkFile)
}
}
// ---- подключение и проверки ----
func depoEventsDB(rc *RuntimeConfig) (*sql.DB, error) {
if rc == nil {
return nil, fmt.Errorf("нет конфигурации")
}
fs := rc.Snapshot().Fancy
if strings.TrimSpace(fs.Server) == "" {
return nil, fmt.Errorf("сервер Fancy не настроен")
}
return fancyOpen(fs, fs.DepoDB())
}
func depoHasColumn(ctx context.Context, db *sql.DB, table, col string) bool {
var n int
_ = db.QueryRowContext(ctx, "SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME=@t AND COLUMN_NAME=@c", sql.Named("t", table), sql.Named("c", col)).Scan(&n)
return n > 0
}
func depoTableExists(ctx context.Context, db *sql.DB, name string) bool {
var id sql.NullInt64
if err := db.QueryRowContext(ctx, "SELECT OBJECT_ID('dbo."+name+"')").Scan(&id); err != nil {
return false
}
return id.Valid
}
var depoTablesWarned time.Time
// depoEvent — одна строка FTV_M2M_EVENTS (идемпотентно по GUID+REFERENCE+EVENT_TYPE), время события — сейчас.
// params — параметры события для bj2depo (PARAMETERS): номер поручения НРД, номер поручения МНП, текст причины отказа.
func depoEvent(rc *RuntimeConfig, guid, ref, typ, codes, params string) {
depoEventAt(rc, time.Now(), guid, ref, typ, codes, params)
}
// depoEventAt — то же с явным временем события (выгрузка истории). COMMENT и STATE не пишем:
// их заполняет bj2depo (STATE по умолчанию TODO).
func depoEventAt(rc *RuntimeConfig, at time.Time, guid, ref, typ, codes, params string) {
guid = strings.ToUpper(strings.TrimSpace(guid))
if !uuidRe.MatchString(guid) {
return // тестовые/служебные идентификаторы в DEPO не отдаём
}
db, err := depoEventsDB(rc)
if err != nil {
return
}
defer db.Close()
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
if !depoTableExists(ctx, db, "FTV_M2M_EVENTS") {
if time.Since(depoTablesWarned) > time.Hour {
depoTablesWarned = time.Now()
log.Printf("depo-events: таблицы FTV_M2M_EVENTS нет в %s — события не пишутся", rc.Snapshot().Fancy.DepoDB())
}
return
}
if at.IsZero() {
at = time.Now()
}
if len([]rune(params)) > 4000 {
params = string([]rune(params)[:4000])
}
var n int
_ = db.QueryRowContext(ctx, fsql.QEv01,
sql.Named("g", guid), sql.Named("r", ref), sql.Named("t", typ)).Scan(&n)
if n > 0 {
return
}
q := fsql.Ev01
if depoHasColumn(ctx, db, "FTV_M2M_EVENTS", "PARAMETERS") {
q = fsql.Ev02
}
_, err = db.ExecContext(ctx, q,
sql.Named("g", guid), sql.Named("r", nullStr(ref)), sql.Named("t", typ), sql.Named("at", at.In(time.FixedZone("МСК", 3*3600)).Format("2006-01-02T15:04:05.000")),
sql.Named("c", nullStr(codes)), sql.Named("p", nullStr(params)))
if err != nil {
log.Printf("depo-events: %s %s/%s: %v", typ, shortGUID(guid), ref, err)
return
}
log.Printf("depo-events: %s %s %s %s %s", typ, shortGUID(guid), ref, codes, params)
}
type depoLine struct {
Ref string
ISIN string
Code string
Amount float64
Place string
}
// depoUpsertOrder — шапка FTV_M2M + строки FTV_M2M_LINES (одна транзакция, идемпотентно по GUID).
func depoUpsertOrder(rc *RuntimeConfig, guid, direction, cpCode, deponentName, deponentDoc string, ser, num string, lines []depoLine, comment string) bool {
guid = strings.ToUpper(strings.TrimSpace(guid))
if !uuidRe.MatchString(guid) {
return false
}
db, err := depoEventsDB(rc)
if err != nil {
return false
}
defer db.Close()
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if !depoTableExists(ctx, db, "FTV_M2M") || !depoTableExists(ctx, db, "FTV_M2M_LINES") {
return false
}
var n int
_ = db.QueryRowContext(ctx, fsql.QEv02, sql.Named("g", guid)).Scan(&n)
if n > 0 {
return true
}
// идентификаторы DEPO: лицо (SELF_ID) по паспорту, бумага (OD_VALUES.ID) по ISIN
var deponentID sql.NullInt64
serN := strings.ReplaceAll(strings.TrimSpace(ser), " ", "")
if serN != "" && strings.TrimSpace(num) != "" {
_ = db.QueryRowContext(ctx, fsql.QEv03, sql.Named("s", serN), sql.Named("n", strings.TrimSpace(num))).Scan(&deponentID)
}
// счёт клиента для DEPO_NUMBER строк: открытый торговый ВТНКЦ…, при нескольких договорах — самый
// ранний (то же правило, что при зачислении в QUIK); джоба по номеру находит OD_CLIENTS.ID (DEPO).
var clientAcc sql.NullString
if serN != "" && strings.TrimSpace(num) != "" {
_ = db.QueryRowContext(ctx, fsql.Ev03,
sql.Named("s", serN), sql.Named("n", strings.TrimSpace(num))).Scan(&clientAcc)
}
hasDepoNumber := depoHasColumn(ctx, db, "FTV_M2M_LINES", "DEPO_NUMBER")
hasDeponentID := depoHasColumn(ctx, db, "FTV_M2M", "DEPONENT_ID")
tx, err := db.BeginTx(ctx, nil)
if err != nil {
log.Printf("depo-events: FTV_M2M %s: %v", shortGUID(guid), err)
return false
}
// COMMENT не пишем — его заполняет bj2depo (17.09.2026); comment остаётся только для журнала bj.
head := fsql.Ev04
if hasDeponentID {
head = fsql.Ev05
}
if _, err := tx.ExecContext(ctx, head,
sql.Named("g", guid), sql.Named("d", direction), sql.Named("cp", nullStr(cpCode)), sql.Named("nm", nullStr(deponentName)), sql.Named("doc", nullStr(deponentDoc)),
sql.Named("did", deponentID), sql.Named("cm", nullStr(comment))); err != nil {
tx.Rollback()
log.Printf("depo-events: FTV_M2M %s: %v", shortGUID(guid), err)
return false
}
// SHARE (ID бумаги в DEPO) — заполняем, если колонка есть (джоба может подставить сама)
hasShare := depoHasColumn(ctx, db, "FTV_M2M_LINES", "SHARE")
hasLineTimeNew := depoHasColumn(ctx, db, "FTV_M2M_LINES", "TimeNew")
for _, ln := range lines {
var share sql.NullInt64
if ln.ISIN != "" && hasShare {
_ = db.QueryRowContext(ctx, fsql.QEv04, sql.Named("i", ln.ISIN)).Scan(&share)
}
// LINK_NUMBER — наш депозитарный счёт в НРД: по нему джоба bj2depo находит регистр аналитики (В. Кокин, 22.09.2026)
cols, vals := "GUID, REFERENCE, ISIN, SEC_CODE, AMOUNT, PLACE_NUMBER, LINK_NUMBER", "@g, @r, @i, @c, @a, @p, @lnk"
if hasShare {
cols, vals = cols+", SHARE", vals+", @sh"
}
if hasDepoNumber {
cols, vals = cols+", DEPO_NUMBER", vals+", @acc"
}
// TimeNew — явно: у FTV_M2M_LINES в боевой DEPO нет умолчания GETDATE() (29.09.2026), время было пустым
if hasLineTimeNew {
now := "GETDATE()"
if rc.Snapshot().Fancy.IsPostgres() {
now = "LOCALTIMESTAMP"
}
cols, vals = cols+", TimeNew", vals+", "+now
}
q := fsql.Ev06 + cols + `) VALUES (` + vals + `)`
if _, err := tx.ExecContext(ctx, q,
sql.Named("g", guid), sql.Named("r", ln.Ref), sql.Named("i", nullStr(ln.ISIN)), sql.Named("c", nullStr(ln.Code)), sql.Named("sh", share), sql.Named("a", ln.Amount), sql.Named("p", nullStr(ln.Place)), sql.Named("lnk", strings.TrimSpace(rc.Snapshot().NSD.AccountID)), sql.Named("acc", clientAcc)); err != nil {
tx.Rollback()
log.Printf("depo-events: FTV_M2M_LINES %s/%s: %v", shortGUID(guid), ln.Ref, err)
return false
}
}
if err := tx.Commit(); err != nil {
log.Printf("depo-events: commit %s: %v", shortGUID(guid), err)
return false
}
log.Printf("depo-events: FTV_M2M %s %s создано (%d строк, счёт клиента %s)", direction, shortGUID(guid), len(lines), pickStr(clientAcc.String, "не найден"))
return true
}
// handbookCodeByINN — MC-код депозитария по ИНН из сохранённого справочника.
func handbookCodeByINN(inn string) string {
raw, err := readHandbook()
if err != nil {
return ""
}
var hb struct {
Participants []HandbookEntry `json:"participants"`
}
if json.Unmarshal(raw, &hb) != nil {
return ""
}
best := ""
for _, p := range hb.Participants {
if p.INN == strings.TrimSpace(inn) && p.Code != "" {
if p.Kind == "depo" {
return p.Code
}
if best == "" {
best = p.Code
}
}
}
return best
}
func parseQty(s string) float64 {
f, _ := strconv.ParseFloat(strings.ReplaceAll(strings.TrimSpace(s), ",", "."), 64)
return f
}
// ---- события по этапам ----
// входящий M2MTR получен → шапка IN + строки + BJ_RECEIVED
func depoOnIncomingReceived(rc *RuntimeConfig, it *IncomingTransfer) {
if rc == nil || it == nil {
return
}
place := rc.Snapshot().NSD.SectionID
var lines []depoLine
for _, s := range it.Securities {
lines = append(lines, depoLine{Ref: s.ReferenceID, ISIN: s.ISIN, Code: s.SecurityCode, Amount: parseQty(s.Quantity), Place: place})
}
name := strings.TrimSpace(strings.Join([]string{it.InvestorLast, it.InvestorFirst, it.InvestorMiddle}, " "))
doc := strings.TrimSpace(it.DocSeries + " " + it.DocNumber)
if depoUpsertOrder(rc, it.GUID, "IN", it.SenderCode, name, doc, it.DocSeries, it.DocNumber, lines, "входящий перевод M2M от "+it.SenderCode) {
// по строкам: джоба bj2depo ищет строку по GUID+REFERENCE для любого события (22.09.2026)
for _, s := range it.Securities {
depoEvent(rc, it.GUID, s.ReferenceID, "BJ_RECEIVED", "", "")
}
}
}
// наш M2MTR ушёл в НРД → шапка OUT + строки + BJ_SENT
func depoOnOutgoingSent(rc *RuntimeConfig, req *m2m.M2MTransferRequest) {
if rc == nil || req == nil {
return
}
inv := req.Data.InvestorInformation
ser := ""
if inv.IdentityDocument.DocumentSeries != nil {
ser = string(*inv.IdentityDocument.DocumentSeries)
}
num := string(inv.IdentityDocument.DocumentNumber)
place := rc.Snapshot().NSD.SectionID
var lines []depoLine
for _, s := range req.Data.TransferredSecurities.Securities {
lines = append(lines, depoLine{Ref: string(s.ReferenceID), ISIN: quikSecISIN(s), Code: string(s.SecurityCode), Amount: quikSecQty(s.Quantity), Place: place})
}
inn := string(req.Data.ReceivingDepository.INN)
cp := handbookCodeByINN(inn)
name := strings.TrimSpace(strings.Join([]string{inv.LastName, inv.FirstName, inv.MiddleName}, " "))
guid := string(req.Header.GUID)
if depoUpsertOrder(rc, guid, "OUT", cp, name, strings.TrimSpace(ser+" "+num), ser, num, lines, "исходящий перевод M2M, получатель ИНН "+inn) {
for _, s := range req.Data.TransferredSecurities.Securities {
depoEvent(rc, guid, string(s.ReferenceID), "BJ_SENT", "", "")
}
}
}
// наше решение по входящему (M2MTD)
func depoOnIncomingDecision(rc *RuntimeConfig, it *IncomingTransfer, decisions []SecurityDecision) {
if rc == nil || it == nil {
return
}
for _, d := range decisions {
if d.Confirm {
depoEvent(rc, it.GUID, d.ReferenceID, "BJ_DECISION_CONFIRM", "", "")
} else {
depoEvent(rc, it.GUID, d.ReferenceID, "BJ_DECISION_REJECT", d.RejectCode, m2mCodeLabel(d.RejectCode))
}
}
}
// ответ контрагента по нашему исходящему
func depoOnCounterpartyDecision(rc *RuntimeConfig, decision *m2m.M2MTransferDecision) {
if rc == nil || decision == nil {
return
}
guid := string(decision.Header.GUID)
for _, sec := range decision.Data.Securities {
ref := string(sec.ReferenceID)
if sec.TransferDecision.Rejection != nil {
codes := strings.Join(sec.TransferDecision.Rejection.Codes, ";")
depoEvent(rc, guid, ref, "BJ_CP_REJECT", codes, rejectCodesLabel(sec.TransferDecision.Rejection.Codes))
} else if sec.TransferDecision.Confirmation != nil {
depoEvent(rc, guid, ref, "BJ_CP_CONFIRM", "", "")
}
}
}
func depoOnServiceError(rc *RuntimeConfig, guid, codes, reason string) {
// по строкам: джоба bj2depo ищет строку по GUID+REFERENCE для любого события
for _, ref := range depoLineRefs(rc, guid, "") {
depoEvent(rc, guid, ref, "BJ_SERVICE_ERROR", strings.ReplaceAll(strings.TrimSpace(codes), " ", ";"), reason)
}
}
// depoLineRefs — REFERENCE строк перевода из FTV_M2M_LINES (по ISIN, если задан; иначе все строки).
func depoLineRefs(rc *RuntimeConfig, guid, isin string) []string {
db, err := depoEventsDB(rc)
if err != nil {
return nil
}
defer db.Close()
ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second)
defer cancel()
rows, err := db.QueryContext(ctx, fsql.QEv09, sql.Named("g", strings.ToUpper(guid)), sql.Named("i", strings.ToUpper(strings.TrimSpace(isin))))
if err != nil {
return nil
}
defer rows.Close()
var refs []string
for rows.Next() {
var r string
if rows.Scan(&r) == nil && r != "" {
refs = append(refs, r)
}
}
if len(refs) == 0 && isin != "" {
return depoLineRefs(rc, guid, "")
}
return refs
}
// СТРОЧНЫЕ события по депо-поручениям (договорённость с Fancy 18.09.2026, ответы
// В. Кокина): одно депо-поручение = одна бумага пакета, поэтому подача и исход
// пишутся в DEPO ПО СТРОКАМ с REFERENCE и сразу по MS010 этой строки, не дожидаясь
// остальных — пакет может исполниться частично. Связка contr_d_id↔REFERENCE джобе
// bj2depo не нужна (по REFERENCE она находит всё); contr_d_id кладём в COMMENT для
// разбора. Событие на весь GUID (пустой REFERENCE) больше не пишем: переход на
// строчные — одновременно на bj и bj2depo.
// depoOnOrderLineSent — подано депо-поручение по строке (REFERENCE), contr_d_id — в комментарии.
func depoOnOrderLineSent(rc *RuntimeConfig, guid, ref, contrDID string) {
depoEvent(rc, guid, ref, "BJ_ORDER_SENT", "", contrDID)
}
// depoOnOrderLineResult — исход по строке: accepted | rejected | late | expired.
// accepted → BJ_ORDER_ACCEPTED + BJ_EXECUTED; rejected → BJ_ORDER_REJECTED;
// late → BJ_ORDER_LATE (5 мин без MS010); expired → BJ_CANCELLED (30 дней по бумаге).
// nsdNo — номер ответа НРД (отчёт MS010, BizMsgIdr): PARAMETERS событий по исполнению (29.09.2026).
func depoOnOrderLineResult(rc *RuntimeConfig, guid, ref, kind, text, nsdNo string) {
switch kind {
case "accepted":
depoEvent(rc, guid, ref, "BJ_ORDER_ACCEPTED", "", nsdNo)
depoEvent(rc, guid, ref, "BJ_EXECUTED", "", nsdNo)
case "rejected":
params := nsdNo
if params == "" {
params = text
}
depoEvent(rc, guid, ref, "BJ_ORDER_REJECTED", "", params)
case "late":
depoEvent(rc, guid, ref, "BJ_ORDER_LATE", "", "")
case "expired":
depoEvent(rc, guid, ref, "BJ_CANCELLED", "", "")
}
}
func depoOnQuik(rc *RuntimeConfig, guid, isin, evt, instrNo string) {
typ := map[string]string{"register": "BJ_QUIK_BLOCKED", "deny": "BJ_QUIK_RELEASED", "sign": "BJ_QUIK_SIGNED", "deposit": "BJ_QUIK_DEPOSITED"}[evt]
if typ == "" {
return
}
for _, ref := range depoLineRefs(rc, guid, isin) {
depoEvent(rc, guid, ref, typ, "", instrNo)
}
}
// ---- чтение событий DEPO ----
func depoPollEvents(ctx context.Context, rc *RuntimeConfig) {
db, err := depoEventsDB(rc)
if err != nil {
return
}
defer db.Close()
cctx, cancel := context.WithTimeout(ctx, 20*time.Second)
defer cancel()
if !depoTableExists(cctx, db, "FTV_M2M_EVENTS") {
return
}
// Событий от bj2depo нет (17.09.2026: мастер — bj): номер документа — FTV_M2M.DOC, результат
// обработки — STATE/COMMENT наших событий и шапки.
depoPollOrders(cctx, db, rc)
depoAlertEventFailures(cctx, db, rc)
}
// depoPollOrders — джоба bj2depo ставит номер поручения DEPO прямо в FTV_M2M.DOC (и DOL по строкам),
// а ошибки — STATE='ERROR' + COMMENT. Забираем это в связки без отдельных событий.
func depoPollOrders(ctx context.Context, db *sql.DB, rc *RuntimeConfig) {
if !depoTableExists(ctx, db, "FTV_M2M") {
return
}
docCol := "NULL"
if depoHasColumn(ctx, db, "FTV_M2M", "DOC") {
docCol = "CAST(m.DOC AS varchar(32))"
} else if depoHasColumn(ctx, db, "FTV_M2M", "FANSY_DOC") {
docCol = "CAST(m.FANSY_DOC AS varchar(32))"
}
rows, err := db.QueryContext(ctx, fsql.Ev07+docCol+fsql.Ev08+docCol+fsql.Ev09+docCol+`)
FROM dbo.FTV_M2M m WHERE m.TimeNew > DATEADD(day,-45,GETDATE()) ORDER BY m.ID DESC`)
if err != nil {
log.Printf("depo-events: чтение FTV_M2M: %v", err)
return
}
defer rows.Close()
for rows.Next() {
var guid, state, doc, comment, lineErr, docState string
var procAt sql.NullTime
if rows.Scan(&guid, &state, &doc, &comment, &lineErr, &docState, &procAt) != nil {
continue
}
l := globalDepoLinks.Get(guid)
// состояние документа в DEPO (ручные действия депозитария: отмена, архив)
if docState != "" && (l == nil || l.DocState != docState) {
globalDepoLinks.update(guid, func(x *DepoLink) {
x.DocState = docState
if procAt.Valid {
x.DocStateAt = procAt.Time
}
})
if l != nil && l.DocState != "" && docState != l.DocState {
log.Printf("depo-events: документ DEPO %s по %s: состояние «%s»", doc, shortGUID(guid), docState)
if strings.Contains(strings.ToLower(docState), "отмен") {
if !isDepoTestGUID(guid) {
raiseSystemAlert(rc, "DEPO: депозитарий отменил документ по переводу "+shortGUID(guid), "Документ №"+doc+": «"+docState+"». Проверьте перевод и позицию клиента в QUIK.")
}
}
}
}
if doc != "" && doc != "0" && (l == nil || l.DocNo != doc) {
globalDepoLinks.update(guid, func(x *DepoLink) { x.DocNo, x.DocAt, x.LastEvent = doc, time.Now(), "FTV_M2M.DOC" })
log.Printf("depo-events: DEPO создала поручение по %s: %s", shortGUID(guid), doc)
}
if state == "ERROR" || lineErr != "" {
msg := strings.TrimSpace(comment + " " + lineErr)
if l == nil || len(l.Errors) == 0 || !strings.HasSuffix(l.Errors[len(l.Errors)-1], msg) {
globalDepoLinks.update(guid, func(x *DepoLink) {
x.Errors = append(x.Errors, time.Now().Format("02.01 15:04")+" "+msg)
x.LastEvent = "FTV_M2M.STATE=ERROR"
})
if !isDepoTestGUID(guid) {
raiseSystemAlert(rc, "DEPO: джоба не обработала перевод "+shortGUID(guid), msg)
}
}
}
}
}
// ---- справочник участников → FTV_M2M_HANDBOOK (полная перезапись) ----
var depoHandbookWritten time.Time
func depoWriteHandbook(rc *RuntimeConfig) error {
raw, err := readHandbook()
if err != nil {
return err
}
var hb struct {
At string `json:"at"`
Participants []HandbookEntry `json:"participants"`
Places []HandbookEntry `json:"settlement_places"`
}
if err := json.Unmarshal(raw, &hb); err != nil {
return err
}
db, err := depoEventsDB(rc)
if err != nil {
return err
}
defer db.Close()
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
if !depoTableExists(ctx, db, "FTV_M2M_HANDBOOK") {
if _, err := db.ExecContext(ctx, depoTableSpecByName("FTV_M2M_HANDBOOK").createSQL(false)); err != nil {
return fmt.Errorf("создание FTV_M2M_HANDBOOK: %w — создайте таблицы: Настройка → Обмен Fancy → «Трансферные таблицы DEPO»", err)
}
log.Printf("depo-events: таблица FTV_M2M_HANDBOOK создана в %s", rc.Snapshot().Fancy.DepoDB())
}
hbAt, _ := time.Parse(time.RFC3339, hb.At)
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return err
}
// полная перезапись (согласовано с программистом); DELETE — у логина bj нет права на TRUNCATE
if _, err := tx.ExecContext(ctx, fsql.QEv05); err != nil {
tx.Rollback()
return err
}
ins := func(e HandbookEntry, kind string) error {
short := e.Name
if short == "" {
short = e.DisplayName
}
_, err := tx.ExecContext(ctx, fsql.Ev10,
sql.Named("inn", e.INN), sql.Named("code", nullStr(e.Code)), sql.Named("kind", kind), sql.Named("full", nullStr(e.FullName)), sql.Named("short", nullStr(short)),
sql.Named("disp", nullStr(e.DisplayName)), sql.Named("eng", nullStr(e.EngName)), sql.Named("at", hbAt))
return err
}
n := 0
for _, p := range hb.Participants {
kind := "DEPO"
if p.Kind == "broker" {
kind = "BROKER"
}
if err := ins(p, kind); err != nil {
tx.Rollback()
return err
}
n++
}
for _, p := range hb.Places {
if err := ins(p, "PLACE"); err != nil {
tx.Rollback()
return err
}
n++
}
if err := tx.Commit(); err != nil {
return err
}
depoHandbookWritten = time.Now()
log.Printf("depo-events: справочник участников записан в FTV_M2M_HANDBOOK: %d строк", n)
return nil
}
// depoEventsLoop — чтение событий DEPO раз в минуту; справочник — при старте и раз в 6 часов
// (плюс сразу после получения нового от НРД — вызов из saveHandbook).
func (s *Server) depoEventsLoop(ctx context.Context) {
ticker := time.NewTicker(time.Minute)
defer ticker.Stop()
first := true
for {
if !first {
select {
case <-ctx.Done():
return
case <-ticker.C:
}
}
first = false
func() {
defer func() {
if r := recover(); r != nil {
log.Printf("depo-events: panic: %v", r)
}
}()
if time.Since(depoHandbookWritten) > 6*time.Hour {
if err := depoWriteHandbook(s.rc); err != nil {
log.Printf("depo-events: справочник: %v", err)
depoHandbookWritten = time.Now().Add(-5 * time.Hour) // повтор через час
}
}
depoPollEvents(ctx, s.rc)
}()
healthBeat("depo-events")
}
}
// ---- обработка трансферных таблиц силами bj (замена джобы SQL Agent bj2depo) ----
func depoJobProcName(f FancyMSSQLSettings) string {
if p := strings.TrimSpace(f.DepoJobProc); p != "" {
return p
}
return "dbo.FTV_M2M_JOB"
}
func depoJobEvery(f FancyMSSQLSettings) int {
if f.DepoJobEverySec >= 5 {
return f.DepoJobEverySec
}
return 10
}
var depoJobErrAlerted time.Time
// depoJobOnce — один вызов процедуры обработки. Итог сохраняется в настройках (для карточки).
func depoJobOnce(rc *RuntimeConfig) {
f := rc.Snapshot().Fancy
db, err := depoEventsDB(rc)
if err != nil {
return
}
defer db.Close()
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
proc := depoJobProcName(f)
if !regexp.MustCompile(`^[A-Za-z0-9_.\[\]]+$`).MatchString(proc) {
return
}
start := time.Now()
_, err = execProc(ctx, db, proc)
res := fmt.Sprintf("%s: OK за %s", start.Format("02.01 15:04:05"), time.Since(start).Round(time.Millisecond))
if err != nil {
res = fmt.Sprintf("%s: ОШИБКА %s", start.Format("02.01 15:04:05"), quikErrorDetail(err))
if time.Since(depoJobErrAlerted) > time.Hour {
depoJobErrAlerted = time.Now()
hint := ""
if strings.Contains(strings.ToLower(err.Error()), "не удалось найти") || strings.Contains(strings.ToLower(err.Error()), "could not find") || strings.Contains(strings.ToLower(err.Error()), "разрешение") || strings.Contains(strings.ToLower(err.Error()), "permission") {
hint = " Скорее всего, у логина bj нет права на процедуру: GRANT EXECUTE ON " + proc + " TO " + f.User + ";"
}
raiseSystemAlert(rc, "DEPO: bj не может запустить обработку трансферных таблиц", err.Error()+hint)
log.Printf("depo-job: %v", err)
}
}
cur := rc.Snapshot().Fancy
if cur.DepoJobLast != res {
cur.DepoJobLast, cur.DepoJobLastAt = res, time.Now()
_ = rc.UpdateFancy(cur)
}
}
// depoJobLoop — вызывает процедуру обработки, пока включён режим Fancy.DepoJobRun.
func (s *Server) depoJobLoop(ctx context.Context) {
for {
f := s.rc.Snapshot().Fancy
wait := time.Duration(depoJobEvery(f)) * time.Second
if f.DepoJobRun && strings.TrimSpace(f.Server) != "" {
func() {
defer func() {
if r := recover(); r != nil {
log.Printf("depo-job: panic: %v", r)
}
}()
depoJobOnce(s.rc)
}()
} else {
wait = 15 * time.Second
}
healthBeat("depo-job")
select {
case <-ctx.Done():
return
case <-time.After(wait):
}
}
}
// ---- выбор способа обработки при настройке: проверки и создание джобы ----
const depoJobName = "bj2depo"
// depoJobAgentScript — скрипт для администратора SQL (ручной вариант).
func depoJobAgentScript(f FancyMSSQLSettings) string {
return fmt.Sprintf(`USE msdb;
EXEC dbo.sp_add_job @job_name = N'%[1]s', @enabled = 1, @description = N'bj2depo: обработка трансферных таблиц M2M (bj)';
EXEC dbo.sp_add_jobstep @job_name = N'%[1]s', @step_name = N'%[1]s', @subsystem = N'TSQL', @database_name = N'%[2]s', @command = N'exec %[3]s;';
EXEC dbo.sp_add_jobschedule @job_name = N'%[1]s', @name = N'%[1]s', @enabled = 1, @freq_type = 4, @freq_interval = 1, @freq_subday_type = 2, @freq_subday_interval = %[4]d, @active_start_time = 0;
EXEC dbo.sp_add_jobserver @job_name = N'%[1]s';`, depoJobName, f.DepoDB(), depoJobProcName(f), depoJobEvery(f))
}
// 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 — обработчик не запущен (или процедура не знает 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 := execProc(ctx, db, 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).
// depoJobCreateAgent — создать джобу SQL Agent. adminUser/adminPass — логин администратора SQL
// (sysadmin или SQLAgentOperatorRole в msdb): используется один раз и не сохраняется; пусто — логином bj.
func depoJobCreateAgent(ctx context.Context, rc *RuntimeConfig, adminUser, adminPass string) error {
f := rc.Snapshot().Fancy
conn := f
if strings.TrimSpace(adminUser) != "" {
conn.User, conn.Password = strings.TrimSpace(adminUser), adminPass
}
mdb, err := fancyOpen(conn, "msdb")
if err != nil {
return fmt.Errorf("подключение к msdb: %w", err)
}
defer mdb.Close()
var n int
if err := mdb.QueryRowContext(ctx, "SELECT COUNT(*) FROM msdb.dbo.sysjobs WHERE name=@n", sql.Named("n", depoJobName)).Scan(&n); err != nil {
return fmt.Errorf("нет доступа к msdb (%s). Нужны права: %s", quikErrorDetail(err), strings.ReplaceAll(depoJobAgentRightsScript(f), "\n", " "))
}
if n > 0 {
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 {
// по строкам: у каждой бумаги своё поручение 16/1 и свой MS010
for _, ln := range incomingOrderLines(it) {
ev(it.OrderAt, it.GUID, ln.Ref, "BJ_ORDER_SENT", "", ln.ContrDID)
resps := findOrderResponses(ln.ContrDID)
if done, rejected, text := orderRespVerdict(resps); done {
at := orderRespAt(resps)
if rejected {
ev(at, it.GUID, ln.Ref, "BJ_ORDER_REJECTED", "", pickStr(orderRespNo(resps), text))
} else {
ev(at, it.GUID, ln.Ref, "BJ_ORDER_ACCEPTED", "", orderRespNo(resps))
ev(at, it.GUID, ln.Ref, "BJ_EXECUTED", "", orderRespNo(resps))
}
}
}
}
}
}
// исходящие (мы списывали)
if svc != nil {
deals, err := svc.Repo().List(ctx, m2mcore.Filter{Limit: 500})
if err != nil {
fmt.Fprintf(&sb, "исходящие: ошибка чтения заявок: %v\n", err)
}
for _, d := range deals {
if d == nil || d.Request == nil || !uuidRe.MatchString(strings.ToUpper(string(d.GUID))) {
continue
}
guid := string(d.GUID)
req := d.Request
inv := req.Data.InvestorInformation
ser := ""
if inv.IdentityDocument.DocumentSeries != nil {
ser = string(*inv.IdentityDocument.DocumentSeries)
}
num := string(inv.IdentityDocument.DocumentNumber)
var lines []depoLine
refISIN := map[string]string{}
for _, s := range req.Data.TransferredSecurities.Securities {
isin := quikSecISIN(s)
refISIN[string(s.ReferenceID)] = isin
lines = append(lines, depoLine{Ref: string(s.ReferenceID), ISIN: isin, Code: string(s.SecurityCode), Amount: quikSecQty(s.Quantity), Place: place})
}
inn := string(req.Data.ReceivingDepository.INN)
cp := handbookCodeByINN(inn)
if !cpOK(cp) {
continue
}
name := strings.TrimSpace(strings.Join([]string{inv.LastName, inv.FirstName, inv.MiddleName}, " "))
depoUpsertOrder(rc, guid, "OUT", cp, name, strings.TrimSpace(ser+" "+num), ser, num, lines, "TEST3: исходящий, получатель "+cp+" "+handbookNameByCode(cp))
nOut++
ev(d.CreatedAt, guid, "", "BJ_SENT", "", "")
if d.Decision != nil {
for _, sec := range d.Decision.Data.Securities {
ref := string(sec.ReferenceID)
if sec.TransferDecision.Rejection != nil {
ev(d.UpdatedAt, guid, ref, "BJ_CP_REJECT", strings.Join(sec.TransferDecision.Rejection.Codes, ";"), rejectCodesLabel(sec.TransferDecision.Rejection.Codes))
} else if sec.TransferDecision.Confirmation != nil {
ev(d.UpdatedAt, guid, ref, "BJ_CP_CONFIRM", "", "")
}
}
}
if d.Response != nil && strings.EqualFold(string(d.Response.StatusCode), "ERROR") {
var codes []string
for _, rr := range d.Response.Responses {
codes = append(codes, string(rr.Code))
}
ev(d.UpdatedAt, guid, "", "BJ_SERVICE_ERROR", strings.Join(codes, ";"), rejectCodesLabel(codes))
}
if globalOutgoingStore != nil {
if od, ok := scanForm16Deal(globalOutgoingStore, guid); ok && od.Submitted {
// по строкам: один черновик MF010 = одна бумага; REFERENCE — по ISIN из M2MTR
isinRef := map[string]string{}
for ref, isin := range refISIN {
isinRef[strings.ToUpper(isin)] = ref
}
for _, sec := range od.Secs {
if sec.ContrDID == "" {
continue
}
ref := isinRef[strings.ToUpper(sec.ISIN)]
ev(od.OrderAt, guid, ref, "BJ_ORDER_SENT", "", sec.ContrDID)
resps := findOrderResponses(sec.ContrDID)
if done, rejected, text := orderRespVerdict(resps); done {
at := orderRespAt(resps)
if rejected {
ev(at, guid, ref, "BJ_ORDER_REJECTED", "", pickStr(orderRespNo(resps), text))
} else {
ev(at, guid, ref, "BJ_ORDER_ACCEPTED", "", orderRespNo(resps))
ev(at, guid, ref, "BJ_EXECUTED", "", orderRespNo(resps))
}
}
}
}
}
}
}
fmt.Fprintf(&sb, "выгружено: входящих %d, исходящих %d, событий записано (с учётом уже имевшихся) %d", nIn, nOut, nEv)
return sb.String()
}
// ---- тестовый прогон входящего в DEPO с реальным клиентом ----
// depoSimulateIncoming — копия входящего перевода (бумаги и решения как в оригинале) на реального
// клиента DEPO по паспорту: новый GUID, шапка IN + строки + события полного сценария приёма.
// В НРД ничего не отправляется — только трансферные таблицы DEPO.
func depoSimulateIncoming(ctx context.Context, rc *RuntimeConfig, it *IncomingTransfer, ser, num string) (string, error) {
if it == nil {
return "", fmt.Errorf("входящий не найден")
}
db, err := depoEventsDB(rc)
if err != nil {
return "", err
}
defer db.Close()
serN := strings.ReplaceAll(strings.TrimSpace(ser), " ", "")
numN := strings.TrimSpace(num)
var name string
if err := db.QueryRowContext(ctx, fsql.Ev11, sql.Named("s", serN), sql.Named("n", numN)).Scan(&name); err != nil {
return "", fmt.Errorf("клиент с паспортом %s %s и открытым торговым счётом в DEPO не найден", ser, num)
}
guid := strings.ToUpper(fmt.Sprintf("%08x-%04x-4000-8000-%012x", time.Now().Unix()&0xFFFFFFFF, time.Now().UnixNano()&0xFFFF, time.Now().UnixNano()&0xFFFFFFFFFFFF))
place := rc.Snapshot().NSD.SectionID
// у копии — свои REFERENCE: иначе в DEPO появляются M2M-поручения с одинаковым номером строки (задвоение)
ref := depoSimRefs(guid, it.Securities)
var lines []depoLine
for _, s := range it.Securities {
lines = append(lines, depoLine{Ref: ref[s.ReferenceID], ISIN: s.ISIN, Code: s.SecurityCode, Amount: parseQty(s.Quantity), Place: place})
}
cp := handbookNameByCode(it.SenderCode)
if !depoUpsertOrder(rc, guid, "IN", it.SenderCode, name, strings.TrimSpace(ser+" "+num), ser, num, lines, "ТЕСТ по образцу "+shortGUID(it.GUID)+": входящий от "+it.SenderCode+" "+cp+", реальный клиент") {
return "", fmt.Errorf("не удалось записать шапку в FTV_M2M")
}
now := time.Now()
for _, s := range it.Securities {
depoEventAt(rc, now, guid, ref[s.ReferenceID], "BJ_RECEIVED", "", "")
}
anyConfirmed := false
for _, s := range it.Securities {
switch s.Decision {
case "confirmed":
anyConfirmed = true
depoEventAt(rc, now.Add(time.Minute), guid, ref[s.ReferenceID], "BJ_DECISION_CONFIRM", "", "")
case "rejected":
depoEventAt(rc, now.Add(time.Minute), guid, ref[s.ReferenceID], "BJ_DECISION_REJECT", s.RejectCode, m2mCodeLabel(s.RejectCode))
default:
anyConfirmed = true
depoEventAt(rc, now.Add(time.Minute), guid, ref[s.ReferenceID], "BJ_DECISION_CONFIRM", "", "")
}
}
// Исходы — по строкам (REFERENCE), как в бою: по каждой принятой бумаге своё поручение.
if anyConfirmed {
for _, s := range it.Securities {
if s.Decision == "rejected" {
continue
}
depoEventAt(rc, now.Add(2*time.Minute), guid, ref[s.ReferenceID], "BJ_ORDER_SENT", "", "")
depoEventAt(rc, now.Add(3*time.Minute), guid, ref[s.ReferenceID], "BJ_ORDER_ACCEPTED", "", "")
depoEventAt(rc, now.Add(3*time.Minute), guid, ref[s.ReferenceID], "BJ_EXECUTED", "", "")
}
}
return guid, nil
}
// depoSimulateOutgoing — копия исходящего перевода (бумаги и решение контрагента как в оригинале)
// на реального клиента DEPO по паспорту: новый GUID, шапка OUT + строки + события полного сценария
// списания (форма 16 и MS010 имитируются). В НРД ничего не отправляется — только трансферные таблицы DEPO.
func depoSimulateOutgoing(ctx context.Context, rc *RuntimeConfig, svc *Service, srcGUID, ser, num string) (string, error) {
if svc == nil {
return "", fmt.Errorf("сервис сделок не запущен")
}
d, err := svc.Repo().GetByGUID(ctx, m2m.UUID(strings.ToLower(strings.TrimSpace(srcGUID))))
if err != nil || d == nil || d.Request == nil {
if d2, e2 := svc.Repo().GetByGUID(ctx, m2m.UUID(strings.ToUpper(strings.TrimSpace(srcGUID)))); e2 == nil && d2 != nil && d2.Request != nil {
d = d2
} else {
return "", fmt.Errorf("исходящая сделка %s не найдена", shortGUID(srcGUID))
}
}
db, err := depoEventsDB(rc)
if err != nil {
return "", err
}
defer db.Close()
serN := strings.ReplaceAll(strings.TrimSpace(ser), " ", "")
numN := strings.TrimSpace(num)
var name string
if err := db.QueryRowContext(ctx, fsql.Ev11, sql.Named("s", serN), sql.Named("n", numN)).Scan(&name); err != nil {
return "", fmt.Errorf("клиент с паспортом %s %s и открытым торговым счётом в DEPO не найден", ser, num)
}
req := d.Request
guid := strings.ToUpper(fmt.Sprintf("%08x-%04x-4000-8000-%012x", time.Now().Unix()&0xFFFFFFFF, time.Now().UnixNano()&0xFFFF, time.Now().UnixNano()&0xFFFFFFFFFFFF))
place := rc.Snapshot().NSD.SectionID
newRef := map[string]string{}
for k, s := range req.Data.TransferredSecurities.Securities {
newRef[string(s.ReferenceID)] = fmt.Sprintf("M2MS%s%02d", guid[:8], k+1)
}
var lines []depoLine
for _, s := range req.Data.TransferredSecurities.Securities {
lines = append(lines, depoLine{Ref: newRef[string(s.ReferenceID)], ISIN: quikSecISIN(s), Code: string(s.SecurityCode), Amount: quikSecQty(s.Quantity), Place: place})
}
inn := string(req.Data.ReceivingDepository.INN)
cp := handbookCodeByINN(inn)
if !depoUpsertOrder(rc, guid, "OUT", cp, name, strings.TrimSpace(ser+" "+num), ser, num, lines, "ТЕСТ по образцу "+shortGUID(string(d.GUID))+": исходящий, получатель "+cp+" "+handbookNameByCode(cp)) {
return "", fmt.Errorf("не удалось записать шапку в FTV_M2M")
}
now := time.Now()
// решение контрагента по строкам: как в оригинале; без решения — считаем подтверждённым
rejected := map[string]bool{}
if d.Decision != nil {
for _, sec := range d.Decision.Data.Securities {
if sec.TransferDecision.Rejection != nil {
rejected[string(sec.ReferenceID)] = true
}
}
}
for _, s := range req.Data.TransferredSecurities.Securities {
ref := newRef[string(s.ReferenceID)]
orig := string(s.ReferenceID)
depoEventAt(rc, now, guid, ref, "BJ_SENT", "", "")
if rejected[orig] {
var codes []string
for _, sec := range d.Decision.Data.Securities {
if string(sec.ReferenceID) == orig && sec.TransferDecision.Rejection != nil {
codes = sec.TransferDecision.Rejection.Codes
}
}
depoEventAt(rc, now.Add(time.Minute), guid, ref, "BJ_CP_REJECT", strings.Join(codes, ";"), rejectCodesLabel(codes))
continue
}
depoEventAt(rc, now.Add(time.Minute), guid, ref, "BJ_CP_CONFIRM", "", "")
// форма 16 по бумаге и её MS010 — имитация
depoEventAt(rc, now.Add(2*time.Minute), guid, ref, "BJ_ORDER_SENT", "", "")
depoEventAt(rc, now.Add(3*time.Minute), guid, ref, "BJ_ORDER_ACCEPTED", "", "")
depoEventAt(rc, now.Add(3*time.Minute), guid, ref, "BJ_EXECUTED", "", "")
}
return guid, nil
}
// depoSimRefs — новые REFERENCE для копии входящего (уникальны в DEPO: M2MS + начало GUID + №).
func depoSimRefs(guid string, secs []IncomingSecurity) map[string]string {
m := map[string]string{}
for k, s := range secs {
m[s.ReferenceID] = fmt.Sprintf("M2MS%s%02d", guid[:8], k+1)
}
return m
}