Files
zuevav da12a9ec1c 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>
2026-09-15 19:41:12 +03:00

316 lines
11 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 "не выбран"
}