Раздел «Ручной ввод M2M» по бумажным поручениям; тестовые тексты и имитации убраны из клиентского интерфейса

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
zuevav
2026-09-17 18:26:59 +03:00
parent 627dbca6af
commit e4de1ab4d8
38 changed files with 2014 additions and 132 deletions
+472
View File
@@ -0,0 +1,472 @@
package lkgateway
// manual_handlers.go — страницы раздела «Ручной ввод M2M» (/admin/manual).
import (
"context"
"encoding/json"
"fmt"
"html/template"
"log"
"net/http"
"net/url"
"os"
"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 manualHandbook() []HandbookEntry {
raw, err := os.ReadFile(handbookFile)
if err != nil {
return nil
}
var hb struct {
Participants []HandbookEntry `json:"participants"`
}
if json.Unmarshal(raw, &hb) != nil {
return nil
}
var out []HandbookEntry
for _, p := range hb.Participants {
if p.Kind != "place" && p.INN != "" {
out = append(out, p)
}
}
return out
}
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)
})
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 = manualHandbook()
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 = manualHandbook()
d.Problems = manualDataProblems(o)
d.Blockers = manualSendBlockers(rc, o)
d.DepoStale = o.Depo != nil && o.Depo.Hash != o.dataHash()
}
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 manualHandbook() {
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
}