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 после обработки получателем. 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), дата = сейчас. func depoEvent(rc *RuntimeConfig, guid, ref, typ, codes, comment string) { depoEventAt(rc, time.Time{}, guid, ref, typ, codes, comment) } // depoEventAt — то же с явной датой события (для выгрузки истории). func depoEventAt(rc *RuntimeConfig, at time.Time, guid, ref, typ, codes, comment 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 len([]rune(comment)) > 4000 { comment = string([]rune(comment)[: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 } // Параметр даты — типизированный 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))) 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) } 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) } 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 } head := `INSERT INTO dbo.FTV_M2M (GUID, DIRECTION, CONTRAGENT_M2M_CODE, DEPONENT_NAME, DEPONENT_DOC, COMMENT) VALUES (@g, @d, @cp, @nm, @doc, @cm)` 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)` } 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) } q := `INSERT INTO dbo.FTV_M2M_LINES (GUID, REFERENCE, ISIN, SEC_CODE, AMOUNT, PLACE_NUMBER, LINK_NUMBER) VALUES (@g, @r, @i, @c, @a, @p, '')` if hasShare { q = `INSERT INTO dbo.FTV_M2M_LINES (GUID, REFERENCE, ISIN, SEC_CODE, SHARE, AMOUNT, PLACE_NUMBER, LINK_NUMBER) VALUES (@g, @r, @i, @c, @sh, @a, @p, '')` } 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))); 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 строк)", direction, shortGUID(guid), len(lines)) 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), " ", ";"), "отклонено сервисом MOEX МОСТ: "+reason) } func depoOnOrderSent(rc *RuntimeConfig, guid string, contrDIDs []string, form string) { depoEvent(rc, guid, "", "BJ_ORDER_SENT", "", "подано депо-поручение "+form+" в НРД, CONTR_D_ID "+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)") case "rejected": depoEvent(rc, guid, "", "BJ_ORDER_REJECTED", "", "НРД отверг депо-поручение: "+text) case "late": depoEvent(rc, guid, "", "BJ_ORDER_LATE", "", "отчёт MS010 не получен за 5 минут") case "expired": depoEvent(rc, guid, "", "BJ_CANCELLED", "", "истёк срок 30 дней без исполнения — перевод считается отменённым") } } 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, "", "QUIK МНП: поручение №"+instrNo+", "+isin) } // ---- чтение событий 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 } 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)" } 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 по строкам), // а ошибки — 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, STATE, EVENT_TYPE, EVENT_DATE, COMMENT) VALUES (@g, NULL, 'TODO', 'BJ_PING', GETDATE(), N'проверка обработчика из bj')", 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", "", "входящий перевод получен от "+it.SenderCode+" "+cp) 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", "", "подано депо-поручение 16/1 (зачисление), CONTR_D_ID "+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", "", "НРД принял депо-поручение (отчёт MS010)") ev(at, it.GUID, "", "BJ_EXECUTED", "", "перевод исполнен (MS010)") } } } } } // исходящие (мы списывали) 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, ";"), "отклонено сервисом MOEX МОСТ: "+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, ", ")) 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", "", "НРД принял депо-поручение (отчёт MS010)") ev(at, guid, "", "BJ_EXECUTED", "", "перевод исполнен (MS010)") } } } } } } 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", "", "входящий перевод получен от "+it.SenderCode+" "+cp+" (тест по образцу "+shortGUID(it.GUID)+")") 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", "", "подано депо-поручение 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) (тест)") } return guid, nil }