Files
Bridge-and-Join-s/internal/lkgateway/admin.go
T
zuevav fd238c1078 feat(admin): dev-страница «Эмуляторы» — переключатели и настройки эмуляции НРД и МНП QUIK
В режиме разработки новая страница /admin/emulators с двумя клавишами:

- «Эмулятор НРД» (флаг LK.EmulateNSD): заявки ЛК и ручного ввода идут через
  внутренний имитатор, в НРД ничего не отправляется. Настройки: задержка
  решения (0–300 с), исход (подтвердить/отклонить/не отвечать), код отказа
  M2M01–M2M09/18. Сендер перенастраивается в рантайме через потокобезопасный
  mock.Sender.Reconfigure — без перезапуска.
- «Эмулятор МНП QUIK» (флаг QuikMNP.Emulate): шаги цикла без записи в боевую
  БД МНП. Настройки: задержка шага (0–60 с), точечные отказы по шагам
  (регистрация/подпись/отказ/зачисление) — эмулированный отказ ведёт себя как
  боевой: шаг OK=false, состояние не продвигается, повтор на следующем проходе.

Настройки хранятся в setup.json (nsd_emu/mnp_emu), обратная совместимость —
дефолты. Баннер «Режим эмуляции» показывается и при ручном включении
эмуляторов (раньше зависел только от отсутствия ИШ). Пункт меню «Эмуляторы»
и две плитки на дашборде — только в dev. Пути занесены в devOnlyPath.

Попутно: исправлен race в TestLicenseActivateAndRenew (cleanup восстанавливал
DefaultLicensePublicKey без moduleEnforceMu — падал под -race и на чистом HEAD).
2026-10-07 21:34:39 +03:00

957 lines
38 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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{
"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 := ownValidCerts(storeCertsForUI(globalRC.Snapshot()))
if len(own) == 0 {
return nil
}
c := own[0]
for _, x := range own[1:] { // самый долгий — тот, что в работе
if x.NotAfter.After(c.NotAfter) {
c = x
}
}
return map[string]any{"Owner": c.SubjectCN, "Issuer": c.IssuerCN, "Until": c.NotAfter.Format("02.01.2006"), "Days": certDaysLeft(c.NotAfter)}
},
// СКЗИ готова: Валидата установлена и ключ организации подключён (как шаги мастера «Валидата» и «Ключ»)
"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,
"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»)
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
}
// 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 (списание) по этой сделке, если черновик пришёл
Flash string
Fail *SendFailure // последняя неудачная отправка в НРД (sendfail.go)
}
// 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":
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)
// Готовность — по тем же шагам, что в мастере настройки (с учётом лицензии).
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
}
view.Timeline = buildClaimTimeline(r.Context(), view)
if last := timelineLast(view.Timeline); 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))
}
render(w, a.claim, claimData{
Quik: quik, Watch: watch, DepoL: depoL, Fail: fail,
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), без сделки в НРД (отказ шлюза, имитатор): путь целиком
// из сохранённых шагов. Если заявка ушла в настоящий НРД — шаги ЛК входят в общий путь ниже.
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
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 := 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 включён — в боевую базу МНП ничего не пишется"
}
}
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
}