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
@@ -1,4 +1,34 @@
{{define "content"}}
<style>
/* Основной контент — в обычном потоке (широкий, по центру). Таймлайн —
отдельной узкой панелью, прижатой к правому краю экрана (fixed) на широких
мониторах; на узких уходит под контент. */
.claim-grid { display:block }
.claim-main { min-width:0 }
.claim-tl { position: fixed; right: 18px; top: 84px; width: 216px; max-height: 84vh; overflow:auto;
background: var(--card,#fff); border:1px solid var(--border,#e3e3e8);
border-radius:14px; padding:14px 16px; box-shadow:var(--shadow-lg,0 8px 30px rgba(0,0,0,.10)); z-index:40; }
.claim-tl h3 { margin:0 0 12px; font-size:13px }
.tl-step { position:relative; padding:0 0 2px 20px }
.tl-step::before { content:''; position:absolute; left:3px; top:3px; width:10px; height:10px; border-radius:50%;
background:var(--c); box-shadow:0 0 0 3px color-mix(in srgb, var(--c) 20%, transparent); }
.tl-time { font-size:10.5px; color:var(--muted,#888); font-variant-numeric:tabular-nums }
.tl-title { font-size:12.5px; font-weight:600; margin:1px 0; line-height:1.3 }
.tl-detail { font-size:10.5px; color:var(--muted,#888); line-height:1.4; margin-top:2px }
/* стрелка-коннектор: вертикальная линия + ровный CSS-треугольник + длительность */
.tl-arrow { position:relative; min-height:20px; margin:1px 0 1px 0; padding-left:20px; display:flex; align-items:center }
.tl-arrow .ln { position:absolute; left:7px; top:0; width:2px; height:20px; background:var(--c); opacity:.5 }
.tl-arrow .ar { position:absolute; left:3px; top:11px; width:0; height:0;
border-left:5px solid transparent; border-right:5px solid transparent; border-top:7px solid var(--c) }
.tl-arrow .dt { font-size:10.5px; color:var(--muted,#999) }
.tl-db{--c:#2563eb} .tl-crane{--c:#16a34a} .tl-gateway{--c:#7c3aed} .tl-response{--c:#0891b2} .tl-reject{--c:#dc2626}
/* нет места справа (узкие/средние экраны) — таймлайн в потоке под контентом */
@media (max-width:1640px){ .claim-tl{ position:static; width:auto; max-height:none; margin-top:16px; box-shadow:none } }
</style>
<div class="claim-grid">
<div class="claim-main">
<p style="margin:0 0 12px"><a href="/admin/claims" class="btn" onclick="if(history.length>1){history.back();return false}">← Назад</a></p>
{{if .Flash}}<div class="card" style="background:var(--accent-weak,#e7f0fb)">{{.Flash}}</div>{{end}}
<div class="card">
<h2>Заявка <code>{{slice .Claim.ID 0 8}}</code> · <span class="badge {{.Claim.Status}}">{{ruState .Claim.Status}}</span></h2>
<table>
@@ -13,8 +43,38 @@
<tr><td class="muted">ИИС</td><td>{{.Claim.IIAAgreement.AgreementType}} № {{.Claim.IIAAgreement.AgreementNumber}} от {{.Claim.IIAAgreement.AgreementDate}}, брокер ИНН {{.Claim.IIAAgreement.BrokerINN}}</td></tr>
{{end}}
</table>
<div style="margin-top:14px">
<a class="btn btn-ok" href="/admin/claims/{{.Claim.ID}}/export.zip" download>⤓ Выгрузить путь заявки (для регулятора)</a>
</div>
<p class="muted" style="font-size:12px;margin-top:8px">ZIP: отчёт-доказательство (хронология пути) + дословные XML запроса и ответа НРД (windows-1251).</p>
{{if .Claim.Investor.ID}}
<form method="get" action="/admin/claims-export" style="margin-top:14px;padding-top:14px;border-top:1px solid var(--border);display:flex;gap:10px;flex-wrap:wrap;align-items:flex-end">
<input type="hidden" name="investor" value="{{.Claim.Investor.ID}}">
<div><label style="font-size:12px">Заявки клиента с</label><input type="date" name="from" style="margin-top:3px"></div>
<div><label style="font-size:12px">по</label><input type="date" name="to" style="margin-top:3px"></div>
<button type="submit" class="btn">⤓ Выгрузить за период</button>
<span class="muted" style="font-size:12px">Пусто = без ограничения. ZIP: сводка CSV + комплект по каждой заявке.</span>
</form>
{{end}}
</div>
{{with .Claim.TestResult}}
<div class="card" style="background:{{if eq .Status "ok"}}var(--ok-weak){{else if eq .Status "mismatch"}}var(--err-weak){{else if eq .Status "error"}}var(--err-weak){{else}}var(--warn-weak){{end}}">
<h2>
{{if eq .Status "ok"}}<span style="color:var(--ok)">✓ Тест пройден</span>
{{else if eq .Status "mismatch"}}<span style="color:var(--err)">Результат не совпал</span>
{{else if eq .Status "error"}}<span style="color:var(--err)">Ошибка сервиса НРД</span>
{{else}}<span style="color:var(--warn)">⏳ Ожидание ответа</span>{{end}}
· сценарий {{.Scenario}} «{{.Title}}»
</h2>
<table>
<tr><td style="width:200px" class="muted">Ожидали</td><td>{{.Expected}}</td></tr>
<tr><td class="muted">Получили</td><td><b>{{.Got}}</b></td></tr>
{{if .Note}}<tr><td class="muted">Вывод</td><td>{{.Note}}</td></tr>{{end}}
</table>
</div>
{{end}}
<div class="card">
<h2>Ценные бумаги ({{len .Claim.Securities}})</h2>
<table>
@@ -32,6 +92,129 @@
</table>
</div>
{{/* ===== QUIK МНП: поручение «Вывод ЦБ (M2M)» ===== */}}
{{$back := printf "/admin/claims/%s" .Claim.ID}}
<div class="card">
<style>
.qk-steps{display:flex;gap:8px;flex-wrap:wrap;margin:8px 0}
.qk-step{padding:6px 10px;border-radius:9px;border:1.5px solid var(--border);font-size:12.5px;background:var(--card-2)}
.qk-step.ok{border-color:color-mix(in srgb,#16a34a 45%,transparent);background:color-mix(in srgb,#16a34a 12%,var(--card));color:#0f7a37;font-weight:600}
.qk-step.bad{border-color:color-mix(in srgb,#dc2626 45%,transparent);background:color-mix(in srgb,#dc2626 10%,var(--card));color:#b3261e;font-weight:600}
.qk-state{display:inline-block;padding:3px 10px;border-radius:999px;font-size:12px;font-weight:700}
.qk-state.registered{background:color-mix(in srgb,#d97706 15%,var(--card));color:#9a6b12}
.qk-state.signed,.qk-state.deposited{background:color-mix(in srgb,#16a34a 15%,var(--card));color:#0f7a37}
.qk-state.denied{background:var(--card-2);color:var(--muted)}
.qk-state.error{background:color-mix(in srgb,#dc2626 15%,var(--card));color:#b3261e}
.qk-state.manual{background:color-mix(in srgb,#2563eb 12%,var(--card));color:#1d4ed8}
</style>
<div style="display:flex;align-items:center;gap:12px;flex-wrap:wrap">
<h2 style="margin:0">QUIK МНП — блокировка и списание у клиента</h2>
<a href="/admin/quik" class="muted" style="font-size:12px">все поручения →</a>
</div>
{{if .DepoL}}<div style="margin:8px 0 0;padding:8px 12px;border-radius:9px;font-size:13px;{{if .DepoL.Errors}}background:color-mix(in srgb,#dc2626 12%,var(--card));color:#b3261e{{else}}background:var(--card-2){{end}}">🗂 DEPO (bj2depo): {{if .DepoL.DocNo}}поручение <b>№{{.DepoL.DocNo}}</b> создано {{msk .DepoL.DocAt}}{{if .DepoL.DocState}} · состояние в DEPO: <b>{{.DepoL.DocState}}</b>{{end}}{{else}}поручение ещё не создано{{end}}{{range .DepoL.Errors}}<div>ошибка: {{.}}</div>{{end}}</div>{{end}}
<p class="muted" style="font-size:12.5px;margin:6px 0 0">При отправке в НРД bj регистрирует в Модуле неторговых поручений «Вывод ЦБ (M2M)» — QUIK блокирует бумаги на позиции клиента. Отказ контрагента или НРД → поручение снимается с причиной (клиент видит её в терминале). Приём НРД нашего поручения 16 → подпись депозитария, бумаги списываются.</p>
{{if not .Quik}}<p class="muted" style="margin-top:10px">Поручений МНП по этой заявке нет{{if .Claim.M2MGUID}} — можно зарегистрировать вручную:
<form method="post" action="/admin/quik/op" style="margin:6px 0 0;display:flex;gap:8px;flex-wrap:wrap;align-items:center">
<input type="hidden" name="guid" value="{{.Claim.M2MGUID}}"><input type="hidden" name="op" value="register"><input type="hidden" name="back" value="{{$back}}">
<select name="isin" style="font-size:12.5px">{{range .Claim.Securities}}<option value="{{.SecurityDetails.ISIN}}">{{.SecurityDetails.ISIN}} — {{.SecurityCode}}</option>{{end}}</select>
<button class="btn" style="font-size:12px" onclick="return confirm('Зарегистрировать «Вывод ЦБ (M2M)» в QUIK? Бумаги будут заблокированы.')">⊕ Зарегистрировать в QUIK</button>
<span class="muted" style="font-size:12px">или привязать поручение, поданное клиентом из терминала:</span>
<input type="text" name="instr_no" placeholder="№ поручения" style="font-size:12px;width:130px" form="qk-link">
</form>
<form id="qk-link" method="post" action="/admin/quik/op" style="margin:4px 0 0"><input type="hidden" name="guid" value="{{.Claim.M2MGUID}}"><input type="hidden" name="op" value="link"><input type="hidden" name="back" value="{{$back}}"><select name="isin" style="font-size:12.5px">{{range .Claim.Securities}}<option value="{{.SecurityDetails.ISIN}}">{{.SecurityDetails.ISIN}}</option>{{end}}</select> <button class="btn" style="font-size:12px">⛓ Привязать</button></form>{{end}}</p>{{end}}
{{range .Quik}}
<div style="border:1px solid var(--border);border-radius:10px;padding:10px 12px;margin-top:10px">
<div style="display:flex;gap:10px;align-items:center;flex-wrap:wrap">
<b><code>{{.ISIN}}</code> × {{.Qty}}</b>
<span class="muted" style="font-size:12px">клиент QUIK <code>{{.ClientID}}</code>{{if .Contract}} · {{.Contract}}{{end}}{{if .InstrNo}} · поручение МНП <b>№{{.InstrNo}}</b>{{end}}</span>
<span class="qk-state {{.State}}">{{.StateRu}}</span>
</div>
{{if .Err}}<div style="color:#b3261e;font-size:12.5px;margin-top:4px">{{.Err}}</div>{{end}}
{{if .Steps}}<div class="qk-steps">{{range .Steps}}<span class="qk-step {{if .OK}}ok{{else}}bad{{end}}" title="{{.Reply}}">{{msk .At}} · {{.Action}}{{if .ActionID}} #{{.ActionID}}{{end}}: {{.Reply}}</span>{{end}}</div>{{end}}
<div style="display:flex;gap:8px;flex-wrap:wrap;align-items:center;margin-top:6px">
{{if eq .State "registered"}}
<form method="post" action="/admin/quik/op" style="margin:0;display:inline"><input type="hidden" name="guid" value="{{.GUID}}"><input type="hidden" name="isin" value="{{.ISIN}}"><input type="hidden" name="op" value="sign"><input type="hidden" name="back" value="{{$back}}"><button class="btn btn-ok" style="font-size:12px" onclick="return confirm('Подписать поручение МНП №{{.InstrNo}}? Бумаги будут СПИСАНЫ с позиции клиента в QUIK.')">✎ Подписать (исполнить)</button></form>
<form method="post" action="/admin/quik/op" style="margin:0;display:inline"><input type="hidden" name="guid" value="{{.GUID}}"><input type="hidden" name="isin" value="{{.ISIN}}"><input type="hidden" name="op" value="deny"><input type="hidden" name="back" value="{{$back}}"><input type="text" name="reason" placeholder="причина для клиента" style="font-size:12px;width:200px"><button class="btn" style="font-size:12px" onclick="return confirm('Снять поручение №{{.InstrNo}}? Блокировка в QUIK будет снята, клиент увидит причину.')">✕ Снять</button></form>
{{else if or (eq .State "error") (eq .State "manual") (not .InstrNo)}}
<form method="post" action="/admin/quik/op" style="margin:0;display:inline"><input type="hidden" name="guid" value="{{.GUID}}"><input type="hidden" name="isin" value="{{.ISIN}}"><input type="hidden" name="op" value="register"><input type="hidden" name="back" value="{{$back}}"><button class="btn" style="font-size:12px" onclick="return confirm('Зарегистрировать «Вывод ЦБ (M2M)» в QUIK? Бумаги будут заблокированы на позиции клиента.')">⊕ Зарегистрировать (заблокировать)</button></form>
<form method="post" action="/admin/quik/op" style="margin:0;display:inline"><input type="hidden" name="guid" value="{{.GUID}}"><input type="hidden" name="isin" value="{{.ISIN}}"><input type="hidden" name="op" value="link"><input type="hidden" name="back" value="{{$back}}"><input type="text" name="instr_no" placeholder="№ поручения из QUIK" style="font-size:12px;width:150px"><button class="btn" style="font-size:12px">⛓ Привязать</button></form>
{{end}}
</div>
</div>
{{end}}
</div>
{{if .Depo}}
<div class="card">
<style>
.ord-steps{display:flex;gap:12px;margin:14px 0 6px;flex-wrap:wrap}
.ord-step{flex:1 1 190px;position:relative;display:flex;gap:12px;align-items:flex-start;padding:14px 16px;border-radius:13px;border:2px solid var(--border);background:var(--card)}
.ord-step .dot{width:30px;height:30px;border-radius:50%;flex:none;display:flex;align-items:center;justify-content:center;font-size:15px;font-weight:800;background:var(--border);color:var(--muted)}
.ord-step .t{font-size:14px;font-weight:700;line-height:1.2}
.ord-step .s{font-size:11.5px;margin-top:3px;font-weight:600;color:var(--muted)}
.ord-step.done{border-color:#16a34a;background:color-mix(in srgb,#16a34a 13%,var(--card))}
.ord-step.done .dot{background:#16a34a;color:#fff}
.ord-step.done .s{color:#0f7a37}
.ord-step.wait{border-color:#f59e0b;background:color-mix(in srgb,#f59e0b 15%,var(--card))}
.ord-step.wait .dot{background:#f59e0b;color:#fff;box-shadow:0 0 0 4px color-mix(in srgb,#f59e0b 28%,transparent)}
.ord-step.wait .s{color:#b45309}
.ord-step.todo{opacity:.55}
.ord-dl{display:inline-block;margin-top:9px;font-size:11.5px;font-weight:700;padding:4px 10px;border-radius:8px;background:var(--card);border:1px solid #16a34a;color:#0f7a37;text-decoration:none;white-space:nowrap}
.ord-dl:hover{background:color-mix(in srgb,#16a34a 12%,var(--card))}
</style>
<div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap">
<h2 style="margin:0">Депозитарное поручение 16 (списание)</h2>
{{if .Depo.Done}}<span class="badge confirmed">проведено · MS010</span>
{{else if .Depo.Submitted}}<span class="badge awaiting_decision">подано, ждём MS010</span>
{{else}}<span class="badge" style="background:var(--card-2);color:var(--muted)">требует подачи</span>{{end}}
</div>
{{if .Watch}}<div style="margin:8px 0 0;padding:8px 12px;border-radius:9px;font-size:13px;font-weight:600;{{if eq .Watch.Kind "ok"}}background:color-mix(in srgb,#16a34a 12%,var(--card));color:#0f7a37{{else if eq .Watch.Kind "warn"}}background:color-mix(in srgb,#d97706 14%,var(--card));color:#9a6b12{{else if eq .Watch.Kind "bad"}}background:color-mix(in srgb,#dc2626 12%,var(--card));color:#b3261e{{else}}background:var(--card-2);color:var(--muted){{end}}">⏱ Срок по регламенту: {{.Watch.StatusRu}}</div>{{end}}
<p class="muted" style="font-size:12.5px;margin:6px 0 0">Мы — списывающий депозитарий. НРД прислал черновик формы 16; подайте его — НРД сматчит со встречным 16/1 контрагента и пришлёт отчёт <b>MS010</b> о проведённом списании (бумага ушла контрагенту).</p>
<div class="ord-steps">
<div class="ord-step done">
<span class="dot">✓</span>
<div><div class="t">Черновик 16 получен</div><div class="s">{{msk .Depo.DraftAt}}</div>
<a class="ord-dl" href="/admin/outgoing/{{.Depo.GUID}}/draft.zip">⬇ Черновик от НРД</a></div>
</div>
<div class="ord-step {{if .Depo.Submitted}}done{{else}}wait{{end}}">
<span class="dot">{{if .Depo.Submitted}}✓{{else}}2{{end}}</span>
<div><div class="t">Поручение 16 подано</div><div class="s">{{if .Depo.Submitted}}{{msk .Depo.OrderAt}}{{else}}нужно подать в НРД{{end}}</div>
{{if .Depo.OrderXML}}<a class="ord-dl" href="/admin/outgoing/{{.Depo.GUID}}/order.xml">⬇ Отправленная форма 16</a>{{end}}</div>
</div>
<div class="ord-step {{if .Depo.Done}}done{{else if .Depo.Submitted}}wait{{else}}todo{{end}}">
<span class="dot">{{if .Depo.Done}}✓{{else}}3{{end}}</span>
<div><div class="t">Списание проведено · MS010</div><div class="s">{{if .Depo.Done}}отчёт получен от НРД{{else if .Depo.Submitted}}ждём встречный 16/1 + MS010{{else}}после подачи{{end}}</div>
{{if .Depo.Responses}}<a class="ord-dl" href="/admin/outgoing/{{.Depo.GUID}}/ms010.zip">⬇ Ответ НРД (MS010)</a>{{end}}</div>
</div>
</div>
<div style="display:flex;gap:10px;flex-wrap:wrap;align-items:center;margin-top:8px">
{{if not .Depo.Submitted}}
<form method="post" action="/admin/outgoing/{{.Depo.GUID}}/submit-order?back=/admin/claims/{{.Claim.ID}}" onsubmit="return confirm('Подать поручение 16 (списание, тип ЭД ORDXM) в НРД?');">
<button type="submit" class="btn btn-ok">▶ Подать поручение 16 в НРД</button>
</form>
{{end}}
<a href="/admin/outgoing/{{.Depo.GUID}}/export.zip" class="btn">⬇ Выгрузка 16 для регулятора (весь пакет)</a>
</div>
{{if .Depo.Submitted}}
<div class="muted" style="font-size:12px;margin-top:10px">Подано {{msk .Depo.OrderAt}} · пакет ИШ <code>{{.Depo.Pkgs}}</code></div>
{{if .Depo.Responses}}
<table style="margin-top:8px">
<thead><tr><th style="width:150px">Время</th><th>Ответ НРД</th></tr></thead>
<tbody>{{range .Depo.Responses}}<tr><td style="white-space:nowrap">{{.Time}}</td><td>{{.Kind}}</td></tr>{{end}}</tbody>
</table>
{{else}}
<p class="muted" style="font-size:12px;margin-top:8px">Ответ НРД (квитанция → принятие → отчёт MS010 о списании) появится здесь. Обновите страницу.</p>
{{end}}
{{end}}
</div>
{{end}}
<div class="card">
<h2>История FSM</h2>
<table>
@@ -49,6 +232,25 @@
</table>
</div>
{{if .Claim.RequestRawXML}}
<div class="card">
<h2>Отправленный пакет (M2MTransferRequest)</h2>
<p class="muted">Точный XML, который bj-server сформировал и ИШ подписал ключом и отправил в НРД. Тип пакета <code>#M2MTR</code>, канал TEST3.</p>
<a class="btn btn-ok" href="/admin/claims/{{.Claim.ID}}/request.xml" download style="margin-right:8px">⤓ Скачать XML запроса</a>
<button type="button" class="btn" onclick="copyReq(this)">Скопировать</button>
<pre id="raw-request" style="white-space:pre-wrap;word-break:break-all;background:var(--surface-2,#f5f5f7);padding:12px;border-radius:8px;font-size:12px;overflow:auto;max-height:340px">{{.Claim.RequestRawXML}}</pre>
<script>
function copyReq(btn){
var t=document.getElementById('raw-request').innerText;
navigator.clipboard.writeText(t).then(function(){
var o=btn.textContent; btn.textContent='Скопировано ✓';
setTimeout(function(){btn.textContent=o;},1500);
});
}
</script>
</div>
{{end}}
{{if .Claim.M2MResponse}}
<div class="card">
<h2>Ответ сервиса МОСТ (M2MTransferResponse)</h2>
@@ -64,7 +266,7 @@
<thead><tr><th>ReferenceID</th><th>Код</th><th>Текст ответа НРД</th></tr></thead>
<tbody>
{{range .Claim.M2MResponse.Responses}}
<tr><td><code>{{.ReferenceID}}</code></td><td><code>{{.Code}}</code></td><td>{{.Text}}</td></tr>
<tr><td><code>{{.ReferenceID}}</code></td><td><code>{{.Code}}</code></td><td>{{if .Text}}{{.Text}}{{else}}{{m2mCode .Code}}{{end}}</td></tr>
{{end}}
</tbody>
</table>
@@ -83,6 +285,7 @@
<p class="muted" style="margin:8px 0">
Точные байты ответа сервиса МОСТ. Можно дословно переслать в поддержку НРД.
</p>
<a class="btn btn-ok" href="/admin/claims/{{.Claim.ID}}/response.xml" download style="margin-right:8px">⤓ Скачать XML ответа</a>
<button type="button" class="btn" onclick="copyRaw(this)">Скопировать</button>
<pre id="raw-response" style="white-space:pre-wrap;word-break:break-all;background:var(--surface-2,#f5f5f7);padding:12px;border-radius:8px;font-size:12px;overflow:auto;max-height:340px">{{.Claim.M2MResponse.RawXML}}</pre>
</details>
@@ -110,7 +313,7 @@
<tr>
<td><code>{{.ReferenceID}}</code></td>
<td><span class="badge {{if eq .Outcome "confirmed"}}confirmed{{else}}rejected{{end}}">{{ruOutcome .Outcome}}</span></td>
<td>{{range .RejectCodes}}<code>{{.}}</code> {{end}}</td>
<td>{{range .RejectCodes}}{{m2mCode .}}<br>{{end}}</td>
</tr>
{{end}}
</tbody>
@@ -130,4 +333,25 @@
</table>
</div>
{{end}}
</div>{{/* /claim-main */}}
{{if .Claim.Timeline}}
<aside class="claim-tl">
<h3>Путь заявки</h3>
{{range $i, $e := .Claim.Timeline}}
{{if $i}}
<div class="tl-arrow tl-{{$e.Kind}}">
<span class="ln"></span><span class="ar"></span>
{{if $e.Since}}<span class="dt">через {{$e.Since}}</span>{{end}}
</div>
{{end}}
<div class="tl-step tl-{{$e.Kind}}">
<div class="tl-time">{{if $e.Time.IsZero}}ожидание{{else}}{{$e.Time.Format "02.01 15:04:05"}} МСК{{end}}</div>
<div class="tl-title">{{$e.Title}}</div>
{{if $e.Detail}}<div class="tl-detail">{{$e.Detail}}</div>{{end}}
</div>
{{end}}
</aside>
{{end}}
</div>{{/* /claim-grid */}}
{{end}}
@@ -8,12 +8,12 @@
{{range .Items}}
<tr>
<td>{{.CreatedAt.Format "02.01 15:04:05"}}</td>
<td><code>{{slice .ID 0 8}}</code></td>
<td><code>{{slice (printf "%s" .M2MGUID) 0 8}}</code></td>
<td>{{.Investor.LastName}} {{slice .Investor.FirstName 0 1}}.</td>
<td><code>{{short 8 .ID}}</code></td>
<td><code>{{short 8 (printf "%s" .M2MGUID)}}</code></td>
<td>{{.Investor.LastName}} {{initial .Investor.FirstName}}</td>
<td>{{len .Securities}}</td>
<td><code>{{.TransferringDepositoryINN}}</code></td>
<td><code>{{.ReceivingDepositoryINN}}</code></td>
<td>{{$f := m2mParty .TransferringDepositoryINN}}{{if $f}}<span style="font-size:11px">{{$f}}</span><br>{{end}}<code style="font-size:11px">{{.TransferringDepositoryINN}}</code></td>
<td>{{$t := m2mParty .ReceivingDepositoryINN}}{{if $t}}<span style="font-size:11px">{{$t}}</span><br>{{end}}<code style="font-size:11px">{{.ReceivingDepositoryINN}}</code></td>
<td><span class="badge {{.Status}}">{{ruState .Status}}</span></td>
<td><a href="/admin/claims/{{.ID}}">детали →</a></td>
</tr>
@@ -0,0 +1,139 @@
{{define "content"}}
<style>
.sim-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:10px 14px}
.sim-grid label{display:block;font-size:12px;color:var(--muted);margin-bottom:3px}
.sim-grid input,.sim-grid select{width:100%;padding:7px 9px;border-radius:8px;border:1px solid var(--border);background:var(--card);color:var(--text);font-size:13.5px}
.mode{display:flex;gap:8px;flex-wrap:wrap;margin:0 0 12px}
.mode label{display:inline-flex;align-items:center;gap:6px;padding:7px 12px;border-radius:9px;border:1.5px solid var(--border);background:var(--card);cursor:pointer;font-weight:600;font-size:13px}
.mode label:has(input:checked){border-color:var(--accent);background:var(--accent-weak,#e7f0fb)}
.presets{display:flex;gap:8px;flex-wrap:wrap;margin:12px 0 4px}
.presets button{font-size:12.5px;padding:6px 11px;border-radius:9px;border:1px solid var(--border);background:var(--card-2);color:var(--text);cursor:pointer}
.presets button:hover{border-color:var(--accent)}
.out-only{display:none}.is-out .out-only{display:block}
.dc-ok{color:var(--ok,#12813f);font-weight:700}.dc-bad{color:var(--err,#b3261e);font-weight:700}.dc-warn{color:var(--warn,#9a6b12);font-weight:700}
.dc-rec{display:inline-block;padding:5px 14px;border-radius:999px;font-weight:700;font-size:14px}
.dc-rec.confirm{background:var(--ok-weak,#e6f4ea);color:var(--ok,#12813f)}
.dc-rec.reject{background:var(--err-weak,#fbe9e7);color:var(--err,#b3261e)}
.dc-rec.mixed{background:var(--warn-weak,#fff4e0);color:var(--warn,#9a6b12)}
</style>
<p style="margin:0 0 12px"><a href="/admin/m2m-testing" class="btn" onclick="if(history.length>1){history.back();return false}">← Назад</a></p>
<div class="card {{if eq .Form.Mode "out"}}is-out{{end}}" id="simCard">
<h2>Имитация сверки с DEPO</h2>
<p class="muted" style="font-size:13px">Выберите реального клиента и бумагу из DEPO, при желании измените любое поле и нажмите «Проверить» — отработает та же сверка, что и по настоящим переводам, и покажет ответ: <b>принять</b> или <b>отказ с кодом</b>. <b>В НРД и контрагенту ничего не отправляется.</b></p>
<form method="post" action="/admin/m2m-testing/depo-sim" id="simForm">
<div class="mode">
<label><input type="radio" name="mode" value="in" {{if ne .Form.Mode "out"}}checked{{end}} onchange="simMode()"> ↘ Зачисление (к нам приходят бумаги)</label>
<label><input type="radio" name="mode" value="out" {{if eq .Form.Mode "out"}}checked{{end}} onchange="simMode()"> ↗ Списание (мы отправляем)</label>
</div>
<div class="sim-grid" style="margin-bottom:10px">
<div style="grid-column:1/-1">
<label>Клиент из DEPO (подставит ФИО и паспорт)</label>
<select id="pickClient" onchange="simPickClient(this)">
<option value="">— выбрать реального клиента —</option>
{{range .Clients}}<option data-name="{{.Name}}" data-ser="{{.Ser}}" data-num="{{.Num}}">{{.Name}} · паспорт {{.Ser}} № {{.Num}} · {{.Account}}</option>{{end}}
</select>
</div>
<div><label>Фамилия</label><input name="last" id="f_last" value="{{.Form.Last}}"></div>
<div><label>Имя</label><input name="first" id="f_first" value="{{.Form.First}}"></div>
<div><label>Отчество</label><input name="middle" id="f_middle" value="{{.Form.Middle}}"></div>
<div><label>Паспорт — серия</label><input name="ser" id="f_ser" value="{{.Form.Ser}}"></div>
<div><label>Паспорт — номер</label><input name="num" id="f_num" value="{{.Form.Num}}"></div>
<div style="grid-column:1/-1">
<label>Бумага из DEPO (подставит ISIN)</label>
<select id="pickSec" onchange="simPickSec(this)">
<option value="">— выбрать бумагу на обслуживании —</option>
{{range .Secs}}<option data-isin="{{.ISIN}}">{{.Name}} · {{.ISIN}}</option>{{end}}
</select>
</div>
<div><label>ISIN</label><input name="isin" id="f_isin" value="{{.Form.ISIN}}"></div>
<div><label>Код НРД (необязательно)</label><input name="code" id="f_code" value="{{.Form.Code}}" placeholder="SBER/03"></div>
<div><label>Количество</label><input name="qty" id="f_qty" value="{{.Form.Qty}}"></div>
<div class="out-only"><label>Место хранения — счёт (наш в НРД)</label><input name="account" id="f_account" value="{{.Form.Account}}"></div>
<div class="out-only"><label>Место хранения — раздел (начало 36MC04136)</label><input name="section" id="f_section" value="{{.Form.Section}}"></div>
</div>
<div class="muted" style="font-size:12px">Быстрые сценарии — подставят данные, затем нажмите «Проверить»:</div>
<div class="presets">
<button type="button" onclick="simPreset('ok')">🟢 Реальный клиент → принять</button>
<button type="button" onclick="simPreset('badpass')">🔴 Не тот паспорт → M2M04</button>
<button type="button" onclick="simPreset('foreign')">🔴 Иностранная бумага → M2M01</button>
<button type="button" onclick="simPreset('unknown')">🔴 Неизвестный ISIN → M2M05</button>
<button type="button" class="out-only" style="display:inline-block" onclick="simPreset('toomany')">🔴 Списание: больше, чем есть → недостаточно</button>
<button type="button" class="out-only" style="display:inline-block" onclick="simPreset('badplace')">🔴 Списание: не то место хранения</button>
</div>
<div style="display:flex;gap:10px;align-items:center;flex-wrap:wrap;margin-top:10px">
<button type="submit" class="btn btn-ok">▶ Проверить — имитация отправки в Fancy</button>
<span class="muted" style="font-size:12px">ничего не отправляется</span>
</div>
</form>
</div>
{{if .Posted}}
<div class="card">
<div style="display:flex;align-items:center;gap:12px;flex-wrap:wrap">
<h2 style="margin:0">Ответ сверки (имитация) — {{if eq .Result.Mode "out"}}списание{{else}}зачисление{{end}}</h2>
{{if .Result.Ran}}<span class="dc-rec {{.Result.Recommend}}">{{.Result.Summary}}</span>{{end}}
</div>
{{if not .Result.Ran}}
<p class="muted" style="margin-top:10px">Сверка не выполнена: {{.Result.Err}}</p>
{{else}}
<table style="margin-top:10px">
<tr><td class="muted" style="width:220px">Инвестор в картотеке</td><td>{{if .Result.Investor}}<b>{{.Result.Investor}}</b>{{else}}<span class="dc-bad">не найден</span>{{end}}</td></tr>
<tr><td class="muted">Торговый счёт депо</td><td>{{if .Result.Account}}<code>{{.Result.Account}}</code> — открыт{{else}}<span class="dc-bad">открытого ВТНКЦ-счёта нет</span>{{end}}</td></tr>
</table>
{{range .Result.Secs}}
<h3 style="font-size:13.5px;margin:14px 0 4px"><code>{{.Security}}</code> → {{if eq .Recommend "confirm"}}<span class="dc-ok">принять</span>{{else}}<span class="dc-bad">отказ {{.RejectCode}}</span> <span class="muted" style="font-weight:400">({{m2mCode .RejectCode}})</span>{{end}}</h3>
<table>
<thead><tr><th style="width:36px"></th><th style="width:280px">Проверка</th><th>Результат</th></tr></thead>
<tbody>
{{range .Checks}}
<tr><td>{{if not .OK}}<span class="dc-bad">🔴</span>{{else if .Warn}}<span class="dc-warn">🟡</span>{{else}}<span class="dc-ok">🟢</span>{{end}}</td><td>{{.Name}}{{if not .OK}} <span class="muted" style="font-size:11px">→ {{.Code}}</span>{{end}}</td><td style="font-size:12.5px">{{.Detail}}</td></tr>
{{end}}
</tbody>
</table>
{{end}}
<p class="muted" style="font-size:12px;margin-top:12px">🟢 совпало · 🟡 пройдено с предупреждением (данных для отказа в базе нет — решает оператор) · 🔴 не совпало. Так же выглядит сверка в карточке настоящего входящего и в превью исходящей отправки.</p>
{{end}}
</div>
{{end}}
<script>
function simSet(id,v){var e=document.getElementById(id); if(e) e.value=v;}
function simMode(){
var out=document.querySelector('input[name="mode"][value="out"]').checked;
document.getElementById('simCard').classList.toggle('is-out', out);
}
function simPickClient(sel){
var o=sel.options[sel.selectedIndex]; if(!o||!o.dataset.name) return;
var p=o.dataset.name.trim().split(/\s+/);
simSet('f_last',p[0]||''); simSet('f_first',p[1]||''); simSet('f_middle',p.slice(2).join(' '));
simSet('f_ser',o.dataset.ser||''); simSet('f_num',o.dataset.num||'');
}
function simPickSec(sel){ var o=sel.options[sel.selectedIndex]; if(o&&o.dataset.isin) simSet('f_isin',o.dataset.isin); }
// Пресеты меняют ТОЛЬКО поле сценария; человек остаётся тот, которого выбрали/ввели.
// Клиент из списка подставляется лишь если поля ФИО/паспорта совсем пустые.
function simPreset(kind){
var cs=document.getElementById('pickClient');
var empty=!document.getElementById('f_last').value.trim() && !document.getElementById('f_num').value.trim();
if(empty && cs && cs.options.length>1){ cs.selectedIndex=1; simPickClient(cs); }
if(kind==='ok'){
// «как в базе»: вернуть реальный паспорт выбранного в списке человека (если он выбран),
// бумагу оставить выбранную (или SBER), количество 10, наш раздел
if(cs && cs.selectedIndex>0) simPickClient(cs);
if(!document.getElementById('f_isin').value.trim()) { simSet('f_isin','RU0009029540'); simSet('f_code','SBER/03'); }
simSet('f_qty','10'); simSet('f_section','36MC0413600000F00');
}
if(kind==='badpass'){ simSet('f_num','000000'); }
if(kind==='foreign'){ simSet('f_isin','US0378331005'); simSet('f_code',''); }
if(kind==='unknown'){ simSet('f_isin','RU000A0ZZZZ9'); simSet('f_code',''); }
if(kind==='toomany'){ simSet('f_qty','99999999'); }
if(kind==='badplace'){ simSet('f_section','99XX0000000000000'); }
if(kind==='toomany'||kind==='badplace'){ document.querySelector('input[name="mode"][value="out"]').checked=true; simMode(); }
}
</script>
{{end}}
@@ -0,0 +1,76 @@
{{define "content"}}
<div class="hero">
<h1 class="hero-greeting">Обмен с DEPO (Fancy)</h1>
<span class="hero-status">Переводы M2M, которые bj передал в депозитарную систему через трансферные таблицы базы {{.DB}} (FTV_M2M / FTV_M2M_LINES / FTV_M2M_EVENTS). Job bj2depo создаёт по ним документы в DEPO и отвечает событиями; bj ведёт статусы, документ и ошибки.</span>
</div>
{{if .Flash}}<div class="card" style="background:var(--accent-weak)">{{.Flash}}</div>{{end}}
<div class="card">
<h2>Как идёт обмен</h2>
<p class="muted" style="line-height:1.7">
<span class="badge awaiting_decision">FTV_M2M</span> bj записывает перевод (шапка + строки по бумагам) и событие <code>BJ_*</code> со STATE=<b>TODO</b> →
<span class="badge awaiting_decision">bj2depo</span> процедура FTV_M2M_JOB ({{.JobMode}}) читает события, создаёт документ 16/16-1 в DEPO, отвечает <code>DEPO_ORDER_CREATED</code> (номер документа) или <code>DEPO_ERROR</code> →
<span class="badge sent">документ</span> bj следит за состоянием документа (OD_DOCS: начальный ввод → конец обработки / отмена) и при отмене оповещает →
<span class="badge confirmed">события НРД</span> по ответам MS010 и M2M bj дописывает <code>BJ_ORDER_ACCEPTED / BJ_EXECUTED / BJ_REJECTED / BJ_CANCELLED</code>.
Опрос таблиц — раз в минуту. Подробности и словарь событий — <a href="/admin/help/fancy-events">страница для программиста Fancy</a>.
</p>
<div style="display:flex;gap:18px;flex-wrap:wrap;font-size:13px;margin-top:6px">
<span>Переводов: <b>{{.Sum.Total}}</b></span>
<span>Ждут job: <b>{{.Sum.Pending}}</b></span>
<span>С документом DEPO: <b>{{.Sum.WithDoc}}</b></span>
<span>С ошибками: <b style="{{if .Sum.Errors}}color:var(--err){{end}}">{{.Sum.Errors}}</b></span>
{{if not .Sum.LastEventAt.IsZero}}<span>Последнее событие: <b>{{wall .Sum.LastEventAt}}</b></span>{{end}}
<span>Справочник НРД в DEPO: {{if .Sum.HasHandbook}}<b>{{.Sum.Handbook}}</b> участников{{if not .Sum.HandbookAt.IsZero}} (обновлён {{wall .Sum.HandbookAt}}){{end}}{{else}}<span class="muted">таблица FTV_M2M_HANDBOOK не создана</span>{{end}}</span>
<span>Job bj2depo: {{.JobMode}}{{if eq .Fancy.DepoJobMode "bj"}}{{if .Fancy.DepoJobLast}} · последний вызов: {{.Fancy.DepoJobLast}}{{if not .Fancy.DepoJobLastAt.IsZero}} ({{msk .Fancy.DepoJobLastAt}}){{end}}{{end}}{{else if .Fancy.DepoJobCheck}} · проверка готовности: {{if .Fancy.DepoJobCheckOK}}OK{{else}}не пройдена{{end}}{{if not .Fancy.DepoJobCheckAt.IsZero}} ({{msk .Fancy.DepoJobCheckAt}}){{end}}{{end}} · <a href="/admin/setup#fancy">настройки</a></span>
</div>
</div>
{{if or .Failures (not .RunAsOK)}}<div class="card" style="border:1px solid var(--err)">
<h2 style="color:var(--err)">Обработчик DEPO не смог обработать записи{{if .Failures}} ({{len .Failures}}){{end}}</h2>
<p class="muted">Пока причина не устранена, документы в DEPO не создаются, а статусы по заявкам ЛК не ставятся. Устраните причину по тексту ошибки и верните записи в очередь: обработчик возьмёт их заново.</p>
{{if not .RunAsOK}}<p style="color:var(--err)">✗ {{.RunAsMsg}}</p>{{end}}
{{if .Failures}}
<div style="overflow-x:auto"><table style="margin-top:8px">
<thead><tr><th>Запись</th><th>Перевод</th><th>Тип / строка</th><th>Когда</th><th>Ошибка обработчика</th></tr></thead>
<tbody>{{range .Failures}}<tr>
<td>{{.TableRu}} №{{.ID}}</td><td><code>{{.GUID}}</code></td><td>{{.Type}}{{if .Ref}}<div class="muted" style="font-size:11px">{{.Ref}}</div>{{end}}</td>
<td style="white-space:nowrap">{{if not .At.IsZero}}{{wall .At}}{{end}}</td><td style="color:var(--err);font-size:12px">{{.Comment}}</td>
</tr>{{end}}</tbody>
</table></div>
<form method="post" action="/admin/fancy-queue/requeue" style="margin-top:12px"><button type="submit" class="btn btn-ok" onclick="return confirm('Вернуть записи с ошибкой в очередь обработчика DEPO?')">↻ Вернуть в очередь</button></form>
{{end}}
</div>{{end}}
<div class="card">
<h2>Переводы в DEPO ({{len .Rows}})</h2>
{{if .Error}}<p style="color:var(--err)">{{.Error}}</p>{{end}}
{{if .Rows}}
<div style="overflow-x:auto">
<table style="font-size:12.5px">
<thead><tr><th>Создан</th><th>Перевод</th><th>Направление</th><th>Депонент</th><th>Контрагент</th><th>Бумаги</th><th>Документ DEPO</th><th>Состояние</th></tr></thead>
<tbody>
{{range .Rows}}
<tr>
<td style="white-space:nowrap">{{if not .CreatedAt.IsZero}}{{wall .CreatedAt}}{{else}}—{{end}}</td>
<td><a href="/admin/claims?guid={{.GUID}}"><code>{{short 8 .GUID}}</code></a>{{if .Comment}}<div class="muted" style="font-size:11px">{{.Comment}}</div>{{end}}</td>
<td style="white-space:nowrap">{{.DirectionRu}}</td>
<td>{{.Deponent}}{{if .DeponentDoc}}<div class="muted" style="font-size:11px">{{.DeponentDoc}}</div>{{end}}</td>
<td><code>{{.CP}}</code></td>
<td>{{range .Lines}}<div><code>{{.ISIN}}</code>{{if .SecCode}} <span class="muted">{{.SecCode}}</span>{{end}} × {{.Amount}}{{if eq .State "ERROR"}} <span class="badge rejected">ошибка</span>{{end}}</div>{{else}}<span class="muted">—</span>{{end}}</td>
<td>{{if .Doc}}<b>№{{.Doc}}</b><div class="muted" style="font-size:11px">{{.DocState}}{{if not .DocAt.IsZero}} · {{wall .DocAt}}{{end}}</div>{{else}}<span class="muted">не создан</span>{{end}}</td>
<td><span class="badge {{stageCls .}}">{{stageText .}}</span>
{{range .Errors}}<div style="color:var(--err);font-size:11px">{{.}}</div>{{end}}</td>
</tr>
<tr><td colspan="8" style="padding:2px 10px 8px 24px;border-top:none"><div class="muted" style="font-size:11px;display:flex;gap:6px 18px;flex-wrap:wrap">{{range .Events}}<span><span style="{{if eq .State "TODO"}}color:var(--warn);font-weight:600{{end}}">{{.Type}}</span>{{if .Ref}} <code>{{.Ref}}</code>{{end}} · {{wall .At}}{{if .Codes}} · {{.Codes}}{{end}}{{if .Comment}} — {{.Comment}}{{end}}</span>{{else}}<span>событий нет</span>{{end}}</div></td></tr>
{{end}}
</tbody>
</table>
</div>
{{else if not .Error}}
<p class="muted">В трансферных таблицах пока нет переводов. Они появятся при входящем/исходящем переводе M2M или при прогоне «Тестирование M2M → выгрузка истории в DEPO».</p>
{{end}}
</div>
<p style="margin-top:14px"><a href="/admin/help/fancy-events" class="muted">bj ↔ bj2depo: события, таблицы, действия job →</a></p>
{{end}}
@@ -0,0 +1,105 @@
{{define "content"}}
<div class="card">
<h2>Справочник участников M2M</h2>
<p class="muted">Актуальная карта участников сервиса MOEX МОСТ (получена от НРД пакетом <code>#M2MTH</code>): ИНН, код участника (MC/EC/…), тип и наименование. Используется для сопоставления контрагента с кодом участника.
{{if .At}}<br><span style="font-size:12px">Справочник обновлён: <strong>{{.At}}</strong>.</span>{{end}}
</p>
{{if not .Participants}}
<div style="margin-top:12px;padding:14px;background:var(--warn-weak,#f6eed8);border-radius:10px">
<strong>Справочник ещё не загружен.</strong>
<p class="muted" style="margin:6px 0 0">Запросите его у НРД: <a href="/admin/setup">Настройка → Тесты → «Запросить справочник у НРД»</a>. Ответ придёт во входящие ИШ (~30–60 сек) и появится здесь.</p>
</div>
{{end}}
</div>
{{if .Participants}}
<div class="card">
<div style="display:flex;gap:12px;flex-wrap:wrap;align-items:center;justify-content:space-between">
<div style="flex:1;min-width:220px">
<input id="hb-search" type="text" placeholder="Поиск по наименованию, ИНН или коду…" autocomplete="off"
style="width:100%;max-width:520px" oninput="hbFilter()">
</div>
<div style="display:flex;gap:8px;align-items:center">
<select id="hb-kind" onchange="hbFilter()" style="max-width:200px">
<option value="">Все типы</option>
<option value="depo">Депозитарии</option>
<option value="broker">Брокеры</option>
<option value="place">Места расчётов</option>
</select>
<span class="muted" id="hb-count" style="font-size:13px;white-space:nowrap"></span>
</div>
</div>
<div class="tw" style="margin-top:14px">
<table id="hb-table">
<thead>
<tr>
<th style="width:150px">Код участника</th>
<th style="width:140px">ИНН</th>
<th style="width:120px">Тип</th>
<th>Наименование</th>
</tr>
</thead>
<tbody>
{{range .Participants}}
<tr class="hb-row" data-kind="{{.Kind}}"
data-s="{{.Code}} {{.INN}} {{.Name}}">
<td class="mono">{{if .Code}}{{.Code}}{{else}}—{{end}}</td>
<td class="mono">{{.INN}}</td>
<td>
{{if eq .Kind "depo"}}<span class="pill p-depo">депозитарий</span>
{{else if eq .Kind "broker"}}<span class="pill p-broker">брокер</span>
{{else if eq .Kind "place"}}<span class="pill p-place">место расчётов</span>
{{else}}{{.Kind}}{{end}}
</td>
<td>{{.Name}}</td>
</tr>
{{end}}
</tbody>
</table>
</div>
<p class="muted" id="hb-empty" style="display:none;margin-top:12px">Ничего не найдено по запросу.</p>
</div>
<style>
#hb-table td{vertical-align:middle}
.pill{font-family:var(--mono,monospace);font-size:11px;font-weight:600;padding:3px 9px;border-radius:999px;white-space:nowrap;display:inline-block}
.p-depo{background:var(--accent-weak,#e8ecfb);color:var(--accent,#1e4fd8)}
.p-broker{background:var(--warn-weak,#f6eed8);color:var(--warn-ink,#8a5806)}
.p-place{background:var(--ok-weak,#e2f1eb);color:var(--good-ink,#0c6144)}
mark{background:var(--accent-weak,#e8ecfb);color:inherit;padding:0 1px;border-radius:3px}
</style>
<script>
(function(){
var rows=[].slice.call(document.querySelectorAll('.hb-row'));
window.hbFilter=function(){
var q=(document.getElementById('hb-search').value||'').trim().toLowerCase();
var kind=document.getElementById('hb-kind').value;
var shown=0;
rows.forEach(function(r){
var s=(r.getAttribute('data-s')||'').toLowerCase();
var okQ=!q || s.indexOf(q)>=0;
var okK=!kind || r.getAttribute('data-kind')===kind;
var vis=okQ&&okK;
r.style.display=vis?'':'none';
if(vis)shown++;
});
document.getElementById('hb-count').textContent='Показано: '+shown+' из '+rows.length;
document.getElementById('hb-empty').style.display=shown?'none':'block';
};
hbFilter();
// Enter в поиске не отправляет форму
var inp=document.getElementById('hb-search');
inp.addEventListener('keydown',function(e){if(e.key==='Enter')e.preventDefault();});
inp.focus();
})();
</script>
{{end}}
<div class="card">
<details>
<summary style="cursor:pointer;font-weight:600;font-size:15px">Коды ошибок и отказов M2M — справка</summary>
<div style="margin-top:10px">{{.CodesHTML}}</div>
</details>
</div>
{{end}}
@@ -41,5 +41,17 @@
<p class="muted">Полная схема: bj-server → ИШ (на Astra Linux ВМ) → ONYX (НРД) → робот-автотест. Кто на чьей стороне, какое СКЗИ, какие сертификаты, FAQ.</p>
</div>
</a>
<a href="/admin/help/fancy-events" style="text-decoration:none">
<div class="card" style="height:100%">
<h2 style="color:var(--accent)">bj ↔ bj2depo: трансферные таблицы и события — для программиста Fancy →</h2>
<p class="muted">FTV_M2M / FTV_M2M_LINES / FTV_M2M_EVENTS: какие идентификаторы даёт bj, словарь событий в обе стороны (BJ_* и DEPO_*), правила STATE и идемпотентности, порядок по этапам, примеры SQL, открытые вопросы.</p>
</div>
</a>
<a href="/admin/help/quik" style="text-decoration:none">
<div class="card" style="height:100%">
<h2 style="color:var(--accent)">QUIK МНП: подготовка к M2M — для администратора QUIK →</h2>
<p class="muted">Чек-лист для QUIK Administrator: классы INSTROUTM2M / INSTRINM2M, инструменты, включение типов «Ввод/Вывод ЦБ М2М», права технологического пользователя, перезапуск модуля. Со ссылками на страницы документов ARQA и разбором ошибок.</p>
</div>
</a>
</div>
{{end}}
@@ -126,11 +126,11 @@
<div class="card">
<h2>Что у нас уже готово</h2>
<ul>
<li>✅ <strong>REST-клиент ИШ</strong> в <code>internal/nsdadapter/igw/</code> — все 4 endpoint'а по спецификации, упаковщик/распаковщик ZIP, 10 тестов PASS</li>
<li>✅ <strong>Робот-эмулятор</strong> в <code>internal/nsdadapter/mock/</code> — позволяет проверить нашу логику до получения реального ИШ</li>
<li>✅ <strong>Конфигурация в админке</strong> — поля <code>igw_base_url</code> и <code>channel</code> в /admin/setup; авто-определение профилей GUEST/TEST3/PROD</li>
<li>✅ <strong>Подбор URL контуров</strong> — при выборе профиля URL ONYX заполняется автоматически</li>
<li>✅ <strong>Полная документация ИШ</strong> в <code>DOC/</code> и дистрибутив в <code>dist/ish/</code></li>
<li><strong>REST-клиент ИШ</strong> в <code>internal/nsdadapter/igw/</code> — все 4 endpoint'а по спецификации, упаковщик/распаковщик ZIP, 10 тестов PASS</li>
<li><strong>Робот-эмулятор</strong> в <code>internal/nsdadapter/mock/</code> — позволяет проверить нашу логику до получения реального ИШ</li>
<li><strong>Конфигурация в админке</strong> — поля <code>igw_base_url</code> и <code>channel</code> в /admin/setup; авто-определение профилей GUEST/TEST3/PROD</li>
<li><strong>Подбор URL контуров</strong> — при выборе профиля URL ONYX заполняется автоматически</li>
<li><strong>Полная документация ИШ</strong> в <code>DOC/</code> и дистрибутив в <code>dist/ish/</code></li>
</ul>
</div>
{{end}}
@@ -5,15 +5,15 @@
<p class="muted"><strong>Архив от MOEX/НРД содержит «резервную копию», а не готовый профиль.</strong> На Linux рабочий <code>local.gdbm</code> нельзя восстановить headless — Валидата Linux требует GUI-операции «Восстановить справочники из резервной копии». Поэтому профиль готовится один раз на Windows и переносится на сервер через USB.</p>
</div>
<div class="card" style="border-left:4px solid var(--accent)">
<div class="card" style="background:var(--accent-weak)">
<h2>Почему профиль готовится на Windows, а не на сервере</h2>
<p>Боевой Astra Linux SE-сервер с ГОСТ-криптографией <strong>обязан быть headless</strong>: чем меньше пакетов и поверхности атаки, тем проще сертификация ФСТЭК и тем меньше нарушений требований к контуру ЭДО НРД. Установка GUI (X-сервер, GTK, шрифты, VNC/RDP) тянет 50+ пакетов, расширяет surface attack и усложняет аудит — поэтому отказались.</p>
<p>Это <strong>стандартная практика</strong> в фин-секторе: на admin-станции (под Windows или отдельной защищённой ВМ) генерируются и обновляются профили; на боевые серверы они доставляются готовыми через выделенный USB или защищённый канал. Все инструкции MOEX/НРД написаны именно под Windows — этот путь поддерживается официально.</p>
<p class="muted">Альтернативный путь — Linux GUI через X11-forwarding или VNC на дев-стенды — допустим только в песочнице, не в проде. На боевых серверах <code>zcs</code>/<code>vdcsp_cfg</code> не должны запускаться.</p>
</div>
<div class="card" style="border-left:4px solid var(--ok)">
<h2>✅ Подготовка профиля (Windows → USB → bj-server)</h2>
<div class="card" style="background:var(--ok-weak)">
<h2>Подготовка профиля (Windows → USB → bj-server)</h2>
<h3 style="margin-top:14px">Шаг A — на компьютере под Windows</h3>
<ol>
@@ -69,7 +69,7 @@
<ol>
<li><strong>Вставьте флешку в сервер</strong> (физический USB-порт или прокинутая через гипервизор виртуальная флешка).</li>
<li>Откройте <a href="/admin/setup">/admin/setup</a>. Через 2-3 секунды (автодетект монтирования) в блоке <strong>«Носители ключей»</strong> появится строка <code>🔌 USB /run/media/...</code>. Внутри неё — сабблок <strong>«Профиль Валидаты»</strong> с тремя строками: <code>local.pse</code> / <code>local.gdbm</code> / <code>*.vdk</code>.</li>
<li>Откройте <a href="/admin/setup">/admin/setup</a>. Через 2-3 секунды (автодетект монтирования) в блоке <strong>«Носители ключей»</strong> появится строка <code>USB /run/media/...</code>. Внутри неё — сабблок <strong>«Профиль Валидаты»</strong> с тремя строками: <code>local.pse</code> / <code>local.gdbm</code> / <code>*.vdk</code>.</li>
<li>В поле <strong>«Имя профиля»</strong> введите осмысленное имя (например <code>moex</code>) и нажмите <strong>«Импортировать профиль в Валидату»</strong>.<br>
Сервер скопирует файлы в <code>/var/lib/bj/profiles/&lt;имя&gt;/</code>, допишет секцию в <code>/opt/Validata/VDCSP/etc/pki1.conf</code>. Toast подтвердит: «Секция дописана в pki1.conf».</li>
@@ -0,0 +1,243 @@
{{define "content"}}
<div class="doc-header">
<h1>bj ↔ bj2depo: трансферные таблицы FTV_M2M и события</h1>
<p class="lede">Для разработчика Fancy (сервис bj2depo). Как bj заполняет FTV_M2M / FTV_M2M_LINES, какими идентификаторами располагает, какие события пишет в FTV_M2M_EVENTS и какие ожидает в ответ. <strong>Согласовано 10.09.2026 и реализовано в bj:</strong> события BJ_* пишутся, читаются DEPO_ORDER_CREATED и DEPO_ERROR, справочник участников перезаписывается при каждом получении.</p>
</div>
<div class="doc">
<nav class="doc-toc" id="toc">
<div class="toc-group">Основа</div>
<a href="#roles">Кто что делает</a>
<a href="#ids">Идентификаторы у bj</a>
<a href="#tables">Таблицы</a>
<div class="toc-group">События</div>
<a href="#rules">Правила</a>
<a href="#bj-events">События от bj</a>
<a href="#depo-events">События от bj2depo</a>
<a href="#timeline">Порядок по этапам</a>
<a href="#actions">Действия джобы по событиям</a>
<a href="#handbook">Справочник участников</a>
<a href="#runmode">Кто запускает обработку</a>
<a href="#replay">Выгрузка истории тестов</a>
<div class="toc-group">Практика</div>
<a href="#sql">Примеры SQL</a>
<a href="#questions">Открытые вопросы</a>
</nav>
<article class="doc-body">
<h2 id="roles">Кто что делает</h2>
<table>
<thead><tr><th style="width:130px">Сервис</th><th>Роль</th></tr></thead>
<tbody>
<tr><td><strong>bj</strong> (Bridge-and-Join-s)</td><td>обмен с НРД по MOEX МОСТ (M2MTR / M2MTD / M2MER, депо-поручения 16 и 16/1, отчёты MS010), сверка с DEPO/SPECTRE, блокировка и движение бумаг в QUIK МНП, личный кабинет. Пишет поручения и события в трансферные таблицы, читает события от DEPO.</td></tr>
<tr><td><strong>bj2depo</strong> (джоба Fancy)</td><td>читает FTV_M2M / FTV_M2M_LINES → создаёт и меняет сущности DEPO (M2M-поручения D_CLISER_M2M, строки D_CLISER_M2M_DOL); обрабатывает события FTV_M2M_EVENTS от bj; пишет свои события для bj.</td></tr>
</tbody>
</table>
<p class="muted">Работаем только с НРД (MOEX МОСТ), СПБ нет. Переводятся только выпуски с ISIN, начинающимся на RU. База — наша копия DEPO на SQL002 (<code>BJ_DEPO_DATA</code>); джобу bj2depo нужно поднять на ней (сейчас она работает на SQL001/UPD_DE_DATA, и записи в нашей базе никто не забирает).</p>
<h2 id="ids">Какими идентификаторами располагает bj</h2>
<table>
<thead><tr><th style="width:190px">Сущность</th><th>Что есть у bj</th><th style="width:260px">Что можем передавать в таблицы</th></tr></thead>
<tbody>
<tr><td>Депонент</td><td>ФИО + серия/номер паспорта из сообщения НРД. bj сам находит лицо в DEPO по паспорту (серия без пробелов, <code>OD_FACES.DOC_SER/DOC_NUM</code>).</td><td><code>DEPONENT_NAME</code>, <code>DEPONENT_DOC</code> и <code>DEPONENT_ID = OD_FACES.SELF_ID</code> (мастер-ID лица; у дублей указывает на основную запись).</td></tr>
<tr><td>Контрагент (депозитарий)</td><td>код участника МОСТ (<code>MC…</code>, 12 знаков), депозитарный код НРД (<code>HL…</code>), ИНН, наименование из справочника участников M2M. В <code>OD_FACES</code> депозитариев-контрагентов нет (проверено по ИНН), есть только сам НКО АО НРД (ID 18174).</td><td><code>CONTRAGENT_M2M_CODE</code>; при желании ИНН и наименование — доп. поля.</td></tr>
<tr><td>Бумага</td><td>ISIN + код НРД (напр. <code>SBER/03</code>); bj находит <code>OD_VALUES.ID</code> по ISIN (<code>IS_IN=1</code> = принята на обслуживание).</td><td><code>ISIN</code>, <code>SEC_CODE</code> и <code>SHARE = OD_VALUES.ID</code>.</td></tr>
<tr><td>Счёт клиента</td><td>bj находит открытый торговый счёт <code>ВТНКЦ…</code> (<code>OD_CLIENTS</code>, <code>STATE=0</code>). При нескольких договорах берётся самый ранний (правило депозитария).</td><td><code>OD_CLIENTS.ID</code> и <code>NUMBER</code>.</td></tr>
<tr><td>Счёт депозитария (место хранения)</td><td>наш счёт в НРД <code>HL171004001C</code> + раздел <code>36MC04136…</code>. В DEPO это запись <code>OD_CLIENTS</code> с NUMBER <code>HL171004001C/253400M18U5TB02TW421/253400_X1A47/&lt;раздел&gt;</code> (например ID 79 для раздела 36MC0413600000F00). <code>OD_CLIENTS_REC.LINK_NUMBER</code> у нас пустой везде.</td><td><code>PLACE_NUMBER</code> = раздел (или ID записи OD_CLIENTS, если удобнее).</td></tr>
<tr><td>Причины отказа</td><td>коды M2M01…M2M10 по каждой бумаге (может быть несколько), при отказе НРД по поручению 16/16-1 — код и текст из MS010.</td><td><code>M2M_REJECT_CODE</code> через <code>;</code>, текст — в <code>COMMENT</code>.</td></tr>
<tr><td>Ключи</td><td><code>GUID</code> перевода (из M2MTR) и <code>REFERENCE</code> строки (референс бумаги из M2MTR, <code>M2M…</code>).</td><td>как ключи FTV_M2M / FTV_M2M_LINES / FTV_M2M_EVENTS.</td></tr>
</tbody>
</table>
<h2 id="tables">Таблицы (как согласовано, поля, которые заполняет bj)</h2>
<table>
<thead><tr><th style="width:170px">Таблица</th><th>Поля от bj</th></tr></thead>
<tbody>
<tr><td><code>FTV_M2M</code></td><td><code>GUID</code>, <code>DIRECTION</code> (IN — к нам зачисляют, OUT — мы списываем), <code>CONTRAGENT_M2M_CODE</code>, <code>DEPONENT_NAME</code>, <code>DEPONENT_DOC</code>, <code>DEPONENT_ID</code> (если нашли), <code>COMMENT</code>. Вставка вместе со строками в одной транзакции.</td></tr>
<tr><td><code>FTV_M2M_LINES</code></td><td><code>GUID</code>, <code>REFERENCE</code>, <code>ISIN</code>, <code>SEC_CODE</code>, <code>SHARE</code> (если нашли), <code>AMOUNT</code> (штуки, дробные для паёв), <code>PLACE_NUMBER</code>.</td></tr>
<tr><td><code>FTV_M2M_EVENTS</code></td><td><code>GUID</code>, <code>REFERENCE</code> (пусто = по поручению целиком), <code>EVENT_TYPE</code>, <code>EVENT_DATE</code>, <code>M2M_REJECT_CODE</code>, <code>COMMENT</code>, <code>STATE</code> (см. правила).</td></tr>
</tbody>
</table>
<p class="muted">Поручение создаётся в DEPO на самом раннем этапе: сразу после записи в FTV_M2M (для входящих — как только пришёл M2MTR; для исходящих — как только наш M2MTR ушёл в НРД).</p>
<h2 id="rules">Правила для событий</h2>
<ul>
<li><strong>Одно событие — одна строка.</strong> Ключ идемпотентности: <code>GUID + REFERENCE + EVENT_TYPE</code>; повтор не пишется.</li>
<li><strong>Направление по префиксу типа:</strong> <code>BJ_*</code> пишет bj и читает bj2depo; <code>DEPO_*</code> пишет bj2depo и читает bj. Если удобнее, вместо префикса — колонка <code>SOURCE</code>.</li>
<li><strong><code>STATE</code> — состояние обработки получателем:</strong> пишущая сторона ставит <code>TODO</code>; получатель после обработки — <code>SUCCESS</code> или <code>ERROR</code>, в <code>COMMENT</code> — что сделано или почему не вышло; <code>TimeBeg/TimeEnd</code> — время обработки.</li>
<li><strong>Порядок:</strong> события одного GUID обрабатываются по <code>ID</code>. Событие по строке (<code>REFERENCE</code> задан) относится к одной бумаге; без <code>REFERENCE</code> — ко всему поручению.</li>
<li><strong>Коды отказа</strong> в <code>M2M_REJECT_CODE</code> через <code>;</code>, как их даёт НРД (M2M01…M2M10), человекочитаемый текст — в <code>COMMENT</code>.</li>
</ul>
<h2 id="bj-events">События от bj (обрабатывает bj2depo)</h2>
<table>
<thead><tr><th style="width:230px">EVENT_TYPE</th><th>Когда bj пишет</th><th>Уровень</th><th>Что ожидаем от bj2depo</th></tr></thead>
<tbody>
<tr><td><code>BJ_RECEIVED</code></td><td>пришёл входящий M2MTR от другого депозитария (мы зачисляем); одновременно вставлены FTV_M2M + LINES</td><td>поручение</td><td>создать M2M-поручение в DEPO, вернуть <code>DEPO_ORDER_CREATED</code></td></tr>
<tr><td><code>BJ_SENT</code></td><td>наш исходящий M2MTR ушёл в НРД (мы списываем)</td><td>поручение</td><td>то же для списания</td></tr>
<tr><td><code>BJ_DECISION_CONFIRM</code> / <code>BJ_DECISION_REJECT</code></td><td>мы ответили контрагенту по бумаге (M2MTD); при отказе — коды</td><td>строка</td><td>проставить статус строки: принято / отказано с кодами</td></tr>
<tr><td><code>BJ_CP_CONFIRM</code> / <code>BJ_CP_REJECT</code></td><td>контрагент ответил по нашему исходящему (M2MTD)</td><td>строка</td><td>то же для списания</td></tr>
<tr><td><code>BJ_SERVICE_ERROR</code></td><td>сервис МОСТ отверг перевод (M2MER ERROR), код M2Mxx</td><td>поручение</td><td>закрыть поручение как отклонённое</td></tr>
<tr><td><code>BJ_ORDER_SENT</code></td><td>подано депо-поручение 16 (списание) или 16/1 (зачисление) в НРД; в COMMENT — CONTR_D_ID и дата</td><td>поручение</td><td>привязать номер поручения НРД</td></tr>
<tr><td><code>BJ_ORDER_ACCEPTED</code> / <code>BJ_ORDER_REJECTED</code></td><td>НРД принял (отчёт MS010) или отверг наше депо-поручение; при отказе — код и текст</td><td>поручение</td><td>проставить результат; при принятии — провести движение по счёту, если DEPO делает это по нашему сигналу (см. вопросы)</td></tr>
<tr><td><code>BJ_ORDER_LATE</code></td><td>MS010 не получен за 5 минут после подачи (регламент)</td><td>поручение</td><td>для информации / контроля</td></tr>
<tr><td><code>BJ_EXECUTED</code></td><td>перевод исполнен (по отчёту НРД)</td><td>поручение</td><td>закрыть как исполненное</td></tr>
<tr><td><code>BJ_CANCELLED</code></td><td>перевод отозван / аннулирован / истёк срок 30 дней без исполнения</td><td>поручение</td><td>закрыть как отменённое</td></tr>
<tr><td><code>BJ_GATEWAY_REJECT</code></td><td>шлюз bj отказал в исходящем переводе из ЛК до отправки в НРД (сверка: лицо, счёт, бумага, остаток, место хранения, блокировки; либо данные заявки некорректны). Код в M2M_REJECT_CODE (M2Mxx или QTY/PLACE/QUIK), причина в COMMENT; одновременно вставлены FTV_M2M (OUT) + LINES</td><td>строка</td><td>закрыть строку и документ как отклонённые (см. «Действия джобы»); <strong>пометить событие SUCCESS или ERROR</strong> — только после этого bj ставит заявке в ЛК «Отклонена»</td></tr>
<tr><td><code>BJ_PING</code></td><td>проверка готовности обработчика из настроек bj (GUID случайный, без шапки в FTV_M2M)</td><td>служебное</td><td><strong>ничего не делать, только пометить SUCCESS</strong> — bj ждёт отметку до 40 с и удаляет строку</td></tr>
<tr><td><code>BJ_QUIK_BLOCKED</code> / <code>BJ_QUIK_RELEASED</code> / <code>BJ_QUIK_SIGNED</code> / <code>BJ_QUIK_DEPOSITED</code></td><td>блокировка / снятие / списание / зачисление в QUIK МНП; номер поручения МНП в COMMENT</td><td>строка</td><td>только для сверки QUIK ↔ DEPO, в DEPO ничего не менять</td></tr>
</tbody>
</table>
<h2 id="depo-events">События от bj2depo (обрабатывает bj)</h2>
<p><strong>Согласовано:</strong> bj2depo пишет только <code>DEPO_ORDER_CREATED</code> и <code>DEPO_ERROR</code>. Остальные типы из таблицы ниже (проверка, исполнение, отмена) остаются в резерве на случай, если понадобятся позже; bj их пока не ждёт.</p>
<table>
<thead><tr><th style="width:230px">EVENT_TYPE</th><th>Когда bj2depo пишет</th><th>Уровень</th><th>Что делает bj</th></tr></thead>
<tbody>
<tr><td><code>DEPO_ORDER_CREATED</code></td><td>поручение в DEPO создано; номер документа в COMMENT (и/или <code>FANSY_DOC</code> в FTV_M2M)</td><td>поручение</td><td>показывает номер в карточке перевода, далее ссылается на него</td></tr>
<tr><td><code>DEPO_ERROR</code></td><td>джоба не смогла обработать событие или поручение (текст)</td><td>любой</td><td>оповещение оператору bj</td></tr>
</tbody>
</table>
<p class="muted">Согласовано с программистом Fancy: от bj2depo достаточно двух событий, <code>DEPO_ORDER_CREATED</code> и <code>DEPO_ERROR</code>. Проверку строк по регламенту делает bj (сверка с DEPO), состояние документа bj читает сам (раздел «Обратная связь в bj»), поэтому отдельные события о проверке, исполнении и отмене не нужны.</p>
<h2 id="timeline">Порядок событий по этапам</h2>
<table>
<thead><tr><th style="width:280px">Входящий перевод (мы зачисляем)</th><th>Исходящий перевод (мы списываем)</th></tr></thead>
<tbody>
<tr><td>1. bj: FTV_M2M + LINES, <code>BJ_RECEIVED</code></td><td>1. bj: QUIK блокирует бумаги (<code>BJ_QUIK_BLOCKED</code>), M2MTR в НРД, FTV_M2M + LINES, <code>BJ_SENT</code></td></tr>
<tr><td>2. bj2depo: <code>DEPO_ORDER_CREATED</code>; bj сверяет строки с DEPO</td><td>2. bj2depo: <code>DEPO_ORDER_CREATED</code></td></tr>
<tr><td>3. bj: ответ контрагенту — <code>BJ_DECISION_CONFIRM</code> / <code>BJ_DECISION_REJECT</code></td><td>3. bj: ответ контрагента — <code>BJ_CP_CONFIRM</code> / <code>BJ_CP_REJECT</code> (при отказе QUIK снимает блокировку: <code>BJ_QUIK_RELEASED</code>)</td></tr>
<tr><td>4. bj: подача 16/1 — <code>BJ_ORDER_SENT</code></td><td>4. bj: подача 16 — <code>BJ_ORDER_SENT</code></td></tr>
<tr><td>5. bj: <code>BJ_ORDER_ACCEPTED</code> (MS010) или <code>BJ_ORDER_REJECTED</code>; QUIK зачисляет — <code>BJ_QUIK_DEPOSITED</code></td><td>5. bj: <code>BJ_ORDER_ACCEPTED</code> / <code>BJ_ORDER_REJECTED</code>; QUIK списывает — <code>BJ_QUIK_SIGNED</code></td></tr>
<tr><td>6. bj2depo: движение по счёту депо по <code>BJ_ORDER_ACCEPTED</code> / <code>BJ_EXECUTED</code>, документ в «Конец обработки»</td><td>6. bj2depo: то же, списание</td></tr>
<tr><td colspan="2">Отклонения: <code>BJ_GATEWAY_REJECT</code> (шлюз bj отказал до отправки в НРД; статус в ЛК — после обработки джобой), <code>BJ_SERVICE_ERROR</code>, <code>BJ_ORDER_LATE</code> (5 мин без MS010), <code>BJ_CANCELLED</code> (30 дней без исполнения), <code>DEPO_ERROR</code>.</td></tr>
</tbody>
</table>
<h2 id="actions">Действия джобы по событиям: что делать с документом DEPO</h2>
<p>Сейчас джоба создаёт документ по шапке и помечает события обработанными, но документ и его строки не меняются: после отказа документ остаётся в состоянии «Начальный ввод» и его приходится отменять руками, а после приёма остаток на счёте появляется, но документ в конечное состояние не переходит (проверено 14.09.2026 на документах 75070271 и 75070273). Ниже — что джобе нужно делать по каждому событию. Документ всегда находится по <code>GUID</code>, строка — по <code>GUID + REFERENCE</code>; событие без <code>REFERENCE</code> относится ко всему документу.</p>
<table>
<thead><tr><th style="width:230px">Событие</th><th style="width:70px">Уровень</th><th>Что сделать в DEPO</th><th style="width:230px">Конечное состояние</th></tr></thead>
<tbody>
<tr><td><code>BJ_RECEIVED</code>, <code>BJ_SENT</code></td><td>документ</td><td>документ уже создан по шапке; ничего не делать, пометить SUCCESS</td><td>«Начальный ввод» (0) → можно «Начало обработки» (1)</td></tr>
<tr><td><code>BJ_DECISION_REJECT</code> (входящий: мы отказали), <code>BJ_CP_REJECT</code> (исходящий: отказал контрагент)</td><td>строка</td><td>в <code>D_CLISER_M2M_DOL</code> строки записать код из <code>M2M_REJECT_CODE</code> в <code>M2M_STATUS</code>/<code>M2M_TEXT</code> (текст из COMMENT). Если отказаны все строки документа — выполнить шаг «Отмена» (тип шага 0→REJ), как это делает оператор вручную</td><td>строка «отказ, M2Mxx»; документ «Отмена обработки» (3)</td></tr>
<tr><td><code>BJ_DECISION_CONFIRM</code>, <code>BJ_CP_CONFIRM</code></td><td>строка</td><td>отметить строку как принятую (<code>M2M_STATUS</code> «принято»); движения по счёту пока нет — ждём поручение НРД</td><td>строка «принято»; документ «Начало обработки» (1)</td></tr>
<tr><td><code>BJ_ORDER_SENT</code></td><td>документ</td><td>записать номер депо-поручения НРД (CONTR_D_ID из COMMENT); состояние — ожидание</td><td>«Ожидание доставки (ответа)» (10) или «Ожидание исполнения операции» (127)</td></tr>
<tr><td><code>BJ_ORDER_ACCEPTED</code> + <code>BJ_EXECUTED</code></td><td>документ</td><td><strong>провести движение по счёту депо</strong> по всем принятым строкам: входящий — зачисление на торговый счёт клиента (то, что оператор сделал вручную 14.09 для 75070271: появился остаток 10 SBER на 836), исходящий — списание; затем закрыть документ</td><td>«Конец обработки (в архиве)» (2)</td></tr>
<tr><td><code>BJ_GATEWAY_REJECT</code></td><td>строка</td><td>в <code>D_CLISER_M2M_DOL</code> строки записать код из <code>M2M_REJECT_CODE</code> и причину из COMMENT; если отказаны все строки — шаг «Отмена». Документ мог не создаться (например, бумаги с таким ISIN нет в OD_VALUES) — тогда достаточно пометить событие ERROR с текстом причины</td><td>строка «отказ шлюза»; документ «Отмена обработки» (3)</td></tr>
<tr><td><code>BJ_ORDER_REJECTED</code>, <code>BJ_SERVICE_ERROR</code>, <code>BJ_CANCELLED</code></td><td>документ</td><td>записать причину (COMMENT / код) в документ, выполнить шаг «Отмена»</td><td>«Отмена обработки» (3)</td></tr>
<tr><td><code>BJ_ORDER_LATE</code></td><td>документ</td><td>только пометка/комментарий («отчёт НРД не получен за 5 минут»); состояние не менять</td><td>без изменений</td></tr>
<tr><td><code>BJ_QUIK_*</code>, <code>BJ_PING</code></td><td>служебные</td><td>ничего не делать, пометить SUCCESS</td><td>без изменений</td></tr>
</tbody>
</table>
<p class="muted">Названия и коды состояний — из справочника <code>OD_DOC_STATES</code> (D_CAT=0): 0 «Начальный ввод», 1 «Начало обработки (зарегистрирован)», 2 «Конец обработки (в архиве)», 3 «Отмена обработки», 10 «Ожидание доставки (ответа)», 127 «Ожидание исполнения операции». Шаг отмены, который выполнял оператор: <code>OD_STEP_TYPES</code> ID 75069472 «Отмена» (SYS_NAME <code>0-&gt;REJ</code>, OUT_STATE 3).</p>
<p>Запрос, которым джоба находит отказы вместе с документом и строкой:</p>
<pre class="copyable">SELECT e.ID, e.GUID, e.REFERENCE, e.M2M_REJECT_CODE, e.COMMENT, m.DOC, l.DOL
FROM dbo.FTV_M2M_EVENTS e
JOIN dbo.FTV_M2M m ON m.GUID = e.GUID
JOIN dbo.FTV_M2M_LINES l ON l.GUID = e.GUID AND l.REFERENCE = e.REFERENCE
WHERE e.STATE = 'TODO' AND e.EVENT_TYPE IN ('BJ_DECISION_REJECT', 'BJ_CP_REJECT')
ORDER BY e.ID;</pre>
<p>Для событий по документу целиком (REFERENCE пуст) джойн со строками не нужен:</p>
<pre class="copyable">SELECT e.ID, e.GUID, e.EVENT_TYPE, e.M2M_REJECT_CODE, e.COMMENT, m.DOC
FROM dbo.FTV_M2M_EVENTS e
JOIN dbo.FTV_M2M m ON m.GUID = e.GUID
WHERE e.STATE = 'TODO' AND e.REFERENCE IS NULL
ORDER BY e.ID;</pre>
<p><strong>Обратная связь в bj.</strong> bj сам читает состояние документа по номеру (<code>OD_DOCS.STATE</code> по <code>FTV_M2M.DOC</code>) и показывает его в карточке перевода; при ручной отмене в DEPO оператор bj получает оповещение. Поэтому отдельные события от джобы о смене состояния не требуются — достаточно, чтобы документ переходил в состояния из таблицы выше.</p>
<h2 id="handbook">Справочник участников M2M (M2MTH) → таблица FTV_M2M_HANDBOOK</h2>
<p>НРД ведёт справочник участников переводов по MOEX МОСТ: кто депозитарий, кто брокер, их коды участника и ИНН, плюс места расчётов. bj получает его от НРД (сообщение M2MTransferHandbook) при старте и обновляет раз в сутки; по нему bj определяет контрагента по ИНН из перевода и наполняет справочник депозитариев в QUIK МНП. Предлагаем отдавать его в DEPO целиком в отдельную таблицу — тогда <code>CONTRAGENT_M2M_CODE</code> из FTV_M2M раскрывается в ИНН и наименование, и по нему можно заводить контрагентов в DEPO.</p>
<p class="muted">Сейчас в справочнике 158 участников и 2 мест расчётов (получено 2026-09-09). Наш код участника — <code>MC0413600000</code>.</p>
<table>
<thead><tr><th style="width:190px">Поле таблицы</th><th style="width:130px">Тип</th><th>Откуда у bj / что означает</th></tr></thead>
<tbody>
<tr><td><code>INN</code></td><td>varchar(12)</td><td>ИНН организации. Вместе с <code>PARTICIPANT_CODE</code> и <code>KIND</code> — ключ строки.</td></tr>
<tr><td><code>PARTICIPANT_CODE</code></td><td>varchar(12)</td><td>код участника МОСТ (<code>MC…</code>, 12 знаков) — тот самый <code>CONTRAGENT_M2M_CODE</code>. У мест расчётов пусто.</td></tr>
<tr><td><code>KIND</code></td><td>varchar(8)</td><td><code>DEPO</code> — депозитарий, <code>BROKER</code> — брокер (одна организация может быть в обоих качествах — две строки), <code>PLACE</code> — место расчётов.</td></tr>
<tr><td><code>NAME_FULL</code></td><td>nvarchar(255)</td><td>полное наименование (рус.)</td></tr>
<tr><td><code>NAME_SHORT</code></td><td>nvarchar(128)</td><td>краткое наименование (рус.)</td></tr>
<tr><td><code>NAME_DISPLAY</code></td><td>nvarchar(255)</td><td>отображаемое наименование (рус.) — то, что показываем в интерфейсах</td></tr>
<tr><td><code>NAME_ENG</code></td><td>nvarchar(255)</td><td>наименование на английском (есть не у всех)</td></tr>
<tr><td><code>HANDBOOK_AT</code></td><td>datetime</td><td>дата/время версии справочника НРД (CreationTimestamp)</td></tr>
<tr><td><code>UPDATED_AT</code></td><td>datetime</td><td>когда bj записал строку</td></tr>
<tr><td><code>ACTIVE</code></td><td>bit</td><td>всегда 1 (история не ведётся, таблица перезаписывается целиком)</td></tr>
</tbody>
</table>
<p><strong>Обновление (согласовано).</strong> История не нужна: при каждом получении справочника от НРД bj удаляет все строки и записывает содержимое заново одной транзакцией (DEPO не видит пустую таблицу; DELETE вместо TRUNCATE, потому что TRUNCATE требует права ALTER) — аналог нового HANDBOOK.xml. Дополнительно перезапись повторяется при старте bj и раз в 6 часов. <strong>Просьба создать таблицу по скрипту ниже</strong> (создаёт наша сторона: у логина bj нет права CREATE TABLE в BJ_DEPO_DATA). Поля — как в описании; bj2depo использует <code>INN</code>, <code>PARTICIPANT_CODE</code>, <code>NAME_SHORT</code>. Английские наименования и места расчётов bj пишет, джоба их не использует.</p>
<p>Примеры реальных строк:</p>
<table><thead><tr><th>INN</th><th>PARTICIPANT_CODE</th><th>KIND</th><th>NAME_DISPLAY</th></tr></thead><tbody><tr><td><code>7707025725</code></td><td><code>MC0005600000</code></td><td>DEPO</td><td>АО АКБ &quot;ЦентроКредит&quot;</td></tr><tr><td><code>9714012520</code></td><td><code>MC0418000000</code></td><td>DEPO</td><td>ИК ФИАНИТ</td></tr><tr><td><code>7831000034</code></td><td>—</td><td>PLACE</td><td>АО &quot;СПБ Банк&quot;</td></tr>
</tbody></table>
<p class="muted">Как это связано с остальным: в FTV_M2M поле <code>CONTRAGENT_M2M_CODE</code> = <code>PARTICIPANT_CODE</code> строки с <code>KIND=DEPO</code>; ИНН отправителя/получателя в сообщениях НРД (<code>TransferringDepository/ReceivingDepository INN</code>) = <code>INN</code>. Тот же справочник bj загружает в QUIK МНП (таблица <code>M2MDepoRecipients</code>: ИНН + наименование).</p>
<h2 id="runmode">Кто запускает обработку: джоба SQL Agent или bj</h2>
<p>Обработка трансферных таблиц — это вызов процедуры <code>FTV_M2M_JOB</code> в базе DEPO. Есть два равноценных способа её запускать:</p>
<table>
<thead><tr><th style="width:230px">Способ</th><th>Что нужно на сервере клиента</th><th>Плюсы / минусы</th></tr></thead>
<tbody>
<tr><td><strong>A. Джоба SQL Agent</strong> <code>bj2depo</code> (как сейчас у нас: шаг <code>exec FTV_M2M_JOB</code>, каждые 10 с)</td><td>SQL Agent запущен; права на создание джобы (SQLAgentOperatorRole / sysadmin); учётка Agent с правами на BJ_DEPO_DATA</td><td>работает независимо от bj; но у каждого клиента нужно заводить джобу и права, а bj не видит её состояние без доступа к msdb</td></tr>
<tr><td><strong>B. bj вызывает процедуру сам</strong> (Настройка → Обмен Fancy → «Обработка трансферных таблиц DEPO»)</td><td>только <code>GRANT EXECUTE ON dbo.FTV_M2M_JOB TO &lt;логин bj&gt;</code></td><td>одна настройка на все внедрения, состояние и ошибки видны в bj; обработка идёт, только пока работает bj</td></tr>
</tbody>
</table>
<p class="muted">Включать нужно один вариант, иначе процедура будет вызываться параллельно из двух мест. Просьба к разработчику: сделать <code>FTV_M2M_JOB</code> безопасной при повторном/параллельном вызове (обработка только строк со <code>STATE='TODO'</code>, блокировка строки на время обработки) — тогда случайное двойное включение не навредит.</p>
<h2 id="replay">Результат выгрузки истории тестов (14.09.2026)</h2>
<p>Кнопкой «Выгрузить историю в DEPO» на вкладке «Тестирование» bj записал в трансферные таблицы все переводы, проведённые на TEST3 (робот НРД и банк Зенит): 78 переводов, 252 события с реальными датами. Джоба bj2depo создала по каждому документ в DEPO (D_CLISER_M2M + строки), сама определила контрагента, депонента, бумагу и место хранения. Что при этом выяснилось:</p>
<ul>
<li><strong>События по строкам</strong> (<code>BJ_DECISION_*</code>, <code>BJ_CP_*</code>, с REFERENCE) джоба обрабатывает — SUCCESS.</li>
<li><strong>События по поручению целиком</strong> (REFERENCE пуст: <code>BJ_SENT</code>, <code>BJ_RECEIVED</code>, <code>BJ_SERVICE_ERROR</code>, <code>BJ_ORDER_SENT</code>, <code>BJ_ORDER_ACCEPTED</code>, <code>BJ_EXECUTED</code>) джоба отвергает с текстом «Не найдена строка M2M-поручение по GUID + REFERENCE». <strong>Просьба:</strong> для событий без REFERENCE применять действие к документу (или ко всем его строкам).</li>
<li>После обработки событий статусы в DEPO не меняются: у всех документов <code>DOC_STATUS=0</code>, у строк <code>M2M_STATUS=0</code>, <code>DEP_STATUS=0</code>. <strong>Просьба согласовать отображение</strong>, например: <code>BJ_CP_REJECT</code> / <code>BJ_DECISION_REJECT</code> → строка «отказ» с кодом в M2M_TEXT; <code>BJ_CP_CONFIRM</code> / <code>BJ_DECISION_CONFIRM</code> → «принято»; <code>BJ_ORDER_ACCEPTED</code> / <code>BJ_EXECUTED</code> → документ «исполнен»; <code>BJ_SERVICE_ERROR</code> / <code>BJ_CANCELLED</code> → документ «отклонён / отменён». Тогда историю тестов можно будет просмотреть в DEPO с настоящими статусами.</li>
<li>Событие <code>BJ_PING</code> сейчас помечается ERROR — достаточно помечать SUCCESS без действий.</li>
</ul>
<h2 id="sql">Примеры SQL</h2>
<p>Так bj заведёт входящий перевод (одна транзакция):</p>
<pre class="copyable">BEGIN TRAN;
INSERT INTO dbo.FTV_M2M (GUID, DIRECTION, CONTRAGENT_M2M_CODE, DEPONENT_NAME, DEPONENT_DOC, DEPONENT_ID, COMMENT)
VALUES ('5325B860-3265-49B6-986D-FA84FB41FE27', 'IN', 'MC0034900000', N'Погонщикова Виктория Олеговна', '4500 123456', 11773497, N'M2MTR от MC0034900000');
INSERT INTO dbo.FTV_M2M_LINES (GUID, REFERENCE, ISIN, SEC_CODE, SHARE, AMOUNT, PLACE_NUMBER)
VALUES ('5325B860-3265-49B6-986D-FA84FB41FE27', 'M2M0000002675550', 'RU000A107UL4', 'RU000A107UL4', 18824, 1, '36MC0413600000F00');
INSERT INTO dbo.FTV_M2M_EVENTS (GUID, REFERENCE, STATE, EVENT_TYPE, EVENT_DATE, COMMENT)
VALUES ('5325B860-3265-49B6-986D-FA84FB41FE27', NULL, 'TODO', 'BJ_RECEIVED', GETDATE(), N'входящий перевод получен');
COMMIT;</pre>
<p>Так bj2depo сообщает, что документ в DEPO создан:</p>
<pre class="copyable">INSERT INTO dbo.FTV_M2M_EVENTS (GUID, REFERENCE, STATE, EVENT_TYPE, EVENT_DATE, COMMENT)
VALUES ('5325B860-3265-49B6-986D-FA84FB41FE27', NULL, 'TODO', 'DEPO_ORDER_CREATED', GETDATE(), N'75070271');</pre>
<p>Так bj забирает свои события и подтверждает обработку:</p>
<pre class="copyable">SELECT ID, GUID, REFERENCE, EVENT_TYPE, M2M_REJECT_CODE, COMMENT
FROM dbo.FTV_M2M_EVENTS WHERE EVENT_TYPE LIKE 'DEPO_%' AND STATE = 'TODO' ORDER BY ID;
UPDATE dbo.FTV_M2M_EVENTS SET STATE = 'SUCCESS', TimeBeg = GETDATE(), TimeEnd = GETDATE(), COMMENT = N'применено в bj' WHERE ID = @id;</pre>
<p>Структура таблицы справочника и то, как bj её перезаписывает:</p>
<pre class="copyable">CREATE TABLE dbo.FTV_M2M_HANDBOOK (
ID int IDENTITY(1,1) PRIMARY KEY, INN varchar(12) NOT NULL, PARTICIPANT_CODE varchar(12) NULL, KIND varchar(8) NOT NULL,
NAME_FULL nvarchar(255) NULL, NAME_SHORT nvarchar(128) NULL, NAME_DISPLAY nvarchar(255) NULL, NAME_ENG nvarchar(255) NULL,
HANDBOOK_AT datetime NULL, UPDATED_AT datetime NOT NULL DEFAULT GETDATE(), ACTIVE bit NOT NULL DEFAULT 1);
-- при каждом получении справочника от НРД (одна транзакция):
BEGIN TRAN;
DELETE FROM dbo.FTV_M2M_HANDBOOK;
INSERT INTO dbo.FTV_M2M_HANDBOOK (INN, PARTICIPANT_CODE, KIND, NAME_FULL, NAME_SHORT, NAME_DISPLAY, NAME_ENG, HANDBOOK_AT, UPDATED_AT, ACTIVE)
VALUES ('7707025725', 'MC0005600000', 'DEPO', N'…', N'АО АКБ "ЦентроКредит"', N'…', N'…', @hb_at, GETDATE(), 1); -- и так по всем участникам
COMMIT;</pre>
<h2 id="questions">Открытые вопросы</h2>
<p class="muted">Закрыто 15.09.2026:</p>
<ul>
<li>от bj2depo достаточно <code>DEPO_ORDER_CREATED</code> и <code>DEPO_ERROR</code>; <code>DEPO_EXECUTED</code> не нужен, bj его не ждёт;</li>
<li>справочник: поля как в описании, джоба использует <code>INN</code> / <code>PARTICIPANT_CODE</code> / <code>NAME_SHORT</code>, история не нужна, перезапись целиком; таблицу создаёт наша сторона;</li>
<li><code>REFERENCE</code> для всех исходящих переводов, включая созданные из терминала QUIK, присваивает bj при формировании M2MTR (формат <code>M2M…</code>);</li>
<li>джоба <code>bj2depo</code> на SQL002 / <code>BJ_DEPO_DATA</code> работает (EXECUTE AS SYSDBA), логин bj — читатель базы и запись в трансферные таблицы;</li>
<li>события <code>BJ_QUIK_*</code> bj пишет для истории; джоба помечает их SUCCESS без действий.</li>
</ul>
<p>Остаётся:</p>
<ol>
<li><strong>Создать таблицу <code>FTV_M2M_HANDBOOK</code></strong> в BJ_DEPO_DATA по скрипту из раздела «Справочник участников».</li>
<li>Действия джобы по событиям (раздел «Действия джобы»): движение по счёту депо по <code>BJ_ORDER_ACCEPTED</code> / <code>BJ_EXECUTED</code>, запись отказов в строки и шаг «Отмена».</li>
</ol>
</article>
</div>
{{end}}
@@ -0,0 +1,163 @@
{{define "content"}}
<div class="doc-header">
<h1>QUIK МНП: подготовка к переводам M2M</h1>
<p class="lede">Инструкция для администратора сервера QUIK. Что открыть в QUIK Administrator, что создать и включить, какие права выдать, чтобы bj мог блокировать, списывать и зачислять бумаги клиентов через Модуль неторговых поручений (МНП) при переводах по MOEX МОСТ. Каждый шаг со ссылкой на раздел и страницу документа ARQA.</p>
</div>
<div class="doc">
<nav class="doc-toc" id="toc">
<div class="toc-group">Зачем</div>
<a href="#why">Как это работает</a>
<div class="toc-group">Чек-лист</div>
<a href="#s1">1. Классы на сервере QUIK</a>
<a href="#s2">2. Классы в плагине МНП</a>
<a href="#s3">3. Инструменты МНП</a>
<a href="#s4">4. Типы поручений</a>
<a href="#s5">5. Технологический пользователь</a>
<a href="#s6">6. Права клиентов и менеджеров</a>
<a href="#s7">7. Перезапуск модуля</a>
<a href="#s8">8. База данных МНП</a>
<div class="toc-group">Контроль</div>
<a href="#bj">Что bj делает сам</a>
<a href="#verify">Как проверить</a>
<a href="#errors">Типичные ошибки</a>
<a href="#report">Что сообщить нам</a>
<div class="toc-group">Документы</div>
<a href="#docs">Скачать документы</a>
</nav>
<article class="doc-body">
<h2 id="why">Как это работает</h2>
<p>bj не подключается к серверу QUIK напрямую. Он работает с <strong>базой данных Модуля неторговых поручений</strong> (МНП): вызывает хранимые процедуры и читает экспортные таблицы. Модуль сам передаёт поручения на сервер QUIK, проверяет позицию клиента, блокирует бумаги, исполняет или снимает поручение. Поэтому всё, что должен сделать администратор, находится в двух местах: <strong>QUIK Administrator</strong> (справочник «Классы», «Пользователи») и <strong>плагин МНП</strong> в нём же (меню «Модули»).</p>
<table>
<thead><tr><th style="width:190px">Ситуация</th><th>Что делает bj в МНП</th><th style="width:210px">Тип поручения в МНП</th></tr></thead>
<tbody>
<tr><td>Клиент переводит бумаги <strong>из нас</strong> в другой депозитарий</td><td>регистрирует поручение (QUIK блокирует бумаги) → отправляет перевод в НРД → по успеху подписывает поручение (бумаги списываются), по отказу снимает его с причиной (блокировка снимается, клиент видит причину в терминале)</td><td><strong>Вывод ЦБ (M2M)</strong>, класс <code>INSTROUTM2M</code></td></tr>
<tr><td>Бумаги приходят <strong>к нам</strong> из другого депозитария</td><td>после зачисления в НРД регистрирует поручение по ISIN — оно исполняется сразу, бумаги появляются на позиции клиента</td><td><strong>Ввод ЦБ (M2M)</strong>, класс <code>INSTRINM2M</code></td></tr>
</tbody>
</table>
<p class="muted">Эти два типа появились в МНП версии 21.4.0 и по умолчанию <strong>выключены</strong>, а нужные им классы и инструменты нужно создать вручную. Именно это и описано ниже. Основной документ — «Перевод ЦБ в MOEX МОСТ» (ARQA, v1.1, 2026), далее «МОСТ», страницы указаны по нему.</p>
<h2 id="s1">Шаг 1. Создать классы на сервере QUIK</h2>
<p><em>Где:</em> QUIK Administrator → справочник <strong>«Классы»</strong> (это база самого сервера QUIK, не плагин). <em>Документ:</em> МОСТ п. 4.1, стр. 11.</p>
<p>Создайте два класса с параметрами:</p>
<table>
<thead><tr><th>Параметр</th><th>Класс 1</th><th>Класс 2</th></tr></thead>
<tbody>
<tr><td>Код</td><td><code>INSTROUTM2M</code></td><td><code>INSTRINM2M</code></td></tr>
<tr><td>Наименование (рус)</td><td>Поручения на Вывод ЦБ M2M</td><td>Поручения на Ввод ЦБ M2M</td></tr>
<tr><td>Код позиции</td><td><code>|EQTV|</code></td><td><code>|EQTV|</code></td></tr>
<tr><td>Тип класса</td><td>Поручения</td><td>Поручения</td></tr>
<tr><td>Активные операции</td><td>Включено</td><td>Включено</td></tr>
</tbody>
</table>
<p class="muted">Альтернатива из руководства администратора МНП (шаг 5, стр. 10): применить к базе сервера QUIK (<code>QUIK_DB</code>) скрипт из дистрибутива модуля <code>scripts/mssql/add_classes_instructions_ms.sql</code>. Важно применять его именно к базе сервера QUIK, а не к <code>master</code>.</p>
<h2 id="s2">Шаг 2. Добавить классы в плагин МНП</h2>
<p><em>Где:</em> QUIK Administrator → Модули → МНП → справочник <strong>«Классы инструментов»</strong>. <em>Документ:</em> МОСТ п. 4.1, шаг 2, стр. 11.</p>
<p>Добавьте оба класса, созданных на шаге 1, и на каждом выберите фирму, по которой будет меняться позиция клиента: <code>MC0413600000</code> (Фондовый рынок).</p>
<h2 id="s3">Шаг 3. Создать инструменты МНП</h2>
<p><em>Где:</em> QUIK Administrator → Модули → МНП → справочник <strong>«Инструменты»</strong>. <em>Документ:</em> МОСТ п. 4.2, стр. 14.</p>
<table>
<thead><tr><th>Код инструмента</th><th>Привязать к классу</th><th>Назначение</th></tr></thead>
<tbody>
<tr><td><code>RUROUTM2M</code></td><td><code>INSTROUTM2M</code></td><td>псевдоинструмент поручений на вывод; через него списывается комиссия депозитария</td></tr>
<tr><td><code>RURINM2M</code></td><td><code>INSTRINM2M</code></td><td>псевдоинструмент поручений на ввод</td></tr>
</tbody>
</table>
<p>Коды берите <strong>ровно такие</strong>, как в документе: bj передаёт их в вызовах процедур. Если инструменты уже созданы под другими кодами (например, по имени класса), сообщите нам коды — мы подставим их в настройках.</p>
<p>Затем на <strong>сервере QUIK</strong> в настройках Библиотеки расчёта лимитов по фирме <code>MC0413600000</code> добавьте эти инструменты и привяжите к валюте (рубли) и тегу расчётов, с которого списывается комиссия депозитария: раздел «Инструменты» / «Параметры учёта неторговых поручений» (МОСТ п. 4.2, стр. 14, второй абзац).</p>
<h2 id="s4">Шаг 4. Включить типы поручений M2M</h2>
<p><em>Где:</em> QUIK Administrator → Модули → МНП → справочник <strong>«Типы поручений»</strong>. <em>Документ:</em> МОСТ п. 4.3, стр. 16–22.</p>
<ol>
<li>Найдите типы <strong>«Ввод ЦБ М2М»</strong> и <strong>«Вывод ЦБ М2М»</strong> (созданы модулем автоматически, ID 101 и 100). Откройте каждый и установите флажок <strong>«Тип поручения доступен»</strong> — по умолчанию он снят.</li>
<li>У «Вывод ЦБ М2М» в списке классов, для которых доступен тип, выберите режим <strong>«Только выбранные»</strong> и класс <code>INSTROUTM2M</code>.</li>
<li>У «Ввод ЦБ М2М» — то же самое с классом <code>INSTRINM2M</code>.</li>
<li>У всех <em>остальных</em> типов на Вывод / Поставку ЦБ переключите классы на «Только выбранные» и убедитесь, что класс <code>INSTROUTM2M</code> у них <strong>выключен</strong>. Иначе по этому классу можно будет зарегистрировать обычное поручение вместо M2M.</li>
<li>Подписи: у «Вывод ЦБ М2М» по умолчанию одна подпись — <strong>Депозитарий</strong>. Её ставит bj после ответа НРД. Дополнительные подписи не добавляйте без согласования с нами. У «Ввод ЦБ М2М» подписей нет — поручение исполняется сразу.</li>
<li>Поля формы менять не нужно. Если в «Вывод ЦБ М2М» не требуется привязка к номеру договора и проверка ИИС, поля «Договор обслуживания» и «Согласие на закрытие счёта ИИС» можно скрыть (МОСТ, стр. 21).</li>
</ol>
<h2 id="s5">Шаг 5. Права технологического пользователя модуля</h2>
<p><em>Где:</em> QUIK Administrator → справочник <strong>«Пользователи»</strong>. <em>Документ:</em> МОСТ п. 5.1, стр. 24; руководство администратора МНП, раздел 2, шаг 6, стр. 10.</p>
<p><strong>Кто это.</strong> Служебный пользователь QUIK, от имени которого модуль МНП подключается к серверу QUIK. Его логин записан в файле модуля <code>instructions.ini</code>, секция <code>[QuikApi]</code>, параметр <code>User</code>. У него уже есть права на классы МНП (INSTR и другие); нужно добавить такие же на новые классы.</p>
<ol>
<li>Выдайте пользователю <strong>полные права</strong> на классы <code>INSTROUTM2M</code> и <code>INSTRINM2M</code>: «Просмотр поручений», «Активные операции», «Создание поручений», «Просмотр поручений по всем брокерам», все флажки «Виды поручений», все флажки «Право подписи».</li>
<li>В правах по классу «Поручения» по всем классам МНП установите флажок <strong>«Специальные операции»</strong>.</li>
</ol>
<p class="muted">Без этих прав модуль не получает новые классы от сервера QUIK и на любое поручение M2M отвечает «Неверный тип поручения», хотя в базе всё включено.</p>
<h2 id="s6">Шаг 6. Права клиентов и менеджеров</h2>
<p><em>Документ:</em> МОСТ п. 5.2–5.3, стр. 25–27. Для работы bj этот шаг не обязателен, но без него клиенты не смогут сами подавать «Вывод ЦБ (M2M)» из терминала и не увидят исполненные вводы.</p>
<ul>
<li>Клиентам на класс <code>INSTROUTM2M</code>: «Просмотр поручений», «Клиент фирмы», «Активные операции», «Создание поручений», «Вывод бумаг», «Коды клиентов у дилера».</li>
<li>Клиентам на класс <code>INSTRINM2M</code> (без «Активных операций»): «Просмотр поручений», «Клиент фирмы», «Ввод бумаг», «Коды клиентов у дилера».</li>
<li>Менеджерам (только просмотр): «Просмотр поручений», «Просмотр поручений по всем брокерам», «Ввод бумаг», «Вывод бумаг».</li>
</ul>
<h2 id="s7">Шаг 7. Перезапустить модуль МНП</h2>
<p>Типы поручений, классы и права модуль читает <strong>при старте</strong> и при подключении к серверу QUIK. Справочники (клиенты, инструменты, депозитарии) он перечитывает на ходу, а вот настройки шагов 1–5 — нет. После завершения настройки перезапустите службу МНП (обычно это делается ночью по регламенту аутсорсера). Пока модуль не перезапущен, поручения M2M отвергаются с текстом «Неверный тип поручения».</p>
<h2 id="s8">Шаг 8. База данных МНП</h2>
<p>Со стороны базы всё уже настроено, менять ничего не нужно. Для справки:</p>
<ul>
<li>Логин bj состоит в ролях <code>InstrEuImport</code> (справочники), <code>InstrEuBroker</code> (регистрация, подпись, снятие), <code>InstrEx</code> (экспортные таблицы) — руководство администратора, стр. 13 и 16.</li>
<li>Справочник депозитариев M2M (<code>M2MDepoRecipients</code>) и депо-счета клиентов bj заполняет сам процедурами импорта; в QUIK Administrator этот справочник не виден (МОСТ п. 6.1, стр. 29).</li>
<li>Справочник инструментов заполняется автоматически с сервера QUIK (опция <code>AutoFillQuikSecurities=1</code> в <code>instructions.ini</code>, руководство администратора, стр. 45) — для этого технологическому пользователю нужно право «Класс доступен» на биржевые классы, например <code>TQBR</code> (МОСТ п. 6.3, стр. 30).</li>
<li>По желанию: опция <code>[General].AllowReverseExecutedInstruction=1</code> в <code>instructions.ini</code> разрешает отменять уже исполненные поручения (по умолчанию запрещено, руководство администратора, стр. 48).</li>
</ul>
<h2 id="bj">Что bj делает в МНП автоматически</h2>
<p>После настройки цикл включается в bj: <a href="/admin/setup#sec-quik-cycle">Настройка → QUIK МНП → «Автоматический цикл M2M в МНП»</a> (главный предохранитель «Разрешить записи в МНП» и три переключателя). Что происходит при включённых переключателях:</p>
<table>
<thead><tr><th style="width:230px">Событие в bj</th><th>Действие в МНП</th><th style="width:230px">Что видит клиент в QUIK</th></tr></thead>
<tbody>
<tr><td>Исходящий перевод отправляется в НРД</td><td>регистрация «Вывод ЦБ (M2M)» <em>до</em> отправки; если QUIK отказал (нет позиции) — перевод в НРД не отправляется</td><td>поручение «Активно», бумаги заблокированы</td></tr>
<tr><td>Принимающий депозитарий отказал по бумаге / сервис МОСТ отклонил</td><td>снятие поручения с текстом причины</td><td>поручение «Снято», причина в комментарии, блокировка снята</td></tr>
<tr><td>НРД принял наше поручение 16 (отчёт MS010)</td><td>подпись «Депозитарий» → исполнение</td><td>поручение «Исполнено», бумаги списаны</td></tr>
<tr><td>НРД принял наше поручение 16/1 по входящему переводу</td><td>регистрация «Ввод ЦБ (M2M)» по ISIN — исполняется сразу</td><td>поручение «Исполнено», бумаги на позиции</td></tr>
<tr><td>Клиент сам подал «Вывод ЦБ (M2M)» из QUIK</td><td>bj видит его в экспорте, оповещает оператора; после создания заявки поручение привязывается в карточке, дальше — как обычный исходящий</td><td>своё поручение «Активно», затем исполнено или снято с причиной</td></tr>
</tbody>
</table>
<p class="muted">Код клиента QUIK bj берёт из номера портфеля клиента в DEPO: «БР-859» → «BR859». Исключения задаются в той же форме настроек. Каждый шаг с ответом модуля виден в карточке перевода и на странице <a href="/admin/quik">QUIK МНП</a>; при ошибке приходит оповещение и есть кнопки для ручного повтора.</p>
<h2 id="verify">Как проверить, что всё готово</h2>
<ol>
<li>В bj: <a href="/admin/setup#sec-quik">Настройка → QUIK МНП</a> → «Проверить подключение». В отчёте должны быть видны процедуры <code>sp_NewInstrSec</code>, <code>sp_SignInstrSec</code>, <code>cancel_instruction</code> и таблица <code>M2MDepoRecipients</code>.</li>
<li>Скажите нам «проверяйте». Мы на согласованном тестовом клиенте регистрируем «Ввод ЦБ (M2M)» на 1 штуку (должно исполниться сразу, позиция +1), затем «Вывод ЦБ (M2M)» на 1 штуку с подписью депозитария (позиция −1). Весь цикл занимает две минуты, клиент видит оба поручения в терминале.</li>
<li>Признак готовности в самой базе МНП: у типов 100 и 101 снят признак Disabled, у классов INSTROUTM2M / INSTRINM2M есть фирма, инструменты привязаны к классам. Это мы проверяем сами.</li>
</ol>
<h2 id="errors">Типичные ошибки модуля и что они значат</h2>
<table>
<thead><tr><th style="width:320px">Ответ модуля</th><th>Причина</th><th>Что сделать</th></tr></thead>
<tbody>
<tr><td>Неверный тип поручения.</td><td>Модуль не знает тип «Ввод/Вывод ЦБ М2М» или у технологического пользователя нет прав на класс</td><td>Шаги 1, 5, 7: классы на сервере QUIK, права техпользователя, перезапуск модуля</td></tr>
<tr><td>Не найдено описание фирмы.</td><td>Поручение подано типом «Перевод ЦБ», у которого поля «Фирма списания / зачисления» берутся из «Описания» фирмы, а оно пустое</td><td>Для M2M этот тип не используется. Если нужен — заполнить «Описание» в справочнике «Фирмы» плагина МНП</td></tr>
<tr><td>Поле 'ISIN' не поддержано для поручений с заполненным биржевым кодом класса.</td><td>В вызове одновременно код класса и ISIN</td><td>Ошибка вызова на нашей стороне, к настройке не относится</td></tr>
<tr><td>Указан несуществующий счет ДЕПО поставщика-получателя.</td><td>Обычный (не M2M) тип «Получение ЦБ» требует депо-счёт получателя в формате, который принимает только терминал</td><td>Для зачислений использовать «Ввод ЦБ (M2M)», ему депо-реквизиты не нужны</td></tr>
<tr><td>Транзакция проверяется на сервере Quik.</td><td>Не ошибка: промежуточный статус, модуль ждёт ответ сервера QUIK (до минуты)</td><td>Ничего</td></tr>
</tbody>
</table>
<h2 id="report">Что сообщить нам после настройки</h2>
<ul>
<li>Что классы созданы на сервере QUIK и в плагине МНП (шаги 1–2).</li>
<li>Точные коды инструментов, если они отличаются от <code>RUROUTM2M</code> / <code>RURINM2M</code> (шаг 3).</li>
<li>Что типы 100/101 включены и привязаны к классам (шаг 4), права техпользователю выданы (шаг 5).</li>
<li>Время перезапуска модуля (шаг 7).</li>
<li>Код тестового клиента QUIK, на котором разрешено проводить проверку, и что у него есть хотя бы одна бумага на позиции.</li>
</ul>
<h2 id="docs">Документы ARQA</h2>
<ul>
<li><a href="/admin/docs/Perevod-CB-v-MOEX-MOST.pdf">Перевод ЦБ в MOEX МОСТ</a> — основной документ: п. 4.1 (стр. 11), 4.2 (стр. 14), 4.3 (стр. 16), 5.1–5.3 (стр. 24–27), 6 (стр. 27), 8.6 (стр. 58–63).</li>
<li><a href="/admin/docs/MNP-Rukovodstvo-administratora.pdf">МНП — Руководство по администрированию</a> — раздел 2 «Установка и настройка» (технологический пользователь: шаги 5–7, стр. 10–11), справочники плагина (раздел 3), настройки <code>instructions.ini</code> (раздел 4).</li>
<li><a href="/admin/docs/MNP-Tablicy-i-procedury-BD.pdf">МНП — Таблицы и процедуры БД</a> — для разработчика: хранимые процедуры (раздел 3), экспортные таблицы (2.50), импорт справочников (3.19).</li>
</ul>
</article>
</div>
{{end}}
@@ -13,49 +13,63 @@
{{end}}
</div>
{{if .LKUrl}}
{{/* ===== Первичный тест интеграции с ЛК ===== */}}
<div class="card" style="display:flex;gap:16px;align-items:center;flex-wrap:wrap">
<div style="width:42px;height:42px;border-radius:11px;background:var(--accent-weak);color:var(--accent);display:flex;align-items:center;justify-content:center;flex:none">
<svg viewBox="0 0 24 24" width="21" height="21" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M15 3h6v6"/><path d="M10 14 21 3"/><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/></svg>
</div>
<div style="flex:1;min-width:240px">
<div style="font-weight:640;font-size:15px">Первичный тест с личным кабинетом</div>
<div class="muted" style="margin-top:2px">Откройте ЛК, войдите и подайте заявку M2M — она придёт к нам и появится в разделе <a href="/admin/claims">«Переводы»</a>. Статус вернём в ЛK через callback.</div>
</div>
<a class="btn" href="{{.LKUrl}}" target="_blank" rel="noopener">Открыть тестовый ЛК →</a>
</div>
{{end}}
{{/* ===== Крупные плитки задач ===== */}}
<div class="tiles">
<a href="/admin/claims?new=1" class="tile brand">
<span class="ico">+</span>
<a href="#" onclick="document.getElementById('sim-modal').style.display='flex';return false" class="tile brand">
<span class="ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M4 8h12M12 4l4 4-4 4"/><path d="M20 16H8M12 20l-4-4 4-4"/></svg></span>
<span class="t-title">Новый перевод</span>
<span class="t-sub">Заявка на перевод ценных бумаг M2M</span>
<span class="t-arrow">→</span>
<span class="t-sub">Симулятор: сценарий робота + кол-во бумаг</span>
<span class="t-arrow"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14M13 6l6 6-6 6"/></svg></span>
</a>
<a href="/admin/claims" class="tile">
<span class="ico">📋</span>
<span class="ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M8 6h12M8 12h12M8 18h12"/><path d="M3.5 6h.01M3.5 12h.01M3.5 18h.01"/></svg></span>
<span class="t-title">Переводы</span>
<span class="t-sub">{{.Counts.Total}} всего · {{.Counts.InProgress}} в работе</span>
<span class="t-arrow">→</span>
<span class="t-arrow"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14M13 6l6 6-6 6"/></svg></span>
</a>
<a href="/admin/status" class="tile">
<span class="ico">🔍</span>
<span class="ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M3 12h4l2.5 7 5-14 2.5 7h4"/></svg></span>
<span class="t-title">Диагностика</span>
<span class="t-sub">Состояние СКЗИ, ИШ и базы</span>
<span class="t-arrow">→</span>
<span class="t-arrow"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14M13 6l6 6-6 6"/></svg></span>
</a>
<a href="/admin/setup" class="tile">
<span class="ico">⚙️</span>
<span class="ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M5 8V3M5 21v-7M12 21v-9M12 8V3M19 21v-4M19 12V3"/><path d="M2.5 14h5M9.5 8h5M16.5 17h5"/></svg></span>
<span class="t-title">Настройка</span>
<span class="t-sub">Криптография, НРД, подключения</span>
<span class="t-arrow">→</span>
<span class="t-arrow"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14M13 6l6 6-6 6"/></svg></span>
</a>
</div>
{{/* ===== Сводка по переводам ===== */}}
<div class="grid">
<div class="stat">
<div class="stat accent">
<div class="stat-label">Всего переводов</div>
<div class="stat-value">{{.Counts.Total}}</div>
</div>
<div class="stat">
<div class="stat ok">
<div class="stat-label">Подтверждено</div>
<div class="stat-value" style="color:var(--ok)">{{.Counts.Confirmed}}</div>
</div>
<div class="stat">
<div class="stat warn">
<div class="stat-label">В ожидании</div>
<div class="stat-value" style="color:var(--warn)">{{.Counts.InProgress}}</div>
</div>
<div class="stat">
<div class="stat err">
<div class="stat-label">Отказы / таймауты</div>
<div class="stat-value" style="color:var(--err)">{{.Counts.Failed}}</div>
</div>
@@ -73,10 +87,10 @@
<tbody>
{{range .Recent}}
<tr>
<td>{{.CreatedAt.Format "15:04:05"}}</td>
<td><code>{{slice .ID 0 8}}</code></td>
<td>{{.Investor.LastName}} {{slice .Investor.FirstName 0 1}}.</td>
<td>{{len .Securities}}</td>
<td class="num muted" style="white-space:nowrap">{{.CreatedAt.Format "02.01.2006 15:04:05"}}</td>
<td><code>{{short 8 .ID}}</code></td>
<td>{{.Investor.LastName}} {{initial .Investor.FirstName}}</td>
<td class="num">{{len .Securities}}</td>
<td><span class="badge {{.Status}}">{{ruState .Status}}</span></td>
<td><a href="/admin/claims/{{.ID}}">открыть →</a></td>
</tr>
@@ -98,11 +112,39 @@
{{range .News}}
<div style="padding:9px 0;border-bottom:1px solid var(--border)">
<div style="font-weight:600;font-size:13.5px">
{{if eq .Kind "maintenance"}}🔧 {{end}}{{if eq .Kind "feature"}}✨ {{end}}{{if eq .Kind "system"}}⚠️ {{end}}{{if eq .Kind "doc-update"}}📄 {{end}}{{.Title}}
{{if eq .Kind "maintenance"}}{{end}}{{if eq .Kind "feature"}}{{end}}{{if eq .Kind "system"}}{{end}}{{if eq .Kind "doc-update"}}{{end}}{{.Title}}
</div>
{{if .Body}}<div class="muted" style="font-size:12px;margin-top:3px">{{.Body}}</div>{{end}}
</div>
{{end}}
</div>
{{end}}
{{/* ===== Модалка-симулятор: тестовый перевод роботу НРД ===== */}}
<div id="sim-modal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,.45);z-index:1000;align-items:center;justify-content:center"
onclick="if(event.target===this)this.style.display='none'">
<div style="background:var(--card);border:1px solid var(--border);border-radius:14px;padding:24px;max-width:480px;width:92%;box-shadow:var(--shadow-lg)">
<h2 style="margin:0 0 4px">Новый перевод — симулятор</h2>
<p class="muted" style="margin:0 0 16px;font-size:13px">Отправка тестового пакета роботу НРД. Выберите сценарий и количество бумаг. Для сценария 3333 робот пришлёт встречный перевод — он появится во «Входящих» с уведомлением.</p>
<form method="post" action="/admin/setup/test-nsd" style="display:grid;gap:14px">
<label>Сценарий тестирования
<select name="scenario" style="width:100%;margin-top:4px">
<option value="2001">2001 — Принять все бумаги</option>
<option value="2002">2002 — Принять бумаги частично</option>
<option value="1111">1111 — Ответ с отказом</option>
<option value="3333">3333 — Робот принимающая сторона (пришлёт встречный перевод)</option>
</select>
</label>
<label>Количество ценных бумаг
<input type="number" name="securities_count" value="4" min="1" max="20" style="width:100%;margin-top:4px">
<span class="muted" style="font-size:12px">1–20. Эталонный набор — 4 бумаги; больше берётся циклически.</span>
</label>
<input type="hidden" name="channel" value="nsd">
<div style="display:flex;gap:8px;justify-content:flex-end;margin-top:4px">
<button type="button" class="btn btn-secondary" onclick="document.getElementById('sim-modal').style.display='none'">Отмена</button>
<button type="submit" class="btn btn-ok">Отправить</button>
</div>
</form>
</div>
</div>
{{end}}
@@ -0,0 +1,51 @@
{{define "content"}}
<div class="hero">
<h1 class="hero-greeting">Входящие переводы</h1>
<span class="hero-status">Запросы на перевод, где контрагент (или робот в сценарии 3333) — передающая сторона. Нужно ответить: принять или отказать по каждой бумаге.</span>
</div>
<div class="card">
<h2>Список ({{len .Items}})</h2>
{{if .Items}}
<table>
<thead><tr><th>Получен</th><th>GUID</th><th>От кого</th><th>ЦБ</th><th>Статус</th><th></th></tr></thead>
<tbody>
{{range .Items}}
<tr>
<td>{{msk .ReceivedAt}}</td>
<td><code>{{short 8 .GUID}}</code></td>
<td>{{$nm := m2mParty .SenderCode}}{{if not $nm}}{{$nm = m2mParty .SenderINN}}{{end}}{{if $nm}}<strong>{{$nm}}</strong><br>{{end}}<code>{{.SenderCode}}</code>{{if .SenderINN}} · ИНН {{.SenderINN}}{{end}}</td>
<td>{{len .Securities}}</td>
<td>{{$s := inStatus .}}<span class="badge {{$s.Class}}">{{$s.Text}}</span>{{if $s.Outcome}} <span class="badge {{if eq $s.Outcome "Принято"}}confirmed{{else if eq $s.Outcome "Отказано"}}rejected{{else}}awaiting_decision{{end}}">{{$s.Outcome}}</span>{{end}}{{if .DepoSummary}}<br><span class="badge {{if eq .DepoRecommend "confirm"}}confirmed{{else if eq .DepoRecommend "reject"}}rejected{{else}}awaiting_decision{{end}}" style="font-size:11px;margin-top:4px" title="Сверка с DEPO — эмуляция ответа, ничего не отправлено">сверка: {{.DepoSummary}}</span>{{end}}</td>
<td><a href="/admin/incoming/{{.ID}}">{{if eq .Status "answered"}}просмотр{{else}}ответить{{end}} →</a></td>
</tr>
{{end}}
</tbody>
</table>
{{else}}
<p class="muted">Входящих переводов нет. Они появляются, когда другая сторона инициирует перевод к нам (напр. робот НРД в сценарии 3333 «Выступить принимающей стороной»).</p>
{{end}}
<p class="muted" style="font-size:12px;margin-top:10px">🔄 Страница обновляется автоматически при появлении нового входящего.</p>
</div>
<script>
// Авто-обновление: раз в 7с опрашиваем фид; если состав/статусы входящих
// изменились — перезагружаем страницу, чтобы всегда видеть актуальные данные.
(function(){
function sig(d){
if(!d||!d.pending) return String(d&&d.count||0);
return d.pending.map(function(p){return p.id+':'+(p.count||0);}).sort().join(',');
}
var last=null;
async function tick(){
try{
var r=await fetch('/admin/incoming/feed',{headers:{'X-Requested-With':'fetch'}});
if(!r.ok) return;
var d=await r.json(); var s=sig(d);
if(last!==null && s!==last){ location.reload(); return; }
last=s;
}catch(e){}
}
tick(); setInterval(tick, 7000);
})();
</script>
{{end}}
@@ -0,0 +1,381 @@
{{define "content"}}
{{$answered := eq .It.Status "answered"}}
<style>
.in-tl{position:fixed;right:18px;top:84px;width:230px;max-height:84vh;overflow:auto;
background:var(--card,#fff);border:1px solid var(--border,#e3e3e8);border-radius:14px;
padding:14px 16px;box-shadow:var(--shadow-lg,0 8px 30px rgba(0,0,0,.10));z-index:40}
.in-tl h3{margin:0 0 12px;font-size:13px}
.in-tl .st{position:relative;padding:0 0 12px 20px}
.in-tl .st::before{content:'';position:absolute;left:3px;top:3px;width:10px;height:10px;border-radius:50%;
background:var(--c,#2563eb);box-shadow:0 0 0 3px color-mix(in srgb, var(--c,#2563eb) 20%, transparent)}
.in-tl .st::after{content:'';position:absolute;left:7px;top:15px;bottom:0;width:2px;background:var(--border,#e3e3e8)}
.in-tl .st:last-child::after{display:none}
.in-tl .tm{font-size:10.5px;color:var(--muted,#888);font-variant-numeric:tabular-nums}
.in-tl .ti{font-size:12.5px;font-weight:600;margin:1px 0;line-height:1.3}
.in-tl .de{font-size:10.5px;color:var(--muted,#888);line-height:1.4}
.st-req{--c:#2563eb}.st-gw{--c:#7c3aed}.st-resp{--c:#0891b2}.st-rej{--c:#dc2626}.st-ord{--c:#16a34a}.st-nrd{--c:#0891b2}
@media (max-width:1500px){.in-tl{position:static;width:auto;max-height:none;margin-top:16px;box-shadow:none}}
</style>
<p style="margin:0 0 12px"><a href="/admin/incoming" class="btn" onclick="if(history.length>1){history.back();return false}">← Назад</a></p>
<div class="card">
<h2>Входящий перевод <code>{{short 8 .It.GUID}}</code>
{{if $answered}}<span class="badge confirmed">отвечено</span>{{else}}<span class="badge awaiting_decision">ждёт решения</span>{{end}}
</h2>
<table>
<tr><td style="width:220px" class="muted">Запрос сформирован (отправитель)</td><td>{{msk .It.RequestCreated}}</td></tr>
<tr><td class="muted">Забрали из шлюза (получено нами)</td><td>{{msk .It.ReceivedAt}}{{if not .It.RequestCreated.IsZero}} <span class="muted" style="font-size:12px">· прохождение: {{transit .It.RequestCreated .It.ReceivedAt}}</span>{{end}}</td></tr>
{{if $answered}}<tr><td class="muted">Ответ отправлен</td><td>{{msk .It.AnsweredAt}}{{if not .It.ReceivedAt.IsZero}} <span class="muted" style="font-size:12px">· обработка: {{transit .It.ReceivedAt .It.AnsweredAt}}</span>{{end}}</td></tr>{{end}}
<tr><td class="muted">GUID перевода</td><td><code>{{.It.GUID}}</code> <span class="muted">(в нашем решении используется этот же GUID)</span></td></tr>
<tr><td class="muted">От кого (передающий)</td><td>{{$nm := m2mParty .It.SenderCode}}{{if not $nm}}{{$nm = m2mParty .It.SenderINN}}{{end}}{{if $nm}}<strong>{{$nm}}</strong> · {{end}}<code>{{.It.SenderCode}}</code>{{if .It.SenderINN}} · ИНН {{.It.SenderINN}}{{end}}</td></tr>
<tr><td class="muted">Кому (мы)</td><td>{{$rn := m2mParty .It.ReceiverCode}}{{if not $rn}}{{$rn = m2mParty .It.ReceivingINN}}{{end}}{{if $rn}}<strong>{{$rn}}</strong> · {{end}}<code>{{.It.ReceiverCode}}</code>{{if .It.ReceivingINN}} · ИНН {{.It.ReceivingINN}}{{end}}</td></tr>
<tr><td class="muted">Инвестор (ФИО)</td><td><strong>{{.It.InvestorLast}} {{.It.InvestorFirst}} {{.It.InvestorMiddle}}</strong></td></tr>
<tr><td class="muted">Документ</td><td>{{docType .It.DocType}}{{if .It.DocSeries}} · серия {{.It.DocSeries}}{{end}}{{if .It.DocNumber}} · № {{.It.DocNumber}}{{end}}</td></tr>
{{if .It.AnswerNote}}<tr><td class="muted">Результат</td><td>{{if .It.Auto}}<span class="badge confirmed">авто</span> {{end}}{{.It.AnswerNote}}</td></tr>{{end}}
</table>
</div>
<div class="card">
<style>
.dc-tbl td{vertical-align:top}
.dc-ok{color:var(--ok,#12813f);font-weight:700}.dc-bad{color:var(--err,#b3261e);font-weight:700}
.dc-rec{display:inline-block;padding:4px 12px;border-radius:999px;font-weight:700;font-size:13px}
.dc-rec.confirm{background:var(--ok-weak,#e6f4ea);color:var(--ok,#12813f)}
.dc-rec.reject{background:var(--err-weak,#fbe9e7);color:var(--err,#b3261e)}
.dc-rec.mixed{background:var(--warn-weak,#fff4e0);color:var(--warn,#9a6b12)}
</style>
<div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap">
<h2 style="margin:0">Сверка с DEPO — эмуляция ответа</h2>
{{if .Depo.Ran}}<span class="dc-rec {{.Depo.Recommend}}">рекомендуем: {{.Depo.Summary}}</span>{{end}}
</div>
<p class="muted" style="font-size:12.5px;margin:6px 0 0">Проверки по регламенту депозитария (инвестор, торговый счёт депо, бумага на обслуживании, договор не ИИС, блокировки). <b>Ответ контрагенту НЕ отправлен</b> — это только рекомендация; она подставлена в форму ниже, проверьте и нажмите «Отправить решение».</p>
{{if not .Depo.Ran}}
<p class="muted" style="margin-top:10px">Сверка не выполнена: {{.Depo.Err}}</p>
{{else}}
<table style="margin-top:10px">
<tr><td class="muted" style="width:220px">Инвестор в картотеке</td><td>{{if .Depo.Investor}}<b>{{.Depo.Investor}}</b>{{else}}<span class="dc-bad">не найден</span>{{end}}</td></tr>
<tr><td class="muted">Торговый счёт депо</td><td>{{if .Depo.Account}}<code>{{.Depo.Account}}</code> — открыт{{else}}<span class="dc-bad">открытого ВТНКЦ-счёта нет</span>{{end}}</td></tr>
</table>
{{range .Depo.Secs}}
<span class="depo-rec" data-ref="{{.ReferenceID}}" data-rec="{{.Recommend}}" data-code="{{.RejectCode}}" hidden></span>
<h3 style="font-size:13.5px;margin:14px 0 4px"><code>{{.Security}}</code> → {{if eq .Recommend "confirm"}}<span class="dc-ok">принять</span>{{else}}<span class="dc-bad">отказ {{.RejectCode}}</span> <span class="muted" style="font-weight:400">({{m2mCode .RejectCode}})</span>{{end}}</h3>
<table class="dc-tbl">
<thead><tr><th style="width:36px"></th><th style="width:250px">Проверка</th><th>Результат</th></tr></thead>
<tbody>
{{range .Checks}}
<tr><td>{{if .OK}}<span class="dc-ok">🟢</span>{{else}}<span class="dc-bad">🔴</span>{{end}}</td><td>{{.Name}}{{if not .OK}} <span class="muted" style="font-size:11px">→ {{.Code}}</span>{{end}}</td><td style="font-size:12.5px">{{.Detail}}</td></tr>
{{end}}
</tbody>
</table>
{{end}}
{{end}}
</div>
<div class="card">
<h2>Решение по бумагам ({{len .It.Securities}})</h2>
{{if $answered}}
<p class="muted">Решение уже отправлено роботу. Ниже — что было выбрано по каждой бумаге.</p>
<table>
<thead><tr><th>ReferenceId</th><th>Код НРД / ISIN</th><th>Кол-во</th><th>Счёт зачисления</th><th>Решение</th></tr></thead>
<tbody>
{{range .It.Securities}}
<tr>
<td><code>{{.ReferenceID}}</code></td>
<td><code>{{.SecurityCode}}</code>{{if .ISIN}} / {{.ISIN}}{{end}}</td>
<td>{{.Quantity}}</td>
<td style="font-size:12px">{{if .DeponentCode}}депонент <code>{{.DeponentCode}}</code>{{end}}{{if .AccountID}}<br>счёт <code>{{.AccountID}}</code>{{end}}{{if .SectionID}} / раздел <code>{{.SectionID}}</code>{{end}}{{if .SettleINN}}<br>расч.деп. ИНН {{.SettleINN}}{{end}}</td>
<td>{{if eq .Decision "confirmed"}}<span class="badge confirmed">принято</span>{{else if eq .Decision "rejected"}}<span class="badge rejected">отказ</span> <span class="muted" style="font-size:12px">{{m2mCode .RejectCode}}</span>{{else}}—{{end}}</td>
</tr>
{{end}}
</tbody>
</table>
{{else}}
<p class="muted">По каждой бумаге выберите: <b>принять</b> (зачислим на наш депо-счёт из настроек НРД) или <b>отказать</b> (с кодом отказа). Решение уйдёт роботу одним сообщением M2MTransferDecision.</p>
<div style="padding:10px 14px;background:var(--accent-weak,#e7f0fb);border-radius:10px;margin:6px 0 14px">
<label style="font-size:13px;font-weight:600">Сценарий тестирования:
<select id="m2m-scen" style="margin-left:8px;padding:5px 8px;border-radius:7px;border:1px solid var(--border);background:var(--card);color:var(--text)">
<option value="">— выберите кейс —</option>
</select>
</label>
<div id="m2m-guide" style="margin-top:8px;font-size:13px;color:var(--text)">Выберите кейс — подскажу, что нужно сделать (принять или отклонить с кодом). Автоматически ничего не меняется.</div>
</div>
<script>
(function(){
var H = {{m2mHints}};
var sel = document.getElementById('m2m-scen'), g = document.getElementById('m2m-guide');
if(!sel||!Array.isArray(H)) return;
H.forEach(function(h){ var o=document.createElement('option'); o.value=h.id; o.textContent=h.title; sel.appendChild(o); });
sel.addEventListener('change', function(){
var h = null; for(var i=0;i<H.length;i++){ if(H[i].id===sel.value){h=H[i];break;} }
if(!h){ g.textContent='Выберите кейс — подскажу, что нужно сделать.'; return; }
var color = h.action==='confirm' ? '#1a7f37' : '#b3261e';
g.innerHTML = '<b style="color:'+color+'">Что делать: </b>'+h.text.replace(/</g,'&lt;');
});
})();
</script>
<form method="post" action="/admin/incoming/{{.It.ID}}/answer">
<table>
<thead><tr><th>ReferenceId</th><th>Код НРД / ISIN</th><th>Кол-во</th><th>Счёт зачисления</th><th>Принять</th><th>Отказать</th><th>Код отказа</th></tr></thead>
<tbody>
{{range .It.Securities}}
<tr>
<td><code>{{.ReferenceID}}</code></td>
<td><code>{{.SecurityCode}}</code>{{if .ISIN}} / {{.ISIN}}{{end}}</td>
<td>{{.Quantity}}</td>
<td style="font-size:12px">{{if .DeponentCode}}депонент <code>{{.DeponentCode}}</code>{{end}}{{if .AccountID}}<br>счёт <code>{{.AccountID}}</code>{{end}}{{if .SectionID}} / раздел <code>{{.SectionID}}</code>{{end}}{{if .SettleINN}}<br>расч.деп. ИНН {{.SettleINN}}{{end}}</td>
<td style="text-align:center"><input type="radio" class="dec" data-ref="{{.ReferenceID}}" name="decision_{{.ReferenceID}}" value="confirm" checked></td>
<td style="text-align:center"><input type="radio" class="dec" data-ref="{{.ReferenceID}}" name="decision_{{.ReferenceID}}" value="reject"></td>
<td>
<span class="reject-dash" data-ref="{{.ReferenceID}}" style="color:var(--muted)">—</span>
<span class="reject-box" data-ref="{{.ReferenceID}}" style="display:none">
<select name="reject_{{.ReferenceID}}" style="max-width:280px">
{{range rejectOptions}}<option value="{{.Code}}">{{.Label}}</option>{{end}}
</select>
</span>
</td>
</tr>
{{end}}
</tbody>
</table>
<div style="display:flex;gap:8px;margin-top:14px;flex-wrap:wrap">
<button type="submit" class="btn btn-ok">Отправить решение роботу</button>
<button type="button" class="btn" onclick="m2mSetAll('confirm')">Принять все</button>
<button type="button" class="btn" onclick="m2mSetAll('reject')">Отказать все</button>
</div>
</form>
<script>
(function(){
function toggle(ref){
var rej = document.querySelector('input[name="decision_'+ref+'"][value="reject"]');
var box = document.querySelector('.reject-box[data-ref="'+ref+'"]');
var dash = document.querySelector('.reject-dash[data-ref="'+ref+'"]');
var show = rej && rej.checked;
if(box) box.style.display = show ? '' : 'none';
if(dash) dash.style.display = show ? 'none' : '';
}
document.querySelectorAll('input.dec').forEach(function(r){
r.addEventListener('change', function(){ toggle(r.getAttribute('data-ref')); });
});
window.m2mSetAll = function(val){
document.querySelectorAll('input.dec[value="'+val+'"]').forEach(function(r){
r.checked = true; toggle(r.getAttribute('data-ref'));
});
};
})();
</script>
<script>
// Подставляем рекомендацию сверки с DEPO в форму: только предзаполнение,
// отправка — по кнопке оператора.
(function(){
var recs = document.querySelectorAll('.depo-rec');
if(!recs.length) return;
recs.forEach(function(el){
var ref = el.getAttribute('data-ref'), rec = el.getAttribute('data-rec'), code = el.getAttribute('data-code');
var r = document.querySelector('input[name="decision_'+ref+'"][value="'+rec+'"]');
if(r){ r.checked = true; r.dispatchEvent(new Event('change')); }
if(rec === 'reject' && code){
var sel = document.querySelector('select[name="reject_'+ref+'"]');
if(sel) sel.value = code;
}
});
})();
</script>
{{end}}
</div>
{{if .It.RawXML}}
<div class="card">
<details>
<summary style="cursor:pointer;font-weight:600">Данные запроса — поля и сырой XML</summary>
<p class="muted" style="font-size:12px;margin:6px 0">Слева — те же данные из XML, разобранные по полям для удобного просмотра; ниже — исходный XML дословно.</p>
{{xmlFields .It.RawXML}}
<div style="margin-top:12px;font-weight:600;font-size:13px">Сырой XML:</div>
<pre style="white-space:pre-wrap;word-break:break-all;background:var(--surface-2,#f5f5f7);padding:12px;border-radius:8px;font-size:12px;overflow:auto;max-height:340px;margin-top:4px">{{.It.RawXML}}</pre>
</details>
</div>
{{end}}
<div class="card">
<style>
.ord-steps{display:flex;gap:12px;margin:12px 0 14px;flex-wrap:wrap}
.ord-step{flex:1 1 190px;display:flex;gap:12px;align-items:flex-start;padding:14px 16px;border-radius:13px;border:2px solid var(--border);background:var(--card)}
.ord-step .dot{width:30px;height:30px;border-radius:50%;flex:none;display:flex;align-items:center;justify-content:center;font-size:15px;font-weight:800;background:var(--border);color:var(--muted)}
.ord-step .t{font-size:14px;font-weight:700;line-height:1.2}
.ord-step .s{font-size:11.5px;margin-top:3px;font-weight:600;color:var(--muted)}
.ord-step.done{border-color:#16a34a;background:color-mix(in srgb,#16a34a 13%,var(--card))}
.ord-step.done .dot{background:#16a34a;color:#fff}
.ord-step.done .s{color:#0f7a37}
.ord-step.wait{border-color:#f59e0b;background:color-mix(in srgb,#f59e0b 15%,var(--card))}
.ord-step.wait .dot{background:#f59e0b;color:#fff;box-shadow:0 0 0 4px color-mix(in srgb,#f59e0b 28%,transparent)}
.ord-step.wait .s{color:#b45309}
.ord-step.todo{opacity:.55}
.ord-dl{display:inline-block;margin-top:9px;font-size:11.5px;font-weight:700;padding:4px 10px;border-radius:8px;background:var(--card);border:1px solid #16a34a;color:#0f7a37;text-decoration:none;white-space:nowrap}
.ord-dl:hover{background:color-mix(in srgb,#16a34a 12%,var(--card))}
</style>
<div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap">
<h2 style="margin:0">Депозитарное поручение 16/1</h2>
{{if .OrderDone}}<span class="badge confirmed">проведено · MS010</span>
{{else if .It.OrderSubmitted}}<span class="badge awaiting_decision">подано, ждём MS010</span>
{{else}}<span class="badge" style="background:var(--card-2);color:var(--muted)">не подано</span>{{end}}
</div>
{{if .Watch}}<div style="margin:8px 0 0;padding:8px 12px;border-radius:9px;font-size:13px;font-weight:600;{{if eq .Watch.Kind "ok"}}background:color-mix(in srgb,#16a34a 12%,var(--card));color:#0f7a37{{else if eq .Watch.Kind "warn"}}background:color-mix(in srgb,#d97706 14%,var(--card));color:#9a6b12{{else if eq .Watch.Kind "bad"}}background:color-mix(in srgb,#dc2626 12%,var(--card));color:#b3261e{{else}}background:var(--card-2);color:var(--muted){{end}}">⏱ Срок по регламенту: {{.Watch.StatusRu}}</div>{{end}}
<div class="ord-steps">
<div class="ord-step done">
<span class="dot">✓</span>
<div><div class="t">Черновик получен</div><div class="s">{{if .DraftReceived}}{{.DraftReceived}}{{else}}сформирован НРД (MF010){{end}}</div>
<a class="ord-dl" href="/admin/incoming/{{.It.ID}}/draft.zip">⬇ Черновик от НРД</a></div>
</div>
<div class="ord-step {{if .It.OrderSubmitted}}done{{else}}wait{{end}}">
<span class="dot">{{if .It.OrderSubmitted}}✓{{else}}2{{end}}</span>
<div><div class="t">Поручение подано в НРД</div><div class="s">{{if .It.OrderSubmitted}}{{msk .It.OrderAt}}{{else}}нужно подать{{end}}</div>
{{if .It.OrderXML}}<a class="ord-dl" href="/admin/incoming/{{.It.ID}}/order.xml">⬇ Отправленная форма 16/1</a>{{end}}</div>
</div>
<div class="ord-step {{if .OrderDone}}done{{else if .It.OrderSubmitted}}wait{{else}}todo{{end}}">
<span class="dot">{{if .OrderDone}}✓{{else}}3{{end}}</span>
<div><div class="t">Проведено · MS010</div><div class="s">{{if .OrderDone}}{{if .OrderDoneAt}}{{.OrderDoneAt}}{{else}}отчёт получен от НРД{{end}}{{else if .It.OrderSubmitted}}ждём MS010 от НРД{{else}}после подачи{{end}}</div>
{{if .OrderResponses}}<a class="ord-dl" href="/admin/incoming/{{.It.ID}}/ms010.zip">⬇ Ответ НРД (MS010)</a>{{end}}</div>
</div>
</div>
<div style="display:flex;gap:10px;flex-wrap:wrap;align-items:center">
{{if not .It.OrderSubmitted}}
<form method="post" action="/admin/incoming/{{.It.ID}}/submit-order" onsubmit="return confirm('Подать поручение 16/1 (тип ЭД ORDXM) в НРД (TEST3)?');">
<button type="submit" class="btn btn-ok">▶ Подать поручение 16/1 в НРД</button>
</form>
{{end}}
</div>
{{if .It.OrderSubmitted}}
<div class="muted" style="font-size:12px;margin-top:10px">Подано {{msk .It.OrderAt}} · пакет ИШ <code>{{.It.OrderPkgs}}</code>{{if .It.OrderContrDID}} · № поручения <code>{{.It.OrderContrDID}}</code>{{end}}</div>
{{if .OrderResponses}}
<table style="margin-top:8px">
<thead><tr><th style="width:150px">Время</th><th>Ответ НРД</th></tr></thead>
<tbody>
{{range .OrderResponses}}<tr><td style="white-space:nowrap">{{.Time}}</td><td>{{.Kind}}</td></tr>{{end}}
</tbody>
</table>
{{else}}
<p class="muted" style="font-size:12px;margin-top:8px">Ответ НРД (квитанция → принятие → отчёт MS010) появится здесь. Обновите страницу.</p>
{{end}}
{{if .It.OrderXML}}
<details style="margin-top:8px"><summary style="cursor:pointer;font-size:12px;color:var(--muted)">Посмотреть отправленный XML поручения</summary>
<pre style="white-space:pre-wrap;word-break:break-all;background:var(--card-2);padding:12px;border-radius:8px;font-size:12px;overflow:auto;max-height:300px;margin-top:6px">{{.It.OrderXML}}</pre>
</details>
{{end}}
{{end}}
<details style="margin-top:12px"><summary style="cursor:pointer;font-size:12px;color:var(--muted)">Как это работает</summary>
<p class="muted" style="font-size:12px;margin-top:6px">После нашего согласия на прием НРД формирует <b>черновик поручения 16/1</b> (MF010). Депозитарий подаёт его в НРД (тип ЭД <code>ORDXM</code>) — НРД сматчит со встречным поручением 16 контрагента, проведёт зачисление и выпустит отчёт <code>XMLRP</code> = <b>MS010</b>. Кнопка «Подать» отправляет черновик через ИШ (подпись ГОСТ ставит ИШ); пока Fancy-DEPO не подключена, можно скачать черновик и обработать вручную.</p>
</details>
</div>
<p style="margin-top:16px">
<a href="/admin/incoming" class="muted">← Все входящие</a>
&nbsp;·&nbsp;
<a href="/admin/incoming/{{.It.ID}}/export.zip" class="btn">⬇ Выгрузка для регулятора (ZIP: отчёт + request.xml + decision.xml)</a>
</p>
<aside class="in-tl">
<h3>Путь сообщения</h3>
<div class="st st-req">
<div class="tm">{{msk .It.RequestCreated}}</div>
<div class="ti">Запрос сформирован</div>
<div class="de">отправитель (контрагент)</div>
</div>
<div class="st st-gw">
<div class="tm">{{msk .It.ReceivedAt}}</div>
<div class="ti">Получено нами</div>
<div class="de">забрали из шлюза ИШ{{if not .It.RequestCreated.IsZero}} · прохождение {{transit .It.RequestCreated .It.ReceivedAt}}{{end}}</div>
</div>
{{if .DraftReceived}}
<div class="st st-gw">
<div class="tm">{{.DraftReceived}}</div>
<div class="ti">Черновик 16/1 получен</div>
<div class="de">НРД прислал MF010 в INBOX</div>
</div>
{{end}}
{{if $answered}}
<div class="st st-resp">
<div class="tm">{{msk .It.AnsweredAt}}</div>
<div class="ti">Ответ отправлен{{if .It.Auto}} (авто){{end}}</div>
<div class="de">M2MTransferDecision{{if not .It.ReceivedAt.IsZero}} · {{transit .It.ReceivedAt .It.AnsweredAt}}{{end}}</div>
</div>
{{end}}
{{if .It.OrderSubmitted}}
<div class="st st-ord">
<div class="tm">{{msk .It.OrderAt}}</div>
<div class="ti">Поручение 16/1 подано</div>
<div class="de">тип ORDXM · пакет ИШ {{.It.OrderPkgs}}</div>
</div>
{{end}}
{{range .OrderResponses}}
<div class="st st-nrd">
<div class="tm">{{.Time}}</div>
<div class="ti">Ответ НРД</div>
<div class="de">{{.Kind}}</div>
</div>
{{end}}
</aside>
{{/* ===== QUIK МНП: зачисление клиенту ===== */}}
{{$back := printf "/admin/incoming/%s" .It.ID}}
<div class="card">
<h2 style="margin:0">Тестовый прогон в DEPO с реальным клиентом</h2>
<p class="muted" style="font-size:12.5px;margin:6px 0 8px">Копия этого входящего (те же бумаги и решения) записывается в трансферные таблицы DEPO под новым GUID на реального клиента из картотеки — по паспорту. Джоба bj2depo создаёт по нему документ; в НРД ничего не уходит. Полный сценарий приёма: получен → приняли → подали 16/1 → НРД принял → исполнено.</p>
<form method="post" action="/admin/incoming/depo-sim" style="display:flex;gap:8px;flex-wrap:wrap;align-items:center" onsubmit="return confirm('Записать копию перевода в трансферные таблицы DEPO на клиента с этим паспортом?')">
<input type="hidden" name="id" value="{{.It.ID}}">
<label style="margin:0;font-size:12.5px">серия <input type="text" name="ser" value="1110" style="width:70px"></label>
<label style="margin:0;font-size:12.5px">номер <input type="text" name="num" value="754503" style="width:90px"></label>
<button type="submit" class="btn">🗂 Прогнать в DEPO с реальным клиентом</button>
<span class="muted" style="font-size:12px">по умолчанию — Погонщикова В.О. (ВТНКЦ00000816 / BR859)</span>
</form>
</div>
<div class="card">
<style>
.qk-steps{display:flex;gap:8px;flex-wrap:wrap;margin:8px 0}
.qk-step{padding:6px 10px;border-radius:9px;border:1.5px solid var(--border);font-size:12.5px;background:var(--card-2)}
.qk-step.ok{border-color:color-mix(in srgb,#16a34a 45%,transparent);background:color-mix(in srgb,#16a34a 12%,var(--card));color:#0f7a37;font-weight:600}
.qk-step.bad{border-color:color-mix(in srgb,#dc2626 45%,transparent);background:color-mix(in srgb,#dc2626 10%,var(--card));color:#b3261e;font-weight:600}
.qk-state{display:inline-block;padding:3px 10px;border-radius:999px;font-size:12px;font-weight:700}
.qk-state.registered{background:color-mix(in srgb,#d97706 15%,var(--card));color:#9a6b12}
.qk-state.signed,.qk-state.deposited{background:color-mix(in srgb,#16a34a 15%,var(--card));color:#0f7a37}
.qk-state.denied{background:var(--card-2);color:var(--muted)}
.qk-state.error{background:color-mix(in srgb,#dc2626 15%,var(--card));color:#b3261e}
.qk-state.manual{background:color-mix(in srgb,#2563eb 12%,var(--card));color:#1d4ed8}
</style>
<div style="display:flex;align-items:center;gap:12px;flex-wrap:wrap">
<h2 style="margin:0">QUIK МНП — зачисление бумаг клиенту</h2>
<a href="/admin/quik" class="muted" style="font-size:12px">все поручения →</a>
</div>
{{if .DepoL}}<div style="margin:8px 0 0;padding:8px 12px;border-radius:9px;font-size:13px;{{if .DepoL.Errors}}background:color-mix(in srgb,#dc2626 12%,var(--card));color:#b3261e{{else}}background:var(--card-2){{end}}">🗂 DEPO (bj2depo): {{if .DepoL.DocNo}}поручение <b>№{{.DepoL.DocNo}}</b> создано {{msk .DepoL.DocAt}}{{if .DepoL.DocState}} · состояние в DEPO: <b>{{.DepoL.DocState}}</b>{{end}}{{else}}поручение ещё не создано{{end}}{{range .DepoL.Errors}}<div>ошибка: {{.}}</div>{{end}}</div>{{end}}
<p class="muted" style="font-size:12.5px;margin:6px 0 0">Когда НРД принимает наше поручение 16/1 (отчёт MS010), bj регистрирует в Модуле неторговых поручений «Ввод ЦБ (M2M)» по ISIN — оно исполняется сразу, бумаги появляются на позиции клиента в QUIK.{{if not .QuikOn}} <b>Записи в МНП сейчас выключены</b> (Настройка → QUIK МНП).{{end}}</p>
{{if not .Quik}}<div style="margin-top:8px;display:flex;gap:8px;flex-wrap:wrap;align-items:center"><span class="muted">Зачислений по этому переводу ещё нет.</span>
{{range .It.Securities}}{{if eq .Decision "confirmed"}}
<form method="post" action="/admin/quik/op" style="margin:0;display:inline"><input type="hidden" name="guid" value="{{$.It.GUID}}"><input type="hidden" name="isin" value="{{.ISIN}}"><input type="hidden" name="op" value="deposit"><input type="hidden" name="back" value="{{$back}}"><button class="btn" style="font-size:12px" onclick="return confirm('Зачислить {{.ISIN}} × {{.Quantity}} клиенту в QUIK? Исполняется сразу.')">⊕ Зачислить {{.ISIN}} × {{.Quantity}}</button></form>
{{end}}{{end}}</div>{{end}}
{{range .Quik}}
<div style="border:1px solid var(--border);border-radius:10px;padding:10px 12px;margin-top:10px">
<div style="display:flex;gap:10px;align-items:center;flex-wrap:wrap">
<b><code>{{.ISIN}}</code> × {{.Qty}}</b>
<span class="muted" style="font-size:12px">клиент QUIK <code>{{.ClientID}}</code>{{if .Contract}} · {{.Contract}}{{end}}{{if .InstrNo}} · поручение МНП <b>№{{.InstrNo}}</b>{{end}}</span>
<span class="qk-state {{.State}}">{{.StateRu}}</span>
</div>
{{if .Err}}<div style="color:#b3261e;font-size:12.5px;margin-top:4px">{{.Err}}</div>{{end}}
{{if .Steps}}<div class="qk-steps">{{range .Steps}}<span class="qk-step {{if .OK}}ok{{else}}bad{{end}}" title="{{.Reply}}">{{msk .At}} · {{.Action}}{{if .ActionID}} #{{.ActionID}}{{end}}: {{.Reply}}</span>{{end}}</div>{{end}}
<div style="display:flex;gap:8px;flex-wrap:wrap;align-items:center;margin-top:6px">
{{if or (eq .State "error") (eq .State "manual") (not .InstrNo)}}
<form method="post" action="/admin/quik/op" style="margin:0;display:inline"><input type="hidden" name="guid" value="{{.GUID}}"><input type="hidden" name="isin" value="{{.ISIN}}"><input type="hidden" name="op" value="deposit"><input type="hidden" name="back" value="{{$back}}"><button class="btn btn-ok" style="font-size:12px" onclick="return confirm('Зачислить бумаги клиенту в QUIK («Ввод ЦБ (M2M)»)? Исполняется сразу.')">⊕ Зачислить в QUIK</button></form>
{{end}}
</div>
</div>
{{end}}
</div>
{{end}}
@@ -0,0 +1,25 @@
{{define "content"}}
<div class="card">
<h2>Журнал обмена <span class="muted">({{len .Rows}})</span></h2>
<p class="muted">Все переводы в одной ленте — входящие и исходящие. Отдельные виды: <a href="/admin/claims">Исходящие</a> · <a href="/admin/incoming">Входящие</a>.</p>
{{if .Rows}}
<table>
<thead><tr><th>Время</th><th>Направление</th><th>Контрагент</th><th>ЦБ</th><th>Статус</th><th></th></tr></thead>
<tbody>
{{range .Rows}}
<tr>
<td style="white-space:nowrap">{{msk .Time}}</td>
<td>{{if eq .DirKind "out"}}<span class="badge" style="background:#e7f0fb;color:#1f4e79">↗ Исходящий</span>{{else}}<span class="badge" style="background:#e6f4ea;color:#1a7f37">↘ Входящий</span>{{end}}</td>
<td>{{.Party}}</td>
<td>{{.SecN}}</td>
<td><span class="badge {{.Status}}">{{.StatusText}}</span>{{if .Outcome}} <span class="badge {{if eq .Outcome "Принято"}}confirmed{{else if eq .Outcome "Отказано"}}rejected{{else}}awaiting_decision{{end}}">{{.Outcome}}</span>{{end}}</td>
<td><a href="{{.Href}}" class="muted">открыть →</a></td>
</tr>
{{end}}
</tbody>
</table>
{{else}}
<p class="muted">Переводов пока нет.</p>
{{end}}
</div>
{{end}}
@@ -18,7 +18,7 @@
{{else if eq $step.Status "error"}}background:var(--err-weak);color:var(--err)
{{else if eq $step.Status "active"}}background:var(--accent-weak);color:var(--accent)
{{else}}background:var(--surface-2,#eee);color:var(--muted,#999){{end}}">
{{if eq $step.Status "ok"}}✓{{else if eq $step.Status "error"}}✕{{else}}{{add $i 1}}{{end}}
{{if eq $step.Status "ok"}}✓{{else if eq $step.Status "error"}}{{else}}{{add $i 1}}{{end}}
</span>
<div style="flex:1">
<div style="font-weight:600">{{$step.Title}}</div>
@@ -31,7 +31,7 @@
{{/* ===== Действие в зависимости от состояния ===== */}}
{{if $s.Done}}
<div class="card" style="border-left:3px solid var(--ok)">
<div class="card" style="background:var(--ok-weak)">
<h2>✓ Готово</h2>
<p>Ключ установлен на флешку, справочник сертификатов сформирован, Валидата проверена.</p>
{{if $s.Backup}}<p class="muted">Бэкап прежнего носителя: <code>{{$s.Backup}}</code></p>{{end}}
@@ -0,0 +1,128 @@
{{define "content"}}
<style>
.pv-legend{display:flex;gap:16px;flex-wrap:wrap;font-size:12.5px;margin:4px 0 2px}
.pill{display:inline-flex;align-items:center;gap:6px;padding:2px 10px;border-radius:999px;font-size:12px;font-weight:700}
.pill-ok{background:color-mix(in srgb,#16a34a 15%,var(--card));color:#0f7a37;border:1px solid color-mix(in srgb,#16a34a 40%,transparent)}
.pill-bad{background:color-mix(in srgb,#dc2626 15%,var(--card));color:#b3261e;border:1px solid color-mix(in srgb,#dc2626 45%,transparent)}
.pv table{width:100%;border-collapse:collapse;margin:8px 0;font-size:13.5px}
.pv td,.pv th{border:1px solid var(--border);padding:9px 12px;text-align:left;vertical-align:top}
.pv th{background:var(--card-2);font-size:12px;color:var(--muted)}
.pv td.k{width:230px;color:var(--muted)}
.row-ok{background:color-mix(in srgb,#16a34a 8%,var(--card))}
.row-bad{background:color-mix(in srgb,#dc2626 9%,var(--card))}
.banner{padding:12px 15px;border-radius:12px;margin:10px 0;font-size:13.5px}
.banner-ok{background:color-mix(in srgb,#16a34a 13%,var(--card));border:1px solid color-mix(in srgb,#16a34a 35%,transparent)}
.banner-bad{background:color-mix(in srgb,#dc2626 12%,var(--card));border:1px solid color-mix(in srgb,#dc2626 38%,transparent)}
</style>
<p style="margin:0 0 12px"><a href="/admin/m2m-testing" class="btn" onclick="if(history.length>1){history.back();return false}">← Назад к плану</a></p>
<div class="card pv">
<h2 style="margin:0 0 4px">Проверка данных перед отправкой · кейс <code>{{.Case.ID}}</code></h2>
<div class="muted" style="font-size:13px">{{.Case.Debit}} → {{.Case.Credit}} · клиент {{.ClientKey}} · {{.Case.Note}}</div>
<div class="pv-legend" style="margin-top:10px">
<span class="pill pill-ok">🟢 корректные данные</span>
<span class="pill pill-bad">🔴 намеренно неверно (по плану сценария)</span>
</div>
{{if .AlreadySent}}
<div class="banner banner-bad">⚠ <b>По этому кейсу уже отправлялся запрос</b> (GUID <code>{{.SentGUID}}</code>, {{.SentWhen}}).
Повторная отправка создаст <b>новую заявку с новым GUID</b> — НРД примет её как новую (ошибки о дубле GUID <b>не будет</b>). Это правильный способ переотправить кейс, если контрагент ответил неверно.
Контрагент увидит ещё один перевод по тому же инвестору и бумагам — поэтому отправляйте осознанно.</div>
{{end}}
{{if .Positive}}
<div class="banner banner-ok">✓ <b>Позитивный кейс.</b> Все данные корректны — ожидаем <b>успешный перевод</b>. Ничего не подменяем.</div>
{{else}}
<div class="banner banner-bad">🔴 <b>Негативный кейс, ожидаем код {{.Case.Expect}}.</b> {{.DefectDesc}}<br>
<span class="muted" style="font-size:12.5px">Неверный реквизит выделен красным — отправляем именно его, чтобы отказ был настоящим.</span></div>
{{end}}
<h3 style="font-size:14px;margin:16px 0 4px">Инвестор (владелец бумаг)</h3>
<table>
<tr class="{{if .ClientBad}}row-bad{{else}}row-ok{{end}}">
<td class="k">ФИО · документ {{if .ClientBad}}<span class="pill pill-bad">🔴</span>{{else}}<span class="pill pill-ok">🟢</span>{{end}}</td>
<td><b>{{.ClientFIO}}</b> ({{.ClientKey}})<br>{{.ClientDUL}}
{{if .ClientBad}}<div class="muted" style="font-size:12px;color:#b3261e">Этот клиент не заведён у зачисляющего — сработает M2M04.</div>{{end}}</td>
</tr>
</table>
<h3 style="font-size:14px;margin:16px 0 4px">Депозитарии</h3>
<table>
<tr class="row-ok">
<td class="k">Списывающий (мы) <span class="pill pill-ok">🟢</span></td>
<td>{{if .OurName}}<b>{{.OurName}}</b> · {{end}}<code>{{.OurCode}}</code>{{if .OurINN}} · ИНН {{.OurINN}}{{end}}</td>
</tr>
<tr class="{{if eq .DefectField "account"}}row-bad{{else}}row-ok{{end}}">
<td class="k">Счёт списания (место расчётов) {{if eq .DefectField "account"}}<span class="pill pill-bad">🔴</span>{{else}}<span class="pill pill-ok">🟢</span>{{end}}</td>
<td><code>{{.OurAccount}}</code>
{{if eq .DefectField "account"}}<div class="muted" style="font-size:12px;color:#b3261e">{{.DefectDesc}}</div>{{end}}</td>
</tr>
<tr class="row-ok">
<td class="k">Зачисляющий (контрагент) <span class="pill pill-ok">🟢</span></td>
<td>{{if .CpName}}<b>{{.CpName}}</b> · {{end}}<code>{{.CpCode}}</code>{{if .CpINN}} · ИНН {{.CpINN}}{{end}}</td>
</tr>
</table>
{{if .Secs}}
<h3 style="font-size:14px;margin:16px 0 4px">Ценные бумаги ({{len .Secs}})</h3>
<table>
<thead><tr><th style="width:60px"></th><th>ISIN / код НРД</th><th>Наименование</th><th>Кол-во</th></tr></thead>
<tbody>
{{range .Secs}}
<tr class="{{if .Bad}}row-bad{{else}}row-ok{{end}}">
<td>{{if .Bad}}<span class="pill pill-bad">🔴</span>{{else}}<span class="pill pill-ok">🟢</span>{{end}}</td>
<td><code>{{.ISIN}}</code>{{if ne .Code .ISIN}} · {{.Code}}{{end}}</td>
<td>{{.Name}}{{if .Bad}} <span class="muted" style="font-size:12px;color:#b3261e">— неверная для этого кейса</span>{{end}}</td>
<td>{{.Qty}}</td>
</tr>
{{end}}
</tbody>
</table>
{{end}}
{{if .WeInitiate}}
<style>
.dc-ok{color:var(--ok,#12813f);font-weight:700}.dc-bad{color:var(--err,#b3261e);font-weight:700}.dc-warn{color:var(--warn,#9a6b12);font-weight:700}
.dc-rec{display:inline-block;padding:4px 12px;border-radius:999px;font-weight:700;font-size:13px}
.dc-rec.confirm{background:var(--ok-weak,#e6f4ea);color:var(--ok,#12813f)}
.dc-rec.reject{background:var(--err-weak,#fbe9e7);color:var(--err,#b3261e)}
.dc-rec.mixed{background:var(--warn-weak,#fff4e0);color:var(--warn,#9a6b12)}
</style>
<div style="margin-top:18px;padding:12px 14px;border:1px solid var(--border);border-radius:12px;background:var(--card)">
<div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap">
<h3 style="margin:0;font-size:14px">Сверка списания с DEPO — эмуляция</h3>
{{if .Depo.Ran}}<span class="dc-rec {{.Depo.Recommend}}">{{.Depo.Summary}}</span>{{end}}
</div>
<p class="muted" style="font-size:12px;margin:6px 0 0">Как Fancy-DEPO оценила бы это списание: клиент, торговый счёт, остаток, место хранения, блокировки, ИИС. Ничего не отправляется — только подсказка перед отправкой.</p>
{{if not .Depo.Ran}}
<p class="muted" style="margin-top:8px">Сверка не выполнена: {{.Depo.Err}}</p>
{{else}}
<div class="muted" style="font-size:12.5px;margin-top:8px">Инвестор: {{if .Depo.Investor}}<b>{{.Depo.Investor}}</b>{{else}}<span class="dc-bad">не найден в картотеке</span>{{end}} · Торговый счёт: {{if .Depo.Account}}<code>{{.Depo.Account}}</code>{{else}}<span class="dc-bad">нет открытого ВТНКЦ-счёта</span>{{end}}</div>
{{range .Depo.Secs}}
<div style="font-size:13px;font-weight:600;margin:10px 0 4px"><code>{{.Security}}</code> → {{if eq .Recommend "confirm"}}<span class="dc-ok">списать можно</span>{{else}}<span class="dc-bad">отказ {{.RejectCode}}</span> <span class="muted" style="font-weight:400">({{m2mCode .RejectCode}})</span>{{end}}</div>
<table style="font-size:12.5px">
<tbody>
{{range .Checks}}
<tr><td style="width:30px">{{if not .OK}}<span class="dc-bad">🔴</span>{{else if .Warn}}<span class="dc-warn">🟡</span>{{else}}<span class="dc-ok">🟢</span>{{end}}</td><td style="width:270px">{{.Name}}</td><td>{{.Detail}}</td></tr>
{{end}}
</tbody>
</table>
{{end}}
{{end}}
</div>
{{end}}
<div style="display:flex;gap:10px;flex-wrap:wrap;align-items:center;margin-top:18px">
{{if .WeInitiate}}
<form method="post" action="/admin/m2m-testing/initiate" onsubmit="return confirm('{{if .AlreadySent}}Отправить кейс {{.Case.ID}} ЗАНОВО? Уйдёт новая заявка с новым GUID (НРД примет как новую). Контрагент увидит ещё один перевод.{{else}}Отправить кейс {{.Case.ID}} контрагенту? Уйдёт реальный пакет в НРД (TEST3).{{end}}');">
<input type="hidden" name="id" value="{{.Case.ID}}">
<button type="submit" class="btn {{if .AlreadySent}}{{else}}btn-ok{{end}}">{{if .AlreadySent}}▶ Отправить заново (новый GUID){{else}}▶ Подтвердить и отправить в НРД{{end}}</button>
</form>
<a href="/admin/m2m-testing" class="btn">Отмена</a>
{{else}}
<div class="muted">По этому кейсу мы <b>Зачисляющий</b> — данные не отправляем, а отвечаем на странице <a href="/admin/incoming">Входящие</a>{{if .Case.Expect}} (код <code>{{.Case.Expect}}</code>){{end}}.</div>
{{end}}
</div>
</div>
{{end}}
@@ -0,0 +1,197 @@
{{define "content"}}
<style>
.m2m-chips{display:flex;gap:8px;flex-wrap:wrap;margin:10px 0}
.m2m-chips span{padding:4px 12px;border-radius:20px;font-size:13px;font-weight:600;background:var(--card-2);border:1px solid var(--border)}
.m2m-chips .ok{background:var(--ok-weak,#e6f4ea);color:#1a7f37;border-color:#bfe3c9}
.m2m-chips .err{background:var(--err-weak,#fbe9e9);color:#b3261e;border-color:#f0c2bf}
.m2m-chips .prog{background:var(--warn-weak,#fbf3d9);color:#8a6d00;border-color:#ecd9a1}
.cpf{display:flex;gap:10px;flex-wrap:wrap;align-items:flex-end;margin-top:8px}
.cpf label{font-size:12px;color:var(--muted);display:flex;flex-direction:column;gap:3px}
.cpf input{padding:6px 8px;border:1px solid var(--border);border-radius:7px;background:var(--card);color:var(--text);min-width:150px}
table.m2m{width:100%;border-collapse:collapse;font-size:13px}
table.m2m th,table.m2m td{padding:6px 8px;border-bottom:1px solid var(--border);text-align:left;vertical-align:top}
table.m2m th{position:sticky;top:0;background:var(--card-2);font-size:11px;text-transform:uppercase;letter-spacing:.03em;color:var(--muted)}
table.m2m tr.st-passed{background:var(--ok-weak,#e6f4ea)}
table.m2m tr.st-failed{background:var(--err-weak,#fbe9e9)}
table.m2m tr.st-in_progress{background:var(--warn-weak,#fbf3d9)}
table.m2m tr.st-excluded{opacity:.45}
table.m2m tr.st-excluded td{color:var(--muted)}
.live{font-size:11px;margin-top:3px;font-weight:600}
table.m2m .i{color:var(--accent,#1f6feb);cursor:help;font-size:12px}
table.m2m tr:hover{outline:1px solid var(--accent-weak,#cfe0fb)}
.role-we{font-weight:700;color:var(--accent-strong,#1f4e79)}
.code{font-family:monospace;font-weight:700;color:#b3261e}
.small{font-size:11px}
.stf{display:inline-flex;gap:3px}
.stf button{border:1px solid var(--border);background:var(--card);border-radius:6px;padding:3px 7px;cursor:pointer;font-size:13px;line-height:1}
.stf button.b-ok:hover{background:#e6f4ea}
.stf button.b-err:hover{background:#fbe9e9}
.b-init{border:1px solid #1f4e79;background:var(--accent-weak,#e7f0fb);color:#1f4e79;border-radius:7px;padding:5px 10px;cursor:pointer;font-weight:600;font-size:12px;white-space:nowrap}
.b-init:hover{background:#d7e6fb}
.scen-row td{background:var(--card-2);font-weight:700;color:var(--text);font-size:12px}
.noteinp{width:100%;margin-top:3px;padding:3px 5px;border:1px solid var(--border);border-radius:5px;font-size:11px;background:var(--card);color:var(--text)}
</style>
<div class="card">
<h2>Тестирование M2M — с контрагентом (МБ / НРД)</h2>
<div style="padding:10px 14px;background:var(--accent-weak,#e7f0fb);border-radius:10px;margin:6px 0 10px;font-size:14px">
<strong>{{if .CounterpartyName}}{{.CounterpartyName}}{{else}}Контрагент 1{{end}}</strong> — <em>списывающий</em> (Контрагент 1) &nbsp;→&nbsp; <strong>Фонтвьель</strong> — <em>зачисляющий</em> (Контрагент 2, мы).
</div>
<p class="muted">По кейсам, где мы <em>Списывающий</em>, жмём «Инициировать отправку»; где <em>Зачисляющий</em> — отвечаем на странице <a href="/admin/incoming">Входящие</a> (для негативных кейсов — с кодом отказа). По инициированным кейсам статус и исход НРД <strong>подтягиваются автоматически</strong>. Статусы сохраняются между перезапусками.</p>
<div class="m2m-chips">
<span>Всего: {{.Total}}</span>
<span class="ok">✓ Успех: {{.Passed}}</span>
<span class="err">✗ Провал: {{.Failed}}</span>
<span class="prog">● В процессе: {{.Prog}}</span>
<span>○ Не начато: {{.Pending}}</span>
{{if .Excluded}}<span style="opacity:.6">⊘ Не тестируем: {{.Excluded}}</span>{{end}}
</div>
{{if or .Passed .Prog .Failed}}
<div style="display:flex;gap:8px;flex-wrap:wrap;align-items:center;margin:10px 0 4px">
<a href="/admin/m2m-testing/report-bundle.zip" class="btn btn-ok" download title="ZIP: сводка + доказательства по каждому пройденному кейсу">⬇ Отчёты по тестам</a>
<a href="/admin/m2m-testing/fancy-bundle.zip" class="btn" download title="ZIP: входящие и исходящие обмены с контрагентом + наши решения — для программиста Fancy">⬇ Эталон для Fancy</a>
<form method="post" action="/admin/m2m-testing/depo-replay" style="margin:0;display:inline-flex;gap:6px;align-items:center" onsubmit="return confirm('Выгрузить проведённые переводы с выбранным контрагентом (решения, поручения 16/16-1, отчёты MS010) в трансферные таблицы DEPO? Джоба bj2depo создаст по ним поручения в тестовой базе депозитария. Повторный запуск ничего не дублирует.')"><input type="text" name="cp" value="{{.CounterpartyCode}}" title="код контрагента МОСТ (пусто = все)" style="width:130px;font-size:12px"><button type="submit" class="btn" title="FTV_M2M + FTV_M2M_LINES + FTV_M2M_EVENTS в нашей копии DEPO — чтобы увидеть, как тесты выглядят внутри депозитария">🗂 Выгрузить историю в DEPO</button></form>
<a href="/admin/m2m-testing/process" target="_blank" class="btn" title="Наглядная схема процесса M2M (16 / 16.1) для программиста">🔄 Как работает процесс</a>
<a href="/admin/m2m-testing/depo-sim" class="btn" title="Песочница: выбрать клиента/бумагу из DEPO, изменить поля и посмотреть, как ответит сверка (ничего не отправляется)">🧪 Имитация сверки с DEPO</a>
<a href="/admin/m2m-testing/spravka" target="_blank" class="btn" title="Расшифровка кодов ошибок M2M">📖 Коды ошибок</a>
<span class="muted" style="font-size:12px;flex-basis:100%;margin-top:2px">Архивы для отправки контрагенту / программисту Fancy. Наведите на кнопку — подсказка, что внутри.</span>
</div>
{{end}}
<form method="post" action="/admin/m2m-testing/counterparty" class="cpf" id="cpForm">
<label style="min-width:340px">Контрагент (из справочника M2M)
<input id="cp-search" list="cp-list" autocomplete="off" placeholder="начните вводить наименование или код…"
value="{{if .CounterpartyName}}{{.CounterpartyName}} ({{.CounterpartyCode}}){{end}}">
<datalist id="cp-list"></datalist>
</label>
<div class="muted small" id="cp-info" style="align-self:center">
{{if .CounterpartyCode}}Код: <code>{{.CounterpartyCode}}</code> · ИНН: {{if .CounterpartyINN}}{{.CounterpartyINN}}{{else}}—{{end}}{{else}}— выберите контрагента из списка —{{end}}
</div>
<input type="hidden" name="counterparty_name" id="cp-name" value="{{.CounterpartyName}}">
<input type="hidden" name="counterparty_code" id="cp-code" value="{{.CounterpartyCode}}">
<input type="hidden" name="counterparty_inn" id="cp-inn" value="{{.CounterpartyINN}}">
<input type="hidden" name="our_inn" value="{{.OurINN}}">
<button class="b-init" style="background:var(--card);border-color:var(--border);color:var(--text)">Сохранить</button>
</form>
<div style="margin-top:10px"><a href="/admin/m2m-testing/plan.html" class="btn" title="Самостоятельный HTML: участники, тестовые клиенты, остатки, кейсы с ожидаемыми ответами — для отправки команде контрагента">📄 План для контрагента (HTML)</a> <a href="/admin/m2m-testing/plan.html?inline=1" target="_blank" class="muted small">открыть в браузере</a></div>
<div class="muted small" style="margin-top:6px">Наш депозитарий: <strong>{{if .OurName}}{{.OurName}}{{else}}Фонтвьель{{end}}</strong> · код <code>{{.OurCode}}</code>{{if .OurINN}} · ИНН {{.OurINN}}{{end}} <span style="opacity:.7">(из справочника)</span></div>
</div>
<script>
(function(){
var HB = {{m2mHandbook}};
var dl = document.getElementById('cp-list'), search = document.getElementById('cp-search');
if(!Array.isArray(HB)||!dl||!search) return;
var byLabel = {};
HB.forEach(function(p){
var label = p.name + ' — ' + p.code + (p.inn?(' (ИНН '+p.inn+')'):'');
var o=document.createElement('option'); o.value=label; dl.appendChild(o);
byLabel[label]=p;
});
function resolve(){
var p = byLabel[search.value];
if(!p){
var v = search.value.trim().toLowerCase();
for(var i=0;i<HB.length;i++){ var x=HB[i];
if((x.code&&x.code.toLowerCase()===v)
||(x.inn&&x.inn.toLowerCase()===v)
||(x.name&&x.name.toLowerCase()===v)
||(x.name&&(x.name+' ('+x.code+')').toLowerCase()===v)){ p=x; break; } }
}
if(!p) return false;
document.getElementById('cp-name').value=p.name;
document.getElementById('cp-code').value=p.code;
document.getElementById('cp-inn').value=p.inn||'';
document.getElementById('cp-info').innerHTML='Код: <code>'+p.code+'</code> · ИНН: '+(p.inn||'—');
search.value=p.name+' ('+p.code+')';
return true;
}
search.addEventListener('change', resolve);
// на всякий случай резолвим и перед сохранением формы
document.getElementById('cpForm').addEventListener('submit', function(e){
if(search.value.trim()!=='' && !document.getElementById('cp-code').value){ resolve(); }
});
})();
</script>
<div class="card">
<h3 style="margin:0 0 8px">Данные для занесения в учётную систему</h3>
<div style="display:flex;gap:24px;flex-wrap:wrap">
<div>
<div class="muted small" style="text-transform:uppercase;letter-spacing:.05em;margin-bottom:4px">Тестовые клиенты</div>
<table class="m2m" style="font-size:12px">
<tr><th></th><th>ФИО</th><th>ДУЛ</th></tr>
<tr><td><strong>К1</strong></td><td>Мостовиков Арсений Тимурович<div class="muted small">14.03.1999, г. Москва</div></td><td>паспорт 4519 № 736284<div class="muted small">ГУ МВД России по г. Москве, 28.03.2019, 770-093</div></td></tr>
<tr><td><strong>К2</strong></td><td>Баранкин Сергей Сергеевич</td><td>паспорт 3763 № 290089 <span class="muted">(кейсы M2M04)</span></td></tr>
</table>
</div>
<div>
<div class="muted small" style="text-transform:uppercase;letter-spacing:.05em;margin-bottom:4px">Бумаги</div>
<table class="m2m" style="font-size:12px">
<tr><th>ISIN</th><th>Код НРД</th><th>Наименование</th></tr>
<tr><td>RU0009029540</td><td>SBER/03</td><td>Акции об. ПАО Сбербанк</td></tr>
<tr><td>RU000A0JUR61</td><td>RU000A0JUR61</td><td>Пай ИПИФ «КОНСЕРВАТОРия»</td></tr>
<tr><td>RU000A102QL3</td><td>RU000A102QL3</td><td>Облигация суборд. ВТБ (СУБ-Т1-2)</td></tr>
<tr><td>RU0007661625</td><td>RU0007661625</td><td>Акции об. ПАО «Газпром» <span class="muted">(M2M05)</span></td></tr>
<tr><td>XS0088543193</td><td>XS0088543193</td><td>Минфин РФ (USD) <span class="muted">(M2M01, не-RU)</span></td></tr>
</table>
</div>
</div>
</div>
<div class="card" style="overflow-x:auto">
<table class="m2m">
<thead><tr>
<th>#</th><th>Списывающий</th><th>Зачисляющий</th><th>Наша роль</th>
<th>Клиент</th><th>Расч.депо</th><th>Бумаги</th><th>Ожидание</th>
<th>Статус</th><th>Действие</th>
</tr></thead>
<tbody>
{{$scen := ""}}
{{range .Rows}}
{{if ne .Scenario $scen}}
<tr class="scen-row"><td colspan="10">Сценарий {{.Scenario}}</td></tr>
{{$scen = .Scenario}}
{{end}}
<tr class="st-{{.StatusClass}}" title="{{.Desc}}">
<td><strong>{{.ID}}</strong> <span class="i" title="{{.Desc}}">ⓘ</span></td>
<td>{{.Debit}}</td>
<td>{{.Credit}}</td>
<td class="{{if .WeInitiate}}role-we{{else}}role-we{{end}}">{{.OurRole}}</td>
<td>{{.Client}}</td>
<td>{{.SettleDeb}}→{{.SettleCred}}</td>
<td class="small">{{.SecList}}</td>
<td>{{if .Expect}}<span class="code">{{.Expect}}</span>{{else}}<span class="ok" style="color:#1a7f37">успех</span>{{end}}<div class="muted small">{{.Note}}</div></td>
<td>
{{if .Excluded}}
<span class="muted small">⊘ не тестируем</span>
{{else}}
<form method="post" action="/admin/m2m-testing/status" class="stf">
<input type="hidden" name="id" value="{{.ID}}">
<button class="b-ok" name="status" value="passed" title="Успех">✓</button>
<button class="b-err" name="status" value="failed" title="Провал">✗</button>
<button name="status" value="reset" title="Сброс">○</button>
</form>
{{if .Live}}<div class="live {{if eq .StatusClass "passed"}}ok{{else if eq .StatusClass "failed"}}err{{else}}muted{{end}}" style="{{if eq .StatusClass "passed"}}color:#1a7f37{{else if eq .StatusClass "failed"}}color:#b3261e{{end}}">⟳ {{.Live}}</div>{{end}}
{{if .State.Note}}<div class="muted small">{{.State.Note}}</div>{{end}}
{{if .State.GUID}}<div class="muted small">GUID: {{.State.GUID}}</div>{{end}}
{{end}}
</td>
<td>
{{if .Excluded}}
<span class="muted small">—</span>
{{else if .WeInitiate}}
{{if .Secs}}
<a href="/admin/m2m-testing/preview?id={{.ID}}" class="b-init" style="text-decoration:none;display:inline-block">▶ Инициировать</a>
{{else}}<span class="muted small">Сценарий 2 — формируется вручную</span>{{end}}
{{else}}
<a href="/admin/incoming" class="muted small">Ответить во «Входящих»{{if .Expect}}<br>код <span class="code">{{.Expect}}</span>{{end}}</a>
{{end}}
</td>
</tr>
{{end}}
</tbody>
</table>
</div>
{{end}}
@@ -2,11 +2,11 @@
<style>
.news-item { background:var(--card); border:1px solid var(--border); border-radius:6px; padding:14px; margin-bottom:10px; }
.news-item.dismissed { opacity:0.5; }
.news-item.kind-maintenance { border-left:4px solid var(--warn); }
.news-item.kind-feature { border-left:4px solid var(--ok); }
.news-item.kind-doc-update { border-left:4px solid var(--accent); }
.news-item.kind-system { border-left:4px solid var(--err); }
.news-item.kind-manual { border-left:4px solid var(--muted); }
.news-item.kind-maintenance { background:var(--warn-weak); }
.news-item.kind-feature { background:var(--ok-weak); }
.news-item.kind-doc-update { background:var(--accent-weak); }
.news-item.kind-system { background:var(--err-weak); }
.news-item.kind-manual { background:var(--card-2); }
.news-meta { font-size:11px; color:var(--muted); margin-bottom:6px; text-transform:uppercase; letter-spacing:0.04em; }
.news-title { font-size:15px; font-weight:600; margin:0 0 6px 0; }
.news-body { font-size:13px; white-space:pre-wrap; }
@@ -14,13 +14,13 @@
.news-validity.active { background:var(--warn-weak); color:var(--warn); }
</style>
{{if .Flash}}<div class="card" style="border-left:3px solid var(--accent)"><p style="margin:0">{{.Flash}}</p></div>{{end}}
{{if .Flash}}<div class="card" style="background:var(--accent-weak)"><p style="margin:0">{{.Flash}}</p></div>{{end}}
<div class="card">
<h2>Новости и события</h2>
<p class="muted">События системы, окна техработ НРД, обновления документации и сертификатов. Лента не очищается — служит журналом для аудита. Скрытые новости можно посмотреть, сняв галочку «Только активные».</p>
<form method="post" action="/admin/news/check-docs" style="margin-top:10px;display:flex;gap:8px;align-items:center;flex-wrap:wrap">
<button type="submit" class="btn">🔄 Проверить обновления документации НРД сейчас</button>
<button type="submit" class="btn">Проверить обновления документации НРД сейчас</button>
{{if not .Settings.News.LastDocCheck.IsZero}}
<span class="muted" style="font-size:12px">Последняя проверка: {{.Settings.News.LastDocCheck.Format "02.01.2006 15:04:05"}}</span>
{{end}}
@@ -0,0 +1,32 @@
{{define "content"}}
{{template "npr_css"}}
<script src="/admin/npr/static/htmx.min.js"></script>
<div class="doc-header" style="margin-bottom:12px"><h1>📊 Риск-монитор: НПР и УДС клиентов</h1><p class="lede">НПР1 / НПР2 / УДС клиентов маржинальной торговли по данным экспорта QUIK. Модуль перенесён как есть, доработки — по мере необходимости.</p></div>
<nav class="ntabs">
<a href="?tab=margin" class="{{if eq .Tab "margin"}}active{{end}}">Маржинальный риск <span class="count" id="tab_margin_count">—</span></a>
<a href="?tab=debt" class="{{if eq .Tab "debt"}}active{{end}}">Дебиторская задолженность <span class="count" id="tab_debt_count">—</span></a>
</nav>
{{if eq .Tab "margin"}}<p class="tab-desc">Клиенты с открытыми маржинальными позициями (есть начальная или минимальная маржа). У кого УДС близок к 1 — под маржин-колл, у кого &lt;0 — под принудительное закрытие.</p>
{{else}}<p class="tab-desc">Клиенты без открытых позиций, но с отрицательной стоимостью портфеля (должны брокеру). УДС у таких счетов в QUIK уходит в −9,99 — это задолженность, а не маржин-колл.</p>{{end}}
<div class="nhelp"><strong>УДС</strong> — уровень достаточности средств (чем меньше, тем опаснее). <strong>НПР1</strong> — запас относительно начальной маржи (отрицательный — запрещено наращивать позиции). <strong>НПР2</strong> — запас относительно минимальной маржи (отрицательный — начинается принудительное закрытие).
<div class="legend"><span class="legend-item"><span class="legend-box" style="background:#fee2e2"></span> маржин-колл (УДС&lt;1)</span><span class="legend-item"><span class="legend-box" style="background:#fef3c7"></span> внимание (УДС&lt;1,5)</span><span class="legend-item"><span class="legend-box" style="background:#d1fae5"></span> «∞» избыточно (УДС≥9,99)</span></div></div>
<section class="nsummary" hx-get="/admin/npr/api/summary?tab={{.Tab}}" hx-trigger="load, every 60s" hx-swap="none" hx-on::after-request="try{const d=JSON.parse(event.detail.xhr.responseText);for(const [k,v] of Object.entries({sum_total:d.total,sum_calls:d.calls,sum_warn:d.warn,sum_ok:d.ok,sum_inf:d.inf,tab_margin_count:d.tab_margin,tab_debt_count:d.tab_debt})){const e=document.getElementById(k);if(e)e.textContent=v;}}catch(e){}">
<article><header>всего клиентов</header><strong id="sum_total">—</strong></article>
<article class="tile-call"><header>маржин-колл (УДС&lt;1)</header><strong id="sum_calls">—</strong></article>
<article class="tile-warn"><header>внимание (УДС&lt;1,5)</header><strong id="sum_warn">—</strong></article>
<article class="tile-ok"><header>норма</header><strong id="sum_ok">—</strong></article>
<article class="tile-ok"><header>избыточно (∞)</header><strong id="sum_inf">—</strong></article>
</section>
<div class="nfresh" hx-get="/admin/npr/api/freshness" hx-trigger="load, every 15s" hx-swap="none" hx-on::after-request="try{const d=JSON.parse(event.detail.xhr.responseText);const now=d.server_now?new Date(d.server_now):new Date();const tick=d.last_tick?new Date(d.last_tick):null;const ago=tick?Math.round((now-tick.getTime())/1000):null;const ok=ago!==null&&ago<90;document.getElementById('fresh_dot').style.background=ok?'#10b981':'#dc2626';document.getElementById('fresh_text').textContent=tick?('Последнее обновление из QUIK: '+tick.toLocaleString('ru-RU')+' ('+ago+' сек. назад)'):'Ждём первый тик от экспортёра…';const ev=d.last_event_ts?new Date(d.last_event_ts):null;document.getElementById('fresh_detail').textContent='События в истории: всего '+d.total_rows.toLocaleString('ru-RU')+' · за час: '+d.events_last_hour+' · за сутки: '+d.events_last_24h+(ev?' · последнее: '+ev.toLocaleString('ru-RU'):'');}catch(e){}">
<span id="fresh_dot" style="width:10px;height:10px;border-radius:50%;background:#9ca3af;display:inline-block"></span><span id="fresh_text">Проверяю актуальность данных…</span><span class="muted" id="fresh_detail" style="margin-left:auto"></span></div>
<form id="filters" class="nfilters" hx-get="/admin/npr/rows" hx-trigger="input changed delay:300ms, change" hx-target="#rows" hx-swap="innerHTML" onsubmit="return false">
<label>Поиск по коду клиента<input type="search" name="q" placeholder="например, BR22…" value="{{.Q}}"></label>
<label>Тип клиента<select name="mt" multiple size="3">{{range $v := list "МД" "МД+" "С"}}<option value="{{$v}}" {{if has $.MT $v}}selected{{end}}>{{$v}}</option>{{end}}</select></label>
<label>УДС не менее<input type="number" step="0.01" name="umn" value="{{.UMN}}"></label>
<label>УДС не более<input type="number" step="0.01" name="umx" value="{{.UMX}}"></label>
<label style="align-self:center;white-space:nowrap;display:flex;gap:6px;align-items:center"><input type="checkbox" name="only_calls" value="true" {{if .OnlyCalls}}checked{{end}} style="width:auto"> только маржин-колл</label>
<label>Сортировка<select name="sort">{{range $k, $t := dict "uds_asc" "УДС ↑ (опаснее сверху)" "uds_desc" "УДС ↓" "client_asc" "Код клиента ↑" "client_desc" "Код клиента ↓" "ts_desc" "Время обновления ↓"}}<option value="{{$k}}" {{if eq $.Sort $k}}selected{{end}}>{{$t}}</option>{{end}}</select></label>
<input type="hidden" name="page" value="{{.Page}}"><input type="hidden" name="tab" value="{{.Tab}}">
</form>
<div id="rows" hx-get="/admin/npr/rows" hx-trigger="load, every 60s" hx-include="#filters" hx-swap="innerHTML"><p class="muted"><em>Загружаю клиентов…</em></p></div>
{{end}}
@@ -0,0 +1,47 @@
{{define "content"}}
{{template "npr_css"}}
<p style="margin:0 0 12px;display:flex;align-items:center;gap:12px"><a href="/admin/npr" class="btn">← К списку клиентов</a>{{if not .Custom}}<span class="muted" id="autoupd" style="margin-left:auto">⟳ Страница обновится автоматически через 60 сек</span>{{end}}</p>
{{if not .Custom}}<script>(function(){let left=60;const el=document.getElementById('autoupd');const t=setInterval(function(){left-=1;if(el)el.textContent='⟳ Обновление через '+left+' сек';if(left<=0){clearInterval(t);location.reload();}},1000);})();</script>{{end}}
<div class="card">
<div style="display:flex;align-items:baseline;gap:12px;flex-wrap:wrap"><h2 style="margin:0">Клиент <code style="font-size:1.1em">{{.C.ClientCode}}</code></h2><span class="muted">Тип: <strong>{{if .C.MarginType}}{{.C.MarginType}}{{else}}—{{end}}</strong> · Данные на {{.C.TS.Local.Format "02.01.2006 15:04:05"}}</span></div>
<section class="nmetrics">
<article class="{{if or (eq .C.UDSFlag "CALL") (and .C.UDS (lt (deref .C.UDS) 1.0))}}m-call{{else if eq .C.UDSFlag "INF"}}m-ok{{end}}"><header>Уровень достаточности средств (УДС)</header><strong>{{if .C.UDS}}{{nprF .C.UDS}}{{else if eq .C.UDSFlag "INF"}}∞{{else if eq .C.UDSFlag "CALL"}}маржин-колл{{else}}—{{end}}</strong>{{if .C.BadgeClass}}<div style="margin-top:4px"><span class="badge {{.C.BadgeClass}}">{{.C.BadgeText}}</span></div>{{end}}</article>
<article><header>НПР1 (запас до начальной маржи)</header><strong>{{nprF .C.RCV1}}</strong></article>
<article><header>НПР2 (запас до минимальной маржи)</header><strong>{{nprF .C.RCV2}}</strong></article>
<article><header>Стоимость портфеля</header><strong>{{nprF .C.PortfolioValue}}</strong></article>
<article><header>Маржинальное требование</header><strong>{{nprF .C.MarginReq}}</strong></article>
</section>
<section style="display:grid;grid-template-columns:repeat(3,1fr);gap:10px;margin:12px 0">
<div class="nmini"><header>Начальная маржа</header><strong>{{nprF .C.InitMargin}}</strong></div>
<div class="nmini"><header>Минимальная маржа</header><strong>{{nprF .C.MinMargin}}</strong></div>
<div class="nmini"><header>Скорректированная маржа</header><strong>{{nprF .C.CorrMargin}}</strong></div>
</section>
</div>
<div class="card">
<h3 style="margin:0 0 8px">История изменений</h3>
<div style="display:flex;gap:6px;align-items:center;flex-wrap:wrap;margin-bottom:8px">{{range .Periods}}<a class="btn{{if and (eq $.Hours .H) (not $.Custom)}} btn-ok{{end}}" href="?hours={{.H}}">{{.T}}</a>{{end}}<span class="muted" style="margin-left:auto">или свой диапазон (МСК):</span></div>
<form method="get" action="" style="display:flex;gap:8px;align-items:end;flex-wrap:wrap;margin-bottom:8px">
<label style="margin:0;font-size:12px">С<br><input type="datetime-local" name="dt_from" value="{{.DtFrom}}"></label>
<label style="margin:0;font-size:12px">По<br><input type="datetime-local" name="dt_to" value="{{.DtTo}}"></label>
<button type="submit" class="btn">Показать</button><a class="btn" href="?hours=24">Сбросить</a>
<a class="btn" style="margin-left:auto" href="/admin/npr/c/{{.C.ClientCode}}/export.csv?{{if .Custom}}dt_from={{.DtFrom}}&dt_to={{.DtTo}}{{else}}hours={{.Hours}}{{end}}">⤓ Скачать CSV (для регулятора)</a>
</form>
<p class="muted">Показано: с <strong>{{.TF.Local.Format "02.01.2006 15:04"}}</strong> по <strong>{{.TT.Local.Format "02.01.2006 15:04"}}</strong> (МСК).</p>
<canvas id="chart" style="max-height:340px;margin-bottom:14px"></canvas>
<h4 style="margin:0 0 6px">Последние {{len .History}} записей</h4>
<table class="ntable"><thead><tr><th>Время</th><th class="num">УДС</th><th>Статус</th><th class="num">НПР1</th><th class="num">НПР2</th><th class="num">Марж. требование</th></tr></thead>
<tbody>{{range .History}}<tr class="{{.Class}}"><td><small>{{.TS.Local.Format "02.01.2006 15:04:05"}}</small></td><td class="num">{{nprF .UDS}}</td><td>{{if .BadgeClass}}<span class="badge {{.BadgeClass}}">{{.BadgeText}}</span>{{else}}—{{end}}</td><td class="num">{{nprF .RCV1}}</td><td class="num">{{nprF .RCV2}}</td><td class="num">{{nprF .MarginReq}}</td></tr>{{end}}</tbody></table>
</div>
<script src="/admin/npr/static/chart.umd.min.js"></script>
<script>
fetch("/admin/npr/c/{{.C.ClientCode}}/series?{{if .Custom}}dt_from={{.DtFrom}}&dt_to={{.DtTo}}{{else}}hours={{.Hours}}{{end}}").then(r=>r.json()).then(d=>{
const labels=d.labels.map(s=>new Date(s).toLocaleString('ru-RU',{day:'2-digit',month:'2-digit',hour:'2-digit',minute:'2-digit'}));
new Chart(document.getElementById('chart'),{type:'line',data:{labels:labels,datasets:[
{label:'НПР1',data:d.rcv1,borderColor:'#059669',backgroundColor:'#05966920',tension:.2,spanGaps:true,yAxisID:'y',pointRadius:1.5},
{label:'НПР2',data:d.rcv2,borderColor:'#2563eb',backgroundColor:'#2563eb20',tension:.2,spanGaps:true,yAxisID:'y',pointRadius:1.5},
{label:'УДС',data:d.uds,borderColor:'#dc2626',backgroundColor:'#dc262620',tension:.2,spanGaps:true,yAxisID:'y2',pointRadius:1.5,borderDash:[5,3]}]},
options:{animation:false,responsive:true,maintainAspectRatio:false,interaction:{mode:'index',intersect:false},plugins:{legend:{position:'top'},tooltip:{callbacks:{label:c=>c.dataset.label+': '+(c.parsed.y===null?'—':c.parsed.y.toLocaleString('ru-RU',{maximumFractionDigits:2}))}}},
scales:{y:{position:'left',title:{display:true,text:'НПР'}},y2:{position:'right',title:{display:true,text:'УДС'},grid:{drawOnChartArea:false},suggestedMin:-1,suggestedMax:3}}}});
});
</script>
{{end}}
@@ -0,0 +1,7 @@
{{define "npr_rows"}}
<p class="muted">Найдено клиентов: <strong>{{.Total}}</strong>.{{if .Pages}} Страница <strong>{{.Page}}</strong> из <strong>{{.Pages}}</strong>.{{end}}</p>
<table class="ntable"><thead><tr><th>Код клиента</th><th>Тип</th><th class="num">УДС</th><th>Статус</th><th class="num">НПР1</th><th class="num">НПР2</th><th class="num">Марж. требование</th><th>Обновлено</th></tr></thead>
<tbody>{{range .Rows}}<tr class="{{.Class}}"><td><a href="/admin/npr/c/{{.ClientCode}}">{{.ClientCode}}</a></td><td>{{if .MarginType}}{{.MarginType}}{{else}}—{{end}}</td><td class="num"><strong>{{nprF .UDS}}</strong></td><td>{{if .BadgeClass}}<span class="badge {{.BadgeClass}}">{{.BadgeText}}</span>{{else}}—{{end}}</td><td class="num">{{nprF .RCV1}}</td><td class="num">{{nprF .RCV2}}</td><td class="num">{{nprF .MarginReq}}</td><td><small>{{.TS.Local.Format "15:04:05"}}</small></td></tr>{{end}}
{{if not .Rows}}<tr><td colspan="8" class="muted" style="text-align:center;padding:2rem"><em>Ни одного клиента под этот фильтр не найдено</em></td></tr>{{end}}</tbody></table>
<nav style="display:flex;gap:8px;justify-content:center;margin-top:10px">{{if gt .Page 1}}<button class="btn" hx-get="/admin/npr/rows" hx-include="#filters" hx-vals='{"page": {{add .Page -1}} }' hx-target="#rows">← Предыдущая</button>{{end}}{{if .HasNext}}<button class="btn" hx-get="/admin/npr/rows" hx-include="#filters" hx-vals='{"page": {{add .Page 1}} }' hx-target="#rows">Следующая →</button>{{end}}</nav>
{{end}}
@@ -0,0 +1,99 @@
{{define "content"}}
{{if .Flash}}<div style="padding:12px 16px;background:var(--ok-weak);border-radius:8px;margin-bottom:16px">{{.Flash}}</div>{{end}}
<div class="card">
<style>
.qk-steps{display:flex;gap:8px;flex-wrap:wrap;margin:8px 0}
.qk-step{padding:6px 10px;border-radius:9px;border:1.5px solid var(--border);font-size:12.5px;background:var(--card-2)}
.qk-step.ok{border-color:color-mix(in srgb,#16a34a 45%,transparent);background:color-mix(in srgb,#16a34a 12%,var(--card));color:#0f7a37;font-weight:600}
.qk-step.bad{border-color:color-mix(in srgb,#dc2626 45%,transparent);background:color-mix(in srgb,#dc2626 10%,var(--card));color:#b3261e;font-weight:600}
.qk-state{display:inline-block;padding:3px 10px;border-radius:999px;font-size:12px;font-weight:700}
.qk-state.registered{background:color-mix(in srgb,#d97706 15%,var(--card));color:#9a6b12}
.qk-state.signed,.qk-state.deposited{background:color-mix(in srgb,#16a34a 15%,var(--card));color:#0f7a37}
.qk-state.denied{background:var(--card-2);color:var(--muted)}
.qk-state.error{background:color-mix(in srgb,#dc2626 15%,var(--card));color:#b3261e}
.qk-state.manual{background:color-mix(in srgb,#2563eb 12%,var(--card));color:#1d4ed8}
</style>
<h2>Цикл M2M: поручения, которые ведёт bj</h2>
<p class="muted" style="font-size:12.5px">Записи в МНП: <b>{{if .Cycle.WritesEnabled}}включены{{else}}выключены{{end}}</b> · исходящие: {{if .Cycle.AutoOut}}авто{{else}}вручную{{end}} · входящие: {{if .Cycle.AutoIn}}авто{{else}}вручную{{end}} · поручения из терминала: {{if .Cycle.PollTerminal}}подхватываются{{else}}не отслеживаются{{end}} — <a href="/admin/setup#sec-quik-cycle">настроить</a>.</p>
{{if not .Links}}<p class="muted">Пока пусто.</p>{{else}}
<table style="font-size:12.5px">
<thead><tr><th>Когда</th><th>Перевод</th><th>Направление</th><th>Клиент</th><th>Бумага</th><th>Поручение МНП</th><th>Состояние</th><th>Последний шаг</th></tr></thead>
<tbody>{{range .Links}}<tr>
<td style="white-space:nowrap">{{msk .UpdatedAt}}</td>
<td>{{if .IsTerminal}}<span class="muted">из терминала</span>{{else}}<a href="/admin/claims?guid={{.GUID}}"><code>{{short 8 .GUID}}</code></a>{{end}}</td>
<td>{{if eq .Direction "out"}}↗ вывод{{else}}↘ ввод{{end}}</td>
<td><code>{{.ClientID}}</code>{{if .Contract}} <span class="muted">{{.Contract}}</span>{{end}}</td>
<td><code>{{.ISIN}}</code> × {{.Qty}}</td>
<td>{{if .InstrNo}}<b>№{{.InstrNo}}</b>{{else}}—{{end}}</td>
<td><span class="qk-state {{.State}}">{{.StateRu}}</span>{{if .Err}}<div style="color:#b3261e;font-size:11.5px">{{.Err}}</div>{{end}}</td>
<td class="muted" style="font-size:11.5px">{{with .LastStep}}{{.Action}}: {{.Reply}}{{end}}</td>
</tr>{{end}}</tbody>
</table>{{end}}
</div>
{{if .Cycle.PollTerminal}}
<div class="hero">
<h1 class="hero-greeting">Поручения QUIK (M2M)</h1>
<span class="hero-status">Неторговые поручения на вывод/ввод ЦБ из Модуля неторговых поручений QUIK. Бумаги по ним уже заблокированы на позиции клиента. Режим — только чтение{{if .DBName}} (БД {{.DBName}}){{end}}.</span>
</div>
<div class="card">
<h2>Как это работает</h2>
<p class="muted" style="line-height:1.7">
Клиент подаёт «Вывод ЦБ (M2M)» из терминала QUIK / мобильного / ЛК → сервер QUIK проверяет остаток и <strong>блокирует бумаги</strong> → поручение попадает в экспортную таблицу МНП → <strong>bj читает его отсюда</strong>, проверяет по депозитарию и отправляет перевод в НРД (MOEX МОСТ) → по ответу НРД поручение <em>подписывается</em> (списание) либо <em>снимается</em> (разблокировка).
<br>Сейчас реализовано <strong>чтение</strong>; запись (подпись/снятие/регистрация) — следующий этап.</p>
</div>
<div class="card">
<h2>
{{if .OnlyActive}}Активные поручения{{else}}Все поручения M2M{{end}} ({{len .Items}})
<span style="float:right;font-size:13px;font-weight:400">
{{if .OnlyActive}}<a href="/admin/quik?all=1">показать все</a>{{else}}<a href="/admin/quik">только активные</a>{{end}}
· <a href="/admin/setup#quik">настройки</a>
</span>
</h2>
{{if .Error}}<p style="color:var(--err)">{{.Error}}</p>{{end}}
{{if .Items}}
<div style="overflow-x:auto">
<table>
<thead><tr>
<th>№ / ID</th><th>Зарегистрировано</th><th>Клиент</th><th>Договор</th>
<th>Бумага (ISIN)</th><th>Кол-во</th><th>Заблок.</th><th>Депозитарий получателя (ИНН)</th><th>Статус</th>
</tr></thead>
<tbody>
{{range .Items}}
<tr>
<td><code>{{.Number}}</code><br><span class="muted" style="font-size:11px">id {{.InstrID}}{{if .ExtID}} · ext {{.ExtID}}{{end}}</span></td>
<td style="white-space:nowrap">{{.Registered}}</td>
<td><code>{{.ClientID}}</code></td>
<td>{{.AgreeServ}}</td>
<td>{{if .ISIN}}<code>{{.ISIN}}</code>{{end}}{{if .SecCode}}<br><span class="muted" style="font-size:11px">{{.SecCode}} / {{.ClassCode}}</span>{{end}}</td>
<td style="text-align:right">{{.Volume}}</td>
<td style="text-align:right">{{if .QtyBlocked}}<b>{{.QtyBlocked}}</b>{{else}}—{{end}}</td>
<td>{{if .RecipINN}}<code>{{.RecipINN}}</code>{{else}}<span class="muted">—</span>{{end}}
{{if .PassExpense}}<br><span class="muted" style="font-size:11px">+ расходы</span>{{end}}
{{if .CloseIIA}}<br><span class="muted" style="font-size:11px">закрытие ИИС</span>{{end}}</td>
<td>
{{if eq .Status 0}}<span class="badge awaiting_decision">{{.StatusName}}</span>
{{else if eq .Status 1}}<span class="badge confirmed">{{.StatusName}}</span>
{{else}}<span class="badge rejected">{{.StatusName}}</span>{{end}}
{{if .Deny}}<br><span class="muted" style="font-size:11px">{{.Deny}}</span>{{end}}
</td>
</tr>
{{end}}
</tbody>
</table>
</div>
{{else if not .Error}}
<p class="muted">Поручений M2M пока нет{{if .OnlyActive}} в активном статусе — <a href="/admin/quik?all=1">посмотреть все</a>{{end}}. Они появятся, когда клиент подаст «Вывод ЦБ (M2M)» в QUIK.</p>
{{end}}
</div>
{{else}}
<div class="card">
<h2>Поручения из терминала QUIK — не отслеживаются</h2>
<p class="muted" style="line-height:1.6">Опция «Принимаем поручения M2M из QUIK» выключена: клиентам и фирме не разрешена подача «Вывод ЦБ (M2M)» из терминала / мобильного приложения, поэтому bj их не опрашивает и не показывает. Все переводы ведутся через заявки bj (таблица выше).
<br>Если брокер откроет клиентам эту возможность (классы INSTROUTM2M/INSTRINM2M в QuikAdmin), включите опцию в <a href="/admin/setup#quik">Настройка → QUIK МНП → Цикл M2M</a> — bj начнёт подхватывать такие поручения и создавать по ним заявки оператору.
<br><span style="font-size:12px">В перспективе: при подключении модуля управления правами клиентов (QuikAdmin) опция будет назначаться клиенту автоматически вместе с нужными классами.</span></p>
</div>
{{end}}
{{end}}
File diff suppressed because it is too large Load Diff
@@ -16,7 +16,7 @@
.wstep.current .lbl { color:var(--accent); font-weight:650; }
.tooltip { display:inline-block; background:var(--border); color:var(--muted); border-radius:50%; width:16px; height:16px; line-height:16px; text-align:center; font-size:11px; cursor:help; margin-left:4px; }
.where { font-size:12px; color:var(--accent); margin-left:8px; }
.help-block { background:var(--accent-weak); border-left:3px solid var(--accent); padding:10px 14px; margin:10px 0; font-size:13px; border-radius:0 8px 8px 0; }
.help-block { background:var(--accent-weak); background:var(--accent-weak); padding:10px 14px; margin:10px 0; font-size:13px; border-radius:0 8px 8px 0; }
.help-block strong { color:var(--accent); }
.wiz-card { max-width:680px; margin:0 auto; }
</style>
@@ -34,7 +34,7 @@
<div class="wstep {{if .Done.TestRun}}done{{end}} {{if eq .Step 5}}current{{end}}"><span class="bub">{{if .Done.TestRun}}✓{{else}}5{{end}}</span><span class="lbl">Проверка</span></div>
</div>
{{if .Flash}}<div class="card wiz-card" style="border-left:3px solid var(--accent)"><p style="margin:0">{{.Flash}}</p></div>{{end}}
{{if .Flash}}<div class="card wiz-card" style="background:var(--accent-weak)"><p style="margin:0">{{.Flash}}</p></div>{{end}}
<div class="wiz-card">
{{/* ============= ШАГ 1: PostgreSQL ============= */}}
@@ -52,7 +52,7 @@
<h3 style="margin:0 0 8px 0;font-size:15px">Вариант А — для тех, у кого нет своего PostgreSQL</h3>
<p class="muted" style="margin:0 0 10px 0">Bridge-and-Join-s сам поднимет PostgreSQL в контейнере (podman-compose), создаст БД <code>bj</code> и накатит миграции. Подходит для дев-стенда. Для продакшена лучше указать свой DSN.</p>
<form method="post" action="/admin/setup/postgres/quick-start" style="margin:0">
<button type="submit" class="btn" style="background:var(--ok)">⚡ Поднять локальный PostgreSQL автоматически</button>
<button type="submit" class="btn" style="background:var(--ok)">Поднять локальный PostgreSQL автоматически</button>
<span class="muted" style="margin-left:10px;font-size:12px">~10-30 сек</span>
</form>
</div>
+274 -66
View File
@@ -6,32 +6,42 @@
<title>{{.Title}} · Bridge&Join</title>
<style>
/* ===================== Дизайн-система ===================== */
/* Светлая тема (по умолчанию) */
/* «Диспетчерская депозитария»: приборная панель оператора НРД/M2M.
Restrained — прохладные нейтрали + один индиго-акцент; MOEX-красный
зарезервирован строго под знак бренда и критику. Все величины —
моноширинными табличными цифрами (приборная подпись). */
/* Светлая тема (дневной свет, рабочее место) */
:root {
--bg:#f5f6f8; --bg-elev:#ffffff; --card:#ffffff; --card-2:#fafbfc;
--border:#e4e7ec; --border-strong:#d0d5dd;
--text:#1a1f29; --text-2:#475067; --muted:#7a8499;
--accent:#2563eb; --accent-weak:rgba(37,99,235,0.10); --accent-strong:#1d4ed8;
--ok:#16a34a; --ok-weak:rgba(22,163,74,0.12);
--warn:#d97706; --warn-weak:rgba(217,119,6,0.12);
--err:#dc2626; --err-weak:rgba(220,38,38,0.12);
--brand:#c5203e; /* MOEX-красный для акцентов бренда */
--shadow:0 1px 2px rgba(16,24,40,0.06), 0 1px 3px rgba(16,24,40,0.10);
--shadow-lg:0 8px 24px rgba(16,24,40,0.12);
--radius:10px; --radius-sm:6px;
--bg:#eef1f6; --bg-elev:#ffffff; --card:#ffffff; --card-2:#f5f7fb;
--border:#e1e6ef; --border-strong:#cbd3e1;
--text:#111725; --text-2:#3d465a; --muted:#69748a;
--accent:#3452d6; --accent-weak:rgba(52,82,214,0.09); --accent-strong:#2740b0;
--ok:#12813f; --ok-weak:rgba(18,129,63,0.11);
--warn:#9a5b06; --warn-weak:rgba(154,91,6,0.12);
--err:#c22a2a; --err-weak:rgba(194,42,42,0.10);
--brand:#c5203e; /* MOEX-красный — только знак бренда и критика */
--grid-line:rgba(17,23,37,0.045);
--shadow:0 1px 2px rgba(16,24,40,0.05), 0 2px 6px rgba(16,24,40,0.06);
--shadow-lg:0 12px 34px rgba(16,24,40,0.14);
--radius:11px; --radius-sm:7px; --radius-lg:15px;
--mono:ui-monospace,"SF Mono","JetBrains Mono",Menlo,Consolas,monospace;
--ease:cubic-bezier(.2,.7,.2,1);
}
/* Тёмная тема */
/* Тёмная тема (терминал диспетчерской) */
[data-theme="dark"] {
--bg:#0f1115; --bg-elev:#161922; --card:#1a1d24; --card-2:#20242e;
--border:#2a2f3a; --border-strong:#3a4150;
--text:#e8eaed; --text-2:#b4bcc9; --muted:#8b94a3;
--accent:#5b9dff; --accent-weak:rgba(91,157,255,0.14); --accent-strong:#7db0ff;
--ok:#3fbf6c; --ok-weak:rgba(63,191,108,0.16);
--warn:#e8b13a; --warn-weak:rgba(232,177,58,0.16);
--err:#e85a5a; --err-weak:rgba(232,90,90,0.16);
--brand:#ff5a78;
--shadow:0 1px 2px rgba(0,0,0,0.3); --shadow-lg:0 8px 28px rgba(0,0,0,0.5);
--bg:#0a0d13; --bg-elev:#0f141d; --card:#131926; --card-2:#19212f;
--border:#232c3d; --border-strong:#354057;
--text:#e8edf5; --text-2:#aab6c9; --muted:#828fa6;
--accent:#7089ff; --accent-weak:rgba(112,137,255,0.15); --accent-strong:#94a7ff;
--ok:#3fce74; --ok-weak:rgba(63,206,116,0.15);
--warn:#e2ab41; --warn-weak:rgba(226,171,65,0.16);
--err:#ff7d7d; --err-weak:rgba(255,125,125,0.15);
--brand:#ff5670;
--grid-line:rgba(255,255,255,0.035);
--shadow:0 1px 2px rgba(0,0,0,0.4); --shadow-lg:0 14px 38px rgba(0,0,0,0.55);
}
/* Приборные цифры: величины (ID, счётчики, время, коды) — моно, табличные. */
.num, .stat-value, .clock, td .id, .mono { font-variant-numeric:tabular-nums; }
* { box-sizing: border-box; }
html, body { margin:0; padding:0; }
body {
@@ -40,31 +50,56 @@ body {
-webkit-font-smoothing: antialiased;
}
/* ---- Шапка ---- */
/* ---- Шапка (панель управления) ---- */
.topbar {
display:flex; align-items:center; gap:20px;
padding:0 24px; height:58px;
display:flex; align-items:center; gap:18px;
padding:0 22px; height:56px;
background: var(--bg-elev); border-bottom:1px solid var(--border);
position:sticky; top:0; z-index:100;
}
.brand { display:flex; align-items:center; gap:9px; font-weight:700; font-size:16px; letter-spacing:-0.01em; }
.brand .logo { width:26px; height:26px; border-radius:7px; background:linear-gradient(135deg,var(--accent),var(--brand)); display:inline-block; }
.nav { display:flex; align-items:center; gap:2px; margin-left:8px; }
.nav .group-label { font-size:10px; text-transform:uppercase; letter-spacing:0.06em; color:var(--muted); padding:0 8px 0 14px; border-left:1px solid var(--border); margin-left:6px; }
.brand { display:flex; align-items:center; gap:10px; font-weight:680; font-size:15.5px; letter-spacing:-0.015em; color:var(--text); flex:none; }
.brand svg { width:26px; height:26px; display:block; flex:none; }
.brand b { font-weight:730; } .brand .brand-j { color:var(--muted); font-weight:600; }
.nav { display:flex; align-items:center; gap:1px; margin-left:6px; flex:1 1 auto; min-width:0; flex-wrap:wrap; }
.nav::-webkit-scrollbar { display:none; }
.user-chip { display:inline-flex; flex-direction:column; line-height:1.15; margin-right:6px; max-width:180px; }
.user-chip .user-name { font-size:12.5px; font-weight:600; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.user-chip .user-role { font-size:10px; color:var(--muted); text-transform:uppercase; letter-spacing:.05em; }
.user-logout { font-size:12px; color:var(--muted); text-decoration:none; padding:4px 8px; border:1px solid var(--border); border-radius:8px; margin-right:6px; }
.user-logout:hover { color:var(--text); border-color:var(--border-strong); }
.nav .group-label { font-size:9.5px; text-transform:uppercase; letter-spacing:0.09em; color:var(--muted); padding:0 9px 0 15px; border-left:1px solid var(--border); margin-left:7px; font-weight:600; }
.nav a {
color:var(--text-2); text-decoration:none; font-size:13.5px; font-weight:500;
padding:7px 11px; border-radius:7px; white-space:nowrap;
color:var(--text-2); text-decoration:none; font-size:13px; font-weight:500;
padding:7px 11px; border-radius:8px; white-space:nowrap; transition:background .12s var(--ease), color .12s var(--ease);
}
.nav a:hover { background:var(--card-2); color:var(--text); }
.nav a.active { background:var(--accent-weak); color:var(--accent); }
.topbar-right { margin-left:auto; display:flex; align-items:center; gap:14px; }
.nav a.active { background:var(--accent-weak); color:var(--accent-strong); font-weight:600; }
.nav .dd { position:relative; }
.nav .dd .ddbtn { display:inline-block; cursor:pointer; color:var(--text-2); font-size:13px; font-weight:500;
padding:7px 11px; border-radius:8px; white-space:nowrap; transition:background .12s var(--ease); }
.nav .dd:hover .ddbtn, .nav .dd.active .ddbtn { background:var(--accent-weak); color:var(--accent-strong); font-weight:600; }
.nav .dd:hover .ddmenu, .nav .dd:focus-within .ddmenu { display:block; }
/* невидимый «мост» под кнопкой — чтобы при спуске мыши к меню hover не срывался */
.nav .dd::after { content:''; position:absolute; top:100%; left:0; right:0; height:10px; }
.nav .ddmenu { display:none; position:absolute; top:100%; left:0; margin-top:8px; background:var(--card); border:1px solid var(--border);
border-radius:10px; box-shadow:0 8px 30px rgba(0,0,0,.14); min-width:210px; z-index:60; padding:6px; }
.nav .ddmenu a { display:block; padding:8px 12px; border-radius:7px; }
.topbar-right { display:flex; align-items:center; gap:12px; flex:none; padding-left:10px; }
@media (max-width:1200px){ .sys-status span:not(.pulse){ display:none } .sys-status{ padding:5px 8px } .topbar .clock{ display:none } }
/* Строка системного статуса — состояние читается всегда */
.sys-status { display:inline-flex; align-items:center; gap:7px; font-size:12px; font-weight:600; color:var(--text-2); padding:5px 11px 5px 9px; border:1px solid var(--border); border-radius:20px; background:var(--card-2); white-space:nowrap; }
.sys-status .pulse { width:8px; height:8px; border-radius:50%; background:var(--ok); box-shadow:0 0 0 0 var(--ok-weak); animation:pulse 2.4s var(--ease) infinite; }
.sys-status.warn .pulse { background:var(--warn); } .sys-status.err .pulse { background:var(--err); }
@keyframes pulse { 0%{box-shadow:0 0 0 0 rgba(18,129,63,.35)} 70%{box-shadow:0 0 0 6px rgba(18,129,63,0)} 100%{box-shadow:0 0 0 0 rgba(18,129,63,0)} }
@media (prefers-reduced-motion: reduce){ .sys-status .pulse{ animation:none } }
.theme-toggle {
background:var(--card-2); border:1px solid var(--border); color:var(--text-2);
width:34px; height:34px; border-radius:8px; cursor:pointer; font-size:15px;
display:flex; align-items:center; justify-content:center; padding:0;
width:34px; height:34px; border-radius:9px; cursor:pointer;
display:flex; align-items:center; justify-content:center; padding:0; transition:background .12s var(--ease), color .12s;
}
.theme-toggle svg { width:16px; height:16px; }
.theme-toggle:hover { background:var(--border); color:var(--text); }
.topbar .clock { font-size:12.5px; color:var(--muted); font-variant-numeric:tabular-nums; }
.topbar .clock { font-size:12px; color:var(--muted); font-family:var(--mono); font-variant-numeric:tabular-nums; letter-spacing:-0.02em; }
/* ---- Контент ---- */
main { padding:24px; max-width:1200px; margin:0 auto; }
@@ -83,10 +118,12 @@ a:hover { text-decoration:underline; }
.card h2:first-child { margin-top:0; }
.grid { display:grid; gap:14px; grid-template-columns:repeat(auto-fit, minmax(220px,1fr)); }
/* ---- Статы ---- */
.stat { padding:16px 18px; background:var(--card); border:1px solid var(--border); border-radius:var(--radius); box-shadow:var(--shadow); }
.stat-label { font-size:11px; color:var(--muted); text-transform:uppercase; letter-spacing:.05em; font-weight:600; }
.stat-value { font-size:26px; font-weight:700; margin-top:6px; letter-spacing:-0.02em; }
/* ---- Статы (приборные читалки) ---- */
.stat { padding:15px 17px; background:var(--card); border:1px solid var(--border); border-radius:var(--radius); box-shadow:var(--shadow); }
.stat-label { display:flex; align-items:center; gap:7px; font-size:10.5px; color:var(--muted); text-transform:uppercase; letter-spacing:.06em; font-weight:600; }
.stat-label::before { content:""; width:7px; height:7px; border-radius:50%; background:var(--border-strong); flex:none; }
.stat.ok .stat-label::before { background:var(--ok); } .stat.warn .stat-label::before { background:var(--warn); } .stat.err .stat-label::before { background:var(--err); } .stat.accent .stat-label::before { background:var(--accent); }
.stat-value { font-size:27px; font-weight:700; margin-top:6px; letter-spacing:-0.03em; font-family:var(--mono); font-variant-numeric:tabular-nums; }
/* ---- Индикаторы ---- */
.dot { display:inline-block; width:8px; height:8px; border-radius:50%; margin-right:7px; vertical-align:middle; }
@@ -152,13 +189,15 @@ label { font-size:13px; font-weight:500; }
text-decoration:none; color:var(--text); transition:transform .14s, box-shadow .14s, border-color .14s;
min-height:128px;
}
.tile:hover { transform:translateY(-3px); box-shadow:var(--shadow-lg); border-color:var(--accent); text-decoration:none; }
.tile .ico { width:46px; height:46px; border-radius:12px; display:flex; align-items:center; justify-content:center; font-size:24px; background:var(--accent-weak); }
.tile.brand .ico { background:linear-gradient(135deg,var(--accent),var(--brand)); }
.tile .t-title { font-size:16px; font-weight:640; letter-spacing:-0.01em; }
.tile .t-sub { font-size:12.5px; color:var(--muted); margin-top:-4px; }
.tile .t-arrow { margin-top:auto; color:var(--muted); font-size:18px; }
.tile:hover .t-arrow { color:var(--accent); }
.tile:hover { transform:translateY(-2px); box-shadow:var(--shadow-lg); border-color:var(--border-strong); text-decoration:none; }
.tile .ico { width:42px; height:42px; border-radius:11px; display:flex; align-items:center; justify-content:center; background:var(--accent-weak); color:var(--accent); }
.tile .ico svg { width:21px; height:21px; }
.tile.brand .ico { background:var(--accent); color:#fff; }
.tile .t-title { font-size:15.5px; font-weight:650; letter-spacing:-0.01em; }
.tile .t-sub { font-size:12.5px; color:var(--muted); margin-top:-4px; line-height:1.4; }
.tile .t-arrow { margin-top:auto; color:var(--border-strong); display:flex; }
.tile .t-arrow svg { width:18px; height:18px; }
.tile:hover .t-arrow { color:var(--accent); transform:translateX(2px); transition:transform .15s var(--ease), color .15s; }
/* ---- Секция (заголовок + контент) ---- */
.section-head { display:flex; align-items:baseline; justify-content:space-between; margin:24px 0 12px; }
@@ -176,7 +215,9 @@ label { font-size:13px; font-weight:500; }
}
.settings-nav button:hover { background:var(--card-2); color:var(--text); }
.settings-nav button.active { background:var(--accent-weak); color:var(--accent); }
.settings-nav button .nico { font-size:16px; width:20px; text-align:center; }
.settings-nav button .nico { width:18px; height:18px; flex:none; display:flex; align-items:center; justify-content:center; color:var(--muted); }
.settings-nav button .nico svg { width:18px; height:18px; }
.settings-nav button.active .nico { color:var(--accent); }
.settings-nav button .ind { margin-left:auto; width:8px; height:8px; border-radius:50%; flex:none; }
.settings-nav button .ind.ok { background:var(--ok); }
.settings-nav button .ind.warn { background:var(--warn); }
@@ -190,8 +231,35 @@ label { font-size:13px; font-weight:500; }
.settings-nav button { white-space:nowrap; width:auto; }
}
/* ---- Документация / справка (Read) ---- */
.doc-header { margin:6px 0 22px; }
.doc-header h1 { font-size:24px; letter-spacing:-0.02em; }
.doc-header .lede { color:var(--text-2); font-size:15px; max-width:70ch; margin-top:6px; }
.doc { display:grid; grid-template-columns:236px minmax(0,1fr); gap:40px; align-items:start; }
.doc-toc { position:sticky; top:74px; max-height:calc(100vh - 96px); overflow:auto; font-size:13px; padding-right:6px; }
.doc-toc .toc-group { font-size:10px; text-transform:uppercase; letter-spacing:.07em; color:var(--muted); font-weight:700; margin:16px 0 6px; }
.doc-toc .toc-group:first-child { margin-top:0; }
.doc-toc a { display:block; color:var(--text-2); text-decoration:none; padding:5px 11px; border-radius:7px; border-left:2px solid transparent; line-height:1.35; }
.doc-toc a:hover { background:var(--card-2); color:var(--text); }
.doc-toc a.active { color:var(--accent-strong); border-left-color:var(--accent); background:var(--accent-weak); font-weight:600; }
.doc-body { max-width:820px; min-width:0; }
.doc-body > h2 { font-size:19px; letter-spacing:-0.015em; margin:34px 0 12px; padding-top:8px; scroll-margin-top:74px; }
.doc-body > h2:first-child { margin-top:0; }
.doc-body h3 { font-size:15px; margin:22px 0 8px !important; }
.doc-body p, .doc-body li { max-width:74ch; }
.doc-body p { color:var(--text-2); }
.doc-body .card { scroll-margin-top:74px; }
.doc-sep { height:1px; background:var(--border); margin:30px 0; border:0; }
.callout { background:var(--card-2); border:1px solid var(--border); border-radius:var(--radius-sm); padding:13px 15px; margin:14px 0; font-size:14px; line-height:1.55; }
.callout.err { background:var(--err-weak); } .callout.accent { background:var(--accent-weak); } .callout.warn { background:var(--warn-weak); } .callout.ok { background:var(--ok-weak); }
.callout strong:first-child { color:var(--text); }
/* Код-блоки справки — тёмная тема учтена */
.doc-body pre, pre.copyable, pre.code { background:var(--card-2) !important; border:1px solid var(--border); border-radius:var(--radius-sm); color:var(--text); }
@media (max-width:900px){ .doc { grid-template-columns:1fr; gap:0; } .doc-toc { position:static; max-height:none; margin-bottom:18px; border-bottom:1px solid var(--border); padding-bottom:12px; } }
/* ---- Toast ---- */
#bj-toast { position:fixed; top:72px; right:24px; z-index:9999; max-width:520px; padding:14px 18px; background:var(--card); border-left:4px solid var(--ok); border-radius:var(--radius-sm); color:var(--text); box-shadow:var(--shadow-lg); font-size:13px; line-height:1.45; opacity:0; transform:translateY(-12px); transition:opacity .25s, transform .25s; }
#bj-toast { position:fixed; top:70px; right:22px; z-index:9999; max-width:520px; padding:14px 18px 14px 40px; background:var(--card); border:1px solid var(--border-strong); border-radius:var(--radius-sm); color:var(--text); box-shadow:var(--shadow-lg); font-size:13px; line-height:1.45; opacity:0; transform:translateY(-12px); transition:opacity .25s var(--ease), transform .25s var(--ease); }
#bj-toast::before { content:""; position:absolute; left:16px; top:17px; width:9px; height:9px; border-radius:50%; background:var(--ok); }
#bj-toast.visible { opacity:1; transform:translateY(0); }
#bj-toast .close { position:absolute; top:6px; right:10px; cursor:pointer; color:var(--muted); font-size:15px; }
#bj-toast .close:hover { color:var(--text); }
@@ -201,26 +269,52 @@ label { font-size:13px; font-weight:500; }
<div id="bj-toast"><span class="close" onclick="document.getElementById('bj-toast').classList.remove('visible')">×</span><div id="bj-toast-text"></div></div>
<header class="topbar">
<span class="brand"><span class="logo"></span>Bridge&amp;Join</span>
<a class="brand" href="/admin/" title="На дашборд" style="text-decoration:none;color:inherit">
<svg viewBox="0 0 26 26" fill="none" aria-hidden="true">
<path d="M5.5 16 C 9 8.5, 17 8.5, 20.5 16" stroke="var(--accent)" stroke-width="1.9" stroke-linecap="round"/>
<circle cx="5.5" cy="16" r="3.1" fill="var(--accent)"/>
<circle cx="20.5" cy="16" r="3.1" fill="var(--brand)"/>
</svg>
<span><b>Bridge</b><span class="brand-j">&amp;Join</span></span>
</a>
<nav class="nav">
<span class="group-label">Оператор</span>
<a href="/admin/" class="{{if eq .Active "home"}}active{{end}}">Дашборд</a>
<a href="/admin/claims" class="{{if eq .Active "claims"}}active{{end}}">Переводы</a>
<a href="/admin/news" class="{{if eq .Active "news"}}active{{end}}">События</a>
<span class="group-label">Администратор</span>
{{if can "view"}}<a href="/admin/" class="{{if eq .Active "home"}}active{{end}}">Дашборд</a>
<span class="dd{{if or (eq .Active "journal") (eq .Active "claims") (eq .Active "incoming")}} active{{end}}">
<span class="ddbtn">Журнал ▾<span id="nav-journal-badge" title="Входящих ждут решения" style="display:none;margin-left:5px;background:var(--err);color:#fff;border-radius:10px;padding:1px 7px;font-size:11px;font-weight:700"></span></span>
<span class="ddmenu">
<a href="/admin/journal">Все (вход + исход)</a>
<a href="/admin/claims">Исходящие переводы</a>
<a href="/admin/incoming">Входящие<span id="nav-incoming-badge" style="display:none;margin-left:5px;background:var(--err);color:#fff;border-radius:10px;padding:1px 7px;font-size:11px;font-weight:700"></span></a>
</span>
</span>
{{end}}{{if can "operate"}}<a href="/admin/m2m-testing" class="{{if eq .Active "m2mtest"}}active{{end}}">Тестирование M2M</a>{{end}}
{{if and .NPR (can "npr")}}<a href="/admin/npr" class="{{if eq .Active "npr"}}active{{end}}">НПР / УДС</a>{{end}}
{{if or (can "view") (can "quik")}}<span class="dd{{if or (eq .Active "fancy") (eq .Active "quik") (eq .Active "news") (eq .Active "handbook")}} active{{end}}">
<span class="ddbtn">Обмен ▾</span>
<span class="ddmenu">
{{if can "view"}}<a href="/admin/fancy-queue">Обмен с DEPO (Fancy)</a>{{end}}
{{if can "quik"}}<a href="/admin/quik">Поручения QUIK</a>{{end}}
{{if can "view"}}<a href="/admin/news">События</a>
<a href="/admin/handbook">Справочник НРД</a>{{end}}
</span>
</span>{{end}}
{{if can "settings"}}<span class="group-label">Администратор</span>
<a href="/admin/setup" class="{{if eq .Active "setup"}}active{{end}}">Настройка</a>
<a href="/admin/status" class="{{if eq .Active "status"}}active{{end}}">Статус</a>
<a href="/admin/status" class="{{if eq .Active "status"}}active{{end}}">Статус</a>{{end}}
<a href="/admin/help" class="{{if eq .Active "help"}}active{{end}}">Справка</a>
</nav>
<div class="topbar-right">
{{with curUser}}<span class="user-chip" title="{{.RoleTitle}}{{if eq .Source "ad"}} · вход через AD{{end}}"><span class="user-name">{{if .Name}}{{.Name}}{{else}}{{.Login}}{{end}}</span><span class="user-role">{{.RoleTitle}}</span></span><a class="user-logout" href="/logout" title="Выйти">Выйти</a>{{else}}{{if authOpen}}<a class="user-logout" href="/admin/setup#users" title="Пользователи не созданы — вход открыт">без входа</a>{{end}}{{end}}
<span class="sys-status {{if .IsMockMode}}warn{{end}}" title="Состояние обмена с НРД">
<span class="pulse"></span>{{if .IsMockMode}}Эмуляция{{else}}НРД на связи{{end}}</span>
<span class="clock">{{.Now}}</span>
<button class="theme-toggle" id="theme-toggle" title="Светлая/тёмная тема" aria-label="Переключить тему">🌙</button>
<button class="theme-toggle" id="theme-toggle" title="Светлая/тёмная тема" aria-label="Переключить тему"></button>
</div>
</header>
{{if .IsMockMode}}
<div class="banner-mock">
<span style="font-size:16px">🟡</span>
<span class="dot warn" style="width:9px;height:9px;margin:0"></span>
<div><strong style="color:var(--warn)">Режим эмуляции</strong> — реального обмена с НРД нет. <span class="muted">{{.MockReason}}</span></div>
<a href="/admin/wizard" style="margin-left:auto">Открыть мастер настройки →</a>
</div>
@@ -235,9 +329,11 @@ label { font-size:13px; font-weight:500; }
// --- Тема: light/dark, сохранение в localStorage ---
var root = document.documentElement;
var toggle = document.getElementById('theme-toggle');
var icoMoon = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12.8A9 9 0 1 1 11.2 3 7 7 0 0 0 21 12.8z"/></svg>';
var icoSun = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4"/></svg>';
function applyTheme(t) {
root.setAttribute('data-theme', t);
toggle.textContent = (t === 'dark') ? '☀️' : '🌙';
toggle.innerHTML = (t === 'dark') ? icoSun : icoMoon;
try { localStorage.setItem('bj-theme', t); } catch(e) {}
}
var saved = 'light';
@@ -258,28 +354,140 @@ label { font-size:13px; font-weight:500; }
}
var params = new URLSearchParams(window.location.search);
var flash = params.get('flash');
if (flash) {
showToast(flash);
if (flash) { showToast(flash); }
// Прокрутка к секции из ?scrollto= (надёжнее фрагмента через редирект).
var anchored = false;
var scrollTarget = params.get('scrollto');
if (scrollTarget) {
var stEl = document.getElementById(scrollTarget);
if (stEl) {
anchored = true;
var doScroll2 = function() { stEl.scrollIntoView({ block: 'start' }); };
doScroll2();
setTimeout(doScroll2, 50);
setTimeout(doScroll2, 200);
window.addEventListener('load', doScroll2);
}
}
if (flash || scrollTarget) {
params.delete('flash');
params.delete('scrollto');
var qs = params.toString();
window.history.replaceState({}, '', window.location.pathname + (qs ? '?' + qs : '') + window.location.hash);
}
// --- Сохранение позиции прокрутки через POST-редиректы ---
// Отключаем авто-восстановление браузера — иначе оно перебивает наше
// (скроллит наверх уже ПОСЛЕ нашего scrollTo).
if ('scrollRestoration' in history) { try { history.scrollRestoration = 'manual'; } catch(e) {} }
// Присваиваем карточкам стабильные id (по порядку рендера) — чтобы вернуться
// к нужной секции по якорю после сохранения.
var cards = document.querySelectorAll('.card');
for (var ci = 0; ci < cards.length; ci++) { if (!cards[ci].id) cards[ci].id = 'sec-' + ci; }
document.addEventListener('submit', function(ev) {
var f = ev.target;
if (f && f.method && f.method.toLowerCase() === 'post') {
try { sessionStorage.setItem('bj-scroll', String(window.scrollY)); } catch(e) {}
// Возврат на ТЕКУЩУЮ страницу (а не на /admin/setup): сервер не всегда
// получает Referer, поэтому говорим путь явно.
if (!f.querySelector('input[name="return_to"]')) {
var rt = document.createElement('input');
rt.type = 'hidden'; rt.name = 'return_to'; rt.value = location.pathname;
f.appendChild(rt);
}
// Подкладываем секцию (ближайшую карточку) — сервер вернёт ?scrollto=.
var card = f.closest ? f.closest('.card') : null;
if (card && card.id && !f.querySelector('input[name="section"]')) {
var inp = document.createElement('input');
inp.type = 'hidden'; inp.name = 'section'; inp.value = card.id;
f.appendChild(inp);
}
}
}, true);
var sc = null;
try { sc = sessionStorage.getItem('bj-scroll'); } catch(e) {}
if (sc !== null) {
window.requestAnimationFrame(function() {
window.scrollTo(0, parseInt(sc, 10));
try { sessionStorage.removeItem('bj-scroll'); } catch(e) {}
});
// При наличии якоря восстановление позиции пропускаем, чтобы не конфликтовать.
if (sc !== null && !anchored) {
var y = parseInt(sc, 10) || 0;
try { sessionStorage.removeItem('bj-scroll'); } catch(e) {}
// Несколько попыток: страница могла ещё не разложиться (шрифты/контент),
// поэтому повторяем сразу, на след. кадре, по таймеру и после полной загрузки.
var restore = function() { window.scrollTo(0, y); };
restore();
window.requestAnimationFrame(restore);
setTimeout(restore, 60);
setTimeout(restore, 200);
window.addEventListener('load', restore);
}
// --- Браузерные уведомления о входящих переводах (сценарий 3333) ---
// Опрашиваем /admin/incoming/feed; на НОВЫЙ входящий показываем Notification,
// клик по которому ведёт в карточку для подтверждения/отказа. Плюс бейдж в меню.
(function() {
var badge = document.getElementById('nav-incoming-badge');
var jbadge = document.getElementById('nav-journal-badge');
function setBadges(n) {
[badge, jbadge].forEach(function(b){
if (!b) return;
if (n > 0) { b.textContent = n; b.style.display = 'inline-block'; }
else { b.style.display = 'none'; }
});
}
function seenSet() {
try { return new Set(JSON.parse(localStorage.getItem('bj-seen-incoming') || '[]')); }
catch(e) { return new Set(); }
}
function saveSeen(s) {
try { localStorage.setItem('bj-seen-incoming', JSON.stringify(Array.from(s))); } catch(e) {}
}
function notify(it) {
// 1) Системное уведомление браузера (только в защищённом контексте: HTTPS
// или localhost; по обычному HTTP браузеры Notification блокируют).
if (('Notification' in window) && Notification.permission === 'granted') {
var n = new Notification('Новый входящий перевод НРД', {
body: 'От ' + it.sender + ' · ' + it.count + ' бумаг · ' + it.received + '\nНажмите, чтобы подтвердить или отказать.',
tag: 'bj-incoming-' + it.id, requireInteraction: true
});
n.onclick = function() { window.focus(); location.href = '/admin/incoming/' + it.id; };
}
// 2) Всегда — заметный баннер на странице (работает и по HTTP).
inPageBanner(it);
}
function bannerStack() {
var c = document.getElementById('bj-incoming-stack');
if (!c) {
c = document.createElement('div');
c.id = 'bj-incoming-stack';
c.style.cssText = 'position:fixed;top:16px;right:16px;z-index:2000;display:flex;flex-direction:column;gap:10px;max-width:360px';
document.body.appendChild(c);
}
return c;
}
function inPageBanner(it) {
var bar = document.createElement('div');
bar.style.cssText = 'background:var(--accent,#2563eb);color:#fff;padding:14px 16px;border-radius:12px;box-shadow:0 12px 40px rgba(0,0,0,.35);cursor:pointer;font-size:14px;line-height:1.4';
bar.innerHTML = '<b>📥 Новый входящий перевод НРД</b><br>От ' + it.sender + ' · ' + it.count + ' бумаг · ' + it.received + '<br><span style="text-decoration:underline">Открыть для подтверждения/отказа →</span>';
bar.onclick = function() { location.href = '/admin/incoming/' + it.id; };
bannerStack().appendChild(bar);
setTimeout(function(){ bar.style.transition='opacity .5s'; bar.style.opacity='0'; setTimeout(function(){bar.remove();}, 600); }, 20000);
}
function poll() {
fetch('/admin/incoming/feed', { cache: 'no-store' }).then(function(r){ return r.json(); }).then(function(d){
setBadges(d.count);
var seen = seenSet(), changed = false;
(d.pending || []).forEach(function(it){
if (!seen.has(it.id)) { notify(it); seen.add(it.id); changed = true; }
});
if (changed) saveSeen(seen);
}).catch(function(){});
}
// Запрашиваем разрешение на уведомления (один раз).
if ('Notification' in window && Notification.permission === 'default') {
Notification.requestPermission().then(function(){ poll(); });
}
poll();
setInterval(poll, 12000); // каждые 12 сек
})();
})();
</script>
</body>
@@ -0,0 +1,14 @@
{{define "npr_css"}}
<style>
.ntabs{display:flex;gap:4px;margin:0 0 10px;border-bottom:2px solid var(--border)}.ntabs a{padding:8px 14px;text-decoration:none;color:var(--text-2);border-bottom:2px solid transparent;margin-bottom:-2px;display:inline-flex;align-items:center;gap:6px;font-size:14px}.ntabs a.active{border-bottom-color:var(--accent);color:var(--accent-strong);font-weight:600}.ntabs .count{background:var(--card-2);color:var(--text-2);font-size:11px;padding:1px 7px;border-radius:10px;font-weight:600}
.tab-desc{font-size:13px;color:var(--muted);margin:0 0 10px}.nhelp{background:var(--card-2);padding:10px 14px;border-radius:6px;margin-bottom:12px;font-size:13px}.nhelp .legend{display:inline-flex;gap:12px;flex-wrap:wrap;margin-top:6px}.legend-item{display:inline-flex;align-items:center;gap:5px}.legend-box{display:inline-block;width:14px;height:14px;border-radius:3px;border:1px solid #999}
.nsummary{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;margin-bottom:10px}.nsummary article{margin:0;padding:8px 10px;text-align:center;border:1px solid var(--border);border-radius:8px;background:var(--card)}.nsummary header{font-size:11px;color:var(--muted);text-transform:uppercase;letter-spacing:.03em}.nsummary strong{font-size:24px;display:block;line-height:1.1}.tile-call strong{color:#b91c1c}.tile-warn strong{color:#b45309}.tile-ok strong{color:#047857}.tile-call{border-color:#fecaca!important;background:color-mix(in srgb,#fee2e2 50%,var(--card))!important}.tile-warn{border-color:#fde68a!important;background:color-mix(in srgb,#fef3c7 50%,var(--card))!important}.tile-ok{border-color:#d1fae5!important;background:color-mix(in srgb,#d1fae5 50%,var(--card))!important}
.nfresh{display:flex;align-items:center;gap:8px;margin-bottom:12px;padding:7px 12px;background:var(--card-2);border-radius:6px;font-size:13px;flex-wrap:wrap}
.nfilters{display:grid;grid-template-columns:2fr 1fr 1fr 1fr auto 1fr;gap:8px;align-items:end;padding:10px;background:var(--card-2);border-radius:8px;margin-bottom:12px}.nfilters label{font-size:12px;color:var(--muted);margin:0}.nfilters input,.nfilters select{margin:0;padding:5px 8px;font-size:13px;width:100%}
.ntable{width:100%;border-collapse:collapse;font-size:13px}.ntable th,.ntable td{padding:5px 8px;border-bottom:1px solid var(--border);vertical-align:middle}.ntable thead th{background:var(--card-2);font-size:11px;text-transform:uppercase;letter-spacing:.03em;text-align:left}.ntable tbody tr:hover{background:var(--card-2)}.ntable td a{text-decoration:none;font-weight:600;color:var(--accent-strong)}.num{text-align:right;font-variant-numeric:tabular-nums}
.row-call{background:color-mix(in srgb,#fee2e2 70%,var(--card))!important}.row-call td{color:#7f1d1d}.row-warn{background:color-mix(in srgb,#fef3c7 70%,var(--card))!important}.row-inf{background:color-mix(in srgb,#d1fae5 60%,var(--card))!important}
.badge{display:inline-block;padding:1px 8px;border-radius:3px;font-size:11px;font-weight:600}.badge-call{background:#dc2626;color:#fff}.badge-warn{background:#f59e0b;color:#fff}.badge-inf{background:#10b981;color:#fff}.badge-ok{background:var(--card-2);color:var(--text-2)}
.nmetrics{display:grid;grid-template-columns:repeat(5,1fr);gap:10px;margin:12px 0 0}.nmetrics article{margin:0;padding:12px;text-align:center;border:1px solid var(--border);border-radius:8px}.nmetrics header{font-size:11px;color:var(--muted);text-transform:uppercase;letter-spacing:.03em;margin-bottom:5px}.nmetrics strong{font-size:26px;display:block;font-variant-numeric:tabular-nums}.m-call strong{color:#b91c1c}.m-ok strong{color:#047857}.nmini{padding:10px;border:1px solid var(--border);border-radius:8px}.nmini header{font-size:11px;color:var(--muted);text-transform:uppercase}
@media (max-width:900px){.nsummary,.nmetrics{grid-template-columns:repeat(2,1fr)}.nfilters{grid-template-columns:1fr 1fr}}
</style>
{{end}}