1d0f904010
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
456 lines
15 KiB
Go
456 lines
15 KiB
Go
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"),
|
||
}
|
||
if o.SettlePlace != "spb" {
|
||
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
|
||
}
|