Files
Bridge-and-Join-s/internal/lkgateway/admin.go
T

780 lines
30 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"
"net/http"
"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 *template.Template
handbook *template.Template
m2mtest, m2mpreview, depoSim, depoTest *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"],
"devOn": authTemplateFuncs["devOn"],
"devLicensed": authTemplateFuncs["devLicensed"],
"mod": func(f string) bool { return globalRC == nil || moduleAllowed(globalRC, f) }, // модуль в лицензии
"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 := parse("admin_help_systems.html")
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)
}
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)
}
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,
incoming: incoming, incomingDetail: incomingDetail, fancyQueue: fancyQueue, quikInstr: quikInstr,
handbook: handbook,
m2mtest: m2mtest,
m2mpreview: m2mpreview,
depoSim: depoSim,
depoTest: depoTest,
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
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
// LKUrl — адрес тестового ЛК (для первичного теста интеграции).
LKUrl string
}
// claimsData — данные журнала.
type claimsData struct {
page
Items []ClaimView
}
// claimData — данные карточки.
type claimData struct {
page
Claim ClaimView
Quik []*QuikLink
Watch *OrderWatch
DepoL *DepoLink
Depo *OutgoingDeal // депозитарное поручение 16 (списание) по этой сделке, если черновик пришёл
Flash string
}
// statusData — данные страницы статуса.
type statusData struct {
page
Checks []Status
CheckedAt time.Time
}
// 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/"):
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":
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,
}
// Готовность системы считаем ТОЛЬКО по обязательным компонентам.
// Опциональные (напр. callback в ЛК) не влияют на «готовность».
for _, c := range status.Checks {
if c.Optional {
continue
}
data.TotalCount++
if !c.OK {
data.NotReadyCount++
}
}
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})
}
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)
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++
}
}
title := "Получено решение контрагента (НРД)"
if devMode(globalRC) {
title = "Получено решение НРД (робот)"
}
add(title, "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 := 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)
render(w, a.status, statusData{
page: nowPage("Статус", "status"), Checks: s.Checks, CheckedAt: s.CheckedAt,
})
}
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.DevMode = devMode(globalRC)
if down, d, det := igwStatus(); down {
p.IGWDown, p.IGWDetail = humanDur(d), 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 — подпись не делается, реальный обмен с НРД невозможен"
}
}
}
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
}