package lkgateway import ( "context" "embed" "encoding/json" "fmt" "html/template" "log" "net/http" "net/url" "path" "strings" "time" "git.zetit.ru/zuevav/Bridge-and-Join-s/internal/m2mcore" ) //go:embed web/templates/*.html var templatesFS embed.FS // admin содержит по одному *template.Template на каждый view (layout + // конкретный content-шаблон). Так html/template не путается с несколькими // {{define "content"}} в разных файлах. type admin struct { home, claims, claim, status, setup *template.Template help, helpDatabase, helpLK, helpCrypto, helpSystems, helpRobot, helpArchitecture, helpQuik, helpFancyEvents, modulePage *template.Template incoming, incomingDetail, fancyQueue, quikInstr *template.Template wizard, news, keyWizard, prepare *template.Template handbook *template.Template m2mtest, m2mpreview, depoSim, depoTest *template.Template emulators *template.Template journal *template.Template } // templateFuncs — функции, доступные внутри шаблонов. Главная задача — // русификация статусов и других технических обозначений (см. требование // «всё UI на русском, кроме программных терминов»). var templateFuncs = template.FuncMap{ "ru": russianText, "ruState": russianState, "ruOutcome": russianOutcome, "now": time.Now, "add": func(a, b int) int { return a + b }, "fallbackTpl": fallback, // logoSrc — data-URI логотипа отчётов: загруженный из настроек или встроенный. "logoSrc": func(uri string) template.URL { return template.URL(defaultLogoDataURI(uri)) }, // m2mCode — человекочитаемое название кода M2M: «Текст (M2Mxx)». "m2mCode": m2mCodeLabel, "quikCodeMap": quikClientCodeMapText, "curUser": authTemplateFuncs["curUser"], "can": authTemplateFuncs["can"], "sendFailText": sendFailText, "realTest": realTestGet, "realNSDAs": realNSDAsText, "quikRecipientsInfo": quikRecipientsInfo, "quikNeedsTrust": quikNeedsTrust, "lkPortfolioStates": lkPortfolioStates, "backupStatus": backupStatus, "backupWhen": backupWhen, "smbWin": smbWin, "smbView": smbView, "pgLocal": pgDSNLocal, // orgKeyCert — сертификат ключа организации (с закрытым ключом, действующий): для карточки «Ключ организации» "orgKeyCert": func() map[string]any { if globalRC == nil { return nil } own := ownValidCerts(storeCertsForUI(globalRC.Snapshot())) if len(own) == 0 { return nil } c := own[0] for _, x := range own[1:] { // самый долгий — тот, что в работе if x.NotAfter.After(c.NotAfter) { c = x } } return map[string]any{"Owner": c.SubjectCN, "Issuer": c.IssuerCN, "Until": c.NotAfter.Format("02.01.2006"), "Days": certDaysLeft(c.NotAfter)} }, // СКЗИ готова: Валидата установлена и ключ организации подключён (как шаги мастера «Валидата» и «Ключ») "cryptoReady": func() bool { if globalRC == nil { return false } v, certs := setupProbes(globalRC) return v != "" && certs > 0 }, "avatarLetter": func(s string) string { // первая буква имени — для кружка в списке сотрудников for _, r := range strings.TrimSpace(s) { return strings.ToUpper(string(r)) } return "?" }, "quikModeCustom": quikModeCustom, "depoJobNameOf": depoJobName, "adDraft": adDraftForView, // неудачная попытка подключить домен — форма открывается заполненной "withRet": func(d any, ret string) map[string]any { return map[string]any{"D": d, "Ret": ret} }, "whForm": func(w WorkHoursSettings, ret string) map[string]any { return map[string]any{"W": w, "Ret": ret} }, "weekdayNames": func() []string { return weekdayShort }, "depoAccPrefix": depoAccPrefix, "certDaysLeft": certDaysLeft, "currentContour": func() string { return contourOf(currentNSDProfile()) }, "httpsCert": func() map[string]any { cn, until, err := tlsCertInfo() return map[string]any{"CN": cn, "Until": until, "Err": err, "Days": certDaysLeft(until)} }, "serverAddrs": func() string { host, ips := localAddrs() var a []string for _, ip := range ips { if ip.To4() != nil && !ip.IsLoopback() { a = append(a, ip.String()) } } return host + " · " + strings.Join(a, ", ") }, "serverIP": func() string { _, ips := localAddrs() for _, ip := range ips { if ip.To4() != nil && !ip.IsLoopback() { return ip.String() } } return "IP-сервера" }, "timeSynced": func() bool { ok, _ := timeStatus(context.Background()); return ok }, "serverTimeZone": serverTimeZone, "letter": letterView, "mailtoLink": mailtoLink, "handbookDepos": handbookDepos, "timeZones": func() any { return wizTimeZones }, "intList": func(v ...int) []int { return v }, "nsdSegTip": nsdSegTip, "devOn": authTemplateFuncs["devOn"], "devLicensed": authTemplateFuncs["devLicensed"], "mod": func(f string) bool { return globalRC == nil || moduleAllowed(globalRC, f) }, // модуль в лицензии "modTitle": moduleTitle, // название признака лицензии по-человечески "isIIS": incomingIsIIS, // входящий перевод на ИИС "depoSubs": func() any { return depoWizardSubs }, // экраны шага 3 мастера "authOpen": authOpen, "internalDocs": func() bool { return internalDocsBuilt }, "roleTitle": roleTitle, "stageCls": func(r depoQueueRow) string { c, _ := r.StageRu(); return c }, "stageText": func(r depoQueueRow) string { _, t := r.StageRu(); return t }, "deref": func(v *float64) float64 { if v == nil { return 0 } return *v }, "list": func(v ...string) []string { return v }, "has": func(l []string, v string) bool { for _, x := range l { if x == v { return true } } return false }, "dict": func(kv ...string) map[string]string { m := map[string]string{} for i := 0; i+1 < len(kv); i += 2 { m[kv[i]] = kv[i+1] } return m }, // inStatus — статус входящего перевода (класс/текст/исход принято-отказано). "inStatus": incomingStatusOf, "liveOut": liveOutgoing, "liveIn": liveIncoming, // healthLabel — человекочитаемое имя фонового компонента для панели здоровья. "healthLabel": HealthLabel, // dur — округление длительности до секунд для вывода возраста отметки. "dur": func(d time.Duration) string { return d.Round(time.Second).String() }, // rejectOptions — варианты кодов отказа для формы решения по входящим. "rejectOptions": m2mRejectOptions, // m2mHints — JSON подсказок по кейсам (Зачисляющий) для «Входящих». "m2mHints": m2mIncomingHintsJSON, // m2mParty — наименование организации по коду/ИНН из справочника M2M. "m2mParty": m2mPartyName, // m2mHandbook — JSON участников справочника для пред-заполнения контрагента. "m2mHandbook": m2mHandbookJSON, // xmlFields — таблица со всеми полями XML (для карточки и отчётов). "xmlFields": xmlFieldsHTML, // docType — наименование типа документа по коду НРД (частые + fallback). "docType": func(code string) string { names := map[string]string{ "21": "Паспорт гражданина РФ", "10": "Паспорт иностранного гражданина", "12": "Вид на жительство", "07": "Военный билет", "03": "Свидетельство о рождении", } if n := names[strings.TrimSpace(code)]; n != "" { return n + " (" + code + ")" } if strings.TrimSpace(code) == "" { return "" } return "код " + code }, // msk — время в московской зоне (UTC+3). Данные хранятся в UTC. "wall": func(t time.Time) string { if t.IsZero() { return "—" } return t.Format("02.01.2006 15:04:05") }, "msk": func(t time.Time) string { if t.IsZero() { return "—" } return t.In(time.FixedZone("МСК", 3*3600)).Format("02.01.2006 15:04:05") }, // transit — длительность между двумя моментами (для «времени прохождения»). "transit": func(a, b time.Time) string { if a.IsZero() || b.IsZero() { return "—" } d := b.Sub(a) if d < 0 { d = -d } return d.Round(time.Second).String() }, // initial — первая буква строки + "." (rune-safe; пусто для пустой строки). // Без него {{slice .FirstName 0 1}} падал на пустом имени и портил кириллицу. "initial": func(s string) string { for _, r := range s { return string(r) + "." } return "" }, // short — первые n рун строки без паники на короткой строке. "short": func(n int, s string) string { rs := []rune(s) if len(rs) <= n { return s } return string(rs[:n]) }, "anyKeymedia": func(ds []flashDrive) bool { for _, d := range ds { if d.IsKeymedia { return true } } return false }, } // russianState переводит технический FSM-state в человекочитаемый // русский, сохраняя CSS-класс для бейджа. func russianState(s string) string { switch s { case "draft": return "Черновик" case "validated": return "Не отправлена" case "submitted_to_nsd": return "Отправлена в НРД" case "awaiting_decision": return "Ожидает решение" case "confirmed": return "Подтверждена" case "awaiting_sub16": return "Ожидает SUB16" case "done": return "Завершена" case "rejected": return "Отклонена" case "timed_out": return "Таймаут SLA" case "manual_approval": return "На ручном разборе" case "pending": return "Ждёт решения" case "answered": return "Отвечено" } return s } // russianOutcome — для NSDDecisionSecurity.Outcome. func russianOutcome(o string) string { switch o { case "confirmed": return "Подтверждено" case "rejected": return "Отказ" } return o } // russianText — fallback функция для произвольных строк (на случай // будущих расширений). Сейчас возвращает строку без изменений. func russianText(s string) string { return s } func newAdmin() (*admin, error) { parse := func(content string) (*template.Template, error) { return template.New("layout.html").Funcs(templateFuncs).ParseFS(templatesFS, "web/templates/layout.html", "web/templates/part_*.html", // общие блоки (например, «Обмен Fancy» для настроек и мастера) "web/templates/"+content) } home, err := parse("admin_home.html") if err != nil { return nil, fmt.Errorf("parse admin_home: %w", err) } claims, err := parse("admin_claims.html") if err != nil { return nil, fmt.Errorf("parse admin_claims: %w", err) } claim, err := parse("admin_claim.html") if err != nil { return nil, fmt.Errorf("parse admin_claim: %w", err) } status, err := parse("admin_status.html") if err != nil { return nil, fmt.Errorf("parse admin_status: %w", err) } setup, err := parse("admin_setup.html") if err != nil { return nil, fmt.Errorf("parse admin_setup: %w", err) } help, err := parse("admin_help.html") if err != nil { return nil, fmt.Errorf("parse admin_help: %w", err) } helpDB, err := parseInternalDoc("admin_help_database.html") if err != nil { return nil, fmt.Errorf("parse admin_help_database: %w", err) } helpLK, err := parse("admin_help_lk.html") if err != nil { return nil, fmt.Errorf("parse admin_help_lk: %w", err) } helpCrypto, err := parseInternalDoc("admin_help_crypto.html") if err != nil { return nil, fmt.Errorf("parse admin_help_crypto: %w", err) } helpSys, err := parseInternalDoc("admin_help_systems.html") // внутреннее: Fansy ETL, мессенджеры — не для клиента if err != nil { return nil, fmt.Errorf("parse admin_help_systems: %w", err) } wizard, err := parse("admin_wizard.html") if err != nil { return nil, fmt.Errorf("parse admin_wizard: %w", err) } prepare, err := parse("admin_prepare.html") if err != nil { return nil, fmt.Errorf("parse admin_prepare: %w", err) } news, err := parse("admin_news.html") if err != nil { return nil, fmt.Errorf("parse admin_news: %w", err) } helpRobot, err := parse("admin_help_robot.html") if err != nil { return nil, fmt.Errorf("parse admin_help_robot: %w", err) } helpArch, err := parseInternalDoc("admin_help_architecture.html") if err != nil { return nil, fmt.Errorf("parse admin_help_architecture: %w", err) } helpQuik, err := parseInternalDoc("admin_help_quik.html") if err != nil { return nil, fmt.Errorf("parse admin_help_quik: %w", err) } helpFancyEvents, err := parseInternalDoc("admin_help_fancy_events.html") if err != nil { return nil, fmt.Errorf("parse admin_help_fancy_events: %w", err) } // Страница модуля (служба модуля отдаёт содержимое, макет и меню — bj): npr.go. modulePage, err := template.New("layout.html").Funcs(templateFuncs).ParseFS(templatesFS, "web/templates/layout.html") if err == nil { _, err = modulePage.New("content").Parse(`{{.Content}}`) } if err != nil { return nil, fmt.Errorf("parse module page: %w", err) } keyWizard, err := parse("admin_keywizard.html") if err != nil { return nil, fmt.Errorf("parse admin_keywizard: %w", err) } incoming, err := parse("admin_incoming.html") if err != nil { return nil, fmt.Errorf("parse admin_incoming: %w", err) } incomingDetail, err := parse("admin_incoming_detail.html") if err != nil { return nil, fmt.Errorf("parse admin_incoming_detail: %w", err) } fancyQueue, err := parse("admin_fancy_queue.html") if err != nil { return nil, fmt.Errorf("parse admin_fancy_queue: %w", err) } quikInstr, err := parse("admin_quik_instructions.html") if err != nil { return nil, fmt.Errorf("parse admin_quik_instructions: %w", err) } handbook, err := parse("admin_handbook.html") if err != nil { return nil, fmt.Errorf("parse admin_handbook: %w", err) } m2mtest, err := parse("admin_m2mtest.html") if err != nil { return nil, fmt.Errorf("parse admin_m2mtest: %w", err) } m2mpreview, err := parse("admin_m2mpreview.html") if err != nil { return nil, fmt.Errorf("parse admin_m2mpreview: %w", err) } depoSim, err := parse("admin_depo_sim.html") if err != nil { return nil, fmt.Errorf("parse admin_depo_sim: %w", err) } depoTest, err := parse("admin_depo_test.html") if err != nil { return nil, fmt.Errorf("parse admin_depo_test: %w", err) } emulators, err := parse("admin_emulators.html") if err != nil { return nil, fmt.Errorf("parse admin_emulators: %w", err) } journal, err := parse("admin_journal.html") if err != nil { return nil, fmt.Errorf("parse admin_journal: %w", err) } return &admin{ home: home, claims: claims, claim: claim, status: status, setup: setup, help: help, helpDatabase: helpDB, helpLK: helpLK, helpCrypto: helpCrypto, helpSystems: helpSys, helpRobot: helpRobot, helpArchitecture: helpArch, helpQuik: helpQuik, helpFancyEvents: helpFancyEvents, modulePage: modulePage, wizard: wizard, news: news, keyWizard: keyWizard, prepare: prepare, incoming: incoming, incomingDetail: incomingDetail, fancyQueue: fancyQueue, quikInstr: quikInstr, handbook: handbook, m2mtest: m2mtest, m2mpreview: m2mpreview, depoSim: depoSim, depoTest: depoTest, emulators: emulators, journal: journal, }, nil } // page — общий "конверт" данных для всех шаблонов. type page struct { Title string Active string Now string IsMockMode bool // true если ИШ или СКЗИ не настроены — обмена с НРД нет MockReason string // короткое описание причины (у клиента — без «mock») MNPReal string // режим разработки: в боевую МНП пишутся РЕАЛЬНЫЕ поручения на подставного клиента (его код; пусто — режим выключен) NPR bool // модуль «НПР / УДС» включён и разрешён лицензией IGWDown string // ИШ НРД не отвечает: «N мин» (пусто — отвечает или не проверялся) IGWDetail string NSDDown string // ИШ отвечает, но НРД не отвечает шлюзу (nsdlinkwatch.go) NSDLink NSDHealth // цепочка «Шлюз · НРД · M2M · Отправка» для шапки (nsdhealth.go) DevMode bool // режим разработки (сервер лицензий): видны служебные разделы } // globalRC — ссылка на runtime-конфиг для template-funcs/page helpers. // Заполняется один раз в RegisterAdmin. Альтернатива — таскать rc через // все renderXxx-функции, что шумно при широком фан-ауте. var globalRC *RuntimeConfig // homeData — данные дашборда. type homeData struct { page Status SystemStatus Counts struct { Total int Confirmed int InProgress int Failed int } Recent []ClaimView News []NewsItem // top-3 активных или свежих новостей // Сводка готовности системы для hero-блока дашборда. AllReady bool NotReadyCount int TotalCount int NotReady []SetupStep // невыполненные шаги мастера — ссылками на них // LKUrl — адрес тестового ЛК (для первичного теста интеграции). LKUrl string // Состояние эмуляторов (dev-плитки дашборда, страница «Эмуляторы»). NSDEmuOn bool QuikEmuOn bool QuikRealCode 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": if !moduleAllowed(globalRC, "esia-finance") && !devMode(globalRC) { // без модуля «ЕСИА Финанс» описания его API нет http.NotFound(w, r) return } render(w, a.helpLK, nowPage("API ЛК", "help")) case p == "help/crypto": render(w, a.helpCrypto, nowPage("Криптография", "help")) case p == "help/systems": render(w, a.helpSystems, nowPage("Внешние системы", "help")) case p == "help/robot": render(w, a.helpRobot, nowPage("Тестирование с роботом", "help")) case p == "help/architecture": render(w, a.helpArchitecture, nowPage("Архитектура обмена", "help")) case p == "help/fancy-events": render(w, a.helpFancyEvents, nowPage("bj ↔ bj2depo: события", "help")) case p == "help/quik": render(w, a.helpQuik, nowPage("QUIK МНП для M2M", "help")) default: http.NotFound(w, r) } }) // Документы вендоров (ARQA и др.) для внутренних инструкций: /var/lib/bj/docs/<файл>. mux.Handle("/admin/docs/", http.StripPrefix("/admin/docs/", http.FileServer(http.Dir("/var/lib/bj/docs")))) mux.HandleFunc("/admin", func(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/admin/", http.StatusSeeOther) }) return a, nil } func (a *admin) renderHome(w http.ResponseWriter, r *http.Request, svc *Service, rc *RuntimeConfig, opts CheckOptions) { ctx := r.Context() status := CheckAll(ctx, opts) recent, err := svc.ListClaims(ctx, m2mcore.Filter{Limit: 10}) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } data := homeData{ page: nowPage("Дашборд", "home"), Status: status, Recent: recent.Items, News: topNews(visibleNews(rc, rc.Snapshot().News.Items), 3), LKUrl: rc.Snapshot().LK.PublicURL, } data.NSDEmuOn, _ = lkEmulateNSD(rc) data.QuikEmuOn = rc.Snapshot().Quik.Emulate && devMode(rc) if q := rc.Snapshot().Quik; q.RealWrite && devMode(rc) { data.QuikRealCode = strings.TrimSpace(q.RealClientCode) } // Готовность — по тем же шагам, что в мастере настройки (с учётом лицензии). for _, st := range setupSteps(rc) { data.TotalCount++ if !st.Done { data.NotReadyCount++ data.NotReady = append(data.NotReady, st) } } data.AllReady = data.TotalCount > 0 && data.NotReadyCount == 0 full, err := svc.ListClaims(ctx, m2mcore.Filter{Limit: 200}) if err == nil { for _, c := range full.Items { data.Counts.Total++ switch c.Status { case string(m2mcore.StateConfirmed), string(m2mcore.StateAwaitingSUB16), string(m2mcore.StateDone): data.Counts.Confirmed++ case string(m2mcore.StateRejected), string(m2mcore.StateTimedOut): data.Counts.Failed++ default: data.Counts.InProgress++ } } } render(w, a.home, data) } func (a *admin) renderClaims(w http.ResponseWriter, r *http.Request, svc *Service) { pageData, err := svc.ListClaims(r.Context(), m2mcore.Filter{Limit: 200}) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } render(w, a.claims, claimsData{page: nowPage("Заявки", "claims"), Items: pageData.Items, Flash: r.URL.Query().Get("flash")}) } // deleteDraft — POST /admin/claims/{id}/delete-draft: удалить черновик (заявка ещё не уходила в НРД). // Если по нему бумаги уже заблокированы в QUIK МНП — сначала поручение снимается. func (a *admin) deleteDraft(w http.ResponseWriter, r *http.Request, svc *Service, id string) { if r.Method != http.MethodPost { http.Error(w, "method", http.StatusMethodNotAllowed) return } id = path.Base(id) back := func(to, msg string) { http.Redirect(w, r, to+"?flash="+url.QueryEscape(msg), http.StatusSeeOther) } d, err := svc.Repo().GetByID(r.Context(), id) if err != nil { back("/admin/claims", "Заявка не найдена") return } if !m2mcore.IsUnsent(d.State) { back("/admin/claims/"+id, "Удалить можно только черновик: эта заявка уже ушла в НРД") return } dd, ok := svc.Repo().(m2mcore.DraftDeleter) if !ok { back("/admin/claims/"+id, "Хранилище заявок не поддерживает удаление") return } var notes []string for _, l := range globalQuikLinks.ForGUID(string(d.GUID)) { if l.State == "registered" && globalRC != nil { if _, err := QuikOutDeny(r.Context(), globalRC, l.GUID, l.ISIN, "черновик удалён оператором"); err != nil { back("/admin/claims/"+id, "Черновик не удалён: не удалось снять поручение в QUIK МНП по "+l.ISIN+" — "+err.Error()) return } notes = append(notes, "поручение QUIK по "+l.ISIN+" снято") } } if err := dd.DeleteDraft(r.Context(), id); err != nil { back("/admin/claims/"+id, "Черновик не удалён: "+err.Error()) return } actor := "" if u := currentUser(w); u != nil { actor = u.Login } log.Printf("admin: черновик %s (GUID %s) удалён %s; %s", id, d.GUID, actor, strings.Join(notes, "; ")) msg := "Черновик " + id[:8] + " удалён" if len(notes) > 0 { msg += ": " + strings.Join(notes, "; ") } back("/admin/claims", msg) } // resendClaim — POST /admin/claims/{id}/resend: заявка не дошла до НРД (контур был недоступен) — // отправить тот же запрос (тот же GUID) ещё раз. func (a *admin) resendClaim(w http.ResponseWriter, r *http.Request, svc *Service, id string) { if r.Method != http.MethodPost { http.Error(w, "method", http.StatusMethodNotAllowed) return } id = path.Base(id) msg := "✓ Отправлено повторно: НРД принял пакет" if err := svc.ResendClaim(r.Context(), id); err != nil { msg = "✗ Повторная отправка не удалась: " + err.Error() } else { actor := "" if u := currentUser(w); u != nil { actor = u.Login } log.Printf("admin: заявка %s отправлена в НРД повторно (%s)", id, actor) } http.Redirect(w, r, "/admin/claims/"+id+"?flash="+url.QueryEscape(msg), http.StatusSeeOther) } func (a *admin) renderClaim(w http.ResponseWriter, r *http.Request, svc *Service, id string) { id = path.Base(id) view, err := svc.GetClaim(r.Context(), id) if err != nil { http.Error(w, err.Error(), http.StatusNotFound) return } view.Timeline = buildClaimTimeline(r.Context(), view) if last := timelineLast(view.Timeline); last.After(view.UpdatedAt) { view.UpdatedAt = last // «Обновлена» — последнее событие по заявке, а не последняя смена состояния сделки } var depo *OutgoingDeal if view.M2MGUID != "" && globalOutgoingStore != nil { if d, ok := scanForm16Deal(globalOutgoingStore, string(view.M2MGUID)); ok { depo = d } } var quik []*QuikLink if view.M2MGUID != "" { quik = globalQuikLinks.ForGUID(string(view.M2MGUID)) } var watch *OrderWatch if view.M2MGUID != "" { watch = globalOrderWatch.Get(string(view.M2MGUID)) } var depoL *DepoLink if view.M2MGUID != "" { depoL = globalDepoLinks.Get(string(view.M2MGUID)) } var fail *SendFailure if view.M2MGUID != "" { fail = sendFailGet(string(view.M2MGUID)) } render(w, a.claim, claimData{ Quik: quik, Watch: watch, DepoL: depoL, Fail: fail, page: nowPage("Заявка", "claims"), Claim: view, Depo: depo, Flash: r.URL.Query().Get("flash"), }) } // buildClaimTimeline собирает путь заявки во времени: положена в БД Fancy → // вердикт «крана» → отправлена в шлюз НРД → ответ НРД. Метки из БД Fancy (по // GUID) и из самой заявки (Deal). Для прямых заявок (не через базу) — короче. func buildClaimTimeline(ctx context.Context, v ClaimView) []TimelineEvent { // MSSQL отдаёт метки в UTC, Postgres-сделка — в МСК. Приводим ВСЕ к МСК по // абсолютному времени, чтобы шаги шли последовательно (дельты на абсолюте // не меняются). msk := time.FixedZone("МСК", 3*60*60) var tl []TimelineEvent add := func(title, kind, detail string, t time.Time, prev time.Time) { ev := TimelineEvent{Title: title, Kind: kind, Detail: detail, Time: t.In(msk)} if !prev.IsZero() && !t.IsZero() && t.After(prev) { ev.Since = formatSince(t.Sub(prev)) } tl = append(tl, ev) } // Заявка ЛК, прошедшая шлюз bj (lkflow.go), без сделки в НРД (отказ шлюза, имитатор): путь целиком // из сохранённых шагов. Если заявка ушла в настоящий НРД — шаги ЛК входят в общий путь ниже. if v.M2MGUID != "" && realTestGet(string(v.M2MGUID)) == nil { if f := globalLKFlows.Get(string(v.M2MGUID)); f != nil && len(f.Steps) > 0 { var prev time.Time for _, st := range f.Steps { add(st.Title, st.Kind, st.Detail, st.At, prev) prev = st.At } if f.Stage == "depo_pending" { add("Ждём ответ Fancy (DEPO)", "db", "Статус в ЛК будет поставлен после обработки отказа джобой bj2depo.", time.Time{}, time.Time{}) } return tl } } return claimTimelineFull(ctx, v) } // formatSince — человекочитаемая длительность между шагами таймлайна. func formatSince(d time.Duration) string { if d < time.Minute { return fmt.Sprintf("%dс", int(d.Seconds())) } if d < time.Hour { return fmt.Sprintf("%dм %dс", int(d.Minutes()), int(d.Seconds())%60) } return fmt.Sprintf("%dч %dм", int(d.Hours()), int(d.Minutes())%60) } // downloadClaimXML отдаёт файл XML (отправленный запрос или ответ НРД) в // исходной кодировке windows-1251 — чтобы сохранить и переслать в поддержку НРД. func (a *admin) downloadClaimXML(w http.ResponseWriter, r *http.Request, svc *Service, id, kind string) { id = path.Base(id) body, err := svc.ClaimRawXML(r.Context(), id, kind) if err != nil { http.Error(w, err.Error(), http.StatusNotFound) return } short := id if len(short) > 8 { short = short[:8] } name := fmt.Sprintf("M2M-%s-%s.xml", kind, short) w.Header().Set("Content-Type", "application/xml; charset=windows-1251") w.Header().Set("Content-Disposition", `attachment; filename="`+name+`"`) _, _ = w.Write(body) } // handbookData — данные страницы «Справочник участников M2M». type handbookData struct { page At string Participants []HandbookEntry CodesHTML template.HTML // справка по кодам ошибок/отказов M2M } // renderHandbook показывает сохранённый справочник участников M2M с поиском. // Места расчётов добавляются к участникам как строки с типом "place". func (a *admin) renderHandbook(w http.ResponseWriter, _ *http.Request) { data := handbookData{page: nowPage("Справочник участников", "handbook")} data.CodesHTML = template.HTML(m2mSpravkaHTML(false)) // справка по кодам ошибок if raw, err := readHandbook(); err == nil { var hb struct { At string `json:"at"` Participants []HandbookEntry `json:"participants"` Places []HandbookEntry `json:"settlement_places"` } if json.Unmarshal(raw, &hb) == nil { if t, e := time.Parse(time.RFC3339, hb.At); e == nil { data.At = t.In(time.FixedZone("МСК", 3*3600)).Format("02.01.2006 15:04") } data.Participants = append(hb.Participants, hb.Places...) } } render(w, a.handbook, data) } func (a *admin) renderStatus(w http.ResponseWriter, r *http.Request, opts CheckOptions) { s := CheckAll(r.Context(), opts) pg := nowPage("Статус", "status") var titles []string for _, sg := range pg.NSDLink.Segs { titles = append(titles, sg.Title) } now := time.Now() render(w, a.status, statusData{ page: pg, Checks: s.Checks, CheckedAt: s.CheckedAt, Strip: nsdStrip(titles, now), StripFrom: now.Add(-24 * time.Hour).Format("02.01 15:04"), Fails: sendFailViews(), Probe: r.URL.Query().Get("probe") != "", }) } func render(w http.ResponseWriter, t *template.Template, data any) { w.Header().Set("Content-Type", "text/html; charset=utf-8") // Админка динамическая — никогда не кэшируем HTML, иначе браузер отдаёт // устаревшую страницу (старые формы/скрипты после обновления bj-server). w.Header().Set("Cache-Control", "no-store, must-revalidate") if err := renderFor(w, t, data); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) } } func nowPage(title, active string) page { p := page{Title: title, Active: active, Now: time.Now().Format("02.01.2006 15:04:05")} if globalRC != nil { p.NPR = nprAvailable(globalRC) p.NSDLink = nsdHealthNow(globalRC, time.Now()) p.DevMode = devMode(globalRC) if down, d, det := igwStatus(); down { p.IGWDown, p.IGWDetail = humanDur(d), det } else if down, det := nsdLinkStatus(); down { p.NSDDown, p.IGWDetail = det, det } s := globalRC.Snapshot() switch { case s.NSD.IGWBaseURL == "": p.IsMockMode = true p.MockReason = "Не подключён интеграционный шлюз НРД." if p.DevMode { p.MockReason = "ИШ НРД не настроен — заявки идут через внутренний mock (Decision эмитируется через 3 сек)" } case s.Crypto.Provider == "" || s.Crypto.Provider == "stub": p.IsMockMode = true p.MockReason = "Не настроено средство подписи (СКЗИ)." if p.DevMode { p.MockReason = "Провайдер СКЗИ = stub — подпись не делается, реальный обмен с НРД невозможен" } case s.LK.EmulateNSD && p.DevMode: // ИШ настроен, но заявки ЛК идут через имитатор (страница «Эмуляторы») — баннер честный. p.IsMockMode = true p.MockReason = "Эмулятор НРД включён вручную — заявки из личного кабинета в НРД не отправляются" } if s.Quik.Emulate && p.DevMode { if p.MockReason != "" { p.MockReason += "; " } p.MockReason += "эмулятор МНП QUIK включён — в боевую базу МНП ничего не пишется" } if s.Quik.RealWrite && p.DevMode { p.MNPReal = strings.TrimSpace(s.Quik.RealClientCode) } } 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 }