feat(bj): модуль ЛК на PostgreSQL, обмен с DEPO, боевой ЛК с имитатором НРД, QUIK, НПР

- bj_exchange и «кран» Fancy убраны: данные модуля ЛК в PostgreSQL (схема lk)
- трансферные таблицы FTV_M2M: создание при установке, события, справочник участников
- сверка с DEPO: счёт, ФИО, дата рождения, ИИС по договору, квал-инвестор из опции DEPO
- боевой ЛК ЕСИА Финанс при тестовом ИШ: журнал, отказ через DEPO, статусы в ЛК
- онлайн-лицензирование в bj, план тестирования M2M для контрагента (интерактивный HTML)
- модули QUIK МНП, экспорт QUIK, НПР/УДС; документы и тестовые материалы

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
zuevav
2026-09-15 19:41:12 +03:00
parent 59fc774f08
commit da12a9ec1c
132 changed files with 29969 additions and 1089 deletions
+384 -10
View File
@@ -1,10 +1,13 @@
package lkgateway
import (
"context"
"embed"
"encoding/json"
"fmt"
"html/template"
"net/http"
"os"
"path"
"strings"
"time"
@@ -19,21 +22,134 @@ var templatesFS embed.FS
// конкретный content-шаблон). Так html/template не путается с несколькими
// {{define "content"}} в разных файлах.
type admin struct {
home, claims, claim, status, setup *template.Template
help, helpDatabase, helpLK, helpCrypto, helpSystems, helpRobot, helpArchitecture *template.Template
wizard, news, keyWizard *template.Template
home, claims, claim, status, setup *template.Template
help, helpDatabase, helpLK, helpCrypto, helpSystems, helpRobot, helpArchitecture, helpQuik, helpFancyEvents, npr, nprClient, nprRows *template.Template
incoming, incomingDetail, fancyQueue, quikInstr *template.Template
wizard, news, keyWizard *template.Template
handbook *template.Template
m2mtest, m2mpreview, depoSim *template.Template
journal *template.Template
}
// templateFuncs — функции, доступные внутри шаблонов. Главная задача —
// русификация статусов и других технических обозначений (см. требование
// «всё UI на русском, кроме программных терминов»).
var templateFuncs = template.FuncMap{
"ru": russianText,
"ruState": russianState,
"ruOutcome": russianOutcome,
"now": time.Now,
"ru": russianText,
"ruState": russianState,
"ruOutcome": russianOutcome,
"now": time.Now,
"add": func(a, b int) int { return a + b },
"fallbackTpl": fallback,
// logoSrc — data-URI логотипа отчётов: загруженный из настроек или встроенный.
"logoSrc": func(uri string) template.URL {
if strings.TrimSpace(uri) == "" {
return template.URL(fontvielleLogoDataURI)
}
return template.URL(uri)
},
// m2mCode — человекочитаемое название кода M2M: «Текст (M2Mxx)».
"m2mCode": m2mCodeLabel,
"quikCodeMap": quikClientCodeMapText,
"nprF": nprF,
"curUser": authTemplateFuncs["curUser"],
"can": authTemplateFuncs["can"],
"authOpen": authOpen,
"roleTitle": roleTitle,
"stageCls": func(r depoQueueRow) string { c, _ := r.StageRu(); return c },
"stageText": func(r depoQueueRow) string { _, t := r.StageRu(); return t },
"deref": func(v *float64) float64 {
if v == nil {
return 0
}
return *v
},
"list": func(v ...string) []string { return v },
"has": func(l []string, v string) bool {
for _, x := range l {
if x == v {
return true
}
}
return false
},
"dict": func(kv ...string) map[string]string {
m := map[string]string{}
for i := 0; i+1 < len(kv); i += 2 {
m[kv[i]] = kv[i+1]
}
return m
},
// inStatus — статус входящего перевода (класс/текст/исход принято-отказано).
"inStatus": incomingStatusOf,
// healthLabel — человекочитаемое имя фонового компонента для панели здоровья.
"healthLabel": HealthLabel,
// dur — округление длительности до секунд для вывода возраста отметки.
"dur": func(d time.Duration) string { return d.Round(time.Second).String() },
// rejectOptions — варианты кодов отказа для формы решения по входящим.
"rejectOptions": m2mRejectOptions,
// m2mHints — JSON подсказок по кейсам (Зачисляющий) для «Входящих».
"m2mHints": m2mIncomingHintsJSON,
// m2mParty — наименование организации по коду/ИНН из справочника M2M.
"m2mParty": m2mPartyName,
// m2mHandbook — JSON участников справочника для пред-заполнения контрагента.
"m2mHandbook": m2mHandbookJSON,
// xmlFields — таблица со всеми полями XML (для карточки и отчётов).
"xmlFields": xmlFieldsHTML,
// docType — наименование типа документа по коду НРД (частые + fallback).
"docType": func(code string) string {
names := map[string]string{
"21": "Паспорт гражданина РФ", "10": "Паспорт иностранного гражданина",
"12": "Вид на жительство", "07": "Военный билет", "03": "Свидетельство о рождении",
}
if n := names[strings.TrimSpace(code)]; n != "" {
return n + " (" + code + ")"
}
if strings.TrimSpace(code) == "" {
return ""
}
return "код " + code
},
// msk — время в московской зоне (UTC+3). Данные хранятся в UTC.
"wall": func(t time.Time) string {
if t.IsZero() {
return "—"
}
return t.Format("02.01.2006 15:04:05")
},
"msk": func(t time.Time) string {
if t.IsZero() {
return "—"
}
return t.In(time.FixedZone("МСК", 3*3600)).Format("02.01.2006 15:04:05")
},
// transit — длительность между двумя моментами (для «времени прохождения»).
"transit": func(a, b time.Time) string {
if a.IsZero() || b.IsZero() {
return "—"
}
d := b.Sub(a)
if d < 0 {
d = -d
}
return d.Round(time.Second).String()
},
// initial — первая буква строки + "." (rune-safe; пусто для пустой строки).
// Без него {{slice .FirstName 0 1}} падал на пустом имени и портил кириллицу.
"initial": func(s string) string {
for _, r := range s {
return string(r) + "."
}
return ""
},
// short — первые n рун строки без паники на короткой строке.
"short": func(n int, s string) string {
rs := []rune(s)
if len(rs) <= n {
return s
}
return string(rs[:n])
},
"anyKeymedia": func(ds []flashDrive) bool {
for _, d := range ds {
if d.IsKeymedia {
@@ -68,6 +184,10 @@ func russianState(s string) string {
return "Таймаут SLA"
case "manual_approval":
return "На ручном разборе"
case "pending":
return "Ждёт решения"
case "answered":
return "Отвечено"
}
return s
}
@@ -149,15 +269,80 @@ func newAdmin() (*admin, error) {
if err != nil {
return nil, fmt.Errorf("parse admin_help_architecture: %w", err)
}
helpQuik, err := parse("admin_help_quik.html")
if err != nil {
return nil, fmt.Errorf("parse admin_help_quik: %w", err)
}
helpFancyEvents, err := parse("admin_help_fancy_events.html")
if err != nil {
return nil, fmt.Errorf("parse admin_help_fancy_events: %w", err)
}
parseNPR := func(content string) (*template.Template, error) {
return template.New("layout.html").Funcs(templateFuncs).ParseFS(templatesFS, "web/templates/layout.html", "web/templates/npr_css.html", "web/templates/"+content)
}
nprT, err := parseNPR("admin_npr.html")
if err != nil {
return nil, fmt.Errorf("parse admin_npr: %w", err)
}
nprC, err := parseNPR("admin_npr_client.html")
if err != nil {
return nil, fmt.Errorf("parse admin_npr_client: %w", err)
}
nprR, err := template.New("npr_rows").Funcs(templateFuncs).ParseFS(templatesFS, "web/templates/admin_npr_rows.html")
if err != nil {
return nil, fmt.Errorf("parse admin_npr_rows: %w", err)
}
keyWizard, err := parse("admin_keywizard.html")
if err != nil {
return nil, fmt.Errorf("parse admin_keywizard: %w", err)
}
incoming, err := parse("admin_incoming.html")
if err != nil {
return nil, fmt.Errorf("parse admin_incoming: %w", err)
}
incomingDetail, err := parse("admin_incoming_detail.html")
if err != nil {
return nil, fmt.Errorf("parse admin_incoming_detail: %w", err)
}
fancyQueue, err := parse("admin_fancy_queue.html")
if err != nil {
return nil, fmt.Errorf("parse admin_fancy_queue: %w", err)
}
quikInstr, err := parse("admin_quik_instructions.html")
if err != nil {
return nil, fmt.Errorf("parse admin_quik_instructions: %w", err)
}
handbook, err := parse("admin_handbook.html")
if err != nil {
return nil, fmt.Errorf("parse admin_handbook: %w", err)
}
m2mtest, err := parse("admin_m2mtest.html")
if err != nil {
return nil, fmt.Errorf("parse admin_m2mtest: %w", err)
}
m2mpreview, err := parse("admin_m2mpreview.html")
if err != nil {
return nil, fmt.Errorf("parse admin_m2mpreview: %w", err)
}
depoSim, err := parse("admin_depo_sim.html")
if err != nil {
return nil, fmt.Errorf("parse admin_depo_sim: %w", err)
}
journal, err := parse("admin_journal.html")
if err != nil {
return nil, fmt.Errorf("parse admin_journal: %w", err)
}
return &admin{
home: home, claims: claims, claim: claim, status: status, setup: setup,
help: help, helpDatabase: helpDB, helpLK: helpLK, helpCrypto: helpCrypto, helpSystems: helpSys,
helpRobot: helpRobot, helpArchitecture: helpArch,
helpRobot: helpRobot, helpArchitecture: helpArch, helpQuik: helpQuik, helpFancyEvents: helpFancyEvents, npr: nprT, nprClient: nprC, nprRows: nprR,
wizard: wizard, news: news, keyWizard: keyWizard,
incoming: incoming, incomingDetail: incomingDetail, fancyQueue: fancyQueue, quikInstr: quikInstr,
handbook: handbook,
m2mtest: m2mtest,
m2mpreview: m2mpreview,
depoSim: depoSim,
journal: journal,
}, nil
}
@@ -168,6 +353,7 @@ type page struct {
Now string
IsMockMode bool // true если ИШ не настроен — bj-server в режиме эмуляции
MockReason string // короткое описание почему mock
NPR bool // модуль «НПР / УДС» включён и разрешён лицензией
}
// globalRC — ссылка на runtime-конфиг для template-funcs/page helpers.
@@ -192,6 +378,9 @@ type homeData struct {
AllReady bool
NotReadyCount int
TotalCount int
// LKUrl — адрес тестового ЛК (для первичного теста интеграции).
LKUrl string
}
// claimsData — данные журнала.
@@ -204,6 +393,11 @@ type claimsData struct {
type claimData struct {
page
Claim ClaimView
Quik []*QuikLink
Watch *OrderWatch
DepoL *DepoLink
Depo *OutgoingDeal // депозитарное поручение 16 (списание) по этой сделке, если черновик пришёл
Flash string
}
// statusData — данные страницы статуса.
@@ -230,11 +424,26 @@ func RegisterAdmin(mux *http.ServeMux, svc *Service, rc *RuntimeConfig, getOpts
a.renderHome(w, r, svc, rc, getOpts())
case p == "claims":
a.renderClaims(w, r, svc)
case strings.HasPrefix(p, "claims/") && strings.HasSuffix(p, "/request.xml"):
id := strings.TrimSuffix(strings.TrimPrefix(p, "claims/"), "/request.xml")
a.downloadClaimXML(w, r, svc, id, "request")
case strings.HasPrefix(p, "claims/") && strings.HasSuffix(p, "/response.xml"):
id := strings.TrimSuffix(strings.TrimPrefix(p, "claims/"), "/response.xml")
a.downloadClaimXML(w, r, svc, id, "response")
case p == "claims-export":
a.exportClientClaims(w, r, svc)
case p == "claims-compare":
a.exportCompare(w, r, svc)
case strings.HasPrefix(p, "claims/") && strings.HasSuffix(p, "/export.zip"):
id := strings.TrimSuffix(strings.TrimPrefix(p, "claims/"), "/export.zip")
a.exportClaim(w, r, svc, id)
case strings.HasPrefix(p, "claims/"):
id := strings.TrimPrefix(p, "claims/")
a.renderClaim(w, r, svc, id)
case p == "status":
a.renderStatus(w, r, getOpts())
case p == "handbook":
a.renderHandbook(w, r)
case p == "help":
render(w, a.help, nowPage("Инструкции", "help"))
case p == "help/database":
@@ -249,10 +458,17 @@ func RegisterAdmin(mux *http.ServeMux, svc *Service, rc *RuntimeConfig, getOpts
render(w, a.helpRobot, nowPage("Тестирование с роботом", "help"))
case p == "help/architecture":
render(w, a.helpArchitecture, nowPage("Архитектура обмена", "help"))
case p == "help/fancy-events":
render(w, a.helpFancyEvents, nowPage("bj ↔ bj2depo: события", "help"))
case p == "help/quik":
render(w, a.helpQuik, nowPage("QUIK МНП для M2M", "help"))
default:
http.NotFound(w, r)
}
})
// Документы вендоров (ARQA и др.) для внутренних инструкций: /var/lib/bj/docs/<файл>.
mux.Handle("/admin/docs/", http.StripPrefix("/admin/docs/", http.FileServer(http.Dir("/var/lib/bj/docs"))))
mux.HandleFunc("/admin", func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/admin/", http.StatusSeeOther)
})
@@ -272,6 +488,7 @@ func (a *admin) renderHome(w http.ResponseWriter, r *http.Request, svc *Service,
Status: status,
Recent: recent.Items,
News: topNews(rc.Snapshot().News.Items, 3),
LKUrl: rc.Snapshot().LK.PublicURL,
}
// Готовность системы считаем ТОЛЬКО по обязательным компонентам.
// Опциональные (напр. callback в ЛК) не влияют на «готовность».
@@ -318,7 +535,160 @@ func (a *admin) renderClaim(w http.ResponseWriter, r *http.Request, svc *Service
http.Error(w, err.Error(), http.StatusNotFound)
return
}
render(w, a.claim, claimData{page: nowPage("Заявка", "claims"), Claim: view})
view.Timeline = buildClaimTimeline(r.Context(), view)
var depo *OutgoingDeal
if view.M2MGUID != "" && globalOutgoingStore != nil {
if d, ok := scanForm16Deal(globalOutgoingStore, string(view.M2MGUID)); ok {
depo = d
}
}
var quik []*QuikLink
if view.M2MGUID != "" {
quik = globalQuikLinks.ForGUID(string(view.M2MGUID))
}
var watch *OrderWatch
if view.M2MGUID != "" {
watch = globalOrderWatch.Get(string(view.M2MGUID))
}
var depoL *DepoLink
if view.M2MGUID != "" {
depoL = globalDepoLinks.Get(string(view.M2MGUID))
}
render(w, a.claim, claimData{
Quik: quik, Watch: watch, DepoL: depoL,
page: nowPage("Заявка", "claims"),
Claim: view, Depo: depo, Flash: r.URL.Query().Get("flash"),
})
}
// buildClaimTimeline собирает путь заявки во времени: положена в БД Fancy →
// вердикт «крана» → отправлена в шлюз НРД → ответ НРД. Метки из БД Fancy (по
// GUID) и из самой заявки (Deal). Для прямых заявок (не через базу) — короче.
func buildClaimTimeline(ctx context.Context, v ClaimView) []TimelineEvent {
// MSSQL отдаёт метки в UTC, Postgres-сделка — в МСК. Приводим ВСЕ к МСК по
// абсолютному времени, чтобы шаги шли последовательно (дельты на абсолюте
// не меняются).
msk := time.FixedZone("МСК", 3*60*60)
var tl []TimelineEvent
add := func(title, kind, detail string, t time.Time, prev time.Time) {
ev := TimelineEvent{Title: title, Kind: kind, Detail: detail, Time: t.In(msk)}
if !prev.IsZero() && !t.IsZero() && t.After(prev) {
ev.Since = formatSince(t.Sub(prev))
}
tl = append(tl, ev)
}
// Заявка ЛК, прошедшая шлюз bj (lkflow.go): путь целиком из сохранённых шагов —
// проверка шлюза, Fancy, имитатор НРД, статус в ЛК.
if v.M2MGUID != "" {
if f := globalLKFlows.Get(string(v.M2MGUID)); f != nil && len(f.Steps) > 0 {
var prev time.Time
for _, st := range f.Steps {
add(st.Title, st.Kind, st.Detail, st.At, prev)
prev = st.At
}
if f.Stage == "depo_pending" {
add("Ждём ответ Fancy (DEPO)", "db", "Статус в ЛК будет поставлен после обработки отказа джобой bj2depo.", time.Time{}, time.Time{})
}
return tl
}
}
// Происхождение из ЛК ЕСИА Финанс (путь напрямую через коннектор, минуя
// Fancy): показываем стартовый шаг с номером заявки в ЛК.
if v.M2MGUID != "" {
if lkID, ok := LKClaimIDByGUID(string(v.M2MGUID)); ok {
add("Заявка из ЛК ЕСИА Финанс", "db",
fmt.Sprintf("Забрана коннектором из личного кабинета (заявка ЛК #%d).", lkID),
v.CreatedAt, time.Time{})
}
}
// Отправлена в шлюз НРД (Deal создаётся в момент отправки).
add("Отправлена в интеграционный шлюз НРД", "gateway", "Подписана ключом и передана в ИШ.", v.CreatedAt, time.Time{})
// Ответ НРД (если получен).
if v.M2MDecision != nil {
conf, rej := 0, 0
for _, s := range v.M2MDecision.Securities {
if s.Outcome == "confirmed" {
conf++
} else {
rej++
}
}
add("Получено решение НРД (робот)", "response",
fmt.Sprintf("Принято %d, отклонено %d.", conf, rej), v.UpdatedAt, v.CreatedAt)
} else if v.M2MResponse != nil {
var codes string
for _, e := range v.M2MResponse.Responses {
if e.Code != "" {
codes += e.Code + " "
}
}
add("Ответ сервиса МОСТ (НРД)", "response",
"Статус: "+v.M2MResponse.StatusCode+". "+strings.TrimSpace(codes), v.UpdatedAt, v.CreatedAt)
}
return tl
}
// formatSince — человекочитаемая длительность между шагами таймлайна.
func formatSince(d time.Duration) string {
if d < time.Minute {
return fmt.Sprintf("%dс", int(d.Seconds()))
}
if d < time.Hour {
return fmt.Sprintf("%dм %dс", int(d.Minutes()), int(d.Seconds())%60)
}
return fmt.Sprintf("%dч %dм", int(d.Hours()), int(d.Minutes())%60)
}
// downloadClaimXML отдаёт файл XML (отправленный запрос или ответ НРД) в
// исходной кодировке windows-1251 — чтобы сохранить и переслать в поддержку НРД.
func (a *admin) downloadClaimXML(w http.ResponseWriter, r *http.Request, svc *Service, id, kind string) {
id = path.Base(id)
body, err := svc.ClaimRawXML(r.Context(), id, kind)
if err != nil {
http.Error(w, err.Error(), http.StatusNotFound)
return
}
short := id
if len(short) > 8 {
short = short[:8]
}
name := fmt.Sprintf("M2M-%s-%s.xml", kind, short)
w.Header().Set("Content-Type", "application/xml; charset=windows-1251")
w.Header().Set("Content-Disposition", `attachment; filename="`+name+`"`)
_, _ = w.Write(body)
}
// handbookData — данные страницы «Справочник участников M2M».
type handbookData struct {
page
At string
Participants []HandbookEntry
CodesHTML template.HTML // справка по кодам ошибок/отказов M2M
}
// renderHandbook показывает сохранённый справочник участников M2M с поиском.
// Места расчётов добавляются к участникам как строки с типом "place".
func (a *admin) renderHandbook(w http.ResponseWriter, _ *http.Request) {
data := handbookData{page: nowPage("Справочник участников", "handbook")}
data.CodesHTML = template.HTML(m2mSpravkaHTML(false)) // справка по кодам ошибок
if raw, err := os.ReadFile(handbookFile); err == nil {
var hb struct {
At string `json:"at"`
Participants []HandbookEntry `json:"participants"`
Places []HandbookEntry `json:"settlement_places"`
}
if json.Unmarshal(raw, &hb) == nil {
if t, e := time.Parse(time.RFC3339, hb.At); e == nil {
data.At = t.In(time.FixedZone("МСК", 3*3600)).Format("02.01.2006 15:04")
}
data.Participants = append(hb.Participants, hb.Places...)
}
}
render(w, a.handbook, data)
}
func (a *admin) renderStatus(w http.ResponseWriter, r *http.Request, opts CheckOptions) {
@@ -330,7 +700,10 @@ func (a *admin) renderStatus(w http.ResponseWriter, r *http.Request, opts CheckO
func render(w http.ResponseWriter, t *template.Template, data any) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := t.ExecuteTemplate(w, "layout", data); err != nil {
// Админка динамическая — никогда не кэшируем HTML, иначе браузер отдаёт
// устаревшую страницу (старые формы/скрипты после обновления bj-server).
w.Header().Set("Cache-Control", "no-store, must-revalidate")
if err := renderFor(w, t, data); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
@@ -338,6 +711,7 @@ func render(w http.ResponseWriter, t *template.Template, data any) {
func nowPage(title, active string) page {
p := page{Title: title, Active: active, Now: time.Now().Format("02.01.2006 15:04:05")}
if globalRC != nil {
p.NPR = nprAvailable(globalRC)
s := globalRC.Snapshot()
switch {
case s.NSD.IGWBaseURL == "":