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/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 и собирает исходящие сделки: все черновики
// с 16, сгруппированные по 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)
}