e31e35e663
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014FsEKyJrYF69Cj9ZXzFLRi
1325 lines
58 KiB
Go
1325 lines
58 KiB
Go
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))
|
||
}
|
||
|
||
// depoJobAgentAdminScript — тот же набор для администратора SQL (вариант «создаёт администратор по
|
||
// скрипту»): с пояснениями и защитой от повторного запуска (джоба уже есть — ничего не делает).
|
||
// depoJobAgentScript остаётся построчным: его выполняет сам bj в варианте «джобу создаёт bj».
|
||
func depoJobAgentAdminScript(f FancyMSSQLSettings) string {
|
||
return fmt.Sprintf(`-- Bridge&Join: джоба SQL Server Agent «%[1]s» — обработка таблиц обмена M2M в базе %[2]s.
|
||
-- Каждые %[4]d с вызывает процедуру %[3]s: записи bj превращаются в документы и операции учёта DEPO.
|
||
-- Выполнить в SSMS или sqlcmd под логином с правами sysadmin (или SQLAgentOperatorRole в msdb).
|
||
-- Служба SQL Server Agent должна быть запущена. Скрипт можно запускать повторно: если джоба уже есть, он её не трогает.
|
||
|
||
USE msdb;
|
||
GO
|
||
|
||
IF NOT EXISTS (SELECT 1 FROM msdb.dbo.sysjobs WHERE name = N'%[1]s')
|
||
BEGIN
|
||
EXEC dbo.sp_add_job @job_name = N'%[1]s', @enabled = 1, @description = N'bj2depo: обработка трансферных таблиц M2M (bj)';
|
||
EXEC dbo.sp_add_jobstep @job_name = N'%[1]s', @step_name = N'%[1]s', @subsystem = N'TSQL', @database_name = N'%[2]s', @command = N'exec %[3]s;';
|
||
EXEC dbo.sp_add_jobschedule @job_name = N'%[1]s', @name = N'%[1]s', @enabled = 1, @freq_type = 4, @freq_interval = 1, @freq_subday_type = 2, @freq_subday_interval = %[4]d, @active_start_time = 0;
|
||
EXEC dbo.sp_add_jobserver @job_name = N'%[1]s';
|
||
PRINT N'Джоба %[1]s создана.';
|
||
END
|
||
ELSE
|
||
PRINT N'Джоба %[1]s уже есть — не изменялась.';
|
||
GO
|
||
|
||
-- Логину bj — чтение списка джоб и их истории: bj проверяет, что джоба запускается по расписанию.
|
||
IF USER_ID(N'%[5]s') IS NULL CREATE USER [%[5]s] FOR LOGIN [%[5]s];
|
||
IF IS_ROLEMEMBER(N'SQLAgentReaderRole', N'%[5]s') = 0 ALTER ROLE SQLAgentReaderRole ADD MEMBER [%[5]s];
|
||
GO
|
||
`, depoJobName, f.DepoDB(), depoJobProcName(f), depoJobEvery(f), pickStr(strings.TrimSpace(f.User), "bj_user"))
|
||
}
|
||
|
||
// depoJobGrantScript — права для варианта «внутри bj».
|
||
func depoJobGrantScript(f FancyMSSQLSettings) string {
|
||
return fmt.Sprintf("USE %s;\nGRANT EXECUTE ON %s TO %s;", f.DepoDB(), depoJobProcName(f), f.User)
|
||
}
|
||
|
||
// depoJobAgentRightsScript — права для варианта «bj создаёт джобу сам».
|
||
func depoJobAgentRightsScript(f FancyMSSQLSettings) string {
|
||
return fmt.Sprintf(`USE msdb;
|
||
CREATE USER %[1]s FOR LOGIN %[1]s;
|
||
ALTER ROLE SQLAgentOperatorRole ADD MEMBER %[1]s;
|
||
-- служебная учётная запись SQL Agent должна иметь право выполнять %[2]s в %[3]s`, f.User, depoJobProcName(f), f.DepoDB())
|
||
}
|
||
|
||
func depoJobPing(ctx context.Context, db *sql.DB, wait time.Duration) (bool, string) {
|
||
guid := strings.ToUpper(fmt.Sprintf("%08x-0000-4000-8000-%012x", time.Now().Unix()&0xFFFFFFFF, time.Now().UnixNano()&0xFFFFFFFFFFFF))
|
||
if _, err := db.ExecContext(ctx, fsql.QEv06, sql.Named("g", guid)); err != nil {
|
||
return false, "не удалось записать проверочное событие: " + quikErrorDetail(err)
|
||
}
|
||
defer db.ExecContext(context.Background(), fsql.QEv07, sql.Named("g", guid))
|
||
deadline := time.Now().Add(wait)
|
||
for time.Now().Before(deadline) {
|
||
var st string
|
||
if err := db.QueryRowContext(ctx, fsql.QEv08, sql.Named("g", guid)).Scan(&st); err == nil && st != "TODO" && st != "" {
|
||
return true, fmt.Sprintf("проверочное событие BJ_PING обработано за %s (STATE=%s)", time.Since(deadline.Add(-wait)).Round(time.Second), st)
|
||
}
|
||
time.Sleep(3 * time.Second)
|
||
}
|
||
return false, fmt.Sprintf("проверочное событие BJ_PING не обработано за %s — процедуры обработки не отработали. Проверьте: 1) в базе DEPO есть процедуры FTV_M2M_JOB и FTV_M2M_PROCESS_*; 2) пользователь, от имени которого они работают (SYSDBA), есть в базе; 3) ошибку последнего запуска джобы — в её истории в SSMS", wait)
|
||
}
|
||
|
||
// depoJobCheck — проверка готовности выбранного способа обработки.
|
||
func depoJobCheck(ctx context.Context, rc *RuntimeConfig) (string, bool) {
|
||
return depoJobRunnerCheck(ctx, rc, "", "")
|
||
}
|
||
|
||
// depoJobRunnerCheck — запускалка обработки (05.10.2026: шаг «Обработка» проверяет только её, а не процедуры):
|
||
// SQL Agent — джоба есть, включена, есть расписание и она запускалась недавно; «bj сам» — право EXECUTE.
|
||
// adminUser/adminPass — разовый логин администратора (если введён), иначе — логин bj (нужен SQLAgentReaderRole в msdb).
|
||
func depoJobRunnerCheck(ctx context.Context, rc *RuntimeConfig, adminUser, adminPass string) (string, bool) {
|
||
f := rc.Snapshot().Fancy
|
||
switch f.DepoJobMode {
|
||
case "bj":
|
||
db, err := depoEventsDB(rc)
|
||
if err != nil {
|
||
return "подключение к DEPO: " + err.Error(), false
|
||
}
|
||
defer db.Close()
|
||
var n int
|
||
_ = db.QueryRowContext(ctx, "SELECT COUNT(*) FROM fn_my_permissions(@p,'OBJECT') WHERE permission_name='EXECUTE'", sql.Named("p", depoJobProcName(f))).Scan(&n)
|
||
if n == 0 {
|
||
return "у логина " + f.User + " нет права EXECUTE на " + depoJobProcName(f) + " — выполните на сервере: " + strings.ReplaceAll(depoJobGrantScript(f), "\n", " "), false
|
||
}
|
||
return fmt.Sprintf("право на %s есть; bj будет вызывать её каждые %d с", depoJobProcName(f), depoJobEvery(f)), true
|
||
case "sql-auto", "sql-manual":
|
||
return depoAgentJobStatus(ctx, f, adminUser, adminPass)
|
||
}
|
||
return "способ обработки не выбран", false
|
||
}
|
||
|
||
// depoAgentJobStatus — состояние джобы SQL Agent по msdb.
|
||
func depoAgentJobStatus(ctx context.Context, f FancyMSSQLSettings, adminUser, adminPass string) (string, bool) {
|
||
conn := f
|
||
if strings.TrimSpace(adminUser) != "" {
|
||
conn.User, conn.Password = strings.TrimSpace(adminUser), adminPass
|
||
}
|
||
mdb, err := fancyOpen(conn, "msdb")
|
||
if err != nil {
|
||
return "подключение к msdb: " + err.Error(), false
|
||
}
|
||
defer mdb.Close()
|
||
var (
|
||
jobEnabled, schedEnabled, subType, subInt sql.NullInt64
|
||
runDate, runTime, runStatus sql.NullInt64
|
||
runMsg sql.NullString
|
||
now time.Time
|
||
)
|
||
err = mdb.QueryRowContext(ctx, `SELECT j.enabled, s.enabled, s.freq_subday_type, s.freq_subday_interval,
|
||
h.run_date, h.run_time, h.run_status, h.message, GETDATE()
|
||
FROM msdb.dbo.sysjobs j
|
||
LEFT JOIN msdb.dbo.sysjobschedules js ON js.job_id = j.job_id
|
||
LEFT JOIN msdb.dbo.sysschedules s ON s.schedule_id = js.schedule_id
|
||
OUTER APPLY (SELECT TOP 1 run_date, run_time, run_status, message FROM msdb.dbo.sysjobhistory
|
||
WHERE job_id = j.job_id AND step_id = 0 ORDER BY instance_id DESC) h
|
||
WHERE j.name = @n`, sql.Named("n", depoJobName)).Scan(&jobEnabled, &schedEnabled, &subType, &subInt, &runDate, &runTime, &runStatus, &runMsg, &now)
|
||
if err == sql.ErrNoRows {
|
||
return "джобы " + depoJobName + " на сервере нет — создайте её (вариант «создаёт bj» с логином администратора или скрипт для администратора)", false
|
||
}
|
||
if err != nil {
|
||
return "список джоб недоступен логину " + conn.User + " (" + quikErrorDetail(err) + "). Чтобы bj мог проверять джобу, администратору SQL — один раз: " + strings.ReplaceAll(depoJobReaderGrant(f), "\n", " "), false
|
||
}
|
||
if jobEnabled.Int64 != 1 {
|
||
return "джоба " + depoJobName + " есть, но ВЫКЛЮЧЕНА — включите её в SQL Server Agent", false
|
||
}
|
||
if !schedEnabled.Valid || schedEnabled.Int64 != 1 {
|
||
return "у джобы " + depoJobName + " нет включённого расписания — она не будет запускаться", false
|
||
}
|
||
every := time.Duration(depoJobEvery(f)) * time.Second
|
||
if subType.Int64 == 2 && subInt.Int64 > 0 { // каждые N секунд
|
||
every = time.Duration(subInt.Int64) * time.Second
|
||
} else if subType.Int64 == 4 && subInt.Int64 > 0 { // каждые N минут
|
||
every = time.Duration(subInt.Int64) * time.Minute
|
||
}
|
||
sched := fmt.Sprintf("джоба %s включена, запускается каждые %s", depoJobName, humanEvery(every))
|
||
if !runDate.Valid || runDate.Int64 == 0 {
|
||
return sched + ", но ещё ни разу не запускалась — запущена ли служба SQL Server Agent?", false
|
||
}
|
||
d, t := runDate.Int64, runTime.Int64
|
||
last := time.Date(int(d/10000), time.Month(d/100%100), int(d%100), int(t/10000), int(t/100%100), int(t%100), 0, now.Location())
|
||
age := now.Sub(last)
|
||
limit := 3 * every
|
||
if limit < time.Minute {
|
||
limit = time.Minute
|
||
}
|
||
if age > limit {
|
||
return fmt.Sprintf("%s, но последний запуск был %s (%s назад) — запущена ли служба SQL Server Agent?", sched, last.Format("02.01 15:04:05"), humanDur(age)), false
|
||
}
|
||
msg := fmt.Sprintf("%s; последний запуск %s (%d с назад)", sched, last.Format("15:04:05"), int(age.Seconds()))
|
||
if runStatus.Int64 == 0 { // запуск есть, но обработка внутри упала — это не про джобу
|
||
txt := strings.TrimSpace(runMsg.String)
|
||
if r := []rune(txt); len(r) > 300 {
|
||
txt = string(r[:300]) + "…"
|
||
}
|
||
msg += ". Запуск завершился ошибкой обработки — это уже не про джобу, а про процедуры (см. «Обработка событий»): " + txt
|
||
}
|
||
return msg, true
|
||
}
|
||
|
||
func humanEvery(d time.Duration) string {
|
||
if d < time.Minute {
|
||
return fmt.Sprintf("%d с", int(d.Seconds()))
|
||
}
|
||
return fmt.Sprintf("%d мин", int(d.Minutes()))
|
||
}
|
||
|
||
// depoJobReaderGrant — право логину bj видеть джобы и их историю (для проверки джобы из bj).
|
||
func depoJobReaderGrant(f FancyMSSQLSettings) string {
|
||
return fmt.Sprintf("USE msdb;\nIF USER_ID(N'%[1]s') IS NULL CREATE USER [%[1]s] FOR LOGIN [%[1]s];\nIF IS_ROLEMEMBER(N'SQLAgentReaderRole', N'%[1]s') = 0 ALTER ROLE SQLAgentReaderRole ADD MEMBER [%[1]s];", f.User)
|
||
}
|
||
|
||
// depoProcCheck — работает ли сама обработка событий (процедуры FTV_M2M_*): таблицы, пользователь
|
||
// процедур, ошибки обработчика, проверочное событие BJ_PING. Отдельно от запускалки (джобы).
|
||
func depoProcCheck(ctx context.Context, rc *RuntimeConfig) (string, bool) {
|
||
f := rc.Snapshot().Fancy
|
||
db, err := depoEventsDB(rc)
|
||
if err != nil {
|
||
return "подключение к DEPO: " + err.Error(), false
|
||
}
|
||
defer db.Close()
|
||
if !depoTableExists(ctx, db, "FTV_M2M_EVENTS") {
|
||
return "в базе " + f.DepoDB() + " нет таблиц FTV_M2M*", false
|
||
}
|
||
if ok, msg := depoRunAsCheck(ctx, db, f); !ok {
|
||
return msg, false
|
||
}
|
||
if fails, err := depoJobFailures(ctx, db, 1, 5); err == nil && len(fails) > 0 {
|
||
return fmt.Sprintf("обработчик за последние сутки не смог обработать записей: %d (последняя: %s %s — %s). Устраните причину и верните их в очередь: Обмен с DEPO → «Вернуть в очередь»",
|
||
len(fails), fails[0].TableRu(), fails[0].Type, fails[0].Comment), false
|
||
}
|
||
if f.DepoJobMode == "bj" {
|
||
if _, err := execProc(ctx, db, depoJobProcName(f)); err != nil {
|
||
return "вызов " + depoJobProcName(f) + ": " + quikErrorDetail(err), false
|
||
}
|
||
}
|
||
ok, msg := depoJobPing(ctx, db, 40*time.Second)
|
||
return msg, ok
|
||
}
|
||
|
||
// depoJobCreateAgent — создать джобу SQL Agent от имени логина bj (нужна роль SQLAgentOperatorRole).
|
||
// depoJobCreateAgent — создать джобу SQL Agent. adminUser/adminPass — логин администратора SQL
|
||
// (sysadmin или SQLAgentOperatorRole в msdb): используется один раз и не сохраняется; пусто — логином bj.
|
||
func depoJobCreateAgent(ctx context.Context, rc *RuntimeConfig, adminUser, adminPass string) error {
|
||
f := rc.Snapshot().Fancy
|
||
conn := f
|
||
if strings.TrimSpace(adminUser) != "" {
|
||
conn.User, conn.Password = strings.TrimSpace(adminUser), adminPass
|
||
}
|
||
mdb, err := fancyOpen(conn, "msdb")
|
||
if err != nil {
|
||
return fmt.Errorf("подключение к msdb: %w", err)
|
||
}
|
||
defer mdb.Close()
|
||
var n int
|
||
if err := mdb.QueryRowContext(ctx, "SELECT COUNT(*) FROM msdb.dbo.sysjobs 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 {
|
||
depoGrantJobReader(ctx, mdb, f) // джоба осталась (msdb не восстанавливали) — права на проверку всё равно выдаём
|
||
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)
|
||
depoGrantJobReader(ctx, mdb, f)
|
||
return nil
|
||
}
|
||
|
||
// depoGrantJobReader — пока есть логин администратора: логину bj — чтение списка джоб (для проверки).
|
||
func depoGrantJobReader(ctx context.Context, mdb *sql.DB, f FancyMSSQLSettings) {
|
||
if !depoLoginRe.MatchString(f.User) {
|
||
return
|
||
}
|
||
for _, q := range strings.Split(depoJobReaderGrant(f), "\n") {
|
||
if strings.HasPrefix(q, "USE ") {
|
||
continue
|
||
}
|
||
if _, err := mdb.ExecContext(ctx, q); err != nil {
|
||
log.Printf("depo-job: право SQLAgentReaderRole логину %s: %s", f.User, quikErrorDetail(err))
|
||
return
|
||
}
|
||
}
|
||
}
|
||
|
||
// DepoJobView — данные карточки «Где запускается обработка».
|
||
type DepoJobView struct {
|
||
Name, Proc, GrantScript, AgentRights, AgentScript string
|
||
AdminScript string // для администратора: с пояснениями и защитой от повтора
|
||
Every int
|
||
}
|
||
|
||
func depoJobView(f FancyMSSQLSettings) DepoJobView {
|
||
return DepoJobView{Name: depoJobName, Proc: depoJobProcName(f), Every: depoJobEvery(f), GrantScript: depoJobGrantScript(f), AgentRights: depoJobAgentRightsScript(f), AgentScript: depoJobAgentScript(f), AdminScript: depoJobAgentAdminScript(f)}
|
||
}
|
||
|
||
// ---- выгрузка истории тестов (TEST3, банк Зенит / робот) в трансферные таблицы ----
|
||
|
||
func orderRespAt(resps []orderResponse) time.Time {
|
||
for _, r := range resps {
|
||
if r.Done {
|
||
if t, err := time.ParseInLocation("02.01.2006 15:04:05", r.Time, time.Local); err == nil {
|
||
return t
|
||
}
|
||
}
|
||
}
|
||
return time.Time{}
|
||
}
|
||
|
||
// depoReplayHistory — переписывает в FTV_M2M* все сохранённые в bj переводы с их фактической
|
||
// историей (получен/отправлен, решения с кодами, поручения 16/16-1, MS010). Идемпотентно:
|
||
// шапки и события по ключам не дублируются. Возвращает отчёт для оператора.
|
||
func depoReplayHistory(ctx context.Context, rc *RuntimeConfig, svc *Service, onlyCP string) string {
|
||
var sb strings.Builder
|
||
cpOK := func(code string) bool {
|
||
if strings.TrimSpace(onlyCP) == "" {
|
||
return true
|
||
}
|
||
for _, c := range strings.Split(onlyCP, ",") {
|
||
if strings.EqualFold(strings.TrimSpace(c), strings.TrimSpace(code)) {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
place := rc.Snapshot().NSD.SectionID
|
||
nIn, nOut, nEv := 0, 0, 0
|
||
ev := func(at time.Time, guid, ref, typ, codes, comment string) {
|
||
depoEventAt(rc, at, guid, ref, typ, codes, comment)
|
||
nEv++
|
||
}
|
||
// входящие (мы зачисляли)
|
||
if globalIncomingStore != nil {
|
||
for _, it := range globalIncomingStore.List() {
|
||
if !uuidRe.MatchString(strings.ToUpper(it.GUID)) || !cpOK(it.SenderCode) {
|
||
continue
|
||
}
|
||
var lines []depoLine
|
||
for _, s := range it.Securities {
|
||
lines = append(lines, depoLine{Ref: s.ReferenceID, ISIN: s.ISIN, Code: s.SecurityCode, Amount: parseQty(s.Quantity), Place: place})
|
||
}
|
||
name := strings.TrimSpace(strings.Join([]string{it.InvestorLast, it.InvestorFirst, it.InvestorMiddle}, " "))
|
||
cp := handbookNameByCode(it.SenderCode)
|
||
depoUpsertOrder(rc, it.GUID, "IN", it.SenderCode, name, strings.TrimSpace(it.DocSeries+" "+it.DocNumber), it.DocSeries, it.DocNumber, lines, "TEST3: входящий от "+it.SenderCode+" "+cp)
|
||
nIn++
|
||
ev(it.ReceivedAt, it.GUID, "", "BJ_RECEIVED", "", "")
|
||
if it.Status == "answered" {
|
||
for _, s := range it.Securities {
|
||
switch s.Decision {
|
||
case "confirmed":
|
||
ev(it.AnsweredAt, it.GUID, s.ReferenceID, "BJ_DECISION_CONFIRM", "", "")
|
||
case "rejected":
|
||
ev(it.AnsweredAt, it.GUID, s.ReferenceID, "BJ_DECISION_REJECT", s.RejectCode, m2mCodeLabel(s.RejectCode))
|
||
}
|
||
}
|
||
}
|
||
if it.OrderSubmitted {
|
||
// по строкам: у каждой бумаги своё поручение 16/1 и свой MS010
|
||
for _, ln := range incomingOrderLines(it) {
|
||
ev(it.OrderAt, it.GUID, ln.Ref, "BJ_ORDER_SENT", "", ln.ContrDID)
|
||
resps := findOrderResponses(ln.ContrDID)
|
||
if done, rejected, text := orderRespVerdict(resps); done {
|
||
at := orderRespAt(resps)
|
||
if rejected {
|
||
ev(at, it.GUID, ln.Ref, "BJ_ORDER_REJECTED", "", pickStr(orderRespNo(resps), text))
|
||
} else {
|
||
ev(at, it.GUID, ln.Ref, "BJ_ORDER_ACCEPTED", "", orderRespNo(resps))
|
||
ev(at, it.GUID, ln.Ref, "BJ_EXECUTED", "", orderRespNo(resps))
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
// исходящие (мы списывали)
|
||
if svc != nil {
|
||
deals, err := svc.Repo().List(ctx, m2mcore.Filter{Limit: 500})
|
||
if err != nil {
|
||
fmt.Fprintf(&sb, "исходящие: ошибка чтения заявок: %v\n", err)
|
||
}
|
||
for _, d := range deals {
|
||
if d == nil || d.Request == nil || !uuidRe.MatchString(strings.ToUpper(string(d.GUID))) {
|
||
continue
|
||
}
|
||
guid := string(d.GUID)
|
||
req := d.Request
|
||
inv := req.Data.InvestorInformation
|
||
ser := ""
|
||
if inv.IdentityDocument.DocumentSeries != nil {
|
||
ser = string(*inv.IdentityDocument.DocumentSeries)
|
||
}
|
||
num := string(inv.IdentityDocument.DocumentNumber)
|
||
var lines []depoLine
|
||
refISIN := map[string]string{}
|
||
for _, s := range req.Data.TransferredSecurities.Securities {
|
||
isin := quikSecISIN(s)
|
||
refISIN[string(s.ReferenceID)] = isin
|
||
lines = append(lines, depoLine{Ref: string(s.ReferenceID), ISIN: isin, Code: string(s.SecurityCode), Amount: quikSecQty(s.Quantity), Place: place})
|
||
}
|
||
inn := string(req.Data.ReceivingDepository.INN)
|
||
cp := handbookCodeByINN(inn)
|
||
if !cpOK(cp) {
|
||
continue
|
||
}
|
||
name := strings.TrimSpace(strings.Join([]string{inv.LastName, inv.FirstName, inv.MiddleName}, " "))
|
||
depoUpsertOrder(rc, guid, "OUT", cp, name, strings.TrimSpace(ser+" "+num), ser, num, lines, "TEST3: исходящий, получатель "+cp+" "+handbookNameByCode(cp))
|
||
nOut++
|
||
ev(d.CreatedAt, guid, "", "BJ_SENT", "", "")
|
||
if d.Decision != nil {
|
||
for _, sec := range d.Decision.Data.Securities {
|
||
ref := string(sec.ReferenceID)
|
||
if sec.TransferDecision.Rejection != nil {
|
||
ev(d.UpdatedAt, guid, ref, "BJ_CP_REJECT", strings.Join(sec.TransferDecision.Rejection.Codes, ";"), rejectCodesLabel(sec.TransferDecision.Rejection.Codes))
|
||
} else if sec.TransferDecision.Confirmation != nil {
|
||
ev(d.UpdatedAt, guid, ref, "BJ_CP_CONFIRM", "", "")
|
||
}
|
||
}
|
||
}
|
||
if d.Response != nil && strings.EqualFold(string(d.Response.StatusCode), "ERROR") {
|
||
var codes []string
|
||
for _, rr := range d.Response.Responses {
|
||
codes = append(codes, string(rr.Code))
|
||
}
|
||
ev(d.UpdatedAt, guid, "", "BJ_SERVICE_ERROR", strings.Join(codes, ";"), rejectCodesLabel(codes))
|
||
}
|
||
if globalOutgoingStore != nil {
|
||
if od, ok := scanForm16Deal(globalOutgoingStore, guid); ok && od.Submitted {
|
||
// по строкам: один черновик MF010 = одна бумага; REFERENCE — по ISIN из M2MTR
|
||
isinRef := map[string]string{}
|
||
for ref, isin := range refISIN {
|
||
isinRef[strings.ToUpper(isin)] = ref
|
||
}
|
||
for _, sec := range od.Secs {
|
||
if sec.ContrDID == "" {
|
||
continue
|
||
}
|
||
ref := isinRef[strings.ToUpper(sec.ISIN)]
|
||
ev(od.OrderAt, guid, ref, "BJ_ORDER_SENT", "", sec.ContrDID)
|
||
resps := findOrderResponses(sec.ContrDID)
|
||
if done, rejected, text := orderRespVerdict(resps); done {
|
||
at := orderRespAt(resps)
|
||
if rejected {
|
||
ev(at, guid, ref, "BJ_ORDER_REJECTED", "", pickStr(orderRespNo(resps), text))
|
||
} else {
|
||
ev(at, guid, ref, "BJ_ORDER_ACCEPTED", "", orderRespNo(resps))
|
||
ev(at, guid, ref, "BJ_EXECUTED", "", orderRespNo(resps))
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
fmt.Fprintf(&sb, "выгружено: входящих %d, исходящих %d, событий записано (с учётом уже имевшихся) %d", nIn, nOut, nEv)
|
||
return sb.String()
|
||
}
|
||
|
||
// ---- тестовый прогон входящего в DEPO с реальным клиентом ----
|
||
|
||
// depoSimulateIncoming — копия входящего перевода (бумаги и решения как в оригинале) на реального
|
||
// клиента DEPO по паспорту: новый GUID, шапка IN + строки + события полного сценария приёма.
|
||
// В НРД ничего не отправляется — только трансферные таблицы DEPO.
|
||
func depoSimulateIncoming(ctx context.Context, rc *RuntimeConfig, it *IncomingTransfer, ser, num string) (string, error) {
|
||
if it == nil {
|
||
return "", fmt.Errorf("входящий не найден")
|
||
}
|
||
db, err := depoEventsDB(rc)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
defer db.Close()
|
||
serN := strings.ReplaceAll(strings.TrimSpace(ser), " ", "")
|
||
numN := strings.TrimSpace(num)
|
||
var name string
|
||
if err := db.QueryRowContext(ctx, 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
|
||
}
|