package lkgateway // outgoing_order.go — депозитарное исполнение ИСХОДЯЩЕГО перевода M2M. // // Когда МЫ выступаем СПИСЫВАЮЩИМ депозитарием (сценарий 2), после согласия // контрагента НРД кладёт нам в INBOX черновик простой формы 16 (списание) — // тот же тип файла ..._MF010.xml, но с 16 (у зачисления // было 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*`).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 и собирает исходящие сделки: все черновики // с 16, сгруппированные по based_on (GUID). Объединяет с // состоянием подачи из стора. store может быть nil (тогда без состояния подачи). 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 } b, e := os.ReadFile(p) if e != nil { return nil } body := string(decodeCP1251(b)) 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) }