4332e3309b
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014FsEKyJrYF69Cj9ZXzFLRi
508 lines
19 KiB
Go
508 lines
19 KiB
Go
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/m2mcore"
|
||
"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
|
||
svc *Service // сделки M2MTR: REFERENCE бумаг для строчных событий DEPO
|
||
}
|
||
|
||
// 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 по всем поручениям
|
||
Rejected 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 {
|
||
return scanForm16DraftsOpt(store, true)
|
||
}
|
||
|
||
// scanForm16DraftsOpt — то же, но ответы НРД (MS010 и др.) собираются только при withResponses:
|
||
// поиск ответов по номерам поручений — самая дорогая часть, и для журнала она нужна лишь
|
||
// по сделкам в работе, а не по всем, что когда-либо были.
|
||
func scanForm16DraftsOpt(store *OutgoingStore, withResponses bool) []*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
|
||
if withResponses {
|
||
d.Responses, d.Done = outgoingResponses(sub.ContrDIDs, orderWatchRefs(d.GUID)...)
|
||
_, d.Rejected, _ = orderRespVerdict(d.Responses)
|
||
}
|
||
}
|
||
}
|
||
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, refs ...string) ([]orderResponse, bool) {
|
||
seen := map[string]bool{}
|
||
var out []orderResponse
|
||
var done bool
|
||
keys := append(append([]string{}, contrDIDs...), refs...) // GS036 «нет встречного» — только по референсу
|
||
for _, cd := range keys {
|
||
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)
|
||
// Подача — по строкам (одно поручение 16 = одна бумага): REFERENCE по ISIN из M2MTR.
|
||
for _, ln := range outgoingLineRefs(ctx, deps.svc, deal) {
|
||
go depoOnOrderLineSent(globalRC, guid, ln.Ref, ln.ContrDID)
|
||
}
|
||
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, "/depo-sim"):
|
||
// тестовый прогон исходящего в DEPO с реальным клиентом (без отправки в НРД)
|
||
if r.Method != http.MethodPost {
|
||
http.Error(w, "method", http.StatusMethodNotAllowed)
|
||
return
|
||
}
|
||
guid := strings.TrimSuffix(p, "/depo-sim")
|
||
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Minute)
|
||
defer cancel()
|
||
newGUID, err := depoSimulateOutgoing(ctx, globalRC, deps.svc, guid, r.FormValue("ser"), r.FormValue("num"))
|
||
back := r.Referer()
|
||
if back == "" {
|
||
back = "/admin/claims"
|
||
}
|
||
if err != nil {
|
||
http.Redirect(w, r, back+"?flash="+urlQ("Прогон в DEPO: "+err.Error()), http.StatusSeeOther)
|
||
return
|
||
}
|
||
http.Redirect(w, r, back+"?flash="+urlQ("Прогон в DEPO записан: новый GUID "+newGUID+", клиент по паспорту "+r.FormValue("ser")+" "+r.FormValue("num")), http.StatusSeeOther)
|
||
case p == "depo-sim-list":
|
||
// список исходящих сделок для прогона: GUID, контрагент, клиент, решение
|
||
deals, _ := deps.svc.Repo().List(r.Context(), m2mcore.Filter{Limit: 500})
|
||
type row struct{ GUID, CP, Name, Created, Decision string }
|
||
var rows []row
|
||
for _, d := range deals {
|
||
if d == nil || d.Request == nil {
|
||
continue
|
||
}
|
||
inv := d.Request.Data.InvestorInformation
|
||
dec := "нет решения"
|
||
if d.Decision != nil {
|
||
var parts []string
|
||
for _, sec := range d.Decision.Data.Securities {
|
||
if sec.TransferDecision.Rejection != nil {
|
||
parts = append(parts, string(sec.ReferenceID)+":reject "+strings.Join(sec.TransferDecision.Rejection.Codes, ","))
|
||
} else {
|
||
parts = append(parts, string(sec.ReferenceID)+":confirm")
|
||
}
|
||
}
|
||
dec = strings.Join(parts, "; ")
|
||
}
|
||
rows = append(rows, row{string(d.GUID), handbookCodeByINN(string(d.Request.Data.ReceivingDepository.INN)), strings.TrimSpace(inv.LastName + " " + inv.FirstName), d.CreatedAt.Format("2006-01-02 15:04"), dec})
|
||
}
|
||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||
_ = json.NewEncoder(w).Encode(rows)
|
||
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)
|
||
}
|