da12a9ec1c
- 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>
531 lines
32 KiB
Go
531 lines
32 KiB
Go
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}}`))
|