DEPO: строчные события по депо-поручениям (частичное исполнение пакета)
По ответам программиста Fancy (В. Кокин, 21.09.2026) на 8 вопросов из docs/fancy-events-partial-m2m.md. Одно депо-поручение (MF010) = одна бумага пакета, поэтому подача и исход теперь пишутся в DEPO ПО СТРОКАМ с REFERENCE и сразу по MS010 этой строки, не дожидаясь остальных: пакет может исполниться частично (Сбер — да, Т-Технологии — отказ). События на весь GUID (пустой REFERENCE) больше не пишем — переход одновременно с bj2depo. - BJ_ORDER_SENT / BJ_ORDER_ACCEPTED / BJ_EXECUTED / BJ_ORDER_REJECTED / BJ_ORDER_LATE / BJ_CANCELLED — по строке (depoOnOrderLineSent/Result); contr_d_id кладём в COMMENT (джобе связка не нужна — по REFERENCE найдёт). - OrderWatch.Lines — состояние по строкам (contr_d_id → REFERENCE/ISIN/MS010/ отказ/истечение); объект закрыт, когда MS010 по всем строкам; 30 дней — BJ_CANCELLED по каждой незакрытой строке, QUIK-блок снимается по её ISIN. - Входящий 16/1 хранит ВСЕ contr_d_id (OrderLines) — раньше только первый (дефект «входящие отслеживают только первый contr_d_id»). Исходящий 16: REFERENCE по ISIN из бумаг M2MTR (outgoingLineRefs, кэш по GUID; svc в deps). - Карточка: «исполнено частично: N из M» (StatusRu), бейдж warn — как статус «частично» в DEPO. Backfill и эмуляция — тоже по строкам. - Попутно: EXEC процедуры обработчика DEPO — через execProc с проверкой имени (sqlident.go), чтобы значение из настроек не стало произвольным T-SQL. Тесты: TestOrderWatchLinesPartial, TestIncomingOrderLines, TestOutgoingLineRefsNoRepo; весь пакет зелёный. РАЗВЁРТЫВАНИЕ — только одновременно с bj2depo (п. 8 ответов Fancy). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q4YVo4UMopqT5DzicqpPjL
This commit is contained in:
@@ -55,3 +55,28 @@ bj готов писать события **по строке пакета** (RE
|
||||
«отказ» — когда отказ по всем, иначе «исполнен частично» с ожиданием остальных;
|
||||
- события в FTV_M2M_EVENTS в согласованном формате (п. 2);
|
||||
- в карточке перевода — статус по каждой бумаге, оповещение оператору о частичном исполнении.
|
||||
|
||||
---
|
||||
|
||||
## Ответы Fancy (В. Кокин, письмо 21.09.2026) и что реализовано в bj
|
||||
|
||||
| # | Вопрос | Ответ | В bj |
|
||||
|---|--------|-------|------|
|
||||
| 1 | Событие с REFERENCE по одной бумаге — провести только её | **Да** | события исходов пишутся по строкам с REFERENCE |
|
||||
| 2 | Формат при частичном исходе | **(а) отдельные события по строкам** — они приходят в разное время | `BJ_EXECUTED`/`BJ_ORDER_REJECTED` по каждой строке |
|
||||
| 3 | Писать по строке сразу при её MS010 или ждать все | **Сразу** | `orderWatchCheck` пишет исход строки в момент её MS010 |
|
||||
| 4 | Состояние документа DEPO при частичном исполнении | **есть статус «частично»**, документ не делится | карточка bj: «исполнено частично: N из M» (`StatusRu`/`Kind`=warn) |
|
||||
| 5 | Отказ после принятия (REORD) — событие отмены проводки? | **Такого не может быть**; экстремальный казус — руками | отдельного события отмены нет |
|
||||
| 6 | 30 дней по одной бумаге → `BJ_CANCELLED` с REFERENCE | **Да** | истечение по строке → `BJ_CANCELLED` с REFERENCE, QUIK-блок снимается по ISIN |
|
||||
| 7 | Связка contr_d_id ↔ REFERENCE джобе | **Не нужна** — по REFERENCE найдёт всё | contr_d_id кладём в COMMENT для разбора |
|
||||
| 8 | Совместимость: строчные только при частичном? | **Сразу везде**, но **одновременно** на bj и bj2depo | все `BJ_ORDER_*`/`BJ_EXECUTED`/`BJ_CANCELLED` — строчные; события на весь GUID больше не пишем |
|
||||
|
||||
**Реализация в bj** (`orderwatch.go`, `depoevents.go`, `incoming*.go`, `outgoing_order.go`):
|
||||
- одно депо-поручение (MF010) = одна бумага; входящий 16/1 теперь хранит **все** `contr_d_id` (`OrderLines`: contr_d_id ↔ ISIN ↔ REFERENCE) — раньше только первый;
|
||||
- исходящий 16: REFERENCE берётся из бумаг M2MTR по ISIN (`outgoingLineRefs`, кэш по GUID);
|
||||
- `OrderWatch.Lines` — состояние по строкам; объект закрыт, когда MS010 по всем строкам;
|
||||
- `BJ_ORDER_SENT` — по строке (COMMENT = contr_d_id); `BJ_ORDER_LATE` — по каждой незакрытой строке при просрочке 5 мин.
|
||||
|
||||
**Развёртывание — только одновременно с bj2depo** (п. 8): до подтверждения Вадима, что джоба читает строчные события, bj на стенд/бой не выкатывается.
|
||||
|
||||
Предложение Вадима: прогнать тесты с реальным контрагентом на функционале вендора (есть компания с потребностью тестировать).
|
||||
|
||||
@@ -380,23 +380,44 @@ func depoOnServiceError(rc *RuntimeConfig, guid, codes, reason string) {
|
||||
depoEvent(rc, guid, "", "BJ_SERVICE_ERROR", strings.ReplaceAll(strings.TrimSpace(codes), " ", ";"), reason)
|
||||
}
|
||||
|
||||
// СТРОЧНЫЕ события по депо-поручениям (договорённость с 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 дней по бумаге).
|
||||
func depoOnOrderLineResult(rc *RuntimeConfig, guid, ref, kind, text string) {
|
||||
switch kind {
|
||||
case "accepted":
|
||||
depoEvent(rc, guid, ref, "BJ_ORDER_ACCEPTED", "", "")
|
||||
depoEvent(rc, guid, ref, "BJ_EXECUTED", "", "")
|
||||
case "rejected":
|
||||
depoEvent(rc, guid, ref, "BJ_ORDER_REJECTED", "", text)
|
||||
case "late":
|
||||
depoEvent(rc, guid, ref, "BJ_ORDER_LATE", "", "")
|
||||
case "expired":
|
||||
depoEvent(rc, guid, ref, "BJ_CANCELLED", "", "")
|
||||
}
|
||||
}
|
||||
|
||||
// depoOnOrderSent / depoOnOrderResult — прежний формат «на весь GUID» (пустой
|
||||
// REFERENCE). Оставлены для совместимости вызовов; новые пути пишут по строкам.
|
||||
func depoOnOrderSent(rc *RuntimeConfig, guid string, contrDIDs []string, form string) {
|
||||
depoEvent(rc, guid, "", "BJ_ORDER_SENT", "", strings.Join(contrDIDs, ";"))
|
||||
}
|
||||
|
||||
// результат по депо-поручению: accepted | rejected | late | expired
|
||||
func depoOnOrderResult(rc *RuntimeConfig, guid, kind, text string) {
|
||||
switch kind {
|
||||
case "accepted":
|
||||
depoEvent(rc, guid, "", "BJ_ORDER_ACCEPTED", "", "")
|
||||
depoEvent(rc, guid, "", "BJ_EXECUTED", "", "")
|
||||
case "rejected":
|
||||
depoEvent(rc, guid, "", "BJ_ORDER_REJECTED", "", text)
|
||||
case "late":
|
||||
depoEvent(rc, guid, "", "BJ_ORDER_LATE", "", "")
|
||||
case "expired":
|
||||
depoEvent(rc, guid, "", "BJ_CANCELLED", "", "")
|
||||
}
|
||||
depoOnOrderLineResult(rc, guid, "", kind, text)
|
||||
}
|
||||
|
||||
func depoOnQuik(rc *RuntimeConfig, guid, isin, evt, instrNo string) {
|
||||
@@ -627,7 +648,7 @@ func depoJobOnce(rc *RuntimeConfig) {
|
||||
return
|
||||
}
|
||||
start := time.Now()
|
||||
_, err = db.ExecContext(ctx, "exec "+proc)
|
||||
_, 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))
|
||||
@@ -743,7 +764,7 @@ func depoJobCheck(ctx context.Context, rc *RuntimeConfig) (string, bool) {
|
||||
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 {
|
||||
if _, err := execProc(ctx, db, depoJobProcName(f)); err != nil {
|
||||
return "вызов " + depoJobProcName(f) + ": " + quikErrorDetail(err), false
|
||||
}
|
||||
return fmt.Sprintf("право есть, процедура %s выполнена за %s; bj будет вызывать её каждые %d с", depoJobProcName(f), time.Since(start).Round(time.Millisecond), depoJobEvery(f)), true
|
||||
@@ -867,15 +888,18 @@ func depoReplayHistory(ctx context.Context, rc *RuntimeConfig, svc *Service, onl
|
||||
}
|
||||
}
|
||||
if it.OrderSubmitted {
|
||||
ev(it.OrderAt, it.GUID, "", "BJ_ORDER_SENT", "", it.OrderContrDID)
|
||||
resps := findOrderResponses(it.OrderContrDID)
|
||||
if done, rejected, text := orderRespVerdict(resps); done {
|
||||
at := orderRespAt(resps)
|
||||
if rejected {
|
||||
ev(at, it.GUID, "", "BJ_ORDER_REJECTED", "", text)
|
||||
} else {
|
||||
ev(at, it.GUID, "", "BJ_ORDER_ACCEPTED", "", "")
|
||||
ev(at, it.GUID, "", "BJ_EXECUTED", "", "")
|
||||
// по строкам: у каждой бумаги своё поручение 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", "", text)
|
||||
} else {
|
||||
ev(at, it.GUID, ln.Ref, "BJ_ORDER_ACCEPTED", "", "")
|
||||
ev(at, it.GUID, ln.Ref, "BJ_EXECUTED", "", "")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -934,15 +958,26 @@ func depoReplayHistory(ctx context.Context, rc *RuntimeConfig, svc *Service, onl
|
||||
}
|
||||
if globalOutgoingStore != nil {
|
||||
if od, ok := scanForm16Deal(globalOutgoingStore, guid); ok && od.Submitted {
|
||||
ev(od.OrderAt, guid, "", "BJ_ORDER_SENT", "", strings.Join(od.ContrDIDs, ";"))
|
||||
resps, _ := outgoingResponses(od.ContrDIDs)
|
||||
if done, rejected, text := orderRespVerdict(resps); done {
|
||||
at := orderRespAt(resps)
|
||||
if rejected {
|
||||
ev(at, guid, "", "BJ_ORDER_REJECTED", "", text)
|
||||
} else {
|
||||
ev(at, guid, "", "BJ_ORDER_ACCEPTED", "", "")
|
||||
ev(at, guid, "", "BJ_EXECUTED", "", "")
|
||||
// по строкам: один черновик 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", "", text)
|
||||
} else {
|
||||
ev(at, guid, ref, "BJ_ORDER_ACCEPTED", "", "")
|
||||
ev(at, guid, ref, "BJ_EXECUTED", "", "")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -998,10 +1033,16 @@ func depoSimulateIncoming(ctx context.Context, rc *RuntimeConfig, it *IncomingTr
|
||||
depoEventAt(rc, now.Add(time.Minute), guid, s.ReferenceID, "BJ_DECISION_CONFIRM", "", "")
|
||||
}
|
||||
}
|
||||
// Исходы — по строкам (REFERENCE), как в бою: по каждой принятой бумаге своё поручение.
|
||||
if anyConfirmed {
|
||||
depoEventAt(rc, now.Add(2*time.Minute), guid, "", "BJ_ORDER_SENT", "", "")
|
||||
depoEventAt(rc, now.Add(3*time.Minute), guid, "", "BJ_ORDER_ACCEPTED", "", "")
|
||||
depoEventAt(rc, now.Add(3*time.Minute), guid, "", "BJ_EXECUTED", "", "")
|
||||
for _, s := range it.Securities {
|
||||
if s.Decision == "rejected" {
|
||||
continue
|
||||
}
|
||||
depoEventAt(rc, now.Add(2*time.Minute), guid, s.ReferenceID, "BJ_ORDER_SENT", "", "")
|
||||
depoEventAt(rc, now.Add(3*time.Minute), guid, s.ReferenceID, "BJ_ORDER_ACCEPTED", "", "")
|
||||
depoEventAt(rc, now.Add(3*time.Minute), guid, s.ReferenceID, "BJ_EXECUTED", "", "")
|
||||
}
|
||||
}
|
||||
return guid, nil
|
||||
}
|
||||
|
||||
@@ -63,11 +63,15 @@ type IncomingTransfer struct {
|
||||
Auto bool `json:"auto,omitempty"` // отвечено автоматически (дубль)
|
||||
Fingerprint string `json:"fingerprint,omitempty"` // отпечаток для детекта дублей
|
||||
// Подача депозитарного поручения 16/1 (ORDXM) в НРД.
|
||||
OrderSubmitted bool `json:"order_submitted,omitempty"`
|
||||
OrderContrDID string `json:"order_contr_did,omitempty"` // contr_d_id поручения (связь с ответами НРД)
|
||||
OrderPkgs string `json:"order_pkgs,omitempty"` // id пакетов ИШ
|
||||
OrderAt time.Time `json:"order_at,omitempty"`
|
||||
OrderXML string `json:"order_xml,omitempty"` // что мы реально отправили (поручение 16/1)
|
||||
OrderSubmitted bool `json:"order_submitted,omitempty"`
|
||||
OrderContrDID string `json:"order_contr_did,omitempty"` // contr_d_id ПЕРВОГО поручения (совместимость карточек/выгрузок)
|
||||
// OrderLines — все поданные депо-поручения 16/1: одно на бумагу (contr_d_id ↔ ISIN ↔
|
||||
// REFERENCE строки пакета). Нужны, чтобы исход каждой бумаги (MS010) писать в DEPO
|
||||
// по своей строке — пакет может исполниться частично.
|
||||
OrderLines []IncomingOrderLine `json:"order_lines,omitempty"`
|
||||
OrderPkgs string `json:"order_pkgs,omitempty"` // id пакетов ИШ
|
||||
OrderAt time.Time `json:"order_at,omitempty"`
|
||||
OrderXML string `json:"order_xml,omitempty"` // что мы реально отправили (поручение 16/1)
|
||||
// Сверка с DEPO (эмуляция ответа, ничего не отправляется) — для бейджа в списке.
|
||||
DepoRecommend string `json:"depo_recommend,omitempty"` // confirm | reject | mixed
|
||||
DepoSummary string `json:"depo_summary,omitempty"` // «принять» / «отказ M2M04» / …
|
||||
@@ -296,14 +300,23 @@ func (s *IncomingStore) MarkAuto(id, note string) {
|
||||
s.save()
|
||||
}
|
||||
|
||||
// SetOrderSubmitted помечает, что подано поручение депо 16/1 (ORDXM) в НРД, и
|
||||
// сохраняет отправленный XML (что именно мы отправили).
|
||||
func (s *IncomingStore) SetOrderSubmitted(id, contrDID, pkgs, orderXML string) {
|
||||
// IncomingOrderLine — одно депо-поручение 16/1 по одной бумаге пакета.
|
||||
type IncomingOrderLine struct {
|
||||
ContrDID string `json:"contr_did"`
|
||||
ISIN string `json:"isin,omitempty"`
|
||||
Ref string `json:"ref,omitempty"` // REFERENCE бумаги в пакете (Security/ReferenceId)
|
||||
}
|
||||
|
||||
// SetOrderSubmitted помечает, что поданы поручения депо 16/1 (ORDXM) в НРД, и
|
||||
// сохраняет отправленный XML (что именно мы отправили). lines — по одному на бумагу;
|
||||
// OrderContrDID остаётся первым из них для совместимости.
|
||||
func (s *IncomingStore) SetOrderSubmitted(id, contrDID, pkgs, orderXML string, lines []IncomingOrderLine) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if it, ok := s.items[id]; ok {
|
||||
it.OrderSubmitted = true
|
||||
it.OrderContrDID = contrDID
|
||||
it.OrderLines = append([]IncomingOrderLine(nil), lines...)
|
||||
it.OrderPkgs = pkgs
|
||||
it.OrderXML = orderXML
|
||||
it.OrderAt = time.Now().UTC()
|
||||
|
||||
@@ -99,14 +99,25 @@ func submitIncomingOrderFor(ctx context.Context, deps incomingDeps, it *Incoming
|
||||
id := it.ID
|
||||
var sentXML []string
|
||||
var contrDID string
|
||||
// Одно MF010 = одна бумага пакета: запоминаем каждую строку (contr_d_id ↔ ISIN ↔
|
||||
// REFERENCE), чтобы исход по MS010 писать в DEPO по своей строке.
|
||||
refByISIN := map[string]string{}
|
||||
for _, s := range it.Securities {
|
||||
if s.ISIN != "" {
|
||||
refByISIN[strings.ToUpper(s.ISIN)] = s.ReferenceID
|
||||
}
|
||||
}
|
||||
var lines []IncomingOrderLine
|
||||
for _, p := range xmls {
|
||||
raw, e := os.ReadFile(p) // windows-1251, шлём как есть
|
||||
if e != nil {
|
||||
firstErr = e.Error()
|
||||
continue
|
||||
}
|
||||
body := string(decodeCP1251(raw))
|
||||
cd := orderContrDID(raw)
|
||||
if contrDID == "" {
|
||||
contrDID = orderContrDID(raw)
|
||||
contrDID = cd
|
||||
}
|
||||
zipBody, e := igw.PackDepoOrder(raw, filepath.Base(p))
|
||||
if e != nil {
|
||||
@@ -119,12 +130,16 @@ func submitIncomingOrderFor(ctx context.Context, deps incomingDeps, it *Incoming
|
||||
continue
|
||||
}
|
||||
sent = append(sent, pkgID)
|
||||
sentXML = append(sentXML, string(decodeCP1251(raw))) // сохраняем что отправили (UTF-8)
|
||||
sentXML = append(sentXML, body) // сохраняем что отправили (UTF-8)
|
||||
isin := strings.ToUpper(of16(body, "security_isin"))
|
||||
lines = append(lines, IncomingOrderLine{ContrDID: cd, ISIN: isin, Ref: refByISIN[isin]})
|
||||
}
|
||||
if len(sent) > 0 {
|
||||
deps.store.SetOrderSubmitted(id, contrDID, strings.Join(sent, ", "), strings.Join(sentXML, "\n\n"))
|
||||
deps.store.SetOrderSubmitted(id, contrDID, strings.Join(sent, ", "), strings.Join(sentXML, "\n\n"), lines)
|
||||
if cur, ok := deps.store.Get(id); ok {
|
||||
go depoOnOrderSent(deps.rc, cur.GUID, []string{contrDID}, "16/1 (зачисление)")
|
||||
for _, l := range lines {
|
||||
go depoOnOrderLineSent(deps.rc, cur.GUID, l.Ref, l.ContrDID)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(sent) == 0 {
|
||||
|
||||
@@ -14,8 +14,11 @@ import (
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/m2mcore"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -28,10 +31,56 @@ type OrderWatch struct {
|
||||
GUID string `json:"guid"`
|
||||
Direction string `json:"direction"` // out (16) | in (16/1)
|
||||
OrderAt time.Time `json:"order_at"`
|
||||
MS010At time.Time `json:"ms010_at,omitempty"` // все строки получили MS010
|
||||
Rejected bool `json:"rejected,omitempty"` // хотя бы одна строка отвергнута
|
||||
LateAt time.Time `json:"late_at,omitempty"` // когда зафиксировали просрочку 5 мин
|
||||
ExpiredAt time.Time `json:"expired_at,omitempty"` // все незакрытые строки истекли (30 дней)
|
||||
// Lines — состояние по каждой строке пакета (одно депо-поручение = одна бумага),
|
||||
// ключ — contr_d_id. По договорённости с Fancy (18.09.2026) исходы пишутся в DEPO
|
||||
// по строкам с REFERENCE и сразу по MS010 этой строки, не дожидаясь остальных:
|
||||
// пакет может исполниться частично (Сбер — да, Т-Технологии — отказ).
|
||||
Lines map[string]*OrderLineWatch `json:"lines,omitempty"`
|
||||
}
|
||||
|
||||
// OrderLineWatch — одна строка поручения: какая бумага (REFERENCE/ISIN) и её исход.
|
||||
type OrderLineWatch struct {
|
||||
Ref string `json:"ref,omitempty"` // REFERENCE строки в FTV_M2M_LINES
|
||||
ISIN string `json:"isin,omitempty"`
|
||||
MS010At time.Time `json:"ms010_at,omitempty"`
|
||||
Rejected bool `json:"rejected,omitempty"`
|
||||
LateAt time.Time `json:"late_at,omitempty"` // когда зафиксировали просрочку 5 мин
|
||||
ExpiredAt time.Time `json:"expired_at,omitempty"` // когда истекли 30 дней
|
||||
ExpiredAt time.Time `json:"expired_at,omitempty"`
|
||||
}
|
||||
|
||||
// orderLine — строка поручения для наблюдателя: contr_d_id ↔ бумага (REFERENCE, ISIN).
|
||||
type orderLine struct {
|
||||
ContrDID string
|
||||
Ref string
|
||||
ISIN string
|
||||
}
|
||||
|
||||
// lineDone — строка в финальном состоянии (MS010 получен или срок истёк).
|
||||
func (l *OrderLineWatch) lineDone() bool {
|
||||
return l != nil && (!l.MS010At.IsZero() || !l.ExpiredAt.IsZero())
|
||||
}
|
||||
|
||||
// linesSummary — сколько строк закрыто MS010 / отвергнуто / истекло из общего числа.
|
||||
// Нужно для частичного исполнения: пакет может быть исполнен не целиком.
|
||||
func (w *OrderWatch) linesSummary() (total, executed, rejected, expired int) {
|
||||
if w == nil {
|
||||
return
|
||||
}
|
||||
for _, l := range w.Lines {
|
||||
total++
|
||||
switch {
|
||||
case !l.MS010At.IsZero() && l.Rejected:
|
||||
rejected++
|
||||
case !l.MS010At.IsZero():
|
||||
executed++
|
||||
case !l.ExpiredAt.IsZero():
|
||||
expired++
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Состояние по-русски для карточек.
|
||||
@@ -39,6 +88,20 @@ func (w *OrderWatch) StatusRu() string {
|
||||
if w == nil {
|
||||
return ""
|
||||
}
|
||||
// Частичное исполнение: часть строк закрыта, часть ещё ждёт / отвергнута / истекла.
|
||||
if total, executed, rejected, expired := w.linesSummary(); total > 1 && executed > 0 && executed < total {
|
||||
s := fmt.Sprintf("исполнено частично: %d из %d бумаг", executed, total)
|
||||
if rejected > 0 {
|
||||
s += fmt.Sprintf(", отвергнуто %d", rejected)
|
||||
}
|
||||
if expired > 0 {
|
||||
s += fmt.Sprintf(", истёк срок по %d", expired)
|
||||
}
|
||||
if executed+rejected+expired < total {
|
||||
s += fmt.Sprintf(", ожидаем MS010 по %d", total-executed-rejected-expired)
|
||||
}
|
||||
return s
|
||||
}
|
||||
switch {
|
||||
case !w.MS010At.IsZero() && w.Rejected:
|
||||
return "НРД отверг поручение (MS010 " + w.MS010At.Local().Format("02.01 15:04") + ")"
|
||||
@@ -58,6 +121,12 @@ func (w *OrderWatch) StatusRu() string {
|
||||
}
|
||||
|
||||
func (w *OrderWatch) Kind() string {
|
||||
if w != nil {
|
||||
// частично исполнено — предупреждение, а не «отказ» и не «ок»
|
||||
if total, executed, _, _ := w.linesSummary(); total > 1 && executed > 0 && executed < total {
|
||||
return "warn"
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case w == nil:
|
||||
return ""
|
||||
@@ -133,9 +202,57 @@ func (s *orderWatchStore) update(guid string, fn func(w *OrderWatch)) *OrderWatc
|
||||
|
||||
var orderWatchStarted = time.Now()
|
||||
|
||||
// orderWatchCheck — один объект: подано когда, ответы НРД → фиксируем MS010/просрочку/истечение.
|
||||
func orderWatchCheck(ctx context.Context, rc *RuntimeConfig, guid, direction string, orderAt time.Time, resps []orderResponse, label string) {
|
||||
if guid == "" || orderAt.IsZero() {
|
||||
// outgoingLineRefsCache — REFERENCE по ISIN для исходящей сделки: берётся из
|
||||
// M2MTR-запроса в репозитории; после отправки не меняется, поэтому кэшируем по GUID.
|
||||
var outgoingLineRefsCache = struct {
|
||||
mu sync.Mutex
|
||||
m map[string]map[string]string // guid → ISIN → ref
|
||||
}{m: map[string]map[string]string{}}
|
||||
|
||||
// outgoingLineRefs — строки исходящего поручения 16: contr_d_id ↔ ISIN ↔ REFERENCE.
|
||||
// Один черновик MF010 = одна бумага; REFERENCE берём из бумаг M2MTR по ISIN.
|
||||
func outgoingLineRefs(ctx context.Context, svc *Service, od *OutgoingDeal) []orderLine {
|
||||
if od == nil {
|
||||
return nil
|
||||
}
|
||||
outgoingLineRefsCache.mu.Lock()
|
||||
byISIN, ok := outgoingLineRefsCache.m[od.GUID]
|
||||
outgoingLineRefsCache.mu.Unlock()
|
||||
if !ok && svc != nil {
|
||||
byISIN = map[string]string{}
|
||||
if deals, err := svc.Repo().List(ctx, m2mcore.Filter{Limit: 500}); err == nil {
|
||||
for _, d := range deals {
|
||||
if d == nil || d.Request == nil || !strings.EqualFold(string(d.GUID), od.GUID) {
|
||||
continue
|
||||
}
|
||||
for _, sec := range d.Request.Data.TransferredSecurities.Securities {
|
||||
byISIN[strings.ToUpper(quikSecISIN(sec))] = string(sec.ReferenceID)
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
if len(byISIN) > 0 {
|
||||
outgoingLineRefsCache.mu.Lock()
|
||||
outgoingLineRefsCache.m[od.GUID] = byISIN
|
||||
outgoingLineRefsCache.mu.Unlock()
|
||||
}
|
||||
}
|
||||
var out []orderLine
|
||||
for _, sec := range od.Secs {
|
||||
if sec.ContrDID == "" {
|
||||
continue
|
||||
}
|
||||
isin := strings.ToUpper(sec.ISIN)
|
||||
out = append(out, orderLine{ContrDID: sec.ContrDID, Ref: byISIN[isin], ISIN: isin})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// orderWatchCheck — один объект (перевод): подано когда, ответы НРД ПО КАЖДОЙ СТРОКЕ →
|
||||
// фиксируем MS010/просрочку/истечение и пишем строчные события в DEPO сразу по
|
||||
// MS010 этой бумаги, не дожидаясь остальных (частичное исполнение пакета).
|
||||
func orderWatchCheck(ctx context.Context, rc *RuntimeConfig, guid, direction string, orderAt time.Time, lines []orderLine, label string) {
|
||||
if guid == "" || orderAt.IsZero() || len(lines) == 0 {
|
||||
return
|
||||
}
|
||||
w := globalOrderWatch.Get(guid)
|
||||
@@ -143,60 +260,120 @@ func orderWatchCheck(ctx context.Context, rc *RuntimeConfig, guid, direction str
|
||||
w = globalOrderWatch.update(guid, func(w *OrderWatch) { w.Direction, w.OrderAt = direction, orderAt })
|
||||
}
|
||||
if !w.MS010At.IsZero() || !w.ExpiredAt.IsZero() {
|
||||
return // финальное состояние уже зафиксировано
|
||||
}
|
||||
if done, rejected, text := orderRespVerdict(resps); done {
|
||||
at := time.Now()
|
||||
for _, r := range resps {
|
||||
if r.Done {
|
||||
if t, err := time.ParseInLocation("02.01.2006 15:04:05", r.Time, time.Local); err == nil {
|
||||
at = t
|
||||
}
|
||||
}
|
||||
}
|
||||
globalOrderWatch.update(guid, func(w *OrderWatch) { w.MS010At, w.Rejected = at, rejected })
|
||||
if rejected {
|
||||
go depoOnOrderResult(rc, guid, "rejected", text)
|
||||
} else {
|
||||
go depoOnOrderResult(rc, guid, "accepted", "")
|
||||
}
|
||||
return
|
||||
return // финальное состояние уже зафиксировано по всем строкам
|
||||
}
|
||||
now := time.Now()
|
||||
fresh := orderAt.After(orderWatchStarted) // по старым поручениям при первом запуске не шумим
|
||||
|
||||
// 1) исходы по строкам: у каждой бумаги свой MS010
|
||||
for _, ln := range lines {
|
||||
st := w.Lines[ln.ContrDID]
|
||||
if st.lineDone() {
|
||||
continue
|
||||
}
|
||||
resps := findOrderResponses(ln.ContrDID)
|
||||
done, rejected, text := orderRespVerdict(resps)
|
||||
if !done {
|
||||
continue
|
||||
}
|
||||
at := orderRespAt(resps)
|
||||
globalOrderWatch.update(guid, func(w *OrderWatch) {
|
||||
if w.Lines == nil {
|
||||
w.Lines = map[string]*OrderLineWatch{}
|
||||
}
|
||||
w.Lines[ln.ContrDID] = &OrderLineWatch{Ref: ln.Ref, ISIN: ln.ISIN, MS010At: at, Rejected: rejected}
|
||||
if rejected {
|
||||
w.Rejected = true
|
||||
}
|
||||
})
|
||||
if rejected {
|
||||
go depoOnOrderLineResult(rc, guid, ln.Ref, "rejected", text)
|
||||
} else {
|
||||
go depoOnOrderLineResult(rc, guid, ln.Ref, "accepted", "")
|
||||
}
|
||||
}
|
||||
w = globalOrderWatch.Get(guid)
|
||||
|
||||
// 2) все строки закрыты MS010 → финальное состояние объекта
|
||||
if allLinesMS010(w, lines) {
|
||||
latest := time.Time{}
|
||||
for _, ln := range lines {
|
||||
if st := w.Lines[ln.ContrDID]; st != nil && st.MS010At.After(latest) {
|
||||
latest = st.MS010At
|
||||
}
|
||||
}
|
||||
globalOrderWatch.update(guid, func(w *OrderWatch) { w.MS010At = latest })
|
||||
return
|
||||
}
|
||||
|
||||
// 3) просрочка 5 минут — один сигнал на объект, событие по каждой незакрытой строке
|
||||
if w.LateAt.IsZero() && now.Sub(orderAt) > orderMS010Within {
|
||||
globalOrderWatch.update(guid, func(w *OrderWatch) { w.LateAt = now })
|
||||
go depoOnOrderResult(rc, guid, "late", "")
|
||||
for _, ln := range lines {
|
||||
if !w.Lines[ln.ContrDID].lineDone() {
|
||||
go depoOnOrderLineResult(rc, guid, ln.Ref, "late", "")
|
||||
}
|
||||
}
|
||||
if fresh {
|
||||
raiseSystemAlert(rc, "НРД: депо-поручение не исполнено в срок 5 минут",
|
||||
fmt.Sprintf("%s (перевод %s): подано %s, отчёт MS010 не получен. Ожидаем исполнения до %s, после этого поручение считается отменённым.",
|
||||
label, shortGUID(guid), orderAt.Local().Format("02.01.2006 15:04"), orderAt.Add(orderExecWithin).Local().Format("02.01.2006")))
|
||||
}
|
||||
}
|
||||
if w.ExpiredAt.IsZero() && now.Sub(orderAt) > orderExecWithin {
|
||||
globalOrderWatch.update(guid, func(w *OrderWatch) { w.ExpiredAt = now })
|
||||
go depoOnOrderResult(rc, guid, "expired", "")
|
||||
raiseSystemAlert(rc, "НРД: истёк срок 30 дней по депо-поручению — считается отменённым",
|
||||
fmt.Sprintf("%s (перевод %s): подано %s, отчёт MS010 так и не получен. Поручение считается отменённым; проверьте перевод в НРД и позицию клиента.",
|
||||
label, shortGUID(guid), orderAt.Local().Format("02.01.2006 15:04")))
|
||||
if direction == "out" {
|
||||
for _, l := range globalQuikLinks.ForGUID(guid) {
|
||||
if l.State == "registered" {
|
||||
_, _ = QuikOutDeny(ctx, rc, guid, l.ISIN, "Истёк срок исполнения поручения в НРД (30 дней) — перевод отменён")
|
||||
|
||||
// 4) 30 дней — по каждой незакрытой строке BJ_CANCELLED с REFERENCE; QUIK-блокировка снимается по ISIN
|
||||
if now.Sub(orderAt) > orderExecWithin {
|
||||
expiredAny := false
|
||||
for _, ln := range lines {
|
||||
if w.Lines[ln.ContrDID].lineDone() {
|
||||
continue
|
||||
}
|
||||
expiredAny = true
|
||||
globalOrderWatch.update(guid, func(w *OrderWatch) {
|
||||
if w.Lines == nil {
|
||||
w.Lines = map[string]*OrderLineWatch{}
|
||||
}
|
||||
w.Lines[ln.ContrDID] = &OrderLineWatch{Ref: ln.Ref, ISIN: ln.ISIN, ExpiredAt: now}
|
||||
})
|
||||
go depoOnOrderLineResult(rc, guid, ln.Ref, "expired", "")
|
||||
if direction == "out" {
|
||||
for _, l := range globalQuikLinks.ForGUID(guid) {
|
||||
if l.State == "registered" && (ln.ISIN == "" || strings.EqualFold(l.ISIN, ln.ISIN)) {
|
||||
_, _ = QuikOutDeny(ctx, rc, guid, l.ISIN, "Истёк срок исполнения поручения в НРД (30 дней) — перевод отменён")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if expiredAny {
|
||||
globalOrderWatch.update(guid, func(w *OrderWatch) { w.ExpiredAt = now })
|
||||
raiseSystemAlert(rc, "НРД: истёк срок 30 дней по депо-поручению — считается отменённым",
|
||||
fmt.Sprintf("%s (перевод %s): подано %s, отчёт MS010 так и не получен. Поручение считается отменённым; проверьте перевод в НРД и позицию клиента.",
|
||||
label, shortGUID(guid), orderAt.Local().Format("02.01.2006 15:04")))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func orderWatchTick(ctx context.Context, rc *RuntimeConfig) {
|
||||
// allLinesMS010 — по всем строкам получен MS010.
|
||||
func allLinesMS010(w *OrderWatch, lines []orderLine) bool {
|
||||
if w == nil || len(lines) == 0 {
|
||||
return false
|
||||
}
|
||||
for _, ln := range lines {
|
||||
st := w.Lines[ln.ContrDID]
|
||||
if st == nil || st.MS010At.IsZero() {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func orderWatchTick(ctx context.Context, rc *RuntimeConfig, svc *Service) {
|
||||
if globalOutgoingStore != nil {
|
||||
for _, od := range scanForm16Drafts(globalOutgoingStore) {
|
||||
if !od.Submitted {
|
||||
continue
|
||||
}
|
||||
resps, _ := outgoingResponses(od.ContrDIDs)
|
||||
orderWatchCheck(ctx, rc, od.GUID, "out", od.OrderAt, resps, "Поручение 16 (списание)")
|
||||
orderWatchCheck(ctx, rc, od.GUID, "out", od.OrderAt, outgoingLineRefs(ctx, svc, od), "Поручение 16 (списание)")
|
||||
}
|
||||
}
|
||||
if globalIncomingStore != nil {
|
||||
@@ -204,11 +381,30 @@ func orderWatchTick(ctx context.Context, rc *RuntimeConfig) {
|
||||
if !it.OrderSubmitted {
|
||||
continue
|
||||
}
|
||||
orderWatchCheck(ctx, rc, it.GUID, "in", it.OrderAt, findOrderResponses(it.OrderContrDID), "Поручение 16/1 (зачисление)")
|
||||
orderWatchCheck(ctx, rc, it.GUID, "in", it.OrderAt, incomingOrderLines(it), "Поручение 16/1 (зачисление)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// incomingOrderLines — строки поданных 16/1. Старые записи (до строчных событий) знали
|
||||
// только первый contr_d_id — для них одна строка без REFERENCE.
|
||||
func incomingOrderLines(it *IncomingTransfer) []orderLine {
|
||||
if it == nil {
|
||||
return nil
|
||||
}
|
||||
if len(it.OrderLines) > 0 {
|
||||
out := make([]orderLine, 0, len(it.OrderLines))
|
||||
for _, l := range it.OrderLines {
|
||||
out = append(out, orderLine{ContrDID: l.ContrDID, Ref: l.Ref, ISIN: l.ISIN})
|
||||
}
|
||||
return out
|
||||
}
|
||||
if it.OrderContrDID != "" {
|
||||
return []orderLine{{ContrDID: it.OrderContrDID}}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// orderWatchLoop — фоновый контроль сроков (раз в минуту).
|
||||
func (s *Server) orderWatchLoop(ctx context.Context) {
|
||||
ticker := time.NewTicker(time.Minute)
|
||||
@@ -225,7 +421,7 @@ func (s *Server) orderWatchLoop(ctx context.Context) {
|
||||
log.Printf("order-watch: panic: %v", r)
|
||||
}
|
||||
}()
|
||||
orderWatchTick(ctx, s.rc)
|
||||
orderWatchTick(ctx, s.rc, s.svc)
|
||||
}()
|
||||
healthBeat("order-watch")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
package lkgateway
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Строчные события (договорённость с Fancy 18.09.2026): пакет исполняется по бумагам,
|
||||
// состояние объекта складывается из состояний строк.
|
||||
func TestOrderWatchLinesPartial(t *testing.T) {
|
||||
now := time.Now()
|
||||
w := &OrderWatch{GUID: "G1", Lines: map[string]*OrderLineWatch{
|
||||
"cd-sber": {Ref: "R1", ISIN: "RU0009029540", MS010At: now}, // исполнено
|
||||
"cd-tech": {Ref: "R2", ISIN: "RU000A106YF0", MS010At: now, Rejected: true}, // отказ
|
||||
"cd-gaz": {Ref: "R3", ISIN: "RU0007661625"}, // ждём
|
||||
}}
|
||||
lines := []orderLine{{ContrDID: "cd-sber", Ref: "R1"}, {ContrDID: "cd-tech", Ref: "R2"}, {ContrDID: "cd-gaz", Ref: "R3"}}
|
||||
|
||||
if allLinesMS010(w, lines) {
|
||||
t.Error("не все строки получили MS010 — объект не должен считаться закрытым")
|
||||
}
|
||||
total, executed, rejected, expired := w.linesSummary()
|
||||
if total != 3 || executed != 1 || rejected != 1 || expired != 0 {
|
||||
t.Errorf("сводка строк: total=%d executed=%d rejected=%d expired=%d", total, executed, rejected, expired)
|
||||
}
|
||||
if got := w.StatusRu(); got != "исполнено частично: 1 из 3 бумаг, отвергнуто 1, ожидаем MS010 по 1" {
|
||||
t.Errorf("частичный статус: %q", got)
|
||||
}
|
||||
if w.Kind() != "warn" {
|
||||
t.Errorf("частичное исполнение должно быть warn, got %q", w.Kind())
|
||||
}
|
||||
|
||||
// все строки закрыты → объект закрыт
|
||||
w.Lines["cd-gaz"].MS010At = now
|
||||
if !allLinesMS010(w, lines) {
|
||||
t.Error("все строки с MS010 — объект должен быть закрыт")
|
||||
}
|
||||
// строка, истёкшая по сроку, тоже финальна
|
||||
if !(&OrderLineWatch{ExpiredAt: now}).lineDone() || (&OrderLineWatch{}).lineDone() || (*OrderLineWatch)(nil).lineDone() {
|
||||
t.Error("lineDone: истёкшая строка финальна, пустая/nil — нет")
|
||||
}
|
||||
}
|
||||
|
||||
// Строки входящего 16/1: новые записи хранят все contr_d_id с REFERENCE; старые — только первый.
|
||||
func TestIncomingOrderLines(t *testing.T) {
|
||||
it := &IncomingTransfer{OrderLines: []IncomingOrderLine{
|
||||
{ContrDID: "c1", ISIN: "RU0009029540", Ref: "R1"},
|
||||
{ContrDID: "c2", ISIN: "RU000A106YF0", Ref: "R2"},
|
||||
}}
|
||||
got := incomingOrderLines(it)
|
||||
if len(got) != 2 || got[0].ContrDID != "c1" || got[0].Ref != "R1" || got[1].Ref != "R2" {
|
||||
t.Errorf("строки нового формата: %+v", got)
|
||||
}
|
||||
legacy := &IncomingTransfer{OrderContrDID: "c-old"}
|
||||
if got := incomingOrderLines(legacy); len(got) != 1 || got[0].ContrDID != "c-old" || got[0].Ref != "" {
|
||||
t.Errorf("старая запись: одна строка без REFERENCE, got %+v", got)
|
||||
}
|
||||
if incomingOrderLines(&IncomingTransfer{}) != nil || incomingOrderLines(nil) != nil {
|
||||
t.Error("без поручений строк нет")
|
||||
}
|
||||
}
|
||||
|
||||
// Строки исходящего 16 без репозитория: contr_d_id/ISIN из черновиков, REFERENCE пустой.
|
||||
func TestOutgoingLineRefsNoRepo(t *testing.T) {
|
||||
od := &OutgoingDeal{GUID: "G2", Secs: []OutgoingSec{
|
||||
{ISIN: "ru0009029540", ContrDID: "cd1"},
|
||||
{ISIN: "RU000A106YF0", ContrDID: ""}, // без номера поручения — не подавалась
|
||||
{ISIN: "RU0007661625", ContrDID: "cd3"},
|
||||
}}
|
||||
got := outgoingLineRefs(t.Context(), nil, od)
|
||||
if len(got) != 2 || got[0].ContrDID != "cd1" || got[0].ISIN != "RU0009029540" || got[1].ContrDID != "cd3" {
|
||||
t.Errorf("строки исходящего: %+v", got)
|
||||
}
|
||||
if outgoingLineRefs(t.Context(), nil, nil) != nil {
|
||||
t.Error("nil-сделка → нет строк")
|
||||
}
|
||||
}
|
||||
@@ -43,6 +43,7 @@ type outgoingDeps struct {
|
||||
store *OutgoingStore
|
||||
igwClient *igw.Client
|
||||
igwChan string
|
||||
svc *Service // сделки M2MTR: REFERENCE бумаг для строчных событий DEPO
|
||||
}
|
||||
|
||||
// OutgoingSubmit — сохраняемое состояние подачи поручения 16 по одной сделке
|
||||
@@ -332,7 +333,10 @@ func submitOutgoingFor(ctx context.Context, deps outgoingDeps, guid string) (pkg
|
||||
}
|
||||
orderXML = strings.Join(xmls, "\n\n")
|
||||
deps.store.SetSubmitted(guid, contrDIDs, strings.Join(pkgs, ", "), orderXML)
|
||||
go depoOnOrderSent(globalRC, guid, contrDIDs, "16 (списание)")
|
||||
// Подача — по строкам (одно поручение 16 = одна бумага): REFERENCE по ISIN из M2MTR.
|
||||
for _, ln := range outgoingLineRefs(ctx, deps.svc, deal) {
|
||||
go depoOnOrderLineSent(globalRC, guid, ln.Ref, ln.ContrDID)
|
||||
}
|
||||
return pkgs, contrDIDs, orderXML, firstErr // firstErr — если часть не ушла
|
||||
}
|
||||
|
||||
|
||||
@@ -195,6 +195,7 @@ func NewServer(cfg ServerConfig) (*Server, error) {
|
||||
store: globalOutgoingStore,
|
||||
igwClient: igwClient,
|
||||
igwChan: igwChannel,
|
||||
svc: svc, // REFERENCE бумаг из M2MTR — для строчных событий DEPO
|
||||
}
|
||||
globalOutgoingDeps = &outDeps // автоматический режим M2M
|
||||
registerOutgoing(mux, adminTpl, outDeps)
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
package lkgateway
|
||||
|
||||
// sqlident.go — имена объектов MSSQL из настроек (процедура обработчика DEPO,
|
||||
// база, логин) параметризовать нельзя, они склеиваются в текст запроса. Чтобы
|
||||
// значение из формы не превратилось в произвольный T-SQL, перед EXEC имя
|
||||
// проверяется по строгому шаблону идентификатора.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"regexp"
|
||||
)
|
||||
|
||||
// sqlIdentRe — допустимое имя: буквы, цифры, подчёркивание, точка (схема.имя),
|
||||
// квадратные скобки MSSQL. Ни пробелов, ни кавычек, ни точки с запятой.
|
||||
var sqlIdentRe = regexp.MustCompile(`^[A-Za-z0-9_.\[\]]{1,128}$`)
|
||||
|
||||
// sqlIdentOK — строка похожа на имя объекта SQL, а не на код.
|
||||
func sqlIdentOK(s string) bool { return sqlIdentRe.MatchString(s) }
|
||||
|
||||
// execProc — выполнить хранимую процедуру по имени из настроек, предварительно
|
||||
// проверив имя. Сигнатура как у ExecContext, чтобы подставлять на место "exec "+name.
|
||||
func execProc(ctx context.Context, db *sql.DB, proc string) (sql.Result, error) {
|
||||
if !sqlIdentOK(proc) {
|
||||
return nil, fmt.Errorf("недопустимое имя процедуры %q: разрешены буквы, цифры, _ . [ ]", proc)
|
||||
}
|
||||
return db.ExecContext(ctx, "exec "+proc)
|
||||
}
|
||||
Reference in New Issue
Block a user