feat(bj): модуль ЛК на PostgreSQL, обмен с DEPO, боевой ЛК с имитатором НРД, QUIK, НПР
- bj_exchange и «кран» Fancy убраны: данные модуля ЛК в PostgreSQL (схема lk) - трансферные таблицы FTV_M2M: создание при установке, события, справочник участников - сверка с DEPO: счёт, ФИО, дата рождения, ИИС по договору, квал-инвестор из опции DEPO - боевой ЛК ЕСИА Финанс при тестовом ИШ: журнал, отказ через DEPO, статусы в ЛК - онлайн-лицензирование в bj, план тестирования M2M для контрагента (интерактивный HTML) - модули QUIK МНП, экспорт QUIK, НПР/УДС; документы и тестовые материалы Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
+384
-10
@@ -1,10 +1,13 @@
|
||||
package lkgateway
|
||||
|
||||
import (
|
||||
"context"
|
||||
"embed"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"net/http"
|
||||
"os"
|
||||
"path"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -19,21 +22,134 @@ var templatesFS embed.FS
|
||||
// конкретный content-шаблон). Так html/template не путается с несколькими
|
||||
// {{define "content"}} в разных файлах.
|
||||
type admin struct {
|
||||
home, claims, claim, status, setup *template.Template
|
||||
help, helpDatabase, helpLK, helpCrypto, helpSystems, helpRobot, helpArchitecture *template.Template
|
||||
wizard, news, keyWizard *template.Template
|
||||
home, claims, claim, status, setup *template.Template
|
||||
help, helpDatabase, helpLK, helpCrypto, helpSystems, helpRobot, helpArchitecture, helpQuik, helpFancyEvents, npr, nprClient, nprRows *template.Template
|
||||
incoming, incomingDetail, fancyQueue, quikInstr *template.Template
|
||||
wizard, news, keyWizard *template.Template
|
||||
handbook *template.Template
|
||||
m2mtest, m2mpreview, depoSim *template.Template
|
||||
journal *template.Template
|
||||
}
|
||||
|
||||
// templateFuncs — функции, доступные внутри шаблонов. Главная задача —
|
||||
// русификация статусов и других технических обозначений (см. требование
|
||||
// «всё UI на русском, кроме программных терминов»).
|
||||
var templateFuncs = template.FuncMap{
|
||||
"ru": russianText,
|
||||
"ruState": russianState,
|
||||
"ruOutcome": russianOutcome,
|
||||
"now": time.Now,
|
||||
"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 {
|
||||
if strings.TrimSpace(uri) == "" {
|
||||
return template.URL(fontvielleLogoDataURI)
|
||||
}
|
||||
return template.URL(uri)
|
||||
},
|
||||
// m2mCode — человекочитаемое название кода M2M: «Текст (M2Mxx)».
|
||||
"m2mCode": m2mCodeLabel,
|
||||
"quikCodeMap": quikClientCodeMapText,
|
||||
"nprF": nprF,
|
||||
"curUser": authTemplateFuncs["curUser"],
|
||||
"can": authTemplateFuncs["can"],
|
||||
"authOpen": authOpen,
|
||||
"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,
|
||||
// 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 {
|
||||
@@ -68,6 +184,10 @@ func russianState(s string) string {
|
||||
return "Таймаут SLA"
|
||||
case "manual_approval":
|
||||
return "На ручном разборе"
|
||||
case "pending":
|
||||
return "Ждёт решения"
|
||||
case "answered":
|
||||
return "Отвечено"
|
||||
}
|
||||
return s
|
||||
}
|
||||
@@ -149,15 +269,80 @@ func newAdmin() (*admin, error) {
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse admin_help_architecture: %w", err)
|
||||
}
|
||||
helpQuik, err := parse("admin_help_quik.html")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse admin_help_quik: %w", err)
|
||||
}
|
||||
helpFancyEvents, err := parse("admin_help_fancy_events.html")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse admin_help_fancy_events: %w", err)
|
||||
}
|
||||
parseNPR := func(content string) (*template.Template, error) {
|
||||
return template.New("layout.html").Funcs(templateFuncs).ParseFS(templatesFS, "web/templates/layout.html", "web/templates/npr_css.html", "web/templates/"+content)
|
||||
}
|
||||
nprT, err := parseNPR("admin_npr.html")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse admin_npr: %w", err)
|
||||
}
|
||||
nprC, err := parseNPR("admin_npr_client.html")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse admin_npr_client: %w", err)
|
||||
}
|
||||
nprR, err := template.New("npr_rows").Funcs(templateFuncs).ParseFS(templatesFS, "web/templates/admin_npr_rows.html")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse admin_npr_rows: %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)
|
||||
}
|
||||
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,
|
||||
helpRobot: helpRobot, helpArchitecture: helpArch, helpQuik: helpQuik, helpFancyEvents: helpFancyEvents, npr: nprT, nprClient: nprC, nprRows: nprR,
|
||||
wizard: wizard, news: news, keyWizard: keyWizard,
|
||||
incoming: incoming, incomingDetail: incomingDetail, fancyQueue: fancyQueue, quikInstr: quikInstr,
|
||||
handbook: handbook,
|
||||
m2mtest: m2mtest,
|
||||
m2mpreview: m2mpreview,
|
||||
depoSim: depoSim,
|
||||
journal: journal,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -168,6 +353,7 @@ type page struct {
|
||||
Now string
|
||||
IsMockMode bool // true если ИШ не настроен — bj-server в режиме эмуляции
|
||||
MockReason string // короткое описание почему mock
|
||||
NPR bool // модуль «НПР / УДС» включён и разрешён лицензией
|
||||
}
|
||||
|
||||
// globalRC — ссылка на runtime-конфиг для template-funcs/page helpers.
|
||||
@@ -192,6 +378,9 @@ type homeData struct {
|
||||
AllReady bool
|
||||
NotReadyCount int
|
||||
TotalCount int
|
||||
|
||||
// LKUrl — адрес тестового ЛК (для первичного теста интеграции).
|
||||
LKUrl string
|
||||
}
|
||||
|
||||
// claimsData — данные журнала.
|
||||
@@ -204,6 +393,11 @@ type claimsData struct {
|
||||
type claimData struct {
|
||||
page
|
||||
Claim ClaimView
|
||||
Quik []*QuikLink
|
||||
Watch *OrderWatch
|
||||
DepoL *DepoLink
|
||||
Depo *OutgoingDeal // депозитарное поручение 16 (списание) по этой сделке, если черновик пришёл
|
||||
Flash string
|
||||
}
|
||||
|
||||
// statusData — данные страницы статуса.
|
||||
@@ -230,11 +424,26 @@ func RegisterAdmin(mux *http.ServeMux, svc *Service, rc *RuntimeConfig, getOpts
|
||||
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":
|
||||
@@ -249,10 +458,17 @@ func RegisterAdmin(mux *http.ServeMux, svc *Service, rc *RuntimeConfig, getOpts
|
||||
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)
|
||||
})
|
||||
@@ -272,6 +488,7 @@ func (a *admin) renderHome(w http.ResponseWriter, r *http.Request, svc *Service,
|
||||
Status: status,
|
||||
Recent: recent.Items,
|
||||
News: topNews(rc.Snapshot().News.Items, 3),
|
||||
LKUrl: rc.Snapshot().LK.PublicURL,
|
||||
}
|
||||
// Готовность системы считаем ТОЛЬКО по обязательным компонентам.
|
||||
// Опциональные (напр. callback в ЛК) не влияют на «готовность».
|
||||
@@ -318,7 +535,160 @@ func (a *admin) renderClaim(w http.ResponseWriter, r *http.Request, svc *Service
|
||||
http.Error(w, err.Error(), http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
render(w, a.claim, claimData{page: nowPage("Заявка", "claims"), Claim: view})
|
||||
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++
|
||||
}
|
||||
}
|
||||
add("Получено решение НРД (робот)", "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 := os.ReadFile(handbookFile); 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) {
|
||||
@@ -330,7 +700,10 @@ func (a *admin) renderStatus(w http.ResponseWriter, r *http.Request, opts CheckO
|
||||
|
||||
func render(w http.ResponseWriter, t *template.Template, data any) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
if err := t.ExecuteTemplate(w, "layout", data); err != nil {
|
||||
// Админка динамическая — никогда не кэшируем 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)
|
||||
}
|
||||
}
|
||||
@@ -338,6 +711,7 @@ func render(w http.ResponseWriter, t *template.Template, data any) {
|
||||
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)
|
||||
s := globalRC.Snapshot()
|
||||
switch {
|
||||
case s.NSD.IGWBaseURL == "":
|
||||
|
||||
Reference in New Issue
Block a user