feat(bj): модуль ЛК на PostgreSQL, обмен с DEPO, боевой ЛК с имитатором НРД, QUIK, НПР
- 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>
This commit is contained in:
@@ -0,0 +1,416 @@
|
||||
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 (тогда без состояния подачи).
|
||||
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
|
||||
}
|
||||
files := collectOutgoingResponseRaw(sub.ContrDIDs)
|
||||
if len(files) == 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)
|
||||
for _, f := range files {
|
||||
if wf, e := zw.Create(f.Name); e == nil {
|
||||
_, _ = wf.Write(f.Data)
|
||||
}
|
||||
}
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user