Files
Bridge-and-Join-s/internal/lkgateway/admin.go
T
zuevav c62225dd7d ИШ ждёт флешку с ключом — bj это видит и перезапускает шлюз сам; итог установки ключа; список копий; скрытые блоки
- ИШ без флешки ждёт её в окне Валидаты («Вставьте ключевой носитель»): статус «ждёт флешку», без кнопки
  переустановки; сторож не тратит перезапуски, а когда флешка появилась — перезапускает ИШ сам; письмо
- шаг ключа: итог — карточка «Ключ подключён», «Заменить ключ» вместо «Установить ещё один», этапы свёрнуты
- копии: «Копии на хранилище» ищет там же, куда ложится копия (подпапка по умолчанию bj-backup, не из старых
  настроек); поля показываются разобранными
- [hidden] прячет всегда (пустая полоска под версией ИШ); «Шлюз НРД на другом сервере?» — что это за адрес

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014FsEKyJrYF69Cj9ZXzFLRi
2026-10-07 00:11:48 +03:00

896 lines
35 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
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,
"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 := 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)
}
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)
}
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,
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
}
// 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":
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,
}
// Готовность — по тем же шагам, что в мастере настройки (с учётом лицензии).
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 — подпись не делается, реальный обмен с НРД невозможен"
}
}
}
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
}