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
+315
View File
@@ -0,0 +1,315 @@
package lkgateway
// Страница «Обмен с DEPO (Fancy)» — /admin/fancy-queue. Показывает реальный
// обмен через трансферные таблицы FTV_M2M / FTV_M2M_LINES / FTV_M2M_EVENTS:
// какие переводы bj положил в DEPO, что ответил job bj2depo (DEPO_ORDER_CREATED /
// DEPO_ERROR), в каком состоянии документ в депозитарии и какие события ещё
// не обработаны. Заменила прежнюю «очередь на проверку крана».
import (
"context"
"database/sql"
"fmt"
"log"
"net/http"
"net/url"
"sort"
"strings"
"time"
)
type depoQueueLine struct {
ISIN, SecCode string
Amount string
State string
Comment string
}
type depoQueueEvent struct {
Ref, State, Type, Codes, Comment string
At time.Time
}
type depoQueueRow struct {
GUID string
State string
Direction string
Deponent string
DeponentDoc string
CP string
CreatedAt time.Time
Doc string
DocState string
DocAt time.Time
Comment string
Lines []depoQueueLine
Events []depoQueueEvent
Pending int // события со STATE=TODO (job ещё не обработал)
Errors []string
LastEvent string
LastAt time.Time
Link *DepoLink
}
func (r depoQueueRow) DirectionRu() string {
if strings.EqualFold(r.Direction, "OUT") {
return "↗ списание (мы отдаём)"
}
return "↘ зачисление (мы принимаем)"
}
// StageRu — человеческое состояние перевода по совокупности признаков.
func (r depoQueueRow) StageRu() (cls, text string) {
up := strings.ToUpper(r.State)
switch {
case len(r.Errors) > 0 || up == "ERROR":
return "rejected", "ошибка DEPO"
case strings.Contains(strings.ToLower(r.DocState), "отмен"):
return "rejected", "документ отменён в DEPO"
case strings.Contains(strings.ToLower(r.DocState), "конец"):
return "confirmed", "документ проведён"
case r.Doc != "":
return "sent", "документ создан: " + r.DocState
case r.Pending > 0:
return "awaiting_decision", "ждёт job bj2depo"
case up == "SUCCESS" || up == "DONE":
return "confirmed", "обработан"
}
if up == "" {
return "", "—"
}
return "", up
}
type depoQueueSummary struct {
Total, Pending, WithDoc, Errors int
Handbook int
HandbookAt time.Time
HasHandbook bool
LastEventAt time.Time
}
func depoQueueLoad(ctx context.Context, rc *RuntimeConfig, limit int) ([]depoQueueRow, depoQueueSummary, error) {
var sum depoQueueSummary
db, err := depoEventsDB(rc)
if err != nil {
return nil, sum, err
}
ctx, cancel := context.WithTimeout(ctx, 20*time.Second)
defer cancel()
if !depoTableExists(ctx, db, "FTV_M2M") {
return nil, sum, errNoDepoTables
}
docCol := "NULL"
if depoHasColumn(ctx, db, "FTV_M2M", "DOC") {
docCol = "CAST(m.DOC AS varchar(32))"
}
timeCol := "NULL"
for _, c := range []string{"TimeNew", "TIME_NEW", "CREATED_AT"} {
if depoHasColumn(ctx, db, "FTV_M2M", c) {
timeCol = "m." + c
break
}
}
rows, err := db.QueryContext(ctx, `SELECT TOP (@n) CAST(m.GUID AS varchar(36)), ISNULL(m.STATE,''), ISNULL(m.DIRECTION,''), ISNULL(m.DEPONENT_NAME,''), ISNULL(m.DEPONENT_DOC,''), ISNULL(m.CONTRAGENT_M2M_CODE,''), `+timeCol+`, ISNULL(`+docCol+`,''), ISNULL(m.COMMENT,''),
ISNULL((SELECT TOP 1 ISNULL(st.NAME,'') FROM OD_DOCS d LEFT JOIN OD_DOC_STATES st ON st.ID=d.STATE AND st.D_CAT=0 WHERE CAST(d.ID AS varchar(32))=`+docCol+`),''),
(SELECT TOP 1 d.PROC_DATE FROM OD_DOCS d WHERE CAST(d.ID AS varchar(32))=`+docCol+`)
FROM dbo.FTV_M2M m ORDER BY `+strings.Replace(timeCol, "NULL", "m.GUID", 1)+` DESC`, sql.Named("n", limit))
if err != nil {
return nil, sum, err
}
var out []depoQueueRow
idx := map[string]int{}
for rows.Next() {
var r depoQueueRow
var created, docAt sql.NullTime
if err := rows.Scan(&r.GUID, &r.State, &r.Direction, &r.Deponent, &r.DeponentDoc, &r.CP, &created, &r.Doc, &r.Comment, &r.DocState, &docAt); err != nil {
rows.Close()
return nil, sum, err
}
if created.Valid {
r.CreatedAt = created.Time
}
if docAt.Valid {
r.DocAt = docAt.Time
}
r.GUID = strings.ToUpper(r.GUID)
r.Link = globalDepoLinks.Get(r.GUID)
idx[r.GUID] = len(out)
out = append(out, r)
}
rows.Close()
if len(out) == 0 {
return out, sum, nil
}
// строки
if depoTableExists(ctx, db, "FTV_M2M_LINES") {
lr, err := db.QueryContext(ctx, `SELECT CAST(l.GUID AS varchar(36)), ISNULL(l.ISIN,''), ISNULL(l.SEC_CODE,''), ISNULL(CAST(l.AMOUNT AS varchar(40)),''), ISNULL(l.STATE,''), ISNULL(l.COMMENT,'') FROM dbo.FTV_M2M_LINES l WHERE l.GUID IN (SELECT TOP (@n) GUID FROM dbo.FTV_M2M ORDER BY `+strings.Replace(strings.TrimPrefix(timeCol, "m."), "NULL", "GUID", 1)+` DESC)`, sql.Named("n", limit))
if err == nil {
for lr.Next() {
var g string
var l depoQueueLine
if lr.Scan(&g, &l.ISIN, &l.SecCode, &l.Amount, &l.State, &l.Comment) == nil {
if i, ok := idx[strings.ToUpper(g)]; ok {
l.Amount = strings.TrimRight(strings.TrimRight(l.Amount, "0"), ".")
out[i].Lines = append(out[i].Lines, l)
if strings.EqualFold(l.State, "ERROR") && l.Comment != "" {
out[i].Errors = append(out[i].Errors, l.Comment)
}
}
}
}
lr.Close()
}
}
// события
if depoTableExists(ctx, db, "FTV_M2M_EVENTS") {
er, err := db.QueryContext(ctx, `SELECT CAST(e.GUID AS varchar(36)), ISNULL(e.REFERENCE,''), ISNULL(e.STATE,''), ISNULL(e.EVENT_TYPE,''), e.EVENT_DATE, ISNULL(e.M2M_REJECT_CODE,''), ISNULL(e.COMMENT,'') FROM dbo.FTV_M2M_EVENTS e WHERE e.GUID IN (SELECT TOP (@n) GUID FROM dbo.FTV_M2M ORDER BY `+strings.Replace(strings.TrimPrefix(timeCol, "m."), "NULL", "GUID", 1)+` DESC) ORDER BY e.EVENT_DATE`, sql.Named("n", limit))
if err == nil {
for er.Next() {
var g string
var e depoQueueEvent
var at sql.NullTime
if er.Scan(&g, &e.Ref, &e.State, &e.Type, &at, &e.Codes, &e.Comment) == nil {
if at.Valid {
e.At = at.Time
}
if i, ok := idx[strings.ToUpper(g)]; ok {
out[i].Events = append(out[i].Events, e)
if strings.EqualFold(e.State, "TODO") {
out[i].Pending++
}
if e.Type == "DEPO_ERROR" && e.Comment != "" {
out[i].Errors = append(out[i].Errors, e.Comment)
}
if e.At.After(out[i].LastAt) {
out[i].LastAt, out[i].LastEvent = e.At, e.Type
}
if e.At.After(sum.LastEventAt) {
sum.LastEventAt = e.At
}
}
}
}
er.Close()
}
}
if depoTableExists(ctx, db, "FTV_M2M_HANDBOOK") {
sum.HasHandbook = true
_ = db.QueryRowContext(ctx, "SELECT count(*) FROM dbo.FTV_M2M_HANDBOOK").Scan(&sum.Handbook)
if depoHasColumn(ctx, db, "FTV_M2M_HANDBOOK", "HANDBOOK_AT") {
var t sql.NullTime
if db.QueryRowContext(ctx, "SELECT max(HANDBOOK_AT) FROM dbo.FTV_M2M_HANDBOOK").Scan(&t) == nil && t.Valid {
sum.HandbookAt = t.Time
}
}
}
for i := range out {
sum.Total++
if out[i].Pending > 0 {
sum.Pending++
}
if out[i].Doc != "" {
sum.WithDoc++
}
if len(out[i].Errors) > 0 {
sum.Errors++
}
}
sort.SliceStable(out, func(i, j int) bool {
ai, aj := out[i].CreatedAt, out[j].CreatedAt
if ai.IsZero() {
ai = out[i].LastAt
}
if aj.IsZero() {
aj = out[j].LastAt
}
return ai.After(aj)
})
return out, sum, nil
}
var errNoDepoTables = errNoDepo("в базе DEPO нет таблицы FTV_M2M — обмен ещё не настроен программистом Fancy")
type errNoDepo string
func (e errNoDepo) Error() string { return string(e) }
// fancyQueue — GET /admin/fancy-queue: обмен с DEPO через FTV_M2M*.
func (h *setupHandlers) fancyQueue(w http.ResponseWriter, r *http.Request) {
s := h.rc.Snapshot().Fancy
var rows []depoQueueRow
var sum depoQueueSummary
var loadErr string
if s.Server == "" {
loadErr = "Подключение к базе DEPO (Fancy) не настроено — Настройка → Обмен Fancy"
} else if rs, sm, err := depoQueueLoad(r.Context(), h.rc, 100); err != nil {
loadErr = "не удалось прочитать трансферные таблицы: " + err.Error()
} else {
rows, sum = rs, sm
}
// ошибки обработчика и пользователь для EXECUTE AS — видны сразу на странице
var fails []depoJobFailure
runAsOK, runAsMsg := true, ""
if s.Server != "" && loadErr == "" {
if db, err := depoEventsDB(h.rc); err == nil {
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
fails, _ = depoJobFailures(ctx, db, 7, 50)
runAsOK, runAsMsg = depoRunAsCheck(ctx, db, s)
cancel()
db.Close()
}
}
data := struct {
page
Rows []depoQueueRow
Sum depoQueueSummary
Error string
Flash string
DB string
Fancy FancyMSSQLSettings
JobMode string
Failures []depoJobFailure
RunAs string
RunAsOK bool
RunAsMsg string
}{nowPage("Обмен с DEPO", "fancy"), rows, sum, loadErr, r.URL.Query().Get("flash"), s.DepoDB(), s, depoJobModeRu(s.DepoJobMode), fails, depoJobRunAs(s), runAsOK, runAsMsg}
render(w, h.tpl.a.fancyQueue, data)
}
// fancyQueueRequeue — POST /admin/fancy-queue/requeue: вернуть записи с ошибкой обработчика в очередь.
func (h *setupHandlers) fancyQueueRequeue(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method", http.StatusMethodNotAllowed)
return
}
msg := ""
db, err := depoEventsDB(h.rc)
if err == nil {
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
var o, e int64
o, e, err = depoRequeueFailures(ctx, db, 7)
cancel()
db.Close()
if err == nil {
msg = fmt.Sprintf("Возвращено в очередь обработчика: поручений %d, событий %d. Обновите страницу через 10–20 секунд.", o, e)
log.Printf("depo-events: записи с ошибкой возвращены в очередь: поручений %d, событий %d", o, e)
}
}
if err != nil {
msg = "Не удалось вернуть записи в очередь: " + quikErrorDetail(err)
}
http.Redirect(w, r, "/admin/fancy-queue?flash="+url.QueryEscape(msg), http.StatusSeeOther)
}
func depoJobModeRu(m string) string {
switch m {
case "bj":
return "процедуру вызывает bj"
case "sql-auto":
return "SQL Agent (создан из bj)"
case "sql-manual":
return "SQL Agent (настроен вручную)"
}
return "не выбран"
}