da12a9ec1c
- bj_exchange и «кран» Fancy убраны: данные модуля ЛК в PostgreSQL (схема lk) - трансферные таблицы FTV_M2M: создание при установке, события, справочник участников - сверка с DEPO: счёт, ФИО, дата рождения, ИИС по договору, квал-инвестор из опции DEPO - боевой ЛК ЕСИА Финанс при тестовом ИШ: журнал, отказ через DEPO, статусы в ЛК - онлайн-лицензирование в bj, план тестирования M2M для контрагента (интерактивный HTML) - модули QUIK МНП, экспорт QUIK, НПР/УДС; документы и тестовые материалы Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
381 lines
14 KiB
Go
381 lines
14 KiB
Go
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"
|
|
"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)
|
|
}
|
|
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
|
|
}
|