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

432 lines
18 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 — FTV_M2M, FTV_M2M_LINES, FTV_M2M_EVENTS,
// FTV_M2M_HANDBOOK — в базе DEPO клиента (Fancy.DepoDB). Структура повторяет
// таблицы разработчика Fancy (сняты с SQL002 15.09.2026) и описана один раз:
// из неё строится DDL для MSSQL и PostgreSQL. При установке таблицы создаёт
// либо bj (логином с правами, пароль не сохраняется), либо администратор СУБД
// по скрипту. DDL идемпотентный: недостающие таблицы, колонки и индексы
// добавляются, существующие данные не трогаются.
import (
"context"
"database/sql"
"fmt"
"regexp"
"strings"
)
// depoDefaultDatabase — база DEPO, если в настройках не задана (наша копия на SQL002).
const depoDefaultDatabase = "BJ_DEPO_DATA"
// DepoDB — имя базы депозитария DEPO на сервере Fancy.
func (s FancyMSSQLSettings) DepoDB() string {
if d := strings.TrimSpace(s.DepoDatabase); d != "" {
return d
}
return depoDefaultDatabase
}
// IISMarkers — с чего начинается название брокерского договора ИИС в DEPO (OD_CLIENTS_PORTS.PORT_NUM).
func (s FancyMSSQLSettings) IISMarkers() []string {
var out []string
for _, m := range strings.Split(s.IISContractMarker, ",") {
if m = strings.TrimSpace(m); m != "" {
out = append(out, m)
}
}
if len(out) == 0 {
out = []string{"БРИИС"}
}
return out
}
type depoCol struct {
Name, MSSQL, PG string
NotNull bool
DefMSSQL, DefPG string // выражение DEFAULT, "" — без значения по умолчанию
}
func depoNull(name, ms, pg string) depoCol { return depoCol{Name: name, MSSQL: ms, PG: pg} }
func depoNotNull(name, ms, pg string) depoCol {
return depoCol{Name: name, MSSQL: ms, PG: pg, NotNull: true}
}
func (c depoCol) def(ms, pg string) depoCol {
c.DefMSSQL, c.DefPG = ms, pg
return c
}
type depoTableSpec struct {
Name string
Cols []depoCol // без ID: у всех таблиц это identity и первичный ключ
Indexes [][]string
}
var (
depoColState = depoNotNull("STATE", "varchar(64)", "varchar(64)").def("'TODO'", "'TODO'")
depoColTimeNew = depoNull("TimeNew", "datetime", "timestamp").def("GETDATE()", "LOCALTIMESTAMP")
depoColTimeBeg = depoNull("TimeBeg", "datetime", "timestamp")
depoColTimeEnd = depoNull("TimeEnd", "datetime", "timestamp")
depoColComment = depoNull("COMMENT", "varchar(max)", "text")
depoColGUID = depoNotNull("GUID", "uniqueidentifier", "uuid")
)
var depoTableSpecs = []depoTableSpec{
{Name: "FTV_M2M", Cols: []depoCol{
depoColGUID, depoColState, depoColTimeNew, depoColTimeBeg, depoColTimeEnd, depoColComment,
depoNotNull("DIRECTION", "varchar(4)", "varchar(4)"),
depoNotNull("CONTRAGENT_M2M_CODE", "varchar(255)", "varchar(255)"),
depoNotNull("DEPONENT_NAME", "varchar(255)", "varchar(255)"),
depoNotNull("DEPONENT_DOC", "varchar(255)", "varchar(255)"),
depoNull("DOC", "int", "integer"),
depoNull("CONTRAGENT", "int", "integer"),
depoNull("DEPONENT", "int", "integer"),
}, Indexes: [][]string{{"GUID"}}},
{Name: "FTV_M2M_LINES", Cols: []depoCol{
depoColGUID,
depoNull("REFERENCE", "varchar(36)", "varchar(36)"),
depoColState, depoColTimeNew, depoColTimeBeg, depoColTimeEnd,
depoNull("ISIN", "varchar(12)", "varchar(12)"),
depoNull("SEC_CODE", "varchar(255)", "varchar(255)"),
depoNull("AMOUNT", "numeric(25,7)", "numeric(25,7)"),
depoNull("PLACE_NUMBER", "varchar(128)", "varchar(128)"),
depoNull("LINK_NUMBER", "varchar(64)", "varchar(64)"),
depoNull("DEPO_NUMBER", "varchar(128)", "varchar(128)"),
depoNull("DOC", "int", "integer"),
depoNull("DOL", "int", "integer"),
depoNull("SHARE", "int", "integer"),
depoNull("PLACE", "int", "integer"),
depoNull("DEPO", "int", "integer"),
depoColComment,
}, Indexes: [][]string{{"GUID"}}},
{Name: "FTV_M2M_EVENTS", Cols: []depoCol{
depoColGUID,
depoNull("REFERENCE", "varchar(36)", "varchar(36)"),
depoColState, depoColTimeNew, depoColTimeBeg, depoColTimeEnd, depoColComment,
depoNotNull("EVENT_TYPE", "varchar(64)", "varchar(64)"),
depoNotNull("EVENT_DATE", "datetime", "timestamp"),
depoNull("M2M_REJECT_CODE", "varchar(max)", "text"),
}, Indexes: [][]string{{"GUID"}, {"EVENT_TYPE", "STATE"}}},
{Name: "FTV_M2M_HANDBOOK", Cols: []depoCol{
depoNotNull("INN", "varchar(12)", "varchar(12)"),
depoNull("PARTICIPANT_CODE", "varchar(12)", "varchar(12)"),
depoNotNull("KIND", "varchar(8)", "varchar(8)"),
depoNull("NAME_FULL", "nvarchar(255)", "varchar(255)"),
depoNull("NAME_SHORT", "nvarchar(128)", "varchar(128)"),
depoNull("NAME_DISPLAY", "nvarchar(255)", "varchar(255)"),
depoNull("NAME_ENG", "nvarchar(255)", "varchar(255)"),
depoNull("HANDBOOK_AT", "datetime", "timestamp"),
depoNotNull("UPDATED_AT", "datetime", "timestamp").def("GETDATE()", "LOCALTIMESTAMP"),
depoNotNull("ACTIVE", "bit", "smallint").def("1", "1"), // smallint: bj пишет литерал 1
}, Indexes: [][]string{{"INN"}}},
}
func depoTableSpecByName(name string) depoTableSpec {
for _, t := range depoTableSpecs {
if t.Name == name {
return t
}
}
return depoTableSpec{}
}
func depoTableNames() []string {
out := make([]string, 0, len(depoTableSpecs))
for _, t := range depoTableSpecs {
out = append(out, t.Name)
}
return out
}
// colSQL — определение колонки. forAdd — для ALTER TABLE ADD в существующую
// таблицу: NOT NULL без значения по умолчанию туда не добавить, колонка будет NULL.
func (c depoCol) colSQL(pg, forAdd bool, table string) string {
typ, def := c.MSSQL, c.DefMSSQL
if pg {
typ, def = c.PG, c.DefPG
}
s := c.Name + " " + typ
if c.NotNull && (!forAdd || def != "") {
s += " NOT NULL"
} else {
s += " NULL"
}
if def != "" {
if pg {
s += " DEFAULT " + def
} else {
s += fmt.Sprintf(" CONSTRAINT DF_%s_%s DEFAULT (%s)", table, c.Name, def)
}
}
return s
}
func (t depoTableSpec) createSQL(pg bool) string {
var b strings.Builder
if pg {
fmt.Fprintf(&b, "CREATE TABLE IF NOT EXISTS %s (\n ID integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY", t.Name)
} else {
fmt.Fprintf(&b, "IF OBJECT_ID(N'dbo.%[1]s', N'U') IS NULL\nCREATE TABLE dbo.%[1]s (\n ID int IDENTITY(1,1) NOT NULL CONSTRAINT PK_%[1]s PRIMARY KEY CLUSTERED", t.Name)
}
for _, c := range t.Cols {
b.WriteString(",\n " + c.colSQL(pg, false, t.Name))
}
b.WriteString("\n)")
return b.String()
}
// addColumnsSQL — недостающие колонки в таблице, созданной по более ранней структуре.
func (t depoTableSpec) addColumnsSQL(pg bool) string {
parts := make([]string, 0, len(t.Cols))
for _, c := range t.Cols {
if pg {
parts = append(parts, "ADD COLUMN IF NOT EXISTS "+c.colSQL(true, true, t.Name))
} else {
parts = append(parts, fmt.Sprintf("IF COL_LENGTH(N'dbo.%[1]s', N'%[2]s') IS NULL ALTER TABLE dbo.%[1]s ADD %[3]s;", t.Name, c.Name, c.colSQL(false, true, t.Name)))
}
}
if pg {
return "ALTER TABLE " + t.Name + "\n " + strings.Join(parts, ",\n ")
}
return strings.Join(parts, "\n")
}
func (t depoTableSpec) indexSQL(pg bool) []string {
var out []string
for _, cols := range t.Indexes {
name := "IX_" + t.Name + "_" + strings.Join(cols, "_")
if pg {
out = append(out, fmt.Sprintf("CREATE INDEX IF NOT EXISTS %s ON %s (%s)", strings.ToLower(name), t.Name, strings.Join(cols, ", ")))
} else {
out = append(out, fmt.Sprintf("IF INDEXPROPERTY(OBJECT_ID(N'dbo.%[1]s'), N'%[2]s', 'IndexID') IS NULL CREATE INDEX %[2]s ON dbo.%[1]s (%[3]s)", t.Name, name, strings.Join(cols, ", ")))
}
}
return out
}
// depoTablesDDL — все стейтменты создания/догонки структуры, по одному на вызов Exec.
func depoTablesDDL(pg bool) []string {
var out []string
for _, t := range depoTableSpecs {
out = append(out, t.createSQL(pg), t.addColumnsSQL(pg))
out = append(out, t.indexSQL(pg)...)
}
return out
}
var depoLoginRe = regexp.MustCompile(`^[A-Za-z0-9_.\-]+$`)
// depoTablesGrantSQL — права логину bj на чтение и запись трансферных таблиц.
func depoTablesGrantSQL(pg bool, user string) []string {
if !depoLoginRe.MatchString(user) {
return nil
}
if pg {
return []string{
fmt.Sprintf(`GRANT USAGE ON SCHEMA public TO "%s"`, user),
fmt.Sprintf(`GRANT SELECT ON ALL TABLES IN SCHEMA public TO "%s"`, user), // учётные таблицы DEPO (сверка)
fmt.Sprintf(`GRANT SELECT, INSERT, UPDATE, DELETE ON %s TO "%s"`, strings.Join(depoTableNames(), ", "), user),
}
}
out := []string{
fmt.Sprintf("IF USER_ID(N'%[1]s') IS NULL CREATE USER [%[1]s] FOR LOGIN [%[1]s]", user),
// чтение учётных таблиц DEPO (лица, счета, остатки, опции) — для сверки заявок
fmt.Sprintf("IF IS_ROLEMEMBER(N'db_datareader', N'%[1]s') = 0 ALTER ROLE db_datareader ADD MEMBER [%[1]s]", user),
}
for _, n := range depoTableNames() {
out = append(out, fmt.Sprintf("GRANT SELECT, INSERT, UPDATE, DELETE ON dbo.%s TO [%s]", n, user))
}
return out
}
func depoTablesFileName(f FancyMSSQLSettings) string {
if f.IsPostgres() {
return "bj-depo-tables-postgres.sql"
}
return "bj-depo-tables-mssql.sql"
}
// depoTablesScript — скрипт для администратора СУБД (вариант «по скрипту»).
func depoTablesScript(f FancyMSSQLSettings) string {
pg := f.IsPostgres()
db, user := f.DepoDB(), strings.TrimSpace(f.User)
sep := "\nGO\n\n"
var b strings.Builder
if pg {
sep = ";\n\n"
fmt.Fprintf(&b, "-- Bridge&Join: трансферные таблицы обмена с DEPO. PostgreSQL 10 и новее.\n-- Выполнить в базе %[1]s владельцем базы или суперпользователем, например:\n-- psql -h <сервер> -U postgres -d %[1]s -f %[2]s\n", db, depoTablesFileName(f))
} else {
fmt.Fprintf(&b, "-- Bridge&Join: трансферные таблицы обмена с DEPO. Microsoft SQL Server.\n-- Выполнить в SSMS или sqlcmd под логином с ролью db_owner в базе %s\n-- (роль нужна, чтобы добавить логин bj в db_datareader).\n", db)
}
b.WriteString("-- Скрипт можно запускать повторно: добавляются только недостающие таблицы, колонки и индексы, данные не меняются.\n\n")
if !pg {
fmt.Fprintf(&b, "USE [%s];%s", db, sep)
}
for _, s := range depoTablesDDL(pg) {
b.WriteString(s + sep)
}
if grants := depoTablesGrantSQL(pg, user); len(grants) > 0 {
fmt.Fprintf(&b, "-- Права логину bj (%s): чтение и запись трансферных таблиц.\n", user)
for _, s := range grants {
b.WriteString(s + sep)
}
} else {
b.WriteString("-- Права логину bj: выдайте SELECT, INSERT, UPDATE, DELETE на таблицы выше (логин bj в настройках не указан).\n\n")
}
b.WriteString("-- Кроме того, логину bj нужно право SELECT на учётные таблицы DEPO (OD_CLIENTS, OD_CLIENTS_PORTS, OD_RESTS, OD_VALUES и др.) для сверки переводов.\n")
return b.String()
}
func depoTableVisible(ctx context.Context, db *sql.DB, pg bool, name string) bool {
q, arg := "SELECT CASE WHEN OBJECT_ID(@p1, 'U') IS NULL THEN 0 ELSE 1 END", "dbo."+name
if pg {
q, arg = "SELECT to_regclass($1) IS NOT NULL", name
}
var ok bool
return db.QueryRowContext(ctx, q, arg).Scan(&ok) == nil && ok
}
func depoMissingColumns(ctx context.Context, db *sql.DB, pg bool, t depoTableSpec) []string {
q, arg := "SELECT name FROM sys.columns WHERE object_id = OBJECT_ID(@p1)", "dbo."+t.Name
if pg {
q, arg = "SELECT column_name FROM information_schema.columns WHERE table_schema = current_schema() AND table_name = lower($1)", t.Name
}
rows, err := db.QueryContext(ctx, q, arg)
if err != nil {
return nil
}
defer rows.Close()
have := map[string]bool{}
for rows.Next() {
var n string
if rows.Scan(&n) == nil {
have[strings.ToUpper(n)] = true
}
}
var miss []string
for _, c := range append([]depoCol{{Name: "ID"}}, t.Cols...) {
if !have[strings.ToUpper(c.Name)] {
miss = append(miss, c.Name)
}
}
return miss
}
func depoStmtHead(s string) string {
s, _, _ = strings.Cut(strings.TrimSpace(s), "\n")
if len(s) > 70 {
s = s[:70] + "…"
}
return s
}
func depoTablesRightsHint(f FancyMSSQLSettings, login string, err error) string {
m := strings.ToLower(err.Error())
if !strings.Contains(m, "permission") && !strings.Contains(m, "denied") && !strings.Contains(m, "разрешени") && !strings.Contains(m, "запрещ") {
return ""
}
if f.IsPostgres() {
return fmt.Sprintf(". У роли %s нет права создавать таблицы в базе %s: укажите владельца базы или суперпользователя либо выберите вариант «по скрипту»", login, f.DepoDB())
}
return fmt.Sprintf(". У логина %s нет права создавать таблицы в базе %s: нужна роль db_owner (или db_ddladmin, а для выдачи прав bj ещё db_securityadmin) либо вариант «по скрипту»", login, f.DepoDB())
}
// depoTablesCreate — создать таблицы в базе DEPO. adminUser/adminPass — логин с правами
// (используется один раз, не сохраняется); пусто — логин bj из настроек. Если логин
// другой, bj выдаёт своему логину права на таблицы.
func depoTablesCreate(ctx context.Context, f FancyMSSQLSettings, adminUser, adminPass string) (string, error) {
pg := f.IsPostgres()
conn, grantTo := f, ""
if u := strings.TrimSpace(adminUser); u != "" {
conn.User, conn.Password = u, adminPass
if !strings.EqualFold(u, strings.TrimSpace(f.User)) {
grantTo = strings.TrimSpace(f.User)
}
}
db, err := fancyOpen(conn, f.DepoDB())
if err != nil {
return "", err
}
defer db.Close()
if err := db.PingContext(ctx); err != nil {
return "", fmt.Errorf("подключение к базе %s логином %s: %s", f.DepoDB(), conn.User, quikErrorDetail(err))
}
existed := 0
for _, t := range depoTableSpecs {
if depoTableVisible(ctx, db, pg, t.Name) {
existed++
}
}
for _, s := range depoTablesDDL(pg) {
if _, err := db.ExecContext(ctx, s); err != nil {
return "", fmt.Errorf("%s → %s%s", depoStmtHead(s), quikErrorDetail(err), depoTablesRightsHint(f, conn.User, err))
}
}
msg := fmt.Sprintf("в базе %s создано таблиц: %d, уже были: %d", f.DepoDB(), len(depoTableSpecs)-existed, existed)
if grantTo != "" {
grants := depoTablesGrantSQL(pg, grantTo)
if grants == nil {
return msg + "; права логину " + grantTo + " не выданы: недопустимые символы в имени", nil
}
for _, s := range grants {
if _, err := db.ExecContext(ctx, s); err != nil {
return "", fmt.Errorf("%s, но выдать права логину %s не удалось: %s → %s", msg, grantTo, depoStmtHead(s), quikErrorDetail(err))
}
}
msg += ", права логину " + grantTo + " выданы"
}
return msg, nil
}
// depoTablesCheck — проверка логином bj: таблицы видны, колонки на месте, есть права на запись.
func depoTablesCheck(ctx context.Context, f FancyMSSQLSettings) (string, bool) {
pg := f.IsPostgres()
db, err := fancyOpen(f, f.DepoDB())
if err != nil {
return "подключение: " + err.Error(), false
}
defer db.Close()
if err := db.PingContext(ctx); err != nil {
return fmt.Sprintf("нет подключения к базе %s: %s", f.DepoDB(), quikErrorDetail(err)), false
}
var problems []string
for _, t := range depoTableSpecs {
if !depoTableVisible(ctx, db, pg, t.Name) {
problems = append(problems, "нет таблицы "+t.Name+" (или у логина "+f.User+" нет к ней доступа)")
continue
}
var lacks []string
for _, p := range []string{"SELECT", "INSERT", "UPDATE", "DELETE"} {
q, arg := "SELECT ISNULL(HAS_PERMS_BY_NAME(@p1, 'OBJECT', @p2), 0)", "dbo."+t.Name
if pg {
q, arg = "SELECT has_table_privilege($1, $2)", t.Name
}
var has bool
if db.QueryRowContext(ctx, q, arg, p).Scan(&has) != nil || !has {
lacks = append(lacks, p)
}
}
if len(lacks) > 0 {
problems = append(problems, fmt.Sprintf("у логина %s нет прав %s на %s", f.User, strings.Join(lacks, "/"), t.Name))
}
if miss := depoMissingColumns(ctx, db, pg, t); len(miss) > 0 {
problems = append(problems, t.Name+": нет колонок "+strings.Join(miss, ", "))
}
}
if len(problems) > 0 {
return strings.Join(problems, "; "), false
}
return fmt.Sprintf("в базе %s все %d таблицы на месте, колонки совпадают, у логина %s есть SELECT/INSERT/UPDATE/DELETE", f.DepoDB(), len(depoTableSpecs), f.User), true
}
// DepoTablesView — данные карточки «Трансферные таблицы DEPO».
type DepoTablesView struct {
DB, User, Script, ScriptFile string
Postgres bool
}
func depoTablesView(f FancyMSSQLSettings) DepoTablesView {
return DepoTablesView{DB: f.DepoDB(), User: f.User, Script: depoTablesScript(f), ScriptFile: depoTablesFileName(f), Postgres: f.IsPostgres()}
}