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"
"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)
}
if deps.igwClient == nil || deps.igwChan == "" {
redirect("ИШ не настроен — нельзя отправить поручение")
return
}
// только .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 {
redirect("Черновик поручения 16/1 (MF010) по этому переводу в INBOX ИШ не найден")
return
}
ctx, cancel := context.WithTimeout(r.Context(), 40*time.Second)
defer cancel()
var sent, sentXML []string
var firstErr, contrDID string
for _, p := range xmls {
raw, e := os.ReadFile(p) // windows-1251, шлём как есть
if e != nil {
firstErr = e.Error()
continue
}
if contrDID == "" {
contrDID = orderContrDID(raw)
}
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, string(decodeCP1251(raw))) // сохраняем что отправили (UTF-8)
}
if len(sent) > 0 {
deps.store.SetOrderSubmitted(id, contrDID, strings.Join(sent, ", "), strings.Join(sentXML, "\n\n"))
if cur, ok := deps.store.Get(id); ok {
go depoOnOrderSent(deps.rc, cur.GUID, []string{contrDID}, "16/1 (зачисление)")
}
}
if len(sent) == 0 {
msg := "не удалось"
if firstErr != "" {
msg = firstErr
}
redirect("Поручение 16/1 (ORDXM): отправка в НРД не удалась: " + msg)
return
}
note := fmt.Sprintf("Поручение 16/1 (тип ЭД ORDXM) отправлено в НРД: пакет(ы) ИШ %s. "+
"Ждём отчёт по поручению депо (XMLRP) = «MS010». Ответ появится ниже на странице.", strings.Join(sent, ", "))
if firstErr != "" {
note += " ⚠ часть файлов не ушла: " + firstErr
}
redirect(note)
}
// writeOrderResponseZip отдаёт на скачивание ZIP с сырыми файлами ответа НРД
// (отчёт MS010 + квитанции) по поручению с данным contr_d_id.
func writeOrderResponseZip(w http.ResponseWriter, contrDID, fname string) {
files := collectOrderResponseRaw(contrDID)
if len(files) == 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()
for _, f := range files {
if wf, e := zw.Create(f.Name); e == nil {
_, _ = wf.Write(f.Data)
}
}
}
// 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 (операция проведена)
}
// 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
}
entries, err := os.ReadDir(igwInboxDir)
if err != nil {
return nil
}
type rr struct {
resp orderResponse
mt time.Time
}
var tmp []rr
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 == "" || !strings.Contains(body, contrDID) {
continue
}
kind, done := orderRespKind(dir, body)
tmp = append(tmp, rr{orderResponse{
Pkg: dir, Kind: kind, Done: done,
Time: mt.In(time.FixedZone("МСК", 3*3600)).Format("02.01.2006 15:04:05"),
Text: orderRespSummary(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). Возвращает текст и время.
func readPkgText(dir string) (string, time.Time) {
var sb strings.Builder
var mt time.Time
files, _ := os.ReadDir(dir)
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"):
return "Отчёт по поручению депо MS010 — операция проведена, бумаги зачислены", true
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
}