package lkgateway import ( "context" "database/sql" "encoding/json" "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) { data := struct { page Items []*IncomingTransfer }{nowPage("Входящие переводы", "incoming"), deps.store.List()} 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, "SELECT CAST(DOC AS varchar(32)) FROM dbo.FTV_M2M WHERE GUID=@g", 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 bool var orderDoneAt string if it.OrderSubmitted { orderResps = findOrderResponses(it.OrderContrDID) for _, rp := range orderResps { if rp.Done { // отчёт MS010 — операция проведена orderDone = true orderDoneAt = rp.Time } } } rejected, codes := incomingRejectedAll(it) data := incomingDetailData{page: nowPage("Входящий перевод", "incoming"), It: it, Flash: r.URL.Query().Get("flash"), OrderResponses: orderResps, OrderDone: orderDone, 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_ = "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 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 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, ", ") }