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:
zuevav
2026-09-15 19:41:12 +03:00
parent 59fc774f08
commit da12a9ec1c
132 changed files with 29969 additions and 1089 deletions
+530
View File
@@ -0,0 +1,530 @@
package lkgateway
// m2mplan_export.go — план тестирования M2M для команды контрагента: самостоятельный
// HTML-документ (открывается в браузере, печатается, пересылается письмом). Строится из
// того же тест-плана, что и страница /admin/m2m-testing: кейсы, тестовые клиенты, бумаги,
// ожидаемые коды. GET /admin/m2m-testing/plan.html[?date=16.09.2026][&inline=1]
//
// Документ интерактивный: контрагент отмечает итог и комментарий по каждому кейсу, подготовку и
// контакты. Отметки сохраняются в его браузере (localStorage), «Скачать файл с отметками» отдаёт
// тот же HTML с отметками внутри (<script id="marks-data">) — его и присылают обратно; есть CSV.
import (
"bytes"
"html/template"
"net/http"
"sort"
"strconv"
"strings"
"time"
)
// m2mClientCard — анкетные данные тестового клиента для занесения контрагентом (сверх ФИО и паспорта).
type m2mClientCard struct{ Birth, BirthPlace, Issued, INN string }
var m2mClientCards = map[string]m2mClientCard{
"К1": {Birth: "14.03.1999", BirthPlace: "г. Москва", Issued: "ГУ МВД России по г. Москве, 28.03.2019, код подразделения 770-093", INN: "772941830554"},
}
type m2mDocSec struct{ ISIN, Code, Name, Qty string }
type m2mDocCase struct {
ID, Debit, Credit, Client, Settle, Result, Action, Note string
Negative bool
Secs []m2mDocSec
}
type m2mDocHolding struct{ Party, Client, ISIN, Code, Name, Qty string }
type m2mDocClient struct {
Key, FIO, Doc string
Card m2mClientCard
Register bool
}
type m2mDocCheck struct{ ID, Side, Codes, Note string }
// m2mDocMeta — сведения о кейсе для выгрузки отметок в CSV.
type m2mDocMeta struct{ ID, Scen, Debit, Credit, Client, Result string }
type m2mDocData struct {
Date, Generated string
CpName, CpCode, CpINN string
OurName, OurCode, OurINN string
Clients []m2mDocClient
Holdings []m2mDocHolding
Cases []m2mDocCase
Checks []m2mDocCheck
Codes []struct{ Code, Text string }
Meta []m2mDocMeta
Key string
LogoSrc template.URL // логотип отчётов: загруженный в настройках или встроенный
Positive, Negative, TotalCases int
}
func writeM2MPlanHTML(w http.ResponseWriter, r *http.Request, h *setupHandlers) {
m2mMu.Lock()
plan := m2mLoadPlan()
m2mMu.Unlock()
ourCode := h.rc.Snapshot().NSD.DeponentCode
d := m2mDocData{
Date: strings.TrimSpace(r.URL.Query().Get("date")),
Generated: time.Now().Format("02.01.2006"),
CpName: nonEmpty(plan.CounterpartyName, "Контрагент"), CpCode: plan.CounterpartyCode, CpINN: plan.CounterpartyINN,
OurName: nonEmpty(m2mPartyName(ourCode), "Фонтвьель"), OurCode: ourCode, OurINN: nonEmpty(plan.OurINN, m2mINNByCode(ourCode)),
}
if d.Date == "" {
d.Date = time.Now().AddDate(0, 0, 1).Format("02.01.2006")
}
d.LogoSrc = template.URL(fontvielleLogoDataURI)
if u := strings.TrimSpace(h.rc.Snapshot().Branding.LogoDataURI); strings.HasPrefix(u, "data:image/") {
d.LogoSrc = template.URL(u)
}
party := func(k string) string {
if k == "Контрагент 1" {
return d.CpName
}
return d.OurName
}
// Требуемые остатки у списывающей стороны: сумма по успешным кейсам, по отказам — наибольшее количество.
type key struct{ party, client, isin string }
sum, peak := map[key]float64{}, map[key]float64{}
order := map[key]int{} // порядок первого появления бумаги в кейсах
codesUsed := map[string]bool{}
for _, c := range m2mCases {
if m2mExcluded[c.ID] {
continue
}
switch c.Scenario {
case "1":
case "2":
side := "мы — зачисляющий"
if c.Debit == "Контрагент 1" {
side = party("Контрагент 2") + " — списывающий"
}
if strings.HasSuffix(c.ID, "-cred") {
side = "зачисляющий"
} else if strings.HasSuffix(c.ID, "-deb") || c.ID == "2-FORM" {
side = "списывающий"
}
d.Checks = append(d.Checks, m2mDocCheck{ID: c.ID, Side: side, Codes: c.Expect, Note: c.Note})
continue
default:
continue
}
cl := m2mTestClients[c.Client]
dc := m2mDocCase{
ID: c.ID, Debit: party(c.Debit), Credit: party(c.Credit),
Client: c.Client + " " + cl.Last + " " + cl.First + " " + cl.Middle,
Settle: c.SettleDeb + " → " + c.SettleCred, Negative: c.Expect != "",
}
desc := m2mCodeDesc[c.Expect]
if desc == "" {
desc = c.Note
}
switch {
case c.Expect == "":
dc.Result, dc.Action = "Успешный перевод", "Подтвердить перевод; после подтверждения стороны подают поручения депо в НРД"
d.Positive++
case c.Expect == "M2M01":
dc.Result, dc.Action = "Отказ сервиса МОСТ, "+c.Expect+": "+desc, "Ответ не требуется: запрос отклоняет сервис МОСТ"
codesUsed[c.Expect] = true
d.Negative++
default:
dc.Result, dc.Action = "Отказ зачисляющего, "+c.Expect+": "+desc, "Отклонить перевод с кодом "+c.Expect
codesUsed[c.Expect] = true
d.Negative++
}
if c.Expect == "M2M04" {
dc.Note = "Клиент К2 не заведён у зачисляющей стороны"
} else if c.Expect != "" && c.Expect != "M2M01" && c.Expect != "M2M05" {
dc.Note = "Условие имитируется: зачисляющий отвечает кодом из плана"
}
for _, s := range c.Secs {
dc.Secs = append(dc.Secs, m2mDocSec{ISIN: s.ISIN, Code: s.Code, Name: m2mSecName[s.ISIN], Qty: s.Qty})
q, _ := strconv.ParseFloat(s.Qty, 64)
k := key{c.Debit, c.Client, s.ISIN}
if _, seen := order[k]; !seen {
order[k] = len(order)
}
if c.Expect == "" {
sum[k] += q
} else if q > peak[k] {
peak[k] = q
}
}
d.Cases = append(d.Cases, dc)
}
d.TotalCases = d.Positive + d.Negative
need := map[key]float64{}
for k, v := range sum {
need[k] = v
}
for k, v := range peak {
if v > need[k] {
need[k] = v
}
}
var keys []key
for k := range need {
keys = append(keys, k)
}
sort.Slice(keys, func(i, j int) bool {
a, b := keys[i], keys[j]
if a.party != b.party {
return a.party == "Контрагент 1"
}
if a.client != b.client {
return a.client < b.client
}
return order[a] < order[b]
})
secCode := map[string]string{}
for _, c := range m2mCases {
for _, s := range c.Secs {
secCode[s.ISIN] = s.Code
}
}
for _, k := range keys {
d.Holdings = append(d.Holdings, m2mDocHolding{Party: party(k.party), Client: k.client, ISIN: k.isin,
Code: secCode[k.isin], Name: m2mSecName[k.isin], Qty: strconv.FormatFloat(need[k], 'f', -1, 64)})
}
for _, k := range []string{"К1", "К2"} {
cl := m2mTestClients[k]
card, ok := m2mClientCards[k]
d.Clients = append(d.Clients, m2mDocClient{Key: k, FIO: cl.Last + " " + cl.First + " " + cl.Middle,
Doc: "паспорт гражданина РФ " + cl.DocSeries + " № " + cl.DocNumber, Card: card, Register: ok})
}
var codes []string
for c := range codesUsed {
codes = append(codes, c)
}
sort.Strings(codes)
for _, c := range codes {
d.Codes = append(d.Codes, struct{ Code, Text string }{c, m2mCodeDesc[c]})
}
d.Key = "bj-m2m-plan-" + nonEmpty(d.CpCode, "counterparty")
for _, c := range d.Cases {
d.Meta = append(d.Meta, m2mDocMeta{ID: c.ID, Scen: "1", Debit: c.Debit, Credit: c.Credit, Client: c.Client, Result: c.Result})
}
for _, c := range d.Checks {
d.Meta = append(d.Meta, m2mDocMeta{ID: c.ID, Scen: "2", Debit: c.Side, Result: c.Codes + " " + c.Note})
}
var buf bytes.Buffer
if err := m2mPlanDocTpl.Execute(&buf, d); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if r.URL.Query().Get("inline") == "" {
name := "plan-m2m-" + nonEmpty(d.CpCode, "counterparty") + ".html"
w.Header().Set("Content-Disposition", `attachment; filename="`+name+`"`)
}
_, _ = w.Write(buf.Bytes())
}
var m2mPlanDocTpl = template.Must(template.New("plan").Parse(`<!doctype html>
<html lang="ru"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
<title>План тестирования M2M: {{.CpName}} и {{.OurName}}</title>
<style>
:root{--text:#1b1f24;--muted:#5b6470;--line:#d9dee5;--card:#ffffff;--ground:#f5f6f8;--head:#fafbfc;--accent:#1f4e79;--ok:#1a7f37;--okbg:#e8f5ec;--err:#b3261e;--errbg:#fbecea;--warn:#8a6d00;--warnbg:#fbf3d9;color-scheme:light}
@media (prefers-color-scheme: dark){:root{--text:#e6e8eb;--muted:#9aa3ad;--line:#343a42;--card:#1c2026;--ground:#13161a;--head:#22272e;--accent:#8cb8e8;--ok:#5fcf80;--okbg:#17301f;--err:#f08a80;--errbg:#3a1d1a;--warn:#e3c35a;--warnbg:#332a10;color-scheme:dark}}
*{box-sizing:border-box}
body{margin:0;background:var(--ground);color:var(--text);font:14px/1.5 -apple-system,"Segoe UI",Roboto,Arial,sans-serif}
.wrap{max-width:1180px;margin:0 auto;padding:24px 16px 48px}
h1{font-size:24px;margin:0 0 4px}
h2{font-size:17px;margin:0 0 10px;color:var(--accent)}
.sub{color:var(--muted);margin:0 0 18px}
.brand{display:flex;align-items:center;justify-content:space-between;gap:16px;flex-wrap:wrap;margin:0 0 14px}
.brand img{height:44px;max-width:260px;object-fit:contain;background:#fff;border-radius:8px;padding:6px 10px}
.card{background:var(--card);border:1px solid var(--line);border-radius:10px;padding:16px 18px;margin:0 0 14px}
.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(280px,1fr));gap:12px}
.party{border:1px solid var(--line);border-radius:8px;padding:10px 12px}
.party b{font-size:15px}
.k{color:var(--muted);font-size:12px}
.mono{font-family:ui-monospace,Consolas,monospace}
.tw{overflow-x:auto}
table{width:100%;border-collapse:collapse;font-size:13px}
th,td{border-bottom:1px solid var(--line);padding:7px 8px;text-align:left;vertical-align:top}
th{font-size:11px;text-transform:uppercase;letter-spacing:.03em;color:var(--muted);background:var(--head)}
tr.neg td:first-child{border-left:3px solid var(--err)}
tr.pos td:first-child{border-left:3px solid var(--ok)}
tr[data-status=passed] td{background:var(--okbg)}
tr[data-status=failed] td{background:var(--errbg)}
tr[data-status=progress] td{background:var(--warnbg)}
tr[data-status=skip] td{opacity:.55}
.tag{display:inline-block;padding:1px 8px;border-radius:10px;font-size:12px;font-weight:600}
.tag.ok{background:var(--okbg);color:var(--ok)}.tag.err{background:var(--errbg);color:var(--err)}
ol,ul{margin:6px 0 0;padding-left:20px}
li{margin:3px 0}
.note{color:var(--muted);font-size:12px}
.res{min-width:230px}
.st{display:flex;gap:4px;flex-wrap:wrap;margin-bottom:5px}
.st button{border:1px solid var(--line);background:var(--card);color:var(--text);border-radius:6px;padding:3px 8px;cursor:pointer;font:inherit;font-size:12px;line-height:1.4}
.st button:hover{border-color:var(--accent)}
.st button.on[data-s=passed]{background:var(--ok);border-color:var(--ok);color:#fff}
.st button.on[data-s=failed]{background:var(--err);border-color:var(--err);color:#fff}
.st button.on[data-s=progress]{background:var(--warn);border-color:var(--warn);color:#fff}
.st button.on[data-s=skip]{background:var(--muted);border-color:var(--muted);color:#fff}
input.f,textarea.f{width:100%;font:inherit;font-size:12px;border:1px solid var(--line);border-radius:6px;padding:4px 6px;background:var(--card);color:var(--text)}
textarea.f{min-height:34px;resize:vertical;margin-top:4px}
.at{font-size:11px;color:var(--muted);margin-top:2px}
.toolbar{position:sticky;top:0;z-index:5;background:var(--card);border:1px solid var(--line);border-radius:10px;padding:10px 14px;margin:0 0 14px;box-shadow:0 2px 8px rgba(0,0,0,.06)}
.tb-row{display:flex;gap:8px;flex-wrap:wrap;align-items:center}
.chips{display:flex;gap:6px;flex-wrap:wrap}
.chips span{padding:2px 10px;border-radius:14px;font-size:12px;font-weight:600;background:var(--head);border:1px solid var(--line)}
.chips .c-ok{background:var(--okbg);color:var(--ok)}.chips .c-err{background:var(--errbg);color:var(--err)}.chips .c-pr{background:var(--warnbg);color:var(--warn)}
.btn{border:1px solid var(--line);background:var(--card);color:var(--text);border-radius:7px;padding:5px 10px;cursor:pointer;font:inherit;font-size:13px;white-space:nowrap}
.btn:hover{border-color:var(--accent)}
.btn.primary{background:var(--accent);border-color:var(--accent);color:var(--card)}
select.btn{padding:4px 8px}
.saved{font-size:12px;color:var(--muted)}
.prep label{display:flex;gap:8px;align-items:flex-start;margin:5px 0;cursor:pointer}
.prep input{margin-top:3px}
.hint{font-size:12px;color:var(--muted);margin-top:6px}
@media print{body{background:#fff;color:#000}.card{border:none;padding:8px 0;page-break-inside:avoid}.wrap{padding:0}.toolbar,.noprint,.st button:not(.on){display:none}textarea.f,input.f{border:none;padding:0}}
</style></head><body><div class="wrap">
<div class="brand"><img src="{{.LogoSrc}}" alt="{{.OurName}}"><span class="note">{{.OurName}} · код участника {{.OurCode}}</span></div>
<h1>План тестирования переводов ценных бумаг через MOEX МОСТ (M2M)</h1>
<p class="sub">Тестовый контур НРД · дата проведения: <b>{{.Date}}</b> · документ сформирован {{.Generated}}</p>
<div class="toolbar noprint" id="toolbar">
<div class="tb-row" style="justify-content:space-between">
<div class="chips" id="chips"></div>
<span class="saved" id="saved"></span>
</div>
<div class="tb-row" style="margin-top:8px">
<input class="f" id="by" placeholder="Кто отмечает: ФИО, организация" style="max-width:260px">
<select class="btn" id="filter" title="Показать кейсы">
<option value="">Все кейсы</option><option value="none">Не отмечены</option><option value="passed">Успех</option><option value="failed">Провал</option><option value="progress">В процессе</option><option value="skip">Не тестируем</option>
</select>
<button class="btn primary" id="saveHtml" title="Тот же документ с вашими отметками внутри — отправьте его контрагенту">Скачать файл с отметками</button>
<button class="btn" id="saveCsv" title="Таблица для Excel">CSV</button>
<button class="btn" id="loadBtn" title="Открыть отметки из присланного файла">Загрузить отметки</button>
<input type="file" id="loadFile" accept=".html,.htm,.json" hidden>
<button class="btn" id="printBtn">Печать</button>
<button class="btn" id="clearBtn">Очистить</button>
</div>
<div class="hint">Отметки сохраняются в этом браузере автоматически. Чтобы передать результаты, нажмите «Скачать файл с отметками» и отправьте файл.</div>
</div>
<div class="card"><h2>Участники</h2>
<div class="grid">
<div class="party"><div class="k">Контрагент 1</div><b>{{.CpName}}</b><div>код участника <span class="mono">{{.CpCode}}</span></div><div>ИНН <span class="mono">{{.CpINN}}</span></div></div>
<div class="party"><div class="k">Контрагент 2</div><b>{{.OurName}}</b><div>код участника <span class="mono">{{.OurCode}}</span></div><div>ИНН <span class="mono">{{.OurINN}}</span></div></div>
</div>
<p class="note" style="margin:10px 0 0">В плане {{.TotalCases}} кейсов сценария 1: {{.Positive}} успешных и {{.Negative}} с отказом. Кейсы идут парами: в нечётных списывает {{.CpName}}, в чётных — {{.OurName}}, условия пары одинаковые. Сценарий с разными расчётными депозитариями (СПБ) в этот раз не проводится.</p>
</div>
<div class="card"><h2>Порядок проведения кейса</h2>
<ol>
<li>Списывающая сторона отправляет запрос на перевод (M2MTransferRequest) с данными клиента и бумаг из таблицы кейсов.</li>
<li>Зачисляющая сторона получает запрос и отвечает решением (M2MTransferDecision): подтверждает перевод или отклоняет его с кодом, указанным в плане.</li>
<li>По успешным кейсам после подтверждения стороны подают поручения депо в НРД и получают отчёты об исполнении.</li>
<li>Обе стороны сверяют итог с графой «Ожидаемый результат» и отмечают его в графе «Итог».</li>
</ol>
</div>
<div class="card prep"><h2>Подготовка</h2>
<label><input type="checkbox" data-prep="k1"> <span>Клиент К1 заведён: ФИО, дата рождения и паспорт — как в таблице ниже</span></label>
<label><input type="checkbox" data-prep="acc"> <span>У К1 открыт торговый счёт депо, без признака ИИС, клиент не квалифицированный инвестор</span></label>
<label><input type="checkbox" data-prep="hold"> <span>Остатки на счетах К1 и К2 внесены по таблице «Остатки у списывающей стороны»</span></label>
<label><input type="checkbox" data-prep="k2"> <span>Клиент К2 не заведён у зачисляющей стороны (для кейсов с M2M04)</span></label>
<label><input type="checkbox" data-prep="conn"> <span>Проверена связь с сервисом МОСТ в тестовом контуре НРД</span></label>
<textarea class="f" data-prep-note placeholder="Комментарий к подготовке"></textarea>
</div>
<div class="card"><h2>Тестовые клиенты</h2>
<div class="tw"><table>
<thead><tr><th>Клиент</th><th>ФИО</th><th>Документ</th><th>Анкетные данные</th><th>Что сделать</th></tr></thead><tbody>
{{range .Clients}}<tr><td><b>{{.Key}}</b></td><td>{{.FIO}}</td><td>{{.Doc}}{{if .Card.Issued}}<div class="note">выдан {{.Card.Issued}}</div>{{end}}</td>
<td>{{if .Register}}дата рождения {{.Card.Birth}}<br>место рождения {{.Card.BirthPlace}}<br>гражданство РФ<br>ИНН <span class="mono">{{.Card.INN}}</span>{{else}}<span class="note">не требуются</span>{{end}}</td>
<td>{{if .Register}}Завести у обеих сторон: открытый торговый счёт депо, без признака ИИС, не квалифицированный инвестор. ФИО и паспорт — строго как в таблице.{{else}}<b>Не заводить</b> у стороны, которая зачисляет в кейсах с M2M04. Для отправки запроса по К2 достаточно данных из таблицы.{{end}}</td></tr>
{{end}}</tbody></table></div>
</div>
<div class="card"><h2>Остатки у списывающей стороны</h2>
<p class="note" style="margin:0 0 8px">Минимальные остатки на торговом счёте клиента, чтобы провести все кейсы: сумма по успешным переводам и наибольшее количество по кейсам с отказом.</p>
<div class="tw"><table>
<thead><tr><th>Сторона</th><th>Клиент</th><th>ISIN</th><th>Код НРД</th><th>Бумага</th><th>Не менее, шт.</th></tr></thead><tbody>
{{range .Holdings}}<tr><td>{{.Party}}</td><td>{{.Client}}</td><td class="mono">{{.ISIN}}</td><td class="mono">{{.Code}}</td><td>{{.Name}}</td><td><b>{{.Qty}}</b></td></tr>{{end}}
</tbody></table></div>
<p class="note" style="margin:8px 0 0">Бумага XS0088543193 иностранная: запрос с ней отклоняет сервис МОСТ (M2M01). Если учётная система не формирует запрос без остатка, заведите 1 шт. Газпром (RU0007661625) нужен для отказа M2M05: зачисляющая сторона эту бумагу не обслуживает.</p>
</div>
<div class="card"><h2>Кейсы сценария 1</h2>
<div class="tw"><table>
<thead><tr><th>№</th><th>Списывает</th><th>Зачисляет</th><th>Клиент</th><th>Бумаги</th><th>Расчёты</th><th>Ожидаемый результат</th><th>Действие зачисляющего</th><th class="res">Итог</th></tr></thead><tbody>
{{range .Cases}}<tr class="{{if .Negative}}neg{{else}}pos{{end}}" data-case="{{.ID}}" data-scen="1"><td><b>{{.ID}}</b></td><td>{{.Debit}}</td><td>{{.Credit}}</td><td>{{.Client}}</td>
<td>{{range .Secs}}<div><span class="mono">{{.ISIN}}</span> × {{.Qty}}<div class="note">{{.Name}}{{if ne .Code .ISIN}}, код {{.Code}}{{end}}</div></div>{{end}}</td>
<td>{{.Settle}}</td>
<td>{{if .Negative}}<span class="tag err">отказ</span>{{else}}<span class="tag ok">успех</span>{{end}}<div>{{.Result}}</div>{{if .Note}}<div class="note">{{.Note}}</div>{{end}}</td>
<td>{{.Action}}</td>
<td class="res">{{template "mark"}}</td></tr>
{{end}}</tbody></table></div>
</div>
{{if .Codes}}<div class="card"><h2>Коды отказа в плане</h2>
<div class="tw"><table><thead><tr><th style="width:90px">Код</th><th>Значение</th></tr></thead><tbody>
{{range .Codes}}<tr><td class="mono"><b>{{.Code}}</b></td><td>{{.Text}}</td></tr>{{end}}
</tbody></table></div></div>{{end}}
{{if .Checks}}<div class="card"><h2>Сценарий 2: сообщения сервиса МОСТ</h2>
<p class="note" style="margin:0 0 8px">Каждая сторона самостоятельно проверяет, что её система корректно обрабатывает ошибки и информационные сообщения сервиса МОСТ. Взаимодействие с контрагентом не требуется.</p>
<div class="tw"><table><thead><tr><th>Проверка</th><th>Роль</th><th>Коды</th><th>Что проверяется</th><th class="res">Итог</th></tr></thead><tbody>
{{range .Checks}}<tr data-case="{{.ID}}" data-scen="2"><td class="mono">{{.ID}}</td><td>{{.Side}}</td><td class="mono">{{.Codes}}</td><td>{{.Note}}</td><td class="res">{{template "mark"}}</td></tr>{{end}}
</tbody></table></div></div>{{end}}
<div class="card"><h2>Контакты на время тестирования</h2>
<div class="tw"><table><thead><tr><th>Сторона</th><th>Ответственный</th><th>Телефон</th><th>E-mail</th></tr></thead><tbody>
<tr><td>{{.CpName}}</td><td><input class="f" data-contact="cp_name"></td><td><input class="f" data-contact="cp_phone"></td><td><input class="f" data-contact="cp_email"></td></tr>
<tr><td>{{.OurName}}</td><td><input class="f" data-contact="our_name"></td><td><input class="f" data-contact="our_phone"></td><td><input class="f" data-contact="our_email"></td></tr>
</tbody></table></div>
<textarea class="f" data-general-note placeholder="Общий комментарий по итогам тестирования" style="margin-top:10px;min-height:60px"></textarea>
</div>
</div>
<script type="application/json" id="marks-data">{}</script>
<script>
(function(){
var KEY = {{.Key}};
var META = {{.Meta}};
var CPNAME = {{.CpName}};
var SRC = '<!doctype html>\n' + document.documentElement.outerHTML;
var LABELS = {passed:'Успех', failed:'Провал', progress:'В процессе', skip:'Не тестируем'};
var state = {cases:{}, prep:{}, contacts:{}};
function parse(t){ try { var o = JSON.parse(t); return o && typeof o === 'object' ? o : null; } catch(e){ return null; } }
function norm(o){ o = o || {}; o.cases = o.cases || {}; o.prep = o.prep || {}; o.contacts = o.contacts || {}; return o; }
var embedded = norm(parse(document.getElementById('marks-data').textContent));
var local = null;
try { local = parse(localStorage.getItem(KEY)); } catch(e){}
state = norm(local && (!embedded.updated || (local.updated || '') >= embedded.updated) ? local : embedded);
function rows(){ return Array.prototype.slice.call(document.querySelectorAll('tr[data-case]')); }
function fmt(iso){ if(!iso) return ''; var d = new Date(iso); if(isNaN(d)) return ''; function p(n){return (n<10?'0':'')+n;} return p(d.getDate())+'.'+p(d.getMonth()+1)+'.'+d.getFullYear()+' '+p(d.getHours())+':'+p(d.getMinutes()); }
function paintRow(tr){
var c = state.cases[tr.getAttribute('data-case')] || {};
if (c.s) tr.setAttribute('data-status', c.s); else tr.removeAttribute('data-status');
tr.querySelectorAll('.st button').forEach(function(b){ b.classList.toggle('on', b.getAttribute('data-s') === c.s); });
var at = tr.querySelector('.at'); at.textContent = c.at ? 'изменено ' + fmt(c.at) : '';
}
function applyAll(){
rows().forEach(function(tr){
var c = state.cases[tr.getAttribute('data-case')] || {};
tr.querySelector('[data-f=note]').value = c.note || '';
tr.querySelector('[data-f=guid]').value = c.guid || '';
paintRow(tr);
});
document.querySelectorAll('[data-prep]').forEach(function(x){ x.checked = !!state.prep[x.getAttribute('data-prep')]; });
document.querySelector('[data-prep-note]').value = state.prepNote || '';
document.querySelectorAll('[data-contact]').forEach(function(x){ x.value = state.contacts[x.getAttribute('data-contact')] || ''; });
document.querySelector('[data-general-note]').value = state.note || '';
document.getElementById('by').value = state.by || '';
counts(); filter();
document.getElementById('saved').textContent = state.updated ? 'Последнее изменение ' + fmt(state.updated) : '';
}
function counts(){
var n = {all:0, passed:0, failed:0, progress:0, skip:0, none:0}, s2 = 0, s2done = 0;
rows().forEach(function(tr){
var c = state.cases[tr.getAttribute('data-case')] || {};
if (tr.getAttribute('data-scen') === '2') { s2++; if (c.s) s2done++; return; }
n.all++; n[c.s || 'none']++;
});
document.getElementById('chips').innerHTML =
'<span>Кейсов: '+n.all+'</span><span class="c-ok">Успех: '+n.passed+'</span><span class="c-err">Провал: '+n.failed+'</span>' +
'<span class="c-pr">В процессе: '+n.progress+'</span><span>Не тестируем: '+n.skip+'</span><span>Не отмечено: '+n.none+'</span>' +
(s2 ? '<span>Сценарий 2: '+s2done+' из '+s2+'</span>' : '');
}
function filter(){
var v = document.getElementById('filter').value;
rows().forEach(function(tr){
var s = (state.cases[tr.getAttribute('data-case')] || {}).s || 'none';
tr.hidden = !!v && s !== v;
});
}
var timer = null;
function save(){
state.updated = new Date().toISOString();
try { localStorage.setItem(KEY, JSON.stringify(state)); } catch(e){}
document.getElementById('saved').textContent = 'Сохранено в браузере ' + fmt(state.updated);
counts();
}
function saveSoon(){ clearTimeout(timer); timer = setTimeout(save, 400); }
function caseOf(tr){ var id = tr.getAttribute('data-case'); return state.cases[id] || (state.cases[id] = {}); }
document.addEventListener('click', function(e){
var b = e.target.closest('.st button'); if (!b) return;
var tr = b.closest('tr[data-case]'), c = caseOf(tr), s = b.getAttribute('data-s');
c.s = c.s === s ? '' : s; c.at = new Date().toISOString();
paintRow(tr); save(); filter();
});
document.addEventListener('input', function(e){
var t = e.target, tr = t.closest('tr[data-case]');
if (tr && t.hasAttribute('data-f')) { caseOf(tr)[t.getAttribute('data-f')] = t.value; saveSoon(); return; }
if (t.hasAttribute('data-contact')) { state.contacts[t.getAttribute('data-contact')] = t.value; saveSoon(); return; }
if (t.hasAttribute('data-prep-note')) { state.prepNote = t.value; saveSoon(); return; }
if (t.hasAttribute('data-general-note')) { state.note = t.value; saveSoon(); return; }
if (t.id === 'by') { state.by = t.value; saveSoon(); }
});
document.addEventListener('change', function(e){
var t = e.target;
if (t.hasAttribute('data-prep')) { state.prep[t.getAttribute('data-prep')] = t.checked; save(); }
if (t.id === 'filter') filter();
});
function stamp(){ var d = new Date(); function p(n){return (n<10?'0':'')+n;} return d.getFullYear()+p(d.getMonth()+1)+p(d.getDate())+'-'+p(d.getHours())+p(d.getMinutes()); }
function download(name, text, type){
var a = document.createElement('a');
a.href = URL.createObjectURL(new Blob([text], {type: type}));
a.download = name; document.body.appendChild(a); a.click();
setTimeout(function(){ URL.revokeObjectURL(a.href); a.remove(); }, 1000);
}
var MARKS_RE = new RegExp('(<script type="application/json" id="marks-data">)[\\s\\S]*?(<\\/script>)');
document.getElementById('saveHtml').onclick = function(){
save();
var json = JSON.stringify(state).replace(/</g, '\\u003c');
var html = SRC.replace(MARKS_RE, function(_, a, b){ return a + json + b; });
download(KEY.replace('bj-m2m-plan-', 'plan-m2m-') + '-otmetki-' + stamp() + '.html', html, 'text/html;charset=utf-8');
};
document.getElementById('saveCsv').onclick = function(){
function q(v){ v = String(v == null ? '' : v); return '"' + v.replace(/"/g, '""') + '"'; }
var lines = [['№','Сценарий','Списывает / роль','Зачисляет','Клиент','Ожидаемый результат','Итог','GUID / № запроса','Комментарий','Изменено'].map(q).join(';')];
META.forEach(function(m){
var c = state.cases[m.ID] || {};
lines.push([m.ID, m.Scen, m.Debit, m.Credit, m.Client, m.Result, LABELS[c.s] || '', c.guid, c.note, fmt(c.at)].map(q).join(';'));
});
download(KEY.replace('bj-m2m-plan-', 'plan-m2m-') + '-otmetki-' + stamp() + '.csv', '\ufeff' + lines.join('\r\n'), 'text/csv;charset=utf-8');
};
document.getElementById('loadBtn').onclick = function(){ document.getElementById('loadFile').click(); };
document.getElementById('loadFile').onchange = function(){
var f = this.files && this.files[0]; if (!f) return;
var rd = new FileReader();
rd.onload = function(){
var text = String(rd.result), got = null, m = text.match(MARKS_RE);
if (m) { var inner = text.slice(text.indexOf(m[1]) + m[1].length); got = parse(inner.slice(0, inner.indexOf('<\/script>'))); }
else got = parse(text);
if (!got || !got.cases) { alert('В файле не найдены отметки плана тестирования.'); return; }
if (!confirm('Заменить текущие отметки отметками из файла «' + f.name + '»' + (got.by ? ' (отмечал: ' + got.by + ')' : '') + '?')) return;
state = norm(got); save(); applyAll();
};
rd.readAsText(f, 'utf-8'); this.value = '';
};
document.getElementById('printBtn').onclick = function(){ window.print(); };
document.getElementById('clearBtn').onclick = function(){
if (!confirm('Удалить все отметки и комментарии в этом браузере?')) return;
state = norm({}); save(); applyAll();
};
document.title = document.title + (CPNAME ? '' : '');
applyAll();
})();
</script>
</body></html>
{{define "mark"}}<div class="st"><button type="button" data-s="passed">✓ Успех</button><button type="button" data-s="failed">✗ Провал</button><button type="button" data-s="progress">● В процессе</button><button type="button" data-s="skip">⊘ Не тестируем</button></div><input class="f" data-f="guid" placeholder="GUID / № запроса"><textarea class="f" data-f="note" placeholder="Комментарий"></textarea><div class="at"></div>{{end}}`))