Files
Bridge-and-Join-s/internal/lkgateway/outgoing_order.go
T

446 lines
16 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
// outgoing_order.go — депозитарное исполнение ИСХОДЯЩЕГО перевода M2M.
//
// Когда МЫ выступаем СПИСЫВАЮЩИМ депозитарием (сценарий 2), после согласия
// контрагента НРД кладёт нам в INBOX черновик простой формы 16 (списание) —
// тот же тип файла ..._MF010.xml, но с <order_t_id>16</order_t_id> (у зачисления
// было 16/1). Мы обязаны подать это поручение 16 в НРД (тип ЭД ORDXM, как и 16/1).
// НРД сматчит наш 16 со встречным 16/1 контрагента и, когда операция проведётся,
// пришлёт нам отчёт MS010 по нашему поручению — подтверждение, что бумага ушла.
//
// Пока подача — вручную кнопкой (как во входящих). Логика подачи вынесена в
// submitOutgoingFor, чтобы позже её мог дёргать авто-поллер (см. TODO auto).
// Серверная механика (упаковка ORDXM, отправка через ИШ, детект MS010) —
// переиспользуется из входящего потока (igw.PackDepoOrder, findOrderResponses).
import (
"archive/zip"
"context"
"encoding/json"
"fmt"
"net/http"
"net/url"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
"sync"
"time"
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/nsdadapter/igw"
)
const outgoingStateFile = "/var/lib/bj/.bj/outgoing-orders.json"
// globalOutgoingStore — стор исходящего депо-исполнения, доступный карточке заявки
// (renderClaim показывает блок формы 16 прямо внутри исходящего перевода).
var globalOutgoingStore *OutgoingStore
// outgoingDeps — зависимости хендлеров исходящего депо-исполнения.
type outgoingDeps struct {
store *OutgoingStore
igwClient *igw.Client
igwChan string
}
// OutgoingSubmit — сохраняемое состояние подачи поручения 16 по одной сделке
// (ключ — GUID сделки = based_on черновика). Хранится локально (JSON), не в Fancy.
type OutgoingSubmit struct {
GUID string `json:"guid"`
ContrDIDs []string `json:"contr_dids"` // номера поданных поручений (для детекта MS010)
Pkgs string `json:"pkgs"` // id пакетов ИШ
OrderAt time.Time `json:"order_at"`
OrderXML string `json:"order_xml"` // что именно отправили (все формы 16, дословно)
}
// OutgoingStore — потокобезопасное хранилище состояния подачи (переживает рестарт).
type OutgoingStore struct {
mu sync.Mutex
items map[string]*OutgoingSubmit
}
func NewOutgoingStore() *OutgoingStore {
s := &OutgoingStore{items: make(map[string]*OutgoingSubmit)}
if b, err := os.ReadFile(outgoingStateFile); err == nil {
var list []*OutgoingSubmit
if json.Unmarshal(b, &list) == nil {
for _, it := range list {
s.items[it.GUID] = it
}
}
}
return s
}
func (s *OutgoingStore) save() {
list := make([]*OutgoingSubmit, 0, len(s.items))
for _, it := range s.items {
list = append(list, it)
}
b, err := json.MarshalIndent(list, "", " ")
if err != nil {
return
}
tmp := outgoingStateFile + ".tmp"
if os.WriteFile(tmp, b, 0o640) == nil {
_ = os.Rename(tmp, outgoingStateFile)
}
}
func (s *OutgoingStore) Get(guid string) (*OutgoingSubmit, bool) {
s.mu.Lock()
defer s.mu.Unlock()
it, ok := s.items[guid]
if !ok {
return nil, false
}
cp := *it
cp.ContrDIDs = append([]string(nil), it.ContrDIDs...)
return &cp, true
}
// SetSubmitted фиксирует факт подачи поручения(й) 16 в НРД по сделке.
func (s *OutgoingStore) SetSubmitted(guid string, contrDIDs []string, pkgs, xml string) {
s.mu.Lock()
defer s.mu.Unlock()
s.items[guid] = &OutgoingSubmit{
GUID: guid, ContrDIDs: contrDIDs, Pkgs: pkgs,
OrderAt: time.Now().UTC(), OrderXML: xml,
}
s.save()
}
// --- разбор черновика формы 16 из INBOX ---
func of16(body, tag string) string {
m := regexp.MustCompile(`<` + tag + `>\s*([^<]*?)\s*</` + tag + `>`).FindStringSubmatch(body)
if m == nil {
return ""
}
return strings.TrimSpace(m[1])
}
// OutgoingSec — бумага в исходящем поручении 16.
type OutgoingSec struct {
SecurityCode string
ISIN string
Quantity string
ContrDID string // номер поручения по этой бумаге
File string // путь к черновику MF010
}
// OutgoingDeal — сделка исходящего перевода (группа черновиков 16 по одному GUID),
// объединённая с состоянием подачи.
type OutgoingDeal struct {
GUID string
DealRef string
CounterpartyCode string // corr_code — принимающий контрагент
DraftAt time.Time
Secs []OutgoingSec
// подача (из стора):
Submitted bool
Pkgs string
OrderAt time.Time
OrderXML string
ContrDIDs []string
// ответ НРД:
Responses []orderResponse
Done bool // получен MS010 по всем поручениям
}
// scanForm16Drafts живьём читает INBOX и собирает исходящие сделки: все черновики
// с <order_t_id>16</order_t_id>, сгруппированные по based_on (GUID). Объединяет с
// состоянием подачи из стора. store может быть nil (тогда без состояния подачи).
// mf010Cache — текст черновиков MF010 по пути и времени изменения: файл после получения не
// меняется, а обход журнала читал их все при каждом показе.
var mf010Cache = struct {
mu sync.Mutex
m map[string]mf010Entry
}{m: map[string]mf010Entry{}}
type mf010Entry struct {
mod time.Time
size int64
body string
}
func mf010Text(p string, info os.FileInfo) (string, bool) {
mf010Cache.mu.Lock()
if e, ok := mf010Cache.m[p]; ok && e.mod.Equal(info.ModTime()) && e.size == info.Size() {
mf010Cache.mu.Unlock()
return e.body, true
}
mf010Cache.mu.Unlock()
b, err := os.ReadFile(p)
if err != nil {
return "", false
}
body := string(decodeCP1251(b))
mf010Cache.mu.Lock()
if len(mf010Cache.m) > 20000 {
mf010Cache.m = map[string]mf010Entry{}
}
mf010Cache.m[p] = mf010Entry{mod: info.ModTime(), size: info.Size(), body: body}
mf010Cache.mu.Unlock()
return body, true
}
func scanForm16Drafts(store *OutgoingStore) []*OutgoingDeal {
byGUID := map[string]*OutgoingDeal{}
_ = filepath.Walk(igwInboxDir, func(p string, info os.FileInfo, err error) error {
if err != nil || info.IsDir() {
return nil
}
if !strings.HasSuffix(strings.ToUpper(p), "_MF010.XML") {
return nil
}
body, ok := mf010Text(p, info)
if !ok {
return nil
}
if of16(body, "order_t_id") != "16" { // только списание (16), не 16/1
return nil
}
guid := of16(body, "based_on")
if guid == "" {
return nil
}
d := byGUID[guid]
if d == nil {
d = &OutgoingDeal{
GUID: guid,
DealRef: of16(body, "deal_reference"),
CounterpartyCode: of16(body, "corr_code"),
}
byGUID[guid] = d
}
if d.DraftAt.IsZero() || info.ModTime().Before(d.DraftAt) {
d.DraftAt = info.ModTime()
}
sec := OutgoingSec{
SecurityCode: of16(body, "security_c"),
ISIN: of16(body, "security_isin"),
Quantity: of16(body, "security_q"),
ContrDID: of16(body, "contr_d_id"),
File: p,
}
d.Secs = append(d.Secs, sec)
return nil
})
out := make([]*OutgoingDeal, 0, len(byGUID))
for _, d := range byGUID {
if store != nil {
if sub, ok := store.Get(d.GUID); ok {
d.Submitted = true
d.Pkgs = sub.Pkgs
d.OrderAt = sub.OrderAt
d.OrderXML = sub.OrderXML
d.ContrDIDs = sub.ContrDIDs
d.Responses, d.Done = outgoingResponses(sub.ContrDIDs)
}
}
out = append(out, d)
}
sort.Slice(out, func(i, j int) bool { return out[i].DraftAt.After(out[j].DraftAt) })
return out
}
func scanForm16Deal(store *OutgoingStore, guid string) (*OutgoingDeal, bool) {
for _, d := range scanForm16Drafts(store) {
if d.GUID == guid {
return d, true
}
}
return nil, false
}
// outgoingResponses агрегирует ответы НРД (MS010/REORD/…) по всем поручениям сделки.
// done=true, когда MS010 получен хотя бы по одному поручению.
func outgoingResponses(contrDIDs []string) ([]orderResponse, bool) {
seen := map[string]bool{}
var out []orderResponse
var done bool
for _, cd := range contrDIDs {
for _, rp := range findOrderResponses(cd) {
if seen[rp.Pkg] {
continue
}
seen[rp.Pkg] = true
out = append(out, rp)
if rp.Done {
done = true
}
}
}
sort.Slice(out, func(i, j int) bool { return out[i].Time < out[j].Time })
return out, done
}
// submitOutgoingFor подаёт все черновики формы 16 по сделке (GUID) в НРД типом
// ORDXM. Вынесено отдельной функцией, чтобы позже дёргать из авто-поллера.
// Возвращает id пакетов ИШ, номера поручений (contr_d_id) и отправленный XML.
func submitOutgoingFor(ctx context.Context, deps outgoingDeps, guid string) (pkgs, contrDIDs []string, orderXML string, err error) {
if deps.igwClient == nil || deps.igwChan == "" {
return nil, nil, "", fmt.Errorf("ИШ не настроен")
}
deal, ok := scanForm16Deal(deps.store, guid)
if !ok || len(deal.Secs) == 0 {
return nil, nil, "", fmt.Errorf("черновик формы 16 по этой сделке в INBOX не найден")
}
var xmls []string
var firstErr error
for _, sec := range deal.Secs {
raw, e := os.ReadFile(sec.File) // windows-1251, шлём как есть
if e != nil {
firstErr = e
continue
}
zipBody, e := igw.PackDepoOrder(raw, filepath.Base(sec.File))
if e != nil {
firstErr = e
continue
}
pkgID, e := deps.igwClient.SendPackage(ctx, deps.igwChan, "K", zipBody)
if e != nil {
firstErr = e
continue
}
pkgs = append(pkgs, pkgID)
if cd := orderContrDID(raw); cd != "" {
contrDIDs = append(contrDIDs, cd)
}
xmls = append(xmls, string(decodeCP1251(raw)))
}
if len(pkgs) == 0 {
if firstErr != nil {
return nil, nil, "", firstErr
}
return nil, nil, "", fmt.Errorf("не удалось отправить ни одного поручения 16")
}
orderXML = strings.Join(xmls, "\n\n")
deps.store.SetSubmitted(guid, contrDIDs, strings.Join(pkgs, ", "), orderXML)
go depoOnOrderSent(globalRC, guid, contrDIDs, "16 (списание)")
return pkgs, contrDIDs, orderXML, firstErr // firstErr — если часть не ушла
}
// --- HTTP ---
// registerOutgoing регистрирует ТОЛЬКО действия по депозитарному поручению 16
// (подача / скачивание / выгрузка). Отдельной страницы нет — блок формы 16
// показывается внутри карточки исходящего перевода (заявки), как 16/1 у входящих.
// Все действия адресуются по GUID сделки; после подачи возвращаемся на ?back.
func registerOutgoing(mux *http.ServeMux, a *admin, deps outgoingDeps) {
mux.HandleFunc("/admin/outgoing/", func(w http.ResponseWriter, r *http.Request) {
p := strings.TrimPrefix(r.URL.Path, "/admin/outgoing/")
switch {
case strings.HasSuffix(p, "/submit-order"):
submitOutgoingOrder(w, r, deps, strings.TrimSuffix(p, "/submit-order"))
case strings.HasSuffix(p, "/order.xml"):
guid := strings.TrimSuffix(p, "/order.xml")
sub, ok := deps.store.Get(guid)
if !ok || strings.TrimSpace(sub.OrderXML) == "" {
http.Error(w, "отправленное поручение 16 не найдено", http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "application/xml; charset=utf-8")
w.Header().Set("Content-Disposition", `attachment; filename="poruchenie-16-`+shortID(guid)+`.xml"`)
_, _ = w.Write(faithfulXMLBytes(sub.OrderXML))
case strings.HasSuffix(p, "/draft.zip"):
guid := strings.TrimSuffix(p, "/draft.zip")
deal, ok := scanForm16Deal(deps.store, guid)
if !ok || len(deal.Secs) == 0 {
http.Error(w, "черновик формы 16 по этой сделке не найден", http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "application/zip")
w.Header().Set("Content-Disposition", `attachment; filename="chernovik-16-`+shortID(guid)+`.zip"`)
zw := zip.NewWriter(w)
for _, s := range deal.Secs {
for _, fp := range []string{s.File, strings.TrimSuffix(s.File, filepath.Ext(s.File)) + ".SGN"} {
if b, e := os.ReadFile(fp); e == nil {
if wf, e2 := zw.Create(filepath.Base(fp)); e2 == nil {
_, _ = wf.Write(b)
}
}
}
}
zw.Close()
case strings.HasSuffix(p, "/ms010.zip"):
guid := strings.TrimSuffix(p, "/ms010.zip")
sub, ok := deps.store.Get(guid)
if !ok {
http.Error(w, "исходящее поручение не найдено", http.StatusNotFound)
return
}
docs := nsdOrderDocs(sub.ContrDIDs...)
if len(docs) == 0 {
http.Error(w, "ответ НРД (MS010) по этому поручению в INBOX ИШ ещё не найден", http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "application/zip")
w.Header().Set("Content-Disposition", `attachment; filename="otvet-nrd-ms010-`+shortID(guid)+`.zip"`)
zw := zip.NewWriter(w)
writeNSDDocsZip(zw, "", "Документы НРД по поручению 16 (списание) № "+strings.Join(sub.ContrDIDs, ", "), docs, true)
zw.Close()
case strings.HasSuffix(p, "/export.zip"):
exportOutgoing(w, r, deps, strings.TrimSuffix(p, "/export.zip"))
default:
http.NotFound(w, r)
}
})
}
// collectOutgoingResponseRaw собирает сырые файлы ответов НРД по всем поручениям
// сделки (по каждому contr_d_id), дедуп по имени файла.
func collectOutgoingResponseRaw(contrDIDs []string) []respFile {
seen := map[string]bool{}
var out []respFile
for _, cd := range contrDIDs {
for _, rf := range collectOrderResponseRaw(cd) {
if seen[rf.Name] {
continue
}
seen[rf.Name] = true
out = append(out, rf)
}
}
return out
}
func submitOutgoingOrder(w http.ResponseWriter, r *http.Request, deps outgoingDeps, guid string) {
back := r.FormValue("back")
if back == "" {
back = "/admin/claims"
}
sep := "?"
if strings.Contains(back, "?") {
sep = "&"
}
redirect := func(msg string) {
http.Redirect(w, r, back+sep+"flash="+url.QueryEscape(msg), http.StatusSeeOther)
}
if r.Method != http.MethodPost {
http.Error(w, "method", http.StatusMethodNotAllowed)
return
}
ctx, cancel := context.WithTimeout(r.Context(), 40*time.Second)
defer cancel()
pkgs, _, _, err := submitOutgoingFor(ctx, deps, guid)
if len(pkgs) == 0 {
msg := "не удалось"
if err != nil {
msg = err.Error()
}
redirect("Поручение 16 (ORDXM): отправка в НРД не удалась: " + msg)
return
}
note := fmt.Sprintf("Поручение 16 (списание, тип ЭД ORDXM) отправлено в НРД: пакет(ы) ИШ %s. "+
"Ждём встречный 16/1 контрагента и отчёт MS010 о проведённом списании.", strings.Join(pkgs, ", "))
if err != nil {
note += " ⚠ часть файлов не ушла: " + err.Error()
}
redirect(note)
}