58d402e6fa
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014FsEKyJrYF69Cj9ZXzFLRi
778 lines
30 KiB
Go
778 lines
30 KiB
Go
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
|
||
NotReady []SetupStep // невыполненные шаги мастера — ссылками на них
|
||
|
||
// 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,
|
||
}
|
||
// Готовность — по тем же шагам, что в мастере настройки (с учётом лицензии).
|
||
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})
|
||
}
|
||
|
||
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
|
||
}
|