Files

531 lines
32 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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": {}, // заводится у обеих сторон; дополнительные анкетные данные у участников свои
}
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 and .Register .Card.Birth}}дата рождения {{.Card.Birth}}<br>место рождения {{.Card.BirthPlace}}<br>гражданство РФ<br>ИНН <span class="mono">{{.Card.INN}}</span>{{else if .Register}}<span class="note">по учётным данным участника</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}}`))