package lkgateway // manual_handlers.go — страницы раздела «Ручной ввод M2M» (/admin/manual). import ( "context" "fmt" "html/template" "log" "net/http" "net/url" "strconv" "strings" "time" ) type manualPageData struct { page Mode string // list | edit | card | print Flash string List []manualListRow O *ManualOrder Handbook []HandbookEntry DocTypes []struct{ Code, Title string } PreChecks []manualCheckDef PostChecks []manualPostDef Problems []string // незаполненные реквизиты (для формы) Blockers []string // почему нельзя отправить DepoOn bool // учётная система подключена — сверка обязательна DepoStale bool // данные изменены после сверки QuikAuto bool // bj может сам заблокировать бумаги в торговой системе Progress manualProgress NSD NSDSettings Auto bool // автоматический режим M2M включён (поручение 16 подаётся само) } // manualListRow — строка списка. type manualListRow struct { O *ManualOrder State string // draft | sent | declined | done | rejected StateRu string } func manualActor(w http.ResponseWriter) string { if u := currentUser(w); u != nil { if u.Name != "" { return u.Name + " (" + u.Login + ")" } return u.Login } return "без входа" } func registerManual(mux *http.ServeMux, rc *RuntimeConfig, svc *Service) { tpl, err := template.New("layout.html").Funcs(templateFuncs).ParseFS(templatesFS, "web/templates/layout.html", "web/templates/admin_manual.html") if err != nil { log.Fatalf("lk-gateway: шаблон admin_manual: %v", err) } help, err := template.New("layout.html").Funcs(templateFuncs).ParseFS(templatesFS, "web/templates/layout.html", "web/templates/admin_help_manual.html") if err != nil { log.Fatalf("lk-gateway: шаблон admin_help_manual: %v", err) } mux.HandleFunc("/admin/help/manual", func(w http.ResponseWriter, r *http.Request) { render(w, help, nowPage("Ручной ввод M2M: инструкция", "help")) }) base := func(title string) manualPageData { return manualPageData{ page: nowPage(title, "manual"), DocTypes: manualDocTypes, PreChecks: manualPreChecks, PostChecks: manualPostChecks, DepoOn: manualDepoConfigured(rc), QuikAuto: manualQuikAutoAvailable(rc), NSD: rc.Snapshot().NSD, } } back := func(w http.ResponseWriter, r *http.Request, to, msg, anchor string) { if msg != "" { to += "?flash=" + url.QueryEscape(msg) } http.Redirect(w, r, to+anchor, http.StatusSeeOther) } mux.HandleFunc("/admin/manual", func(w http.ResponseWriter, r *http.Request) { d := base("Ручной ввод M2M") d.Mode, d.Flash = "list", r.URL.Query().Get("flash") var drafts map[string]*OutgoingDeal for _, o := range globalManual.List() { row := manualListRow{O: o, State: o.Status, StateRu: manualStatusRu(o.Status)} if o.Status == "sent" { if drafts == nil { // INBOX читаем один раз на список drafts = map[string]*OutgoingDeal{} for _, dd := range scanForm16Drafts(globalOutgoingStore) { drafts[dd.GUID] = dd } } row.StateRu = "в работе" if dd := drafts[o.GUID]; dd != nil && dd.Done { row.State, row.StateRu = "done", "исполнено" } else if o.DealID != "" && svc != nil { if v, err := svc.GetClaim(r.Context(), o.DealID); err == nil && (v.Status == "rejected" || v.Status == "timed_out") { row.State, row.StateRu = "rejected", "отказ" } } } d.List = append(d.List, row) } render(w, tpl, d) }) registerManualDepo(mux, rc) mux.HandleFunc("/admin/manual/new", func(w http.ResponseWriter, r *http.Request) { d := base("Новое поручение") d.Mode = "edit" d.O = &ManualOrder{Status: "draft", DocType: "21", SettlePlace: "nrd", PaperDate: time.Now().Format("2006-01-02"), ReceivedVia: "лично в офисе", Securities: []ManualSec{{}}} d.Handbook = handbookParticipants() render(w, tpl, d) }) // Сохранение формы (новое или черновик). do: save | lookup (заполнить из DEPO) | depo (сверка). mux.HandleFunc("/admin/manual/save", func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "method", http.StatusMethodNotAllowed) return } _ = r.ParseForm() in := manualFromForm(r) id, _ := strconv.Atoi(r.FormValue("id")) actor := manualActor(w) msg := "Поручение сохранено" if id == 0 { in.Status, in.CreatedBy = "draft", actor if err := globalManual.Create(in); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } id = in.ID } else { reset := false err := globalManual.Update(id, func(o *ManualOrder) error { if o.Status != "draft" { return fmt.Errorf("поручение уже %s — изменить нельзя", manualStatusRu(o.Status)) } old := o.dataHash() manualCopyData(o, in) if o.dataHash() != old && (o.Depo != nil || len(o.Checks) > 0) { o.Depo, o.Checks, reset = nil, nil, true } return nil }) if err != nil { back(w, r, fmt.Sprintf("/admin/manual/%d", id), err.Error(), "") return } if reset { msg = "Данные изменены — сверка и отметки сброшены, пройдите их заново" } } switch r.FormValue("do") { case "lookup": msg = manualDoLookup(rc, id) case "depo": msg = manualDoDepo(rc, id, actor) } anchor := "" if r.FormValue("do") == "depo" { anchor = "#depo" } back(w, r, fmt.Sprintf("/admin/manual/%d", id), msg, anchor) }) mux.HandleFunc("/admin/manual/", func(w http.ResponseWriter, r *http.Request) { rest := strings.TrimPrefix(r.URL.Path, "/admin/manual/") parts := strings.SplitN(rest, "/", 2) id, err := strconv.Atoi(parts[0]) if err != nil { http.NotFound(w, r) return } action := "" if len(parts) == 2 { action = parts[1] } o, ok := globalManual.Get(id) if !ok { http.NotFound(w, r) return } card := fmt.Sprintf("/admin/manual/%d", id) if action != "" && action != "print" && r.Method != http.MethodPost { http.Error(w, "method", http.StatusMethodNotAllowed) return } actor := manualActor(w) switch action { case "", "print": d := base("Поручение " + o.Number()) d.O, d.Flash = o, r.URL.Query().Get("flash") d.Mode = "card" if action == "print" { d.Mode = "print" } if o.Status == "draft" { d.Handbook = handbookParticipants() d.Problems = manualDataProblems(o) d.Blockers = manualSendBlockers(rc, o) d.DepoStale = o.Depo != nil && o.Depo.Hash != o.dataHash() if len(o.Securities) == 0 { // форма показывает строку бумаги всегда o.Securities = []ManualSec{{}} } } d.Progress = manualProgressFor(r.Context(), svc, o) d.Auto = rc.Snapshot().M2MAuto.Enabled render(w, tpl, d) case "checks": _ = r.ParseForm() err := globalManual.Update(id, func(o *ManualOrder) error { if o.Status != "draft" { return fmt.Errorf("поручение уже %s", manualStatusRu(o.Status)) } if o.Checks == nil { o.Checks = map[string]ManualMark{} } for _, c := range manualPreChecks { manualMark(o.Checks, c.Key, r.FormValue("check_"+c.Key) != "", actor) } switch m := r.FormValue("quik_mode"); m { case "auto", "manual", "none": o.QuikMode = m } o.QuikInstr = strings.TrimSpace(r.FormValue("quik_instr")) return nil }) msg := "" if err != nil { msg = err.Error() } back(w, r, card, msg, "#checks") case "send": var blockers []string err := globalManual.Update(id, func(o *ManualOrder) error { if blockers = manualSendBlockers(rc, o); len(blockers) > 0 { return fmt.Errorf("отправить нельзя: %s", strings.Join(blockers, "; ")) } o.Status, o.SendErr = "sending", "" return nil }) if err != nil { back(w, r, card, err.Error(), "") return } o, _ = globalManual.Get(id) ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second) guid, dealID, serr := manualSend(ctx, rc, svc, o) cancel() msg := "" _ = globalManual.Update(id, func(o *ManualOrder) error { if serr != nil { o.Status, o.SendErr = "draft", serr.Error() msg = "Поручение не отправлено: " + serr.Error() return nil } o.Status, o.GUID, o.DealID, o.SentAt, o.SentBy = "sent", guid, dealID, time.Now(), actor msg = "Поручение отправлено в НРД. Ход исполнения — ниже на этой странице." return nil }) log.Printf("lk-gateway: ручной ввод %s (%s): отправка — %s", o.Number(), o.FullName(), msg) back(w, r, card, msg, "") case "decline": reason := strings.TrimSpace(r.FormValue("reason")) err := globalManual.Update(id, func(o *ManualOrder) error { if o.Status != "draft" { return fmt.Errorf("поручение уже %s", manualStatusRu(o.Status)) } if reason == "" { return fmt.Errorf("укажите причину отказа") } o.Status, o.DeclineReason, o.DeclinedBy, o.DeclinedAt = "declined", reason, actor, time.Now() return nil }) msg := "Поручение отклонено депозитарием" if err != nil { msg = err.Error() } back(w, r, card, msg, "") case "post": _ = r.ParseForm() err := globalManual.Update(id, func(o *ManualOrder) error { if o.Post == nil { o.Post = map[string]ManualMark{} } for _, c := range manualPostChecks { if _, shown := r.Form["shown_"+c.Key]; shown { manualMark(o.Post, c.Key, r.FormValue("post_"+c.Key) != "", actor) } } return nil }) msg := "" if err != nil { msg = err.Error() } back(w, r, card, msg, "#post") default: http.NotFound(w, r) } }) } // manualMark — поставить или снять отметку (время и автор — при постановке). func manualMark(m map[string]ManualMark, key string, on bool, actor string) { cur := m[key] switch { case on && !cur.Done: m[key] = ManualMark{Done: true, By: actor, At: time.Now()} case !on && cur.Done: delete(m, key) } } // manualFromForm — данные поручения из формы. func manualFromForm(r *http.Request) *ManualOrder { f := func(k string) string { return strings.TrimSpace(r.FormValue(k)) } o := &ManualOrder{ PaperNo: f("paper_no"), PaperDate: f("paper_date"), ReceivedVia: f("received_via"), LastName: f("last_name"), FirstName: f("first_name"), MiddleName: f("middle_name"), BirthDate: f("birth_date"), DocType: f("doc_type"), DocSeries: strings.ReplaceAll(f("doc_series"), " ", ""), DocNumber: strings.ReplaceAll(f("doc_number"), " ", ""), DocIssueDate: f("doc_issue_date"), DocIssuer: f("doc_issuer"), Contract: f("contract"), Account: strings.ToUpper(f("account")), QuikCode: f("quik_code"), Phone: f("phone"), RecvINN: f("recv_inn"), RecvName: f("recv_name"), RecvAccount: f("recv_account"), SettlePlace: f("settle_place"), CostInfo: r.FormValue("cost_info") != "", Comment: f("comment"), } o.SettlePlace = "nrd" // СПБ Банк пока не используется isins, codes, names, qtys := r.Form["sec_isin"], r.Form["sec_code"], r.Form["sec_name"], r.Form["sec_qty"] at := func(a []string, i int) string { if i < len(a) { return strings.TrimSpace(a[i]) } return "" } for i := range isins { s := ManualSec{ISIN: strings.ToUpper(at(isins, i)), Code: at(codes, i), Name: at(names, i), Qty: at(qtys, i)} if s.ISIN == "" && s.Qty == "" && s.Code == "" { continue } o.Securities = append(o.Securities, s) } if o.RecvINN != "" { o.RecvCode = handbookCodeByINN(o.RecvINN) if o.RecvName == "" { for _, p := range handbookParticipants() { if p.INN == o.RecvINN && (o.RecvCode == "" || p.Code == o.RecvCode) { o.RecvName = p.Name break } } } } return o } // manualCopyData — переносит данные формы в сохранённое поручение (служебные поля не трогает). func manualCopyData(dst, src *ManualOrder) { keep := *dst *dst = *src dst.ID, dst.CreatedAt, dst.CreatedBy, dst.UpdatedAt = keep.ID, keep.CreatedAt, keep.CreatedBy, keep.UpdatedAt dst.Depo, dst.Checks, dst.QuikMode, dst.QuikInstr = keep.Depo, keep.Checks, keep.QuikMode, keep.QuikInstr dst.Status, dst.GUID, dst.DealID, dst.SentAt, dst.SentBy, dst.SendErr = keep.Status, keep.GUID, keep.DealID, keep.SentAt, keep.SentBy, keep.SendErr dst.DeclineReason, dst.DeclinedBy, dst.DeclinedAt, dst.Post = keep.DeclineReason, keep.DeclinedBy, keep.DeclinedAt, keep.Post } // manualDoLookup — заполнить из DEPO пустые ФИО, дату рождения, счёт и наименования бумаг. func manualDoLookup(rc *RuntimeConfig, id int) string { o, ok := globalManual.Get(id) if !ok { return "поручение не найдено" } if strings.TrimSpace(o.DocNumber) == "" { return "Для поиска в DEPO укажите серию и номер документа" } c := manualDepoLookup(rc, o.DocSeries, o.DocNumber) if !c.Found { return "Поиск в DEPO: " + c.Err } var isins []string for _, s := range o.Securities { if s.Name == "" && s.ISIN != "" { isins = append(isins, s.ISIN) } } names := manualDepoSecNames(rc, isins) var filled []string _ = globalManual.Update(id, func(o *ManualOrder) error { old := o.dataHash() if o.LastName == "" && o.FirstName == "" { if p := strings.Fields(c.Name); len(p) >= 2 { o.LastName, o.FirstName = p[0], p[1] if len(p) > 2 { o.MiddleName = strings.Join(p[2:], " ") } filled = append(filled, "ФИО") } } if o.BirthDate == "" && c.Birth != "" { o.BirthDate = c.Birth filled = append(filled, "дата рождения") } if o.Account == "" && c.Account != "" { o.Account = c.Account filled = append(filled, "счёт "+c.Account) } for i := range o.Securities { if n := names[o.Securities[i].ISIN]; n != "" && o.Securities[i].Name == "" { o.Securities[i].Name = n filled = append(filled, "наименование "+o.Securities[i].ISIN) } } if o.dataHash() != old { o.Depo, o.Checks = nil, nil } return nil }) msg := "Клиент в DEPO: " + c.Name if c.Account == "" { msg += " (открытого торгового счёта нет)" } if len(filled) > 0 { msg += ". Заполнено: " + strings.Join(filled, ", ") + " — проверьте с поручением клиента" } else { msg += ". Пустых полей для заполнения нет" } return msg } // manualDoDepo — сверка с DEPO. func manualDoDepo(rc *RuntimeConfig, id int, actor string) string { o, ok := globalManual.Get(id) if !ok { return "поручение не найдено" } if p := manualDataProblems(o); len(p) > 0 { return "Сверка не выполнена — сначала заполните реквизиты: " + strings.Join(p, "; ") } chk := manualDepoRun(rc, o) chk.By = actor _ = globalManual.Update(id, func(o *ManualOrder) error { o.Depo = chk return nil }) if !chk.Ran { return "Сверка с DEPO не выполнена: " + chk.Err } return "Сверка с DEPO: " + chk.Summary }