bf7c7b53bd
Настройка в шаге «Время и сеть» и Настройка → Сервер: дни недели, часы (по поясу сервера), включение правила. Запрос, полученный вне рабочего времени (и после включения правила), bj сразу отклоняет по всем бумагам кодом M2M06 «Приостановление операций по счёту депо зачисления» — кода «вне рабочего времени» в перечне НРД (инструкция по обмену M2M, раздел 7.4) нет, решение депозитария. Строка «Рабочее время» в проверке готовности; сквозной тест отказа через ИШ. Файл входящих — переменная (тесты не трогают сервер). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014FsEKyJrYF69Cj9ZXzFLRi
894 lines
35 KiB
Go
894 lines
35 KiB
Go
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,
|
||
"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
|
||
}
|