Files
Bridge-and-Join-s/internal/lkgateway/depotables.go
T
fontvielle 50e420f4a0 DEPO на Firebird в мастере: подготовка базы одним действием, как для MSSQL
Мастер, шаг «Учёт DEPO», и «Обмен с DEPO» в настройках: СУБД «Firebird» (Fancy на Firebird 2.5).
- Подключение: драйвер nakagami/firebirdsql, путь к файлу базы или псевдоним, WIN1251. Запросы bj к DEPO
  (T-SQL) подключение переводит само (fancyfb.go): механически (dbo., TOP, ISNULL, GETDATE, LEN, N, exec)
  вне строк и комментариев, точные тексты — fsql/fb.go (каталоги, LIKE [_], COUNT(*) OVER, SELECT без FROM,
  PATINDEX); @имена — позиционные ?, GUID — в верхнем регистре. Системные запросы из кода — в fsql/depocatalog.go.
- «Подготовить» логином SYSDBA (depo_setup_fb.go): проверка базы и UDF Fancy, логин SYSDBA_BJ (Fancy даёт права
  на документы логинам SYSDBA%; для Firebird — обычный пользователь), оператор-робот Fancy, кодировщик M2M,
  привязки FTV_M2M_SETTINGS по признакам Fancy, таблицы, процедуры, права; обработку запускает bj.
  «Получить скрипт» — то же для isql в WIN1251; письмо администратору, установка процедур, плашка обновления.
- Скрипты Firebird встроены (depoprocs/firebird, тест сверяет с deploy/); количества — DOUBLE PRECISION: из
  диалекта 3 в базу диалекта 1 процедуру с NUMERIC(18,7) не создать.
- Проверено на копии базы Fancy (отдельный экземпляр bj): мастер с нуля — все проверки, BJ_PING; тестовый режим
  DEPO — К1 Бубликов, зачисление и списание до исполнения без ошибок; очередь, справочник НРД.
  Тест FBLive готовит на базе все запросы bj к DEPO: 94 из 101, оставшиеся 7 — ЛК и ручной ввод (следующий этап).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q7eREaYwUt1i4fNLZcGnjn
2026-10-27 18:55:02 +03:00

447 lines
19 KiB
Go
Raw 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"
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/lkgateway/fsql"
"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{
depoNull("GUID", "uniqueidentifier", "uuid"), // NULL — служебные события без перевода (HANDBOOK), как в DEPO Фонтвьель с 12.10.2026
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"),
depoNull("PARAMETERS", "nvarchar(4000)", "text"), // параметры события: № поручения, № МНП, «№ ответа НРД от дата» (17.09.2026)
}, 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(fsql.Tbl01, user), // учётные таблицы DEPO (сверка)
fmt.Sprintf(fsql.Tbl02, 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),
// пользователь базы от прежнего логина (база восстановлена из копии / логин пересоздан) — перепривязать
fmt.Sprintf("IF EXISTS (SELECT 1 FROM sys.database_principals WHERE name = N'%[1]s' AND sid <> SUSER_SID(N'%[1]s')) ALTER USER [%[1]s] WITH 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),
// запись в таблицы Fancy — процедуры обработки M2M (FTV_M2M_JOB…) работают правами вызывающего (bj)
fmt.Sprintf("IF IS_ROLEMEMBER(N'db_datawriter', N'%[1]s') = 0 ALTER ROLE db_datawriter 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"
}
if f.IsFirebird() {
return "bj-depo-tables-firebird.sql"
}
return "bj-depo-tables-mssql.sql"
}
// depoTablesScript — скрипт для администратора СУБД (вариант «по скрипту»).
func depoTablesScript(f FancyMSSQLSettings) string {
if f.IsFirebird() { // таблицы у Firebird — вместе с привязками, процедурами и правами (depo_setup_fb.go)
return depoSetupScriptFB(f, f.DepoDB(), depoSetupLogin(f), "")
}
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(fsql.QTb01)
return b.String()
}
func depoTableVisible(ctx context.Context, db *sql.DB, pg bool, name string) bool {
q, arg := fsql.Cat01, "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 := fsql.Cat02, "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) {
if f.IsFirebird() { // у Firebird таблицы ставятся вместе с привязками, процедурами и правами
return depoProcsInstallFB(ctx, f, adminUser, adminPass)
}
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 := fsql.Cat03, "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, Firebird bool
}
func depoTablesView(f FancyMSSQLSettings) DepoTablesView {
return DepoTablesView{DB: f.DepoDB(), User: f.User, Script: depoTablesScript(f), ScriptFile: depoTablesFileName(f), Postgres: f.IsPostgres(), Firebird: f.IsFirebird()}
}