60b4d46277
«Исходящие», «Входящие», «Журнал обмена»: быстрые периоды (сегодня, 7, 30 дней, всё время), даты с–по, сортировка (время, инвестор/контрагент, направление, статус), первые 50 строк и подгрузка при прокрутке (part=rows&offset=N). Раньше показывались только последние 200/100. Анкета участника — исходящий пакет в НРД: строка в «Исходящих» и «Журнале» с ответом НРД (принята / отклонена с кодом / ждёт), вместо плашки над списком. Отправки копятся в participant-regs.json (прежний participant-reg.json подхватывается). userTemplate — функции текущего пользователя и для порций строк. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q7eREaYwUt1i4fNLZcGnjn
284 lines
11 KiB
Go
284 lines
11 KiB
Go
package lkgateway
|
||
|
||
import (
|
||
"context"
|
||
"database/sql"
|
||
"encoding/json"
|
||
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/lkgateway/fsql"
|
||
"net/http"
|
||
"net/url"
|
||
"path"
|
||
"strings"
|
||
"time"
|
||
)
|
||
|
||
// registerIncoming вешает маршруты «Входящие переводы» (сценарий 3333):
|
||
// список, карточка с выбором по бумагам, отправка решения роботу.
|
||
func registerIncoming(mux *http.ServeMux, a *admin, deps incomingDeps) {
|
||
mux.HandleFunc("/admin/incoming", func(w http.ResponseWriter, r *http.Request) {
|
||
q := parseListQ(r, []string{"time", "party", "status"})
|
||
var items []listItem
|
||
for _, it := range deps.store.List() {
|
||
_, st, _ := incomingJournalStatus(it)
|
||
items = append(items, listItem{T: it.ReceivedAt, V: it, Keys: map[string]string{
|
||
"party": pick(m2mPartyName(it.SenderCode), m2mPartyName(it.SenderINN)) + " " + it.SenderCode, "status": st,
|
||
}})
|
||
}
|
||
rows, total, more := listApply(items, q)
|
||
if r.URL.Query().Get("part") == "rows" {
|
||
listRowsPart(w, a.incoming, "inrow", rows, q, more)
|
||
return
|
||
}
|
||
data := struct {
|
||
page
|
||
Rows []any
|
||
List listView
|
||
}{nowPage("Входящие переводы", "incoming"), listVals(rows),
|
||
newListView("/admin/incoming", "in-body", q, total, more, "time", "по времени", "party", "по отправителю", "status", "по статусу")}
|
||
render(w, a.incoming, data)
|
||
})
|
||
|
||
// JSON-фид для браузерных уведомлений: список входящих, ждущих решения.
|
||
// Фронтенд (layout.html) опрашивает его и показывает Notification на новые.
|
||
mux.HandleFunc("/admin/incoming/feed", func(w http.ResponseWriter, r *http.Request) {
|
||
type feedItem struct {
|
||
ID string `json:"id"`
|
||
GUID string `json:"guid"`
|
||
Sender string `json:"sender"`
|
||
Count int `json:"count"`
|
||
Received string `json:"received"`
|
||
}
|
||
var pending []feedItem
|
||
for _, it := range deps.store.List() {
|
||
if it.Status != "pending" {
|
||
continue
|
||
}
|
||
pending = append(pending, feedItem{
|
||
ID: it.ID, GUID: it.GUID, Sender: it.SenderCode,
|
||
Count: len(it.Securities), Received: it.ReceivedAt.In(time.FixedZone("МСК", 3*3600)).Format("02.01 15:04:05"),
|
||
})
|
||
}
|
||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||
w.Header().Set("Cache-Control", "no-store")
|
||
_ = json.NewEncoder(w).Encode(map[string]any{"count": len(pending), "pending": pending})
|
||
})
|
||
|
||
// Тестовый прогон входящего в DEPO с реальным клиентом (без отправки в НРД).
|
||
mux.HandleFunc("/admin/incoming/depo-sim", func(w http.ResponseWriter, r *http.Request) {
|
||
if r.Method != http.MethodPost {
|
||
http.Error(w, "method", http.StatusMethodNotAllowed)
|
||
return
|
||
}
|
||
id := r.FormValue("id")
|
||
it, ok := deps.store.Get(id)
|
||
if !ok {
|
||
http.NotFound(w, r)
|
||
return
|
||
}
|
||
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Minute)
|
||
defer cancel()
|
||
guid, err := depoSimulateIncoming(ctx, deps.rc, it, r.FormValue("ser"), r.FormValue("num"))
|
||
if err != nil {
|
||
http.Redirect(w, r, "/admin/incoming/"+id+"?flash="+urlQ("Прогон в DEPO: "+err.Error()), http.StatusSeeOther)
|
||
return
|
||
}
|
||
// ждём, создаст ли джоба документ
|
||
doc := ""
|
||
if db, e := depoEventsDB(deps.rc); e == nil {
|
||
for i := 0; i < 8; i++ {
|
||
time.Sleep(5 * time.Second)
|
||
var d sql.NullString
|
||
_ = db.QueryRowContext(ctx, fsql.QIn01, sql.Named("g", guid)).Scan(&d)
|
||
if d.Valid && d.String != "" && d.String != "0" {
|
||
doc = d.String
|
||
break
|
||
}
|
||
}
|
||
db.Close()
|
||
}
|
||
msg := "Прогон в DEPO записан: новый GUID " + guid + ", клиент по паспорту " + r.FormValue("ser") + " " + r.FormValue("num")
|
||
if doc != "" {
|
||
msg += ", джоба создала документ DEPO №" + doc
|
||
} else {
|
||
msg += "; документ DEPO пока не создан (джоба не ответила за 40 с)"
|
||
}
|
||
http.Redirect(w, r, "/admin/incoming/"+id+"?flash="+urlQ(msg), http.StatusSeeOther)
|
||
})
|
||
mux.HandleFunc("/admin/incoming/", func(w http.ResponseWriter, r *http.Request) {
|
||
p := strings.TrimPrefix(r.URL.Path, "/admin/incoming/")
|
||
// POST /admin/incoming/{id}/answer
|
||
if strings.HasSuffix(p, "/answer") {
|
||
id := path.Base(strings.TrimSuffix(p, "/answer"))
|
||
handleIncomingAnswer(w, r, deps, id)
|
||
return
|
||
}
|
||
// GET /admin/incoming/{id}/export.zip — выгрузка для регулятора
|
||
if strings.HasSuffix(p, "/export.zip") {
|
||
id := path.Base(strings.TrimSuffix(p, "/export.zip"))
|
||
exportIncoming(w, r, deps, id)
|
||
return
|
||
}
|
||
// GET /admin/incoming/{id}/draft.zip — черновик поручения 16/1 (MF010) для учётной системы
|
||
if strings.HasSuffix(p, "/draft.zip") {
|
||
id := path.Base(strings.TrimSuffix(p, "/draft.zip"))
|
||
downloadIncomingDraft(w, r, deps, id)
|
||
return
|
||
}
|
||
// POST /admin/incoming/{id}/submit-order — подать поручение 16/1 (ORDXM) в НРД
|
||
if strings.HasSuffix(p, "/submit-order") {
|
||
id := path.Base(strings.TrimSuffix(p, "/submit-order"))
|
||
submitDepoOrder(w, r, deps, id)
|
||
return
|
||
}
|
||
// GET /admin/incoming/{id}/order.xml — скачать отправленную форму 16/1
|
||
if strings.HasSuffix(p, "/order.xml") {
|
||
id := path.Base(strings.TrimSuffix(p, "/order.xml"))
|
||
downloadSentOrder(w, r, deps, id)
|
||
return
|
||
}
|
||
// GET /admin/incoming/{id}/request.xml — исходный запрос контрагента
|
||
if strings.HasSuffix(p, "/request.xml") {
|
||
id := path.Base(strings.TrimSuffix(p, "/request.xml"))
|
||
it, ok := deps.store.Get(id)
|
||
if !ok || it.RawXML == "" {
|
||
http.Error(w, "запрос не найден", http.StatusNotFound)
|
||
return
|
||
}
|
||
w.Header().Set("Content-Type", "application/xml; charset=utf-8")
|
||
w.Header().Set("Content-Disposition", `attachment; filename="request-`+shortID(id)+`.xml"`)
|
||
_, _ = w.Write(faithfulXMLBytes(it.RawXML))
|
||
return
|
||
}
|
||
// GET /admin/incoming/{id}/decision.xml — наш ответ-решение
|
||
if strings.HasSuffix(p, "/decision.xml") {
|
||
id := path.Base(strings.TrimSuffix(p, "/decision.xml"))
|
||
it, ok := deps.store.Get(id)
|
||
if !ok || it.DecisionXML == "" {
|
||
http.Error(w, "решение не найдено", http.StatusNotFound)
|
||
return
|
||
}
|
||
w.Header().Set("Content-Type", "application/xml; charset=utf-8")
|
||
w.Header().Set("Content-Disposition", `attachment; filename="decision-`+shortID(id)+`.xml"`)
|
||
_, _ = w.Write(faithfulXMLBytes(it.DecisionXML))
|
||
return
|
||
}
|
||
// GET /admin/incoming/{id}/ms010.zip — ответ НРД (отчёт MS010) по поручению
|
||
if strings.HasSuffix(p, "/ms010.zip") {
|
||
id := path.Base(strings.TrimSuffix(p, "/ms010.zip"))
|
||
it, ok := deps.store.Get(id)
|
||
if !ok {
|
||
http.Error(w, "не найден", http.StatusNotFound)
|
||
return
|
||
}
|
||
writeOrderResponseZip(w, it.OrderContrDID, "otvet-nrd-ms010-"+shortID(id))
|
||
return
|
||
}
|
||
// GET /admin/incoming/{id}
|
||
id := path.Base(p)
|
||
it, ok := deps.store.Get(id)
|
||
if !ok {
|
||
http.Error(w, "входящий перевод не найден", http.StatusNotFound)
|
||
return
|
||
}
|
||
var orderResps []orderResponse
|
||
var orderDone, orderRejected bool
|
||
var orderDoneAt string
|
||
if it.OrderSubmitted {
|
||
orderResps = findOrderResponses(it.OrderContrDID)
|
||
for _, rp := range orderResps {
|
||
if rp.Done { // отчёт MS010 — операция проведена или завершена отказом
|
||
orderDone = true
|
||
orderDoneAt = rp.Time
|
||
orderRejected = orderRejected || rp.Rejected
|
||
}
|
||
}
|
||
}
|
||
rejected, codes := incomingRejectedAll(it)
|
||
data := incomingDetailData{page: nowPage("Входящий перевод", "incoming"), It: it, Flash: r.URL.Query().Get("flash"),
|
||
OrderResponses: orderResps, OrderDone: orderDone, OrderRejected: orderRejected, OrderDoneAt: orderDoneAt, DraftReceived: draftReceivedTime(it.GUID),
|
||
Quik: globalQuikLinks.ForGUID(it.GUID), QuikOn: quikActive(deps.rc.Snapshot().Quik), Watch: globalOrderWatch.Get(it.GUID), DepoL: globalDepoLinks.Get(it.GUID),
|
||
Rejected: rejected, RejectCodes: codes, AutoMode: deps.rc.Snapshot().M2MAuto.Enabled}
|
||
// Сверка выполняется при открытии карточки; итог сохраняем для бейджа в списке.
|
||
data.Depo = runDepoCheck(it)
|
||
if data.Depo.Ran {
|
||
deps.store.SetDepo(it.ID, data.Depo.Recommend, data.Depo.Summary)
|
||
}
|
||
render(w, a.incomingDetail, data)
|
||
})
|
||
}
|
||
|
||
// handleIncomingAnswer обрабатывает форму решения: по каждой бумаге radio
|
||
// confirm/reject (+ код отказа), собирает решение и отправляет роботу.
|
||
func handleIncomingAnswer(w http.ResponseWriter, r *http.Request, deps incomingDeps, id string) {
|
||
if r.Method != http.MethodPost {
|
||
http.Error(w, "method", http.StatusMethodNotAllowed)
|
||
return
|
||
}
|
||
if err := r.ParseForm(); err != nil {
|
||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||
return
|
||
}
|
||
it, ok := deps.store.Get(id)
|
||
if !ok {
|
||
http.Error(w, "не найден", http.StatusNotFound)
|
||
return
|
||
}
|
||
var decisions []SecurityDecision
|
||
for _, sec := range it.Securities {
|
||
// поле decision_<ReferenceID> = "confirm" | "reject"
|
||
val := r.FormValue("decision_" + sec.ReferenceID)
|
||
d := SecurityDecision{ReferenceID: sec.ReferenceID, Confirm: val == "confirm"}
|
||
if !d.Confirm {
|
||
d.RejectCode = strings.TrimSpace(r.FormValue("reject_" + sec.ReferenceID))
|
||
if d.RejectCode == "" {
|
||
d.RejectCode = "M2M05"
|
||
}
|
||
}
|
||
decisions = append(decisions, d)
|
||
}
|
||
msg := "Решение отправлено контрагенту через НРД"
|
||
if devMode(deps.rc) {
|
||
msg = "Решение отправлено роботу"
|
||
}
|
||
if err := answerIncoming(r.Context(), deps, id, decisions); err != nil {
|
||
msg = "Ошибка отправки решения: " + err.Error()
|
||
}
|
||
http.Redirect(w, r, "/admin/incoming/"+id+"?flash="+url.QueryEscape(msg), http.StatusSeeOther)
|
||
}
|
||
|
||
// incomingDetailData — карточка входящего перевода.
|
||
type incomingDetailData struct {
|
||
page
|
||
It *IncomingTransfer
|
||
Flash string
|
||
OrderResponses []orderResponse
|
||
OrderDone bool
|
||
OrderRejected bool // MS010 с отказом
|
||
OrderDoneAt string
|
||
DraftReceived string
|
||
Depo depoResult // сверка с DEPO — рекомендация по ответу, ничего не отправляется
|
||
Quik []*QuikLink
|
||
QuikOn bool
|
||
Watch *OrderWatch
|
||
DepoL *DepoLink
|
||
Rejected bool // решение отправлено и ни одна бумага не принята — перевод завершён отказом
|
||
RejectCodes string // коды отказа через запятую
|
||
AutoMode bool // автоматический режим M2M (поручение 16/1 подаст bj)
|
||
}
|
||
|
||
// incomingRejectedAll — перевод отвечен и ни одна бумага не принята (поручение 16/1 не нужно).
|
||
func incomingRejectedAll(it *IncomingTransfer) (bool, string) {
|
||
if it.Status != "answered" || incomingHasConfirmed(it) {
|
||
return false, ""
|
||
}
|
||
var codes []string
|
||
seen := map[string]bool{}
|
||
for _, s := range it.Securities {
|
||
if s.RejectCode != "" && !seen[s.RejectCode] {
|
||
seen[s.RejectCode] = true
|
||
codes = append(codes, s.RejectCode)
|
||
}
|
||
}
|
||
return true, strings.Join(codes, ", ")
|
||
}
|