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
@@ -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}}