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
+172
View File
@@ -0,0 +1,172 @@
package lkgateway
import (
"html"
"strings"
)
// m2mCodeInfo — запись справочника кодов M2M: код, краткое название и развёрнутое
// пояснение простым языком (когда и почему возникает).
type m2mCodeInfo struct {
Code, Short, Full string
}
// m2mRejectCatalog — коды ОТКАЗА зачисляющей (принимающей) стороны. Приходят в
// решении M2MTransferDecision по конкретной бумаге.
var m2mRejectCatalog = []m2mCodeInfo{
{"M2M01", "Ценная бумага не допускается к переводам M2M",
"Бумагу нельзя переводить через сервис M2M. Типичный случай — иностранный выпуск (ISIN не начинается с «RU»): сервис МОСТ такие бумаги между депозитариями не пропускает."},
{"M2M02", "Не может быть определён счёт зачисления",
"Зачисляющий депозитарий не смог определить, на какой счёт депо зачислять бумаги: клиент не указал счёт по умолчанию, а он для операции необходим."},
{"M2M03", "Счёт депо зачисления не торговый",
"Указанный счёт зачисления не является торговым — зачисление бумаг на него невозможно."},
{"M2M04", "Инвестор не найден",
"У зачисляющего депозитария нет такого клиента (владельца бумаг): некому зачислять. Возникает, если инвестор не заведён у принимающей стороны."},
{"M2M05", "Бумага не обслуживается зачисляющим депозитарием",
"Зачисляющий депозитарий не оказывает услуги по учёту прав на данную ценную бумагу (не работает с этим выпуском)."},
{"M2M06", "Приостановление операций по счёту зачисления",
"По счёту депо зачисления действует приостановка/блокировка операций — зачислить бумаги нельзя."},
{"M2M07", "Отказ принимающей стороны",
"Общий отказ зачисляющего депозитария. В нашей системе применяется также для повторного (дублирующего) перевода — тот же инвестор и бумаги уже принимались ранее."},
{"M2M08", "Операция нарушит законодательство РФ",
"Проведение перевода приведёт к нарушению закона: например, бумага предназначена только для квалифицированных инвесторов, а инвестор не квалифицирован."},
{"M2M09", "Отказ принимающей стороны (доп.)",
"Дополнительный код отказа зачисляющей стороны (используется в тест-сценариях)."},
}
// m2mServiceCatalog — коды сервиса МОСТ (ошибки/информирование). Приходят в ответе
// сервиса M2MTransferResponse.
var m2mServiceCatalog = []m2mCodeInfo{
{"M2M10", "Получено невалидное сообщение",
"Формат или структура запроса не прошли проверку сервиса МОСТ (сообщение не соответствует схеме)."},
{"M2M14", "Списывающий не в справочнике участников M2M",
"Отправитель (списывающий депозитарий) не зарегистрирован в справочнике участников сервиса M2M."},
{"M2M15", "Зачисляющий не в справочнике участников M2M",
"Получатель (зачисляющий депозитарий) не зарегистрирован в справочнике участников сервиса M2M."},
{"M2M17", "Неверно указан ИНН списывающего депозитария",
"ИНН передающей (списывающей) стороны указан неверно или не совпадает со справочником."},
{"M2M18", "Неверно указан ИНН зачисляющего депозитария",
"ИНН принимающей (зачисляющей) стороны указан неверно или не совпадает со справочником."},
{"M2M19", "Недопустимое место расчётов",
"Указанные депонент / счёт депо / раздел / расчётный депозитарий не входят в допустимые для перевода M2M."},
{"M2M30", "Неверно указан код НРД ценной бумаги",
"Код НРД ценной бумаги указан неверно (не найден в справочнике инструментов)."},
{"M2M31", "Ответ по этому GUID уже обработан",
"Ответ на запрос на перевод M2M с указанным GUID уже был обработан ранее — повторная (дублирующая) отправка ответа/решения."},
}
// m2mSpravkaHTML — справка по кодам ошибок/отказа M2M простым языком (для страницы
// и для вложения в пакет отчётов контрагенту).
func m2mSpravkaHTML(standalone bool) []byte {
esc := html.EscapeString
var b strings.Builder
if standalone {
b.WriteString(`<!doctype html><html lang="ru"><head><meta charset="utf-8"><title>Справка по кодам M2M</title><style>`)
b.WriteString(`body{font-family:-apple-system,Segoe UI,Roboto,Arial,sans-serif;color:#1a1a1a;max-width:900px;margin:24px auto;padding:0 20px;line-height:1.5}`)
b.WriteString(`h1{font-size:20px}h2{font-size:15px;margin:22px 0 8px;color:#1f4e79}table{border-collapse:collapse;width:100%;font-size:13px;margin:8px 0}`)
b.WriteString(`td,th{border:1px solid #ccc;padding:7px 10px;text-align:left;vertical-align:top}th{background:#f0f0f0}`)
b.WriteString(`.c{font-family:monospace;font-weight:700;white-space:nowrap}.muted{color:#666}</style></head><body>`)
}
b.WriteString(`<h1>Справка по кодам ошибок и отказов M2M (MOEX МОСТ)</h1>`)
b.WriteString(`<p class="muted">Коды, которыми сервис МОСТ и зачисляющий депозитарий сообщают о причине отказа или ошибки при переводе ценных бумаг между депозитариями.</p>`)
section := func(title, note string, cat []m2mCodeInfo) {
b.WriteString(`<h2>` + title + `</h2>`)
if note != "" {
b.WriteString(`<p class="muted" style="font-size:12.5px">` + note + `</p>`)
}
b.WriteString(`<table><tr><th style="width:90px">Код</th><th style="width:34%">Кратко</th><th>Что это значит</th></tr>`)
for _, ci := range cat {
b.WriteString(`<tr><td class="c">` + esc(ci.Code) + `</td><td>` + esc(ci.Short) + `</td><td>` + esc(ci.Full) + `</td></tr>`)
}
b.WriteString(`</table>`)
}
section("Отказ зачисляющей (принимающей) стороны",
"Приходят в решении M2MTransferDecision по конкретной ценной бумаге — принимающий депозитарий отказал в зачислении.",
m2mRejectCatalog)
section("Ошибки и информирование сервиса МОСТ",
"Приходят в ответе сервиса M2MTransferResponse — сервис отклонил или прокомментировал запрос.",
m2mServiceCatalog)
b.WriteString(`<p class="muted" style="font-size:12px;margin-top:16px">Прочие коды сервиса (M2M11–M2M13, M2M16, M2M20–M2M29, M2M32–M2M43) определяются Спецификацией электронных документов НРД (Приложение 5) и в данной справке не расшифрованы.</p>`)
if standalone {
b.WriteString(`</body></html>`)
}
return []byte(b.String())
}
// m2mCodeTexts — человекочитаемые названия кодов ошибок/отказа M2M НРД.
// m2mCodeTexts — человекочитаемые названия кодов ошибок/отказа M2M НРД.
// Источники: ответы сервиса МОСТ (Text), инструкция по тестированию с роботом
// (Приложение 5, набор данных об ошибках). M2M01–M2M09 — тестовые коды отказа
// робота (имитация отказа принимающей стороны).
var m2mCodeTexts = map[string]string{
"M2M01": "Ценная бумага не допускается к переводам M2M",
"M2M02": "Не может быть определён счёт для зачисления ценных бумаг",
"M2M03": "Счёт депо зачисления не торговый",
"M2M04": "Инвестор не найден",
"M2M05": "Зачисляющий депозитарий не оказывает услуги по учёту прав на ценную бумагу",
"M2M06": "Приостановление операций по счёту депо зачисления",
"M2M07": "Отказ принимающей стороны (тест 07)",
"M2M08": "Проведение операции приведёт к нарушению законодательства РФ",
"M2M09": "Отказ принимающей стороны (тест 09)",
"M2M10": "Получено невалидное сообщение",
"M2M14": "Отправитель не зарегистрирован в справочнике участников M2M",
"M2M15": "Получатель (зачисляющий депозитарий) не зарегистрирован в справочнике участников M2M", // предположительно (пара к M2M14); уточнить по Спецификации ЭД
"M2M17": "Неверно указан ИНН списывающего депозитария",
"M2M18": "Неверно указан ИНН зачисляющего депозитария", // предположительно (по соседним кодам); уточнить по Спецификации ЭД
"M2M19": "Недопустимое место расчётов",
"M2M30": "Неверно указан код НРД ценной бумаги",
// Внутренние причины отказа сверки списания (не коды НРД): недостаточно бумаг
// на торговом счёте и неверное место хранения в запросе.
"QTY": "Недостаточно бумаг на торговом счёте депо",
"PLACE": "Неверное место хранения (не наш торговый счёт/раздел в НРД)",
}
// m2mCodeText возвращает только человеческий текст кода (без самого кода).
// Для неизвестного кода — пустую строку.
func m2mCodeText(code string) string {
return m2mCodeTexts[strings.ToUpper(strings.TrimSpace(code))]
}
// rejectCodesHuman рендерит коды отказа как «M2M01 (словесное описание)» через
// «; ». Для отчёта регулятору — чтобы рядом с номером кода была расшифровка.
func rejectCodesHuman(codes []string) string {
var parts []string
for _, c := range codes {
c = strings.TrimSpace(c)
if c == "" {
continue
}
if t := m2mCodeText(c); t != "" {
parts = append(parts, c+" ("+t+")")
} else {
parts = append(parts, c)
}
}
return strings.Join(parts, "; ")
}
// m2mCodeLabel возвращает «Человеческий текст (M2Mxx)». Для неизвестного кода —
// сам код. Используется в шаблонах для понятного отображения кодов.
func m2mCodeLabel(code string) string {
code = strings.TrimSpace(code)
if code == "" {
return ""
}
if t := m2mCodeText(code); t != "" {
return t + " (" + code + ")"
}
return code
}
// m2mRejectOptions — варианты кодов отказа для формы решения по входящим
// (в порядке полезности). Каждый — {code, label}.
func m2mRejectOptions() []struct{ Code, Label string } {
codes := []string{"M2M05", "M2M01", "M2M02", "M2M03", "M2M04", "M2M06", "M2M07", "M2M08", "M2M09"}
out := make([]struct{ Code, Label string }, 0, len(codes))
for _, c := range codes {
out = append(out, struct{ Code, Label string }{c, m2mCodeLabel(c)})
}
return out
}