package lkgateway
// incoming_order.go — подача депозитарного поручения 16/1 (форма MF010) в НРД
// как классического поручения депо (тип ЭД ORDXM, config.xml K).
// После согласия по 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(`\s*([^<\s]+)\s*`)
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, ""):
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
}