Files
Bridge-and-Join-s/internal/lkgateway/incoming_order.go
T
fontvielle c476cfd7a3 ЛК-карточка и M2M: операторский путь заявки, баннеры «в НРД не отправлено», дубль-поручение, idempotency, фикс DEPO-алертов
- Карточка заявки: операторский «Путь заявки» (claimTimelineOperator) короткими
  понятными вехами + сворачиваемый технический журнал (buildClaimTimeline);
  QUIK показан пошагово (register/sign/deny/deposit) с верными временами, отказ
  регистрации больше не выглядит успехом (quikActionFailTitles).
- Баннеры: «в НРД не отправлено», когда пакет сформирован, но не ушёл (нет
  submitted_to_nsd и не Fail); заголовок карточки и бейдж учитывают это; красный
  баннер QUIK при отказе, строгий «нет бумаг» только при реальном шорт/недостатке.
- НРД-ответ «Поручение с исходящим номером … уже существует» теперь REG_DUP
  (предупреждение-дубль, не красный «Отказ в регистрации»); не влияет на od.Rejected.
- Idempotency: submitguard.go (inflightSubmit) + защита точек подачи от
  двойного срабатывания (manual + auto-loop, двойной клик): submitOutgoingFor,
  submitIncomingOrderFor, QuikOutRegister (ранний возврат при registered/signed).
- DEPO-алерты: водяной знак depoFailAlertedID засеивается текущим MAX(ID) на
  старте процесса (Hlth03) — после рестарта bj больше не поднимает повторно
  алерты про уже известные ошибки обработки событий BJ_*.
- Тесты: claimnotsent_render_test.go (хелперы + рендер карточки).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XG3zNMMf2LMoDpXo22mwMX
2026-10-15 17:47:28 +03:00

503 lines
20 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package lkgateway
// incoming_order.go — подача депозитарного поручения 16/1 (форма MF010) в НРД
// как классического поручения депо (тип ЭД ORDXM, config.xml <package>K</package>).
// После согласия по M2M НРД кладёт нам в INBOX черновик поручения 16/1; здесь мы
// отправляем его в НРД через ИШ (WSL-канал). НРД проводит зачисление и присылает
// отчёт по поручению депо (тип XMLRP) — это и есть ожидаемый контрагентом «MS010».
// ИШ подписывает документ ГОСТ-подписью при отправке через REST API.
import (
"archive/zip"
"bytes"
"context"
"fmt"
"io"
"net/http"
"net/url"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
"sync"
"time"
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/nsdadapter/igw"
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/nsdxml"
)
var (
reContrDID = regexp.MustCompile(`<contr_d_id>\s*([^<\s]+)\s*</contr_d_id>`)
reTags = regexp.MustCompile(`<[^>]+>`)
)
func decodeCP1251(b []byte) []byte { return nsdxml.DecodeWindows1251(b) }
// faithfulXMLBytes отдаёт XML в кодировке, заявленной в его декларации: если это
// windows-1251 (как у файлов НРД), перекодирует UTF-8-строку обратно в 1251 —
// чтобы сохранённый файл открывался согласно своей же декларации. Иначе — как есть.
func faithfulXMLBytes(s string) []byte {
head := s
if len(head) > 120 {
head = head[:120]
}
if strings.Contains(strings.ToLower(head), "windows-1251") {
if enc, err := nsdxml.EncodeWindows1251([]byte(s)); err == nil {
return enc
}
}
return []byte(s)
}
// submitDepoOrder отправляет поручение депо 16/1 (черновик MF010 из INBOX) в НРД
// типом ЭД ORDXM. По каждой бумаге НРД сформировал отдельный черновик — шлём все.
func submitDepoOrder(w http.ResponseWriter, r *http.Request, deps incomingDeps, id string) {
if r.Method != http.MethodPost {
http.Error(w, "method", http.StatusMethodNotAllowed)
return
}
it, ok := deps.store.Get(id)
if !ok {
http.Error(w, "входящий перевод не найден", http.StatusNotFound)
return
}
redirect := func(msg string) {
http.Redirect(w, r, "/admin/incoming/"+id+"?flash="+url.QueryEscape(msg), http.StatusSeeOther)
}
ctx, cancel := context.WithTimeout(r.Context(), 40*time.Second)
defer cancel()
sent, firstErr, err := submitIncomingOrderFor(ctx, deps, it)
if err != nil {
redirect(err.Error())
return
}
note := fmt.Sprintf("Поручение 16/1 (тип ЭД ORDXM) отправлено в НРД: пакет(ы) ИШ %s. "+
"Ждём отчёт по поручению депо (XMLRP) = «MS010». Ответ появится ниже на странице.", strings.Join(sent, ", "))
if firstErr != "" {
note += " ⚠ часть файлов не ушла: " + firstErr
}
redirect(note)
}
// submitIncomingOrderFor — подать поручение 16/1 (ORDXM) по черновику MF010 из INBOX ИШ (кнопка в
// карточке и автоматический режим). firstErr — если часть файлов не ушла.
func submitIncomingOrderFor(ctx context.Context, deps incomingDeps, it *IncomingTransfer) (sent []string, firstErr string, err error) {
if deps.igwClient == nil || deps.igwChan == "" {
return nil, "", fmt.Errorf("ИШ не настроен — нельзя отправить поручение")
}
// защита от параллельной/повторной подачи (авто-поллер + ручная кнопка/двойной клик)
if !beginSubmit("in:" + it.ID) {
return nil, "", fmt.Errorf("поручение 16/1 по этому переводу уже отправляется (параллельный запрос) — повторная подача отменена")
}
defer endSubmit("in:" + it.ID)
if cur, ok := deps.store.Get(it.ID); ok && cur.OrderSubmitted && strings.TrimSpace(cur.OrderPkgs) != "" {
// уже подано ранее — в НРД повторно НЕ шлём (идемпотентность)
return strings.Split(cur.OrderPkgs, ", "), "", nil
}
// только .xml (без .SGN) — подпись НРД нам не нужна, ИШ подпишет своей ГОСТ.
var xmls []string
for _, p := range findMF010Files(it.GUID) {
if strings.HasSuffix(strings.ToUpper(p), ".XML") {
xmls = append(xmls, p)
}
}
if len(xmls) == 0 {
return nil, "", fmt.Errorf("Черновик поручения 16/1 (MF010) по этому переводу в INBOX ИШ не найден")
}
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 = cd
}
zipBody, e := igw.PackDepoOrder(raw, filepath.Base(p))
if e != nil {
firstErr = e.Error()
continue
}
pkgID, e := deps.igwClient.SendPackage(ctx, deps.igwChan, "K", zipBody)
if e != nil {
firstErr = e.Error()
continue
}
sent = append(sent, pkgID)
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"), lines)
if cur, ok := deps.store.Get(id); ok {
for _, l := range lines {
go depoOnOrderLineSent(deps.rc, cur.GUID, l.Ref, l.ContrDID)
}
}
}
if len(sent) == 0 {
msg := "не удалось"
if firstErr != "" {
msg = firstErr
}
return nil, "", fmt.Errorf("Поручение 16/1 (ORDXM): отправка в НРД не удалась: %s", msg)
}
return sent, firstErr, nil
}
// writeOrderResponseZip отдаёт на скачивание ZIP с сырыми файлами ответа НРД
// (отчёт MS010 + квитанции) по поручению с данным contr_d_id.
func writeOrderResponseZip(w http.ResponseWriter, contrDID, fname string) {
docs := nsdOrderDocs(contrDID)
if len(docs) == 0 {
http.Error(w, "ответ НРД (MS010) по этому поручению в INBOX ИШ ещё не найден", http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "application/zip")
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s.zip"`, fname))
zw := zip.NewWriter(w)
defer zw.Close()
writeNSDDocsZip(zw, "", "Документы НРД по поручению № "+contrDID, docs, true)
}
// downloadSentOrder отдаёт на скачивание XML отправленного поручения 16/1
// (что именно мы отправили в НРД).
func downloadSentOrder(w http.ResponseWriter, r *http.Request, deps incomingDeps, id string) {
it, ok := deps.store.Get(id)
if !ok || strings.TrimSpace(it.OrderXML) == "" {
http.Error(w, "отправленное поручение 16/1 по этому переводу не найдено", http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "application/xml; charset=utf-8")
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="poruchenie-16-1-%s.xml"`, shortID(it.ID)))
_, _ = w.Write(faithfulXMLBytes(it.OrderXML))
}
// draftReceivedTime — время появления черновика 16/1 (MF010) в INBOX ИШ (МСК),
// то есть когда НРД сформировал и прислал нам черновик поручения. "" если нет.
func draftReceivedTime(guid string) string {
var mt time.Time
for _, p := range findMF010Files(guid) {
if info, e := os.Stat(p); e == nil {
if mt.IsZero() || info.ModTime().Before(mt) {
mt = info.ModTime()
}
}
}
if mt.IsZero() {
return ""
}
return mt.In(time.FixedZone("МСК", 3*3600)).Format("02.01.2006 15:04:05")
}
// orderContrDID достаёт contr_d_id (исходящий номер поручения) из XML черновика.
func orderContrDID(raw []byte) string {
t := string(raw)
m := reContrDID.FindStringSubmatch(t)
if m == nil {
return ""
}
return strings.TrimSpace(m[1])
}
// orderResponse — ответ НРД по поданному поручению депо (найден в INBOX ИШ).
type orderResponse struct {
Pkg string // имя пакета
Kind string // человекочитаемый тип (квитанция / принятие / отчёт MS010)
Time string
Text string // краткое содержимое
Done bool // true — это отчёт MS010 (операция проведена или завершена отказом)
// Rejected — MS010 с отказом: операция завершилась, но не исполнена. Показывается красным.
Rejected bool
// No — номер документа НРД (BizMsgIdr), для отчёта MS010 — «номер ответа НРД» в PARAMETERS событий DEPO.
No string
}
// findOrderResponses ищет в INBOX ИШ ответы НРД по нашему поручению (по contr_d_id).
// Читает как распакованные файлы, так и содержимое zip-архивов (отчёты НРД типа
// MS010 приходят dbf+repxml.xml внутри zip, имя пакета числовое). Исключает наши
// собственные #M2M-пакеты (черновик/запрос).
func findOrderResponses(contrDID string) []orderResponse {
contrDID = strings.TrimSpace(contrDID)
if contrDID == "" {
return nil
}
type rr struct {
resp orderResponse
mt time.Time
}
var tmp []rr
for _, pk := range pkgIndex() {
dir, body, mt := pk.dir, pk.body, pk.mt
if !strings.Contains(body, contrDID) {
continue
}
kind, done := orderRespKind(dir, body)
tmp = append(tmp, rr{orderResponse{
Pkg: dir, Kind: kind, Done: done, Rejected: done && respTextRejected(body),
Time: mt.In(time.FixedZone("МСК", 3*3600)).Format("02.01.2006 15:04:05"),
Text: orderRespSummary(body),
No: nsdRespNoDate(body),
}, mt})
}
sort.Slice(tmp, func(i, j int) bool { return tmp[i].mt.Before(tmp[j].mt) })
out := make([]orderResponse, len(tmp))
for i := range tmp {
out[i] = tmp[i].resp
}
return out
}
// respFile — сырой файл ответа НРД (для скачивания и выгрузки регулятору).
type respFile struct {
Name string // имя внутри архива (пакет/файл)
Data []byte
}
// collectOrderResponseRaw собирает СЫРЫЕ файлы ответов НРД по нашему поручению
// (по contr_d_id): отчёт MS010 (repxml.xml/ms010.rpt/dbf внутри zip), квитанции,
// уведомления о принятии. Именно эти файлы важны регулятору как подтверждение,
// что НРД получил и обработал поручение.
func collectOrderResponseRaw(contrDID string) []respFile {
contrDID = strings.TrimSpace(contrDID)
if contrDID == "" {
return nil
}
entries, err := os.ReadDir(igwInboxDir())
if err != nil {
return nil
}
var out []respFile
for _, e := range entries {
if !e.IsDir() {
continue
}
dir := e.Name()
if strings.HasPrefix(dir, "#") || dir == "INBOX" { // наши M2M-пакеты — не ответ
continue
}
body, _ := readPkgText(filepath.Join(igwInboxDir(), dir))
if body == "" || !strings.Contains(body, contrDID) {
continue
}
files, _ := os.ReadDir(filepath.Join(igwInboxDir(), dir))
for _, f := range files {
if f.IsDir() {
continue
}
p := filepath.Join(igwInboxDir(), dir, f.Name())
low := strings.ToLower(f.Name())
switch {
case strings.HasSuffix(low, ".xml"), strings.HasSuffix(low, ".rpt"),
strings.HasSuffix(low, ".htm"), strings.HasSuffix(low, ".dbf"), strings.HasSuffix(low, ".sgn"):
if b, e := os.ReadFile(p); e == nil {
out = append(out, respFile{Name: dir + "/" + f.Name(), Data: b})
}
case strings.HasSuffix(low, ".zip"):
if b, e := os.ReadFile(p); e == nil {
if zr, e2 := zip.NewReader(bytes.NewReader(b), int64(len(b))); e2 == nil {
for _, zf := range zr.File {
if rc, e3 := zf.Open(); e3 == nil {
data, _ := io.ReadAll(rc)
rc.Close()
out = append(out, respFile{Name: dir + "/" + zf.Name, Data: data})
}
}
}
}
}
}
}
return out
}
// readPkgText собирает текст всех документов пакета: распакованные xml/htm/rpt и
// содержимое вложенных zip (декодирует windows-1251). Возвращает текст и время.
// pkgIndex — все пакеты-ответы во входящем каталоге ИШ с их текстом, одним проходом и с
// коротким кэшем: поиск ответов по номерам поручений идёт по памяти, а не по диску на
// каждый номер (сотни номеров × две тысячи каталогов давали сотни миллисекунд на страницу).
type pkgIndexEntry struct {
dir, body string
mt time.Time
}
var pkgIndexCache = struct {
mu sync.Mutex
at time.Time
list []pkgIndexEntry
}{}
func pkgIndex() []pkgIndexEntry {
pkgIndexCache.mu.Lock()
defer pkgIndexCache.mu.Unlock()
if pkgIndexCache.list != nil && time.Since(pkgIndexCache.at) < 3*time.Second {
return pkgIndexCache.list
}
entries, err := os.ReadDir(igwInboxDir())
if err != nil {
return nil
}
list := make([]pkgIndexEntry, 0, len(entries))
for _, e := range entries {
if !e.IsDir() {
continue
}
dir := e.Name()
if strings.HasPrefix(dir, "#") || dir == "INBOX" { // наши M2M-пакеты — не ответ
continue
}
body, mt := readPkgText(filepath.Join(igwInboxDir(), dir))
if body == "" {
continue
}
list = append(list, pkgIndexEntry{dir: dir, body: body, mt: mt})
}
pkgIndexCache.list, pkgIndexCache.at = list, time.Now()
return list
}
// pkgTextCache — разобранный текст пакетов ИШ по каталогу: пакет после получения не меняется,
// поэтому перечитывать его файлы при каждом обходе журнала незачем. Ключ проверки — время
// изменения каталога пакета и число файлов в нём.
var pkgTextCache = struct {
mu sync.Mutex
m map[string]pkgTextEntry
}{m: map[string]pkgTextEntry{}}
type pkgTextEntry struct {
dirMod time.Time
n int
body string
mt time.Time
}
func readPkgText(dir string) (string, time.Time) {
var dirMod time.Time
if st, err := os.Stat(dir); err == nil {
dirMod = st.ModTime()
}
files, _ := os.ReadDir(dir)
pkgTextCache.mu.Lock()
if e, ok := pkgTextCache.m[dir]; ok && e.dirMod.Equal(dirMod) && e.n == len(files) {
pkgTextCache.mu.Unlock()
return e.body, e.mt
}
pkgTextCache.mu.Unlock()
body, mt := readPkgTextUncached(dir, files)
pkgTextCache.mu.Lock()
if len(pkgTextCache.m) > 20000 { // защита от бесконечного роста при чистке каталога
pkgTextCache.m = map[string]pkgTextEntry{}
}
pkgTextCache.m[dir] = pkgTextEntry{dirMod: dirMod, n: len(files), body: body, mt: mt}
pkgTextCache.mu.Unlock()
return body, mt
}
func readPkgTextUncached(dir string, files []os.DirEntry) (string, time.Time) {
var sb strings.Builder
var mt time.Time
for _, f := range files {
if f.IsDir() {
continue
}
p := filepath.Join(dir, f.Name())
if info, e := f.Info(); e == nil && info.ModTime().After(mt) {
mt = info.ModTime()
}
low := strings.ToLower(f.Name())
switch {
case strings.HasSuffix(low, ".xml"), strings.HasSuffix(low, ".htm"), strings.HasSuffix(low, ".rpt"):
if b, e := os.ReadFile(p); e == nil {
sb.Write(decodeCP1251(b))
sb.WriteByte('\n')
}
case strings.HasSuffix(low, ".zip"):
if b, e := os.ReadFile(p); e == nil {
if zr, e2 := zip.NewReader(bytes.NewReader(b), int64(len(b))); e2 == nil {
for _, zf := range zr.File {
zl := strings.ToLower(zf.Name)
if strings.HasSuffix(zl, ".xml") || strings.HasSuffix(zl, ".rpt") || strings.HasSuffix(zl, ".htm") {
if rc, e3 := zf.Open(); e3 == nil {
data, _ := io.ReadAll(rc)
rc.Close()
sb.Write(decodeCP1251(data))
sb.WriteByte('\n')
}
}
}
}
}
}
}
return sb.String(), mt
}
// orderRespKind классифицирует ответ НРД по содержимому (надёжнее префикса имени).
// done=true — это отчёт MS010 (операция проведена).
func orderRespKind(pkg, body string) (string, bool) {
u := strings.ToUpper(body)
low := strings.ToLower(body)
switch {
case strings.Contains(u, "MS010") || strings.Contains(u, "SESE.025") || strings.Contains(u, "SESE.024") || strings.Contains(u, "GS036") || strings.Contains(u, "GS116") || strings.Contains(low, "<reg_no>"):
d := classifyNSDDoc(pkg, body)
return d.Title + " — " + d.About, d.Done
case strings.Contains(u, "ERROR") || strings.Contains(strings.ToUpper(pkg), "ERROR") || strings.Contains(low, "ошибк"):
return "Ошибка обработки пакета (CERROR)", false
case strings.Contains(u, "REORD") || strings.Contains(low, "принят"):
return "Уведомление о принятии поручения депо (REORD)", false
case strings.Contains(u, "XCONF") || strings.Contains(low, "квитанц") || strings.Contains(low, "получен"):
return "Квитанция о получении пакета (XCONF)", false
}
// fallback по первой букве имени пакета
switch {
case strings.HasPrefix(pkg, "K"):
return "Уведомление о принятии поручения депо (REORD)", false
case strings.HasPrefix(pkg, "C"):
return "Квитанция о получении пакета (XCONF)", false
}
return "Ответ НРД (" + pkg + ")", false
}
func orderRespSummary(body string) string {
txt := reTags.ReplaceAllString(body, " ")
txt = strings.Join(strings.Fields(txt), " ")
if len(txt) > 220 {
txt = txt[:220]
}
return txt
}
// respTextRejected — в отчёте НРД сказано об отказе, а не об исполнении.
func respTextRejected(body string) bool {
low := strings.ToLower(body)
return strings.Contains(low, "отказ") || strings.Contains(low, "reject") || strings.Contains(low, "не исполнен")
}
// nsdRespNoDate — «номер от дата» документа НРД: BizMsgIdr и дата CreDt (МСК), например «50878767 от 29.09.2026».
func nsdRespNoDate(body string) string {
no := strings.TrimSpace(xmlTag(body, "BizMsgIdr"))
if no == "" {
return ""
}
if t, err := time.Parse(time.RFC3339, strings.TrimSpace(xmlTag(body, "CreDt"))); err == nil {
return no + " от " + t.In(time.FixedZone("МСК", 3*3600)).Format("02.01.2006")
}
return no
}