package lkgateway
import (
"context"
"embed"
"encoding/json"
"fmt"
"html/template"
"log"
"net/http"
"net/url"
"path"
"strings"
"time"
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/m2mcore"
)
//go:embed web/templates/*.html
var templatesFS embed.FS
// admin содержит по одному *template.Template на каждый view (layout +
// конкретный content-шаблон). Так html/template не путается с несколькими
// {{define "content"}} в разных файлах.
type admin struct {
home, claims, claim, status, setup *template.Template
help, helpDatabase, helpLK, helpCrypto, helpSystems, helpRobot, helpArchitecture, helpQuik, helpFancyEvents, modulePage *template.Template
incoming, incomingDetail, fancyQueue, quikInstr *template.Template
wizard, news, keyWizard, prepare *template.Template
handbook *template.Template
m2mtest, m2mpreview, depoSim, depoTest *template.Template
emulators *template.Template
journal *template.Template
}
// templateFuncs — функции, доступные внутри шаблонов. Главная задача —
// русификация статусов и других технических обозначений (см. требование
// «всё UI на русском, кроме программных терминов»).
var templateFuncs = template.FuncMap{
// Карточка «Регистрация участника M2M» для «Исходящих»: когда отправили анкету и вердикт НРД.
"participantRegHTML": func() template.HTML {
reg := loadParticipantReg()
if reg.SentAt.IsZero() {
return ""
}
esc := template.HTMLEscapeString
var b strings.Builder
b.WriteString(`
Регистрация участника M2M (анкета)
Анкета отправлена ` + reg.SentAt.Format("02.01.2006 15:04") + `, пакет ` + esc(reg.PkgID))
if reg.Name != "" {
b.WriteString(`, наименование «` + esc(reg.Name) + `»`)
}
b.WriteString(`. `)
switch {
case reg.RespAt.IsZero():
b.WriteString(`Ответ НРД пока не получен.`)
case strings.EqualFold(reg.Status, "ERROR"):
b.WriteString(`НРД отклонил ` + reg.RespAt.Format("02.01 15:04") + `: ` + esc(reg.RespText) + `.`)
default:
b.WriteString(`НРД принял ` + reg.RespAt.Format("02.01 15:04") + `. ` + esc(reg.RespText))
}
b.WriteString(` открыть анкету →
`)
return template.HTML(b.String())
},
"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 {
return template.URL(defaultLogoDataURI(uri))
},
// m2mCode — человекочитаемое название кода M2M: «Текст (M2Mxx)».
"m2mCode": m2mCodeLabel,
"quikCodeMap": quikClientCodeMapText,
"curUser": authTemplateFuncs["curUser"],
"can": authTemplateFuncs["can"],
"sendFailText": sendFailText,
"realTest": realTestGet,
"realNSDAs": realNSDAsText,
"quikRecipientsInfo": quikRecipientsInfo,
"quikNeedsTrust": quikNeedsTrust,
"lkPortfolioStates": lkPortfolioStates,
"backupStatus": backupStatus,
"backupWhen": backupWhen,
"smbWin": smbWin,
"smbView": smbView,
"pgLocal": pgDSNLocal,
// orgKeyCert — сертификат ключа организации (с закрытым ключом, действующий): для карточки «Ключ организации»
"orgKeyCert": func() map[string]any {
if globalRC == nil {
return nil
}
// Свой сертификат из справочника активного профиля ИШ — включая истёкший,
// чтобы «Статус» показал владельца и пометку «ИСТЁК», а не просто «не подключён».
own := ownStoreCerts(globalRC.Snapshot())
if len(own) == 0 {
return nil
}
c, ca := own[0], storeNotAfter(own[0])
for _, x := range own[1:] { // самый долгий срок — тот ключ, что в работе
if t := storeNotAfter(x); t.After(ca) {
c, ca = x, t
}
}
contour := map[string]string{"prod": "боевой", "guest": "гостевой", "test3": "тестовый"}[contourOf(currentNSDProfile())]
return map[string]any{"Owner": c.SubjectCN, "Issuer": c.IssuerCN, "Until": ca.Format("02.01.2006"),
"Days": certDaysLeft(ca), "Expired": !ca.IsZero() && ca.Before(time.Now()), "Contour": contour}
},
// СКЗИ готова: Валидата установлена и ключ организации подключён (как шаги мастера «Валидата» и «Ключ»)
"cryptoReady": func() bool {
if globalRC == nil {
return false
}
v, certs := setupProbes(globalRC)
return v != "" && certs > 0
},
"avatarLetter": func(s string) string { // первая буква имени — для кружка в списке сотрудников
for _, r := range strings.TrimSpace(s) {
return strings.ToUpper(string(r))
}
return "?"
},
"quikModeCustom": quikModeCustom,
"depoJobNameOf": depoJobName,
"adDraft": adDraftForView, // неудачная попытка подключить домен — форма открывается заполненной
"withRet": func(d any, ret string) map[string]any { return map[string]any{"D": d, "Ret": ret} },
"whForm": func(w WorkHoursSettings, ret string) map[string]any { return map[string]any{"W": w, "Ret": ret} },
"weekdayNames": func() []string { return weekdayShort },
"depoAccPrefix": depoAccPrefix,
"depoProcText": depoProcEffective,
"depoProcList": depoProcList,
"certDaysLeft": certDaysLeft,
"currentContour": func() string { return contourOf(currentNSDProfile()) },
"httpsCert": func() map[string]any {
cn, until, err := tlsCertInfo()
return map[string]any{"CN": cn, "Until": until, "Err": err, "Days": certDaysLeft(until)}
},
"serverAddrs": func() string {
host, ips := localAddrs()
var a []string
for _, ip := range ips {
if ip.To4() != nil && !ip.IsLoopback() {
a = append(a, ip.String())
}
}
return host + " · " + strings.Join(a, ", ")
},
"serverIP": func() string {
_, ips := localAddrs()
for _, ip := range ips {
if ip.To4() != nil && !ip.IsLoopback() {
return ip.String()
}
}
return "IP-сервера"
},
"timeSynced": func() bool { ok, _ := timeStatus(context.Background()); return ok },
"serverTimeZone": serverTimeZone,
"letter": letterView,
"mailtoLink": mailtoLink,
"handbookDepos": handbookDepos,
"timeZones": func() any { return wizTimeZones },
"intList": func(v ...int) []int { return v },
"nsdSegTip": nsdSegTip,
"devOn": authTemplateFuncs["devOn"],
"devLicensed": authTemplateFuncs["devLicensed"],
"mod": func(f string) bool { return globalRC == nil || moduleAllowed(globalRC, f) }, // модуль в лицензии
"modTitle": moduleTitle, // название признака лицензии по-человечески
"isIIS": incomingIsIIS, // входящий перевод на ИИС
"depoSubs": func() any { return depoWizardSubs }, // экраны шага 3 мастера
"authOpen": authOpen,
"internalDocs": func() bool { return internalDocsBuilt },
"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,
"liveOut": liveOutgoing,
"liveIn": liveIncoming,
// 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 {
return true
}
}
return false
},
}
// russianState переводит технический FSM-state в человекочитаемый
// русский, сохраняя CSS-класс для бейджа.
func russianState(s string) string {
switch s {
case "draft":
return "Черновик"
case "validated":
return "Не отправлена"
case "submitted_to_nsd":
return "Отправлена в НРД"
case "awaiting_decision":
return "Ожидает решение"
case "confirmed":
return "Подтверждена"
case "awaiting_sub16":
return "Ожидает SUB16"
case "done":
return "Завершена"
case "rejected":
return "Отклонена"
case "timed_out":
return "Таймаут SLA"
case "manual_approval":
return "На ручном разборе"
case "pending":
return "Ждёт решения"
case "answered":
return "Отвечено"
}
return s
}
// russianOutcome — для NSDDecisionSecurity.Outcome.
func russianOutcome(o string) string {
switch o {
case "confirmed":
return "Подтверждено"
case "rejected":
return "Отказ"
}
return o
}
// russianText — fallback функция для произвольных строк (на случай
// будущих расширений). Сейчас возвращает строку без изменений.
func russianText(s string) string { return s }
func newAdmin() (*admin, error) {
parse := func(content string) (*template.Template, error) {
return template.New("layout.html").Funcs(templateFuncs).ParseFS(templatesFS,
"web/templates/layout.html",
"web/templates/part_*.html", // общие блоки (например, «Обмен Fancy» для настроек и мастера)
"web/templates/"+content)
}
home, err := parse("admin_home.html")
if err != nil {
return nil, fmt.Errorf("parse admin_home: %w", err)
}
claims, err := parse("admin_claims.html")
if err != nil {
return nil, fmt.Errorf("parse admin_claims: %w", err)
}
claim, err := parse("admin_claim.html")
if err != nil {
return nil, fmt.Errorf("parse admin_claim: %w", err)
}
status, err := parse("admin_status.html")
if err != nil {
return nil, fmt.Errorf("parse admin_status: %w", err)
}
setup, err := parse("admin_setup.html")
if err != nil {
return nil, fmt.Errorf("parse admin_setup: %w", err)
}
help, err := parse("admin_help.html")
if err != nil {
return nil, fmt.Errorf("parse admin_help: %w", err)
}
helpDB, err := parseInternalDoc("admin_help_database.html")
if err != nil {
return nil, fmt.Errorf("parse admin_help_database: %w", err)
}
helpLK, err := parse("admin_help_lk.html")
if err != nil {
return nil, fmt.Errorf("parse admin_help_lk: %w", err)
}
helpCrypto, err := parseInternalDoc("admin_help_crypto.html")
if err != nil {
return nil, fmt.Errorf("parse admin_help_crypto: %w", err)
}
helpSys, err := parseInternalDoc("admin_help_systems.html") // внутреннее: Fansy ETL, мессенджеры — не для клиента
if err != nil {
return nil, fmt.Errorf("parse admin_help_systems: %w", err)
}
wizard, err := parse("admin_wizard.html")
if err != nil {
return nil, fmt.Errorf("parse admin_wizard: %w", err)
}
prepare, err := parse("admin_prepare.html")
if err != nil {
return nil, fmt.Errorf("parse admin_prepare: %w", err)
}
news, err := parse("admin_news.html")
if err != nil {
return nil, fmt.Errorf("parse admin_news: %w", err)
}
helpRobot, err := parse("admin_help_robot.html")
if err != nil {
return nil, fmt.Errorf("parse admin_help_robot: %w", err)
}
helpArch, err := parseInternalDoc("admin_help_architecture.html")
if err != nil {
return nil, fmt.Errorf("parse admin_help_architecture: %w", err)
}
helpQuik, err := parseInternalDoc("admin_help_quik.html")
if err != nil {
return nil, fmt.Errorf("parse admin_help_quik: %w", err)
}
helpFancyEvents, err := parseInternalDoc("admin_help_fancy_events.html")
if err != nil {
return nil, fmt.Errorf("parse admin_help_fancy_events: %w", err)
}
// Страница модуля (служба модуля отдаёт содержимое, макет и меню — bj): npr.go.
modulePage, err := template.New("layout.html").Funcs(templateFuncs).ParseFS(templatesFS, "web/templates/layout.html")
if err == nil {
_, err = modulePage.New("content").Parse(`{{.Content}}`)
}
if err != nil {
return nil, fmt.Errorf("parse module page: %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)
}
depoTest, err := parse("admin_depo_test.html")
if err != nil {
return nil, fmt.Errorf("parse admin_depo_test: %w", err)
}
emulators, err := parse("admin_emulators.html")
if err != nil {
return nil, fmt.Errorf("parse admin_emulators: %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, helpQuik: helpQuik, helpFancyEvents: helpFancyEvents, modulePage: modulePage,
wizard: wizard, news: news, keyWizard: keyWizard, prepare: prepare,
incoming: incoming, incomingDetail: incomingDetail, fancyQueue: fancyQueue, quikInstr: quikInstr,
handbook: handbook,
m2mtest: m2mtest,
m2mpreview: m2mpreview,
depoSim: depoSim,
depoTest: depoTest,
emulators: emulators,
journal: journal,
}, nil
}
// page — общий "конверт" данных для всех шаблонов.
type page struct {
Title string
Active string
Now string
IsMockMode bool // true если ИШ или СКЗИ не настроены — обмена с НРД нет
MockReason string // короткое описание причины (у клиента — без «mock»)
MNPReal string // режим разработки: в боевую МНП пишутся РЕАЛЬНЫЕ поручения на подставного клиента (его код; пусто — режим выключен)
NPR bool // модуль «НПР / УДС» включён и разрешён лицензией
IGWDown string // ИШ НРД не отвечает: «N мин» (пусто — отвечает или не проверялся)
IGWDetail string
NSDDown string // ИШ отвечает, но НРД не отвечает шлюзу (nsdlinkwatch.go)
NSDLink NSDHealth // цепочка «Шлюз · НРД · M2M · Отправка» для шапки (nsdhealth.go)
DevMode bool // режим разработки (сервер лицензий): видны служебные разделы
}
// globalRC — ссылка на runtime-конфиг для template-funcs/page helpers.
// Заполняется один раз в RegisterAdmin. Альтернатива — таскать rc через
// все renderXxx-функции, что шумно при широком фан-ауте.
var globalRC *RuntimeConfig
// homeData — данные дашборда.
type homeData struct {
page
Status SystemStatus
Counts struct {
Total int
Confirmed int
InProgress int
Failed int
}
Recent []ClaimView
News []NewsItem // top-3 активных или свежих новостей
// Сводка готовности системы для hero-блока дашборда.
AllReady bool
NotReadyCount int
TotalCount int
NotReady []SetupStep // невыполненные шаги мастера — ссылками на них
// LKUrl — адрес тестового ЛК (для первичного теста интеграции).
LKUrl string
// Состояние эмуляторов (dev-плитки дашборда, страница «Эмуляторы»).
NSDEmuOn bool
QuikEmuOn bool
QuikRealCode string // режим «реальная запись в МНП на подставного клиента» (код; пусто — выключен)
}
// claimsData — данные журнала.
type claimsData struct {
page
Items []ClaimView
Flash string
}
// claimData — данные карточки.
type claimData struct {
page
Claim ClaimView
Quik []*QuikLink
Watch *OrderWatch
DepoL *DepoLink
Depo *OutgoingDeal // депозитарное поручение 16 (списание) по этой сделке, если черновик пришёл
// Подмена личности (режим эмуляции НРД «подмена»): в НРД ушли данные тест-инвестора вместо
// клиента. SubstAs — под чьими данными ушло; этот XML в карточке — наша запись с реальным клиентом.
SubstAs string
Flash string
Fail *SendFailure // последняя неудачная отправка в НРД (sendfail.go)
// SentToNSD — пакет M2MTransferRequest действительно ушёл в НРД (сделка прошла
// стадию submitted_to_nsd). false → заявка остановлена ДО отправки (например,
// отказ на проверке в QUIK МНП): в НРД по ней ничего не отправлялось.
SentToNSD bool
// QuikFailISIN/QuikFailReply — если регистрация «Вывод ЦБ (M2M)» в QUIK МНП не
// прошла (бумаги не удалось заблокировать), это ISIN и дословный ответ модуля QUIK.
// Пусто — такой неудачи нет. QuikNoSec — ответ модуля однозначно про нехватку/
// отсутствие бумаг на счёте клиента (шорт/короткая позиция/недостаточно ЦБ), а не
// про иную причину отказа; только тогда показываем «нет бумаг».
QuikFailISIN string
QuikFailReply string
QuikNoSec bool
}
// quikReplyIsNoSecurities — ответ модуля QUIK об отказе в регистрации означает,
// что на счёте клиента нет/недостаточно бумаг для блокировки (списания). QUIK не
// возвращает остаток, но на попытку заблокировать больше, чем есть, отвечает про
// «шорт»/«короткую позицию»/«недостаточно ЦБ».
func quikReplyIsNoSecurities(reply string) bool {
r := strings.ToLower(reply)
for _, s := range []string{"шорт", "короткая позиц", "недостаточно", "нет бумаг", "нет цб", "не хватает"} {
if strings.Contains(r, s) {
return true
}
}
return false
}
// claimSentToNSD — прошла ли сделка стадию отправки пакета в НРД.
func claimSentToNSD(v ClaimView) bool {
for _, st := range v.Stages {
if st.State == "submitted_to_nsd" {
return true
}
}
return false
}
// quikRegisterFailure — если по заявке была неуспешная регистрация «Вывод ЦБ (M2M)»
// в QUIK МНП (бумаги не заблокированы), возвращает ISIN и дословный ответ модуля.
// Это достоверное подтверждение «на счёте клиента нет/недостаточно бумаг»: проверку
// достаточности делает сам QUIK при sp_NewInstrSec (в БД МНП остатков нет).
func quikRegisterFailure(links []*QuikLink) (isin, reply string) {
for _, l := range links {
if l == nil || (l.State != "error" && l.State != "manual") {
continue
}
for i := len(l.Steps) - 1; i >= 0; i-- {
if st := l.Steps[i]; st.Action == "register" && !st.OK && strings.TrimSpace(st.Reply) != "" {
return l.ISIN, strings.TrimSpace(st.Reply)
}
}
if strings.TrimSpace(l.Err) != "" {
return l.ISIN, strings.TrimSpace(l.Err)
}
}
return "", ""
}
// statusData — данные страницы статуса.
type statusData struct {
page
Checks []Status
CheckedAt time.Time
Strip []NSDStripRow // связь с НРД за сутки (nsdhealth.go)
StripFrom string // начало полосы
Fails []SendFailView // недоставленные переводы
Probe bool // только что запрошена проверка связи
}
// RegisterAdmin вешает HTML-маршруты /admin/* на mux. Возвращает admin
// со всеми загруженными шаблонами — вызывающий может прокинуть его в
// registerSetup для добавления вкладки «Настройка».
func RegisterAdmin(mux *http.ServeMux, svc *Service, rc *RuntimeConfig, getOpts func() CheckOptions) (*admin, error) {
a, err := newAdmin()
if err != nil {
return nil, err
}
globalRC = rc
mux.HandleFunc("/admin/", func(w http.ResponseWriter, r *http.Request) {
p := strings.TrimPrefix(r.URL.Path, "/admin/")
switch {
case p == "" || p == "index" || p == "home":
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/") && strings.HasSuffix(p, "/resend"):
id := strings.TrimSuffix(strings.TrimPrefix(p, "claims/"), "/resend")
a.resendClaim(w, r, svc, id)
case strings.HasPrefix(p, "claims/") && strings.HasSuffix(p, "/delete-draft"):
id := strings.TrimSuffix(strings.TrimPrefix(p, "claims/"), "/delete-draft")
a.deleteDraft(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/export.json":
a.exportHandbook(w, "json")
case p == "handbook/export.xml":
a.exportHandbook(w, "xml")
case p == "handbook/import":
a.importHandbook(w, r)
case p == "handbook":
a.renderHandbook(w, r)
case p == "help":
render(w, a.help, nowPage("Инструкции", "help"))
case p == "help/database":
render(w, a.helpDatabase, nowPage("База данных", "help"))
case p == "help/lk-api":
if !moduleAllowed(globalRC, "esia-finance") && !devMode(globalRC) { // без модуля «ЕСИА Финанс» описания его API нет
http.NotFound(w, r)
return
}
render(w, a.helpLK, nowPage("API ЛК", "help"))
case p == "help/crypto":
render(w, a.helpCrypto, nowPage("Криптография", "help"))
case p == "help/systems":
render(w, a.helpSystems, nowPage("Внешние системы", "help"))
case p == "help/robot":
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)
})
return a, nil
}
func (a *admin) renderHome(w http.ResponseWriter, r *http.Request, svc *Service, rc *RuntimeConfig, opts CheckOptions) {
ctx := r.Context()
status := CheckAll(ctx, opts)
recent, err := svc.ListClaims(ctx, m2mcore.Filter{Limit: 10})
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
data := homeData{
page: nowPage("Дашборд", "home"),
Status: status,
Recent: recent.Items,
News: topNews(visibleNews(rc, rc.Snapshot().News.Items), 3),
LKUrl: rc.Snapshot().LK.PublicURL,
}
data.NSDEmuOn, _ = lkEmulateNSD(rc)
data.QuikEmuOn = rc.Snapshot().Quik.Emulate && devMode(rc)
if q := rc.Snapshot().Quik; q.RealWrite && devMode(rc) {
data.QuikRealCode = strings.TrimSpace(q.RealClientCode)
}
// Готовность — по тем же шагам, что в мастере настройки (с учётом лицензии).
for _, st := range setupSteps(rc) {
data.TotalCount++
if !st.Done {
data.NotReadyCount++
data.NotReady = append(data.NotReady, st)
}
}
data.AllReady = data.TotalCount > 0 && data.NotReadyCount == 0
full, err := svc.ListClaims(ctx, m2mcore.Filter{Limit: 200})
if err == nil {
for _, c := range full.Items {
data.Counts.Total++
switch c.Status {
case string(m2mcore.StateConfirmed), string(m2mcore.StateAwaitingSUB16), string(m2mcore.StateDone):
data.Counts.Confirmed++
case string(m2mcore.StateRejected), string(m2mcore.StateTimedOut):
data.Counts.Failed++
default:
data.Counts.InProgress++
}
}
}
render(w, a.home, data)
}
func (a *admin) renderClaims(w http.ResponseWriter, r *http.Request, svc *Service) {
pageData, err := svc.ListClaims(r.Context(), m2mcore.Filter{Limit: 200})
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
render(w, a.claims, claimsData{page: nowPage("Заявки", "claims"), Items: pageData.Items, Flash: r.URL.Query().Get("flash")})
}
// deleteDraft — POST /admin/claims/{id}/delete-draft: удалить черновик (заявка ещё не уходила в НРД).
// Если по нему бумаги уже заблокированы в QUIK МНП — сначала поручение снимается.
func (a *admin) deleteDraft(w http.ResponseWriter, r *http.Request, svc *Service, id string) {
if r.Method != http.MethodPost {
http.Error(w, "method", http.StatusMethodNotAllowed)
return
}
id = path.Base(id)
back := func(to, msg string) { http.Redirect(w, r, to+"?flash="+url.QueryEscape(msg), http.StatusSeeOther) }
d, err := svc.Repo().GetByID(r.Context(), id)
if err != nil {
back("/admin/claims", "Заявка не найдена")
return
}
if !m2mcore.IsUnsent(d.State) {
back("/admin/claims/"+id, "Удалить можно только черновик: эта заявка уже ушла в НРД")
return
}
dd, ok := svc.Repo().(m2mcore.DraftDeleter)
if !ok {
back("/admin/claims/"+id, "Хранилище заявок не поддерживает удаление")
return
}
var notes []string
for _, l := range globalQuikLinks.ForGUID(string(d.GUID)) {
if l.State == "registered" && globalRC != nil {
if _, err := QuikOutDeny(r.Context(), globalRC, l.GUID, l.ISIN, "черновик удалён оператором"); err != nil {
back("/admin/claims/"+id, "Черновик не удалён: не удалось снять поручение в QUIK МНП по "+l.ISIN+" — "+err.Error())
return
}
notes = append(notes, "поручение QUIK по "+l.ISIN+" снято")
}
}
if err := dd.DeleteDraft(r.Context(), id); err != nil {
back("/admin/claims/"+id, "Черновик не удалён: "+err.Error())
return
}
actor := ""
if u := currentUser(w); u != nil {
actor = u.Login
}
log.Printf("admin: черновик %s (GUID %s) удалён %s; %s", id, d.GUID, actor, strings.Join(notes, "; "))
msg := "Черновик " + id[:8] + " удалён"
if len(notes) > 0 {
msg += ": " + strings.Join(notes, "; ")
}
back("/admin/claims", msg)
}
// resendClaim — POST /admin/claims/{id}/resend: заявка не дошла до НРД (контур был недоступен) —
// отправить тот же запрос (тот же GUID) ещё раз.
func (a *admin) resendClaim(w http.ResponseWriter, r *http.Request, svc *Service, id string) {
if r.Method != http.MethodPost {
http.Error(w, "method", http.StatusMethodNotAllowed)
return
}
id = path.Base(id)
msg := "✓ Отправлено повторно: НРД принял пакет"
if err := svc.ResendClaim(r.Context(), id); err != nil {
msg = "✗ Повторная отправка не удалась: " + err.Error()
} else {
actor := ""
if u := currentUser(w); u != nil {
actor = u.Login
}
log.Printf("admin: заявка %s отправлена в НРД повторно (%s)", id, actor)
}
http.Redirect(w, r, "/admin/claims/"+id+"?flash="+url.QueryEscape(msg), http.StatusSeeOther)
}
func (a *admin) renderClaim(w http.ResponseWriter, r *http.Request, svc *Service, id string) {
id = path.Base(id)
view, err := svc.GetClaim(r.Context(), id)
if err != nil {
http.Error(w, err.Error(), http.StatusNotFound)
return
}
// На экране: краткий операторский путь (ключевые вехи), полный технический —
// под сворачиваемым «Технический журнал». «Обновлена» — по последнему событию
// полного пути (а не последней смене состояния сделки).
full := buildClaimTimeline(r.Context(), view)
view.TimelineTech = full
view.Timeline = claimTimelineOperator(r.Context(), view)
if last := timelineLast(full); last.After(view.UpdatedAt) {
view.UpdatedAt = last
}
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))
}
var fail *SendFailure
if view.M2MGUID != "" {
fail = sendFailGet(string(view.M2MGUID))
}
substAs := ""
if view.M2MGUID != "" {
if rt := realTestGet(string(view.M2MGUID)); rt != nil {
substAs = rt.As // в НРД ушли данные тест-инвестора вместо клиента (режим «подмена»)
}
}
qfISIN, qfReply := quikRegisterFailure(quik)
render(w, a.claim, claimData{
Quik: quik, Watch: watch, DepoL: depoL, Fail: fail,
page: nowPage("Заявка", "claims"),
Claim: view, Depo: depo, SubstAs: substAs, Flash: r.URL.Query().Get("flash"),
SentToNSD: claimSentToNSD(view), QuikFailISIN: qfISIN, QuikFailReply: qfReply,
QuikNoSec: quikReplyIsNoSecurities(qfReply),
})
}
// 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), без сделки в НРД (отказ шлюза, имитатор): путь целиком
// из сохранённых шагов. Если заявка ушла в настоящий НРД — шаги ЛК входят в общий путь ниже.
if v.M2MGUID != "" && realTestGet(string(v.M2MGUID)) == nil {
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
}
}
return claimTimelineFull(ctx, v)
}
// 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
Flash string
Participants []HandbookEntry
CodesHTML template.HTML // справка по кодам ошибок/отказов M2M
Result HandbookResult // итог обработки справочника джобой Fancy bj2depo
}
// renderHandbook показывает сохранённый справочник участников M2M с поиском.
// Места расчётов добавляются к участникам как строки с типом "place".
func (a *admin) renderHandbook(w http.ResponseWriter, r *http.Request) {
data := handbookData{page: nowPage("Справочник участников", "handbook")}
data.Flash = r.URL.Query().Get("flash")
data.CodesHTML = template.HTML(m2mSpravkaHTML(false)) // справка по кодам ошибок
data.Result = depoHandbookResult(globalRC)
if raw, err := readHandbook(); 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) {
s := CheckAll(r.Context(), opts)
pg := nowPage("Статус", "status")
var titles []string
for _, sg := range pg.NSDLink.Segs {
titles = append(titles, sg.Title)
}
now := time.Now()
render(w, a.status, statusData{
page: pg, Checks: s.Checks, CheckedAt: s.CheckedAt,
Strip: nsdStrip(titles, now), StripFrom: now.Add(-24 * time.Hour).Format("02.01 15:04"),
Fails: sendFailViews(), Probe: r.URL.Query().Get("probe") != "",
})
}
func render(w http.ResponseWriter, t *template.Template, data any) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
// Админка динамическая — никогда не кэшируем 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)
}
}
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)
p.NSDLink = nsdHealthNow(globalRC, time.Now())
p.DevMode = devMode(globalRC)
if down, d, det := igwStatus(); down {
p.IGWDown, p.IGWDetail = humanDur(d), det
} else if down, det := nsdLinkStatus(); down {
p.NSDDown, p.IGWDetail = det, det
}
s := globalRC.Snapshot()
switch {
case s.NSD.IGWBaseURL == "":
p.IsMockMode = true
p.MockReason = "Не подключён интеграционный шлюз НРД."
if p.DevMode {
p.MockReason = "ИШ НРД не настроен — заявки идут через внутренний mock (Decision эмитируется через 3 сек)"
}
case s.Crypto.Provider == "" || s.Crypto.Provider == "stub":
p.IsMockMode = true
p.MockReason = "Не настроено средство подписи (СКЗИ)."
if p.DevMode {
p.MockReason = "Провайдер СКЗИ = stub — подпись не делается, реальный обмен с НРД невозможен"
}
case s.LK.EmulateNSD && p.DevMode:
// ИШ настроен, но заявки ЛК идут через имитатор (страница «Эмуляторы») — баннер честный.
p.IsMockMode = true
p.MockReason = "Эмулятор НРД включён вручную — заявки из личного кабинета в НРД не отправляются"
}
if s.Quik.Emulate && p.DevMode {
if p.MockReason != "" {
p.MockReason += "; "
}
p.MockReason += "эмулятор МНП QUIK включён — в боевую базу МНП ничего не пишется"
}
if s.Quik.RealWrite && p.DevMode {
p.MNPReal = strings.TrimSpace(s.Quik.RealClientCode)
}
}
return p
}
// topNews отбирает максимум N новостей: сначала те, что активны прямо сейчас
// (по ValidFrom..ValidTo), потом просто свежие. Скрытые (Dismissed) — мимо.
func topNews(items []NewsItem, n int) []NewsItem {
now := time.Now()
var active, rest []NewsItem
for _, it := range items {
if it.Dismissed {
continue
}
isActive := !it.ValidFrom.IsZero() && !it.ValidTo.IsZero() &&
now.After(it.ValidFrom) && now.Before(it.ValidTo)
// «Будущие» окна с ValidFrom в будущем тоже считаем актуальными
// (предупредить заранее).
isUpcoming := !it.ValidFrom.IsZero() && now.Before(it.ValidFrom) &&
it.ValidFrom.Sub(now) < 7*24*time.Hour
if isActive || isUpcoming {
active = append(active, it)
} else {
rest = append(rest, it)
}
}
out := active
if len(out) < n {
need := n - len(out)
if need > len(rest) {
need = len(rest)
}
out = append(out, rest[:need]...)
}
if len(out) > n {
out = out[:n]
}
return out
}