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
This commit is contained in:
fontvielle
2026-10-27 18:55:02 +03:00
parent 7073ce2a33
commit 50e420f4a0
30 changed files with 2698 additions and 71 deletions
+470
View File
@@ -0,0 +1,470 @@
package lkgateway
// Fancy DEPO на Firebird (27.10.2026). Fancy работает и на Firebird 2.5 — те же таблицы и процедуры ядра, что в
// MSSQL-версии (MSSQL-версия Fancy — перевод Firebird-версии). Весь SQL bj к DEPO написан на T-SQL; чтобы не
// держать второй текст в каждом месте вызова, подключение к Firebird переводит запросы само (fbConn):
// - точный текст Firebird — для запросов, которые механически не переводятся (fsql.FB, ключ — текст T-SQL);
// - остальное механически, вне строк и комментариев: без dbo., TOP n — FIRST n, ISNULL — COALESCE,
// GETDATE() — CURRENT_TIMESTAMP, LEN — CHAR_LENGTH, N'…' — '…', «exec имя» — EXECUTE PROCEDURE имя;
// - именованные параметры @имя (sql.Named) — позиционные ? в порядке появления в тексте;
// - GUID в параметрах — в верхнем регистре (в таблицах обмена Firebird GUID — строка в верхнем регистре,
// uniqueidentifier MSSQL сравнивается без учёта регистра); время «2006-01-02T15:04:05» — с пробелом вместо T.
// База Fancy на Firebird обычно в диалекте 1 и кодировке NONE с данными в WIN1251 — подключение в WIN1251.
// Таблицы, процедуры и права для Firebird ставит depo_setup_fb.go; DDL сюда не попадает.
import (
"context"
"database/sql/driver"
"errors"
"fmt"
"log"
"net/url"
"regexp"
"strconv"
"strings"
"sync"
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/lkgateway/fsql"
"github.com/nakagami/firebirdsql"
)
// fbDSN — строка подключения драйвера firebirdsql: путь к файлу базы на сервере Firebird или псевдоним.
func fbDSN(s FancyMSSQLSettings, db string) string {
q := url.Values{}
q.Set("charset", "WIN1251")
u := &url.URL{
Scheme: "firebird",
User: url.UserPassword(s.User, s.Password),
Host: fmt.Sprintf("%s:%d", s.Server, s.DefaultPort()),
Path: "/" + db,
RawQuery: q.Encode(),
}
return u.String()
}
// validFBPath — путь к файлу базы Firebird на сервере (D:\Fancy\DEPO.GDB, /var/db/depo.fdb) или псевдоним
// из aliases.conf: без кавычек, точки с запятой и управляющих символов (путь уходит в строку подключения).
func validFBPath(s string) bool {
if strings.TrimSpace(s) == "" || len(s) > 255 {
return false
}
for _, c := range s {
if c < ' ' || strings.ContainsRune(`'";?#`, c) {
return false
}
}
return true
}
// fbIsLoginFailure — Firebird отверг логин или пароль.
func fbIsLoginFailure(err error) bool {
var fe *firebirdsql.FbError
if !errors.As(err, &fe) {
return false
}
for _, c := range fe.GDSCodes {
if c == firebirdsql.ISCLogin || c == firebirdsql.ISCLoginError {
return true
}
}
return false
}
// fbConnector — подключения к Firebird с переводом запросов.
type fbConnector struct{ base driver.Connector }
func (c fbConnector) Driver() driver.Driver { return c.base.Driver() }
func (c fbConnector) Connect(ctx context.Context) (driver.Conn, error) {
conn, err := c.base.Connect(ctx)
if err != nil {
return nil, err
}
return &fbConn{c: conn}, nil
}
// fbConn — подключение драйвера firebirdsql, которому database/sql отдаёт T-SQL bj: текст и параметры
// переводятся в fbRewrite. Остальные методы — как у драйвера.
type fbConn struct{ c driver.Conn }
func (f *fbConn) Prepare(query string) (driver.Stmt, error) {
return f.PrepareContext(context.Background(), query)
}
func (f *fbConn) PrepareContext(ctx context.Context, query string) (driver.Stmt, error) {
q, names := query, []string(nil)
if !fbNative(query) {
q, names = fbParams(fbSQL(query))
}
var st driver.Stmt
var err error
if pc, ok := f.c.(driver.ConnPrepareContext); ok {
st, err = pc.PrepareContext(ctx, q)
} else {
st, err = f.c.Prepare(q)
}
if err != nil {
return nil, fbErr(query, q, err)
}
return &fbStmt{st: st, names: names}, nil
}
func (f *fbConn) Close() error { return f.c.Close() }
func (f *fbConn) Begin() (driver.Tx, error) {
return f.BeginTx(context.Background(), driver.TxOptions{})
}
func (f *fbConn) BeginTx(ctx context.Context, opts driver.TxOptions) (driver.Tx, error) {
if bt, ok := f.c.(driver.ConnBeginTx); ok {
return bt.BeginTx(ctx, opts)
}
return f.c.Begin() //nolint:staticcheck // у драйвера без ConnBeginTx другого способа нет
}
func (f *fbConn) ExecContext(ctx context.Context, query string, args []driver.NamedValue) (driver.Result, error) {
ex, ok := f.c.(driver.ExecerContext)
if !ok {
return nil, driver.ErrSkip
}
q, a, err := fbRewrite(query, args)
if err != nil {
return nil, err
}
res, err := ex.ExecContext(ctx, q, a)
return res, fbErr(query, q, err)
}
func (f *fbConn) QueryContext(ctx context.Context, query string, args []driver.NamedValue) (driver.Rows, error) {
qr, ok := f.c.(driver.QueryerContext)
if !ok {
return nil, driver.ErrSkip
}
q, a, err := fbRewrite(query, args)
if err != nil {
return nil, err
}
rows, err := qr.QueryContext(ctx, q, a)
return rows, fbErr(query, q, err)
}
func (f *fbConn) Ping(ctx context.Context) error {
if p, ok := f.c.(driver.Pinger); ok {
return p.Ping(ctx)
}
return nil
}
func (f *fbConn) CheckNamedValue(nv *driver.NamedValue) error {
if c, ok := f.c.(driver.NamedValueChecker); ok {
return c.CheckNamedValue(nv)
}
return driver.ErrSkip
}
func (f *fbConn) IsValid() bool {
if v, ok := f.c.(driver.Validator); ok {
return v.IsValid()
}
return true
}
func (f *fbConn) ResetSession(ctx context.Context) error {
if r, ok := f.c.(driver.SessionResetter); ok {
return r.ResetSession(ctx)
}
return nil
}
// fbStmt — подготовленный запрос: параметры при выполнении расставляются по именам из текста.
type fbStmt struct {
st driver.Stmt
names []string
}
func (s *fbStmt) Close() error { return s.st.Close() }
func (s *fbStmt) NumInput() int { return -1 }
func (s *fbStmt) Exec(args []driver.Value) (driver.Result, error) {
return s.ExecContext(context.Background(), fbValues(args))
}
func (s *fbStmt) Query(args []driver.Value) (driver.Rows, error) {
return s.QueryContext(context.Background(), fbValues(args))
}
func (s *fbStmt) ExecContext(ctx context.Context, args []driver.NamedValue) (driver.Result, error) {
a, err := fbArgs(s.names, args)
if err != nil {
return nil, err
}
if ex, ok := s.st.(driver.StmtExecContext); ok {
return ex.ExecContext(ctx, a)
}
return s.st.Exec(fbPlain(a)) //nolint:staticcheck // драйвер без StmtExecContext
}
func (s *fbStmt) QueryContext(ctx context.Context, args []driver.NamedValue) (driver.Rows, error) {
a, err := fbArgs(s.names, args)
if err != nil {
return nil, err
}
if q, ok := s.st.(driver.StmtQueryContext); ok {
return q.QueryContext(ctx, a)
}
return s.st.Query(fbPlain(a)) //nolint:staticcheck // драйвер без StmtQueryContext
}
func fbValues(v []driver.Value) []driver.NamedValue {
out := make([]driver.NamedValue, len(v))
for i, x := range v {
out[i] = driver.NamedValue{Ordinal: i + 1, Value: x}
}
return out
}
func fbPlain(a []driver.NamedValue) []driver.Value {
out := make([]driver.Value, len(a))
for i, x := range a {
out[i] = x.Value
}
return out
}
// fbRewrite — текст и параметры запроса T-SQL для Firebird. DDL и блоки PSQL (установка, depo_setup_fb.go)
// написаны для Firebird — уходят как есть.
func fbRewrite(query string, args []driver.NamedValue) (string, []driver.NamedValue, error) {
if fbNative(query) {
return query, args, nil
}
q, names := fbParams(fbSQL(query))
a, err := fbArgs(names, args)
return q, a, err
}
var fbNativeRe = regexp.MustCompile(`(?i)^\s*(CREATE|ALTER|RECREATE|DROP|GRANT|REVOKE|EXECUTE\s+BLOCK|COMMENT\s+ON)\b`)
// fbNative — команда уже на Firebird (установка таблиц, процедур, прав).
func fbNative(q string) bool { return fbNativeRe.MatchString(fbStripComments(q)) }
var (
fbExecRe = regexp.MustCompile(`(?is)^\s*exec(?:ute)?\s+(?:\[?dbo\]?\.)?\[?([A-Za-z0-9_]+)\]?\s*;?\s*$`)
fbObjectIDRe = regexp.MustCompile(`(?i)^SELECT OBJECT_ID\('(?:dbo\.)?([A-Za-z0-9_]+)'\)$`)
fbDboRe = regexp.MustCompile(`(?i)\[?\bdbo\]?\.`)
fbIsNullRe = regexp.MustCompile(`(?i)\bISNULL\s*\(`)
fbGetDateRe = regexp.MustCompile(`(?i)\bGETDATE\s*\(\s*\)`)
fbLenRe = regexp.MustCompile(`(?i)\bLEN\s*\(`)
fbTopRe = regexp.MustCompile(`(?i)\bSELECT(\s+DISTINCT)?\s+TOP\s*(\(\s*@?\w+\s*\)|\d+)`)
fbGUIDRe = regexp.MustCompile(`^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$`)
fbISOTimeRe = regexp.MustCompile(`^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2}(\.\d+)?)?$`)
)
// fbSQL — текст запроса для Firebird: точный перевод из fsql.FB, иначе механический.
func fbSQL(q string) string {
if t, ok := fsql.FB[q]; ok {
return t
}
if m := fbExecRe.FindStringSubmatch(q); m != nil {
return "EXECUTE PROCEDURE " + m[1]
}
if m := fbObjectIDRe.FindStringSubmatch(q); m != nil { // depoTableExists: NULL — таблицы нет
return "SELECT (SELECT FIRST 1 1 FROM RDB$RELATIONS WHERE RDB$RELATION_NAME = '" + strings.ToUpper(m[1]) + "') FROM RDB$DATABASE"
}
return fbOutsideLiterals(q, func(code string) string {
code = fbDboRe.ReplaceAllString(code, "")
code = fbIsNullRe.ReplaceAllString(code, "COALESCE(")
code = fbGetDateRe.ReplaceAllString(code, "CURRENT_TIMESTAMP")
code = fbLenRe.ReplaceAllString(code, "CHAR_LENGTH(")
return fbTopRe.ReplaceAllStringFunc(code, func(s string) string {
m := fbTopRe.FindStringSubmatch(s)
return "SELECT FIRST " + m[2] + m[1] // в Firebird FIRST — до DISTINCT
})
})
}
// fbOutsideLiterals — замены только в коде: строки, имена в кавычках и комментарии не трогаются; N перед
// строкой (N'…' — национальная строка T-SQL) убирается.
func fbOutsideLiterals(q string, fix func(string) string) string {
var b, code strings.Builder
emit := func(lit string) {
c := code.String()
code.Reset()
if strings.HasPrefix(lit, "'") && (strings.HasSuffix(c, "N") || strings.HasSuffix(c, "n")) &&
(len(c) == 1 || !fbIdentChar(c[len(c)-2])) {
c = c[:len(c)-1]
}
b.WriteString(fix(c))
b.WriteString(lit)
}
for i := 0; i < len(q); i++ {
c := q[i]
switch {
case c == '\'' || c == '"':
j := i + 1
for j < len(q) {
if q[j] == c {
if j+1 < len(q) && q[j+1] == c {
j += 2
continue
}
break
}
j++
}
if j >= len(q) {
j = len(q) - 1
}
emit(q[i : j+1])
i = j
case c == '-' && i+1 < len(q) && q[i+1] == '-':
j := strings.IndexByte(q[i:], '\n')
if j < 0 {
j = len(q) - i
}
emit(q[i : i+j])
i += j - 1
case c == '/' && i+1 < len(q) && q[i+1] == '*':
j := strings.Index(q[i+2:], "*/")
end := len(q)
if j >= 0 {
end = i + 2 + j + 2
}
emit(q[i:end])
i = end - 1
default:
code.WriteByte(c)
}
}
b.WriteString(fix(code.String()))
return b.String()
}
// fbParams — @имя вне строк и комментариев заменяются на ?; возвращает имена по порядку (повторы — повторно).
func fbParams(q string) (string, []string) {
if !strings.Contains(q, "@") {
return q, nil
}
var b strings.Builder
var names []string
for i := 0; i < len(q); i++ {
c := q[i]
switch {
case c == '\'' || c == '"': // строка или имя в кавычках — как есть до закрывающей
j := i + 1
for j < len(q) {
if q[j] == c {
if j+1 < len(q) && q[j+1] == c {
j += 2
continue
}
break
}
j++
}
if j >= len(q) {
j = len(q) - 1
}
b.WriteString(q[i : j+1])
i = j
case c == '-' && i+1 < len(q) && q[i+1] == '-':
j := strings.IndexByte(q[i:], '\n')
if j < 0 {
j = len(q) - i
}
b.WriteString(q[i : i+j])
i += j - 1
case c == '/' && i+1 < len(q) && q[i+1] == '*':
j := strings.Index(q[i+2:], "*/")
end := len(q)
if j >= 0 {
end = i + 2 + j + 2
}
b.WriteString(q[i:end])
i = end - 1
case c == '@' && i+1 < len(q) && fbIdentStart(q[i+1]):
j := i + 1
for j < len(q) && fbIdentChar(q[j]) {
j++
}
names = append(names, q[i+1:j])
b.WriteByte('?')
i = j - 1
default:
b.WriteByte(c)
}
}
return b.String(), names
}
func fbIdentStart(c byte) bool { return c == '_' || c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' }
func fbIdentChar(c byte) bool { return fbIdentStart(c) || c >= '0' && c <= '9' }
// fbArgs — значения для ? по именам из текста: sql.Named — по имени, @p1…@pN без имени — по номеру.
// Без имён в тексте параметры передаются как есть (запрос уже с ?).
func fbArgs(names []string, args []driver.NamedValue) ([]driver.NamedValue, error) {
if len(names) == 0 {
out := make([]driver.NamedValue, len(args))
for i, a := range args {
out[i] = driver.NamedValue{Ordinal: i + 1, Value: fbValue(a.Value)}
}
return out, nil
}
out := make([]driver.NamedValue, 0, len(names))
for _, n := range names {
v, ok := fbArg(n, args)
if !ok {
return nil, fmt.Errorf("параметр @%s не передан", n)
}
out = append(out, driver.NamedValue{Ordinal: len(out) + 1, Value: fbValue(v)})
}
return out, nil
}
func fbArg(name string, args []driver.NamedValue) (driver.Value, bool) {
for _, a := range args {
if a.Name != "" && strings.EqualFold(a.Name, name) {
return a.Value, true
}
}
if len(name) > 1 && (name[0] == 'p' || name[0] == 'P') {
if n, err := strconv.Atoi(name[1:]); err == nil && n >= 1 {
for _, a := range args {
if a.Name == "" && a.Ordinal == n {
return a.Value, true
}
}
}
}
return nil, false
}
// fbValue — GUID в верхнем регистре, время ISO 8601 — с пробелом вместо T (Firebird «T» не разбирает).
func fbValue(v driver.Value) driver.Value {
s, ok := v.(string)
if !ok {
return v
}
switch {
case fbGUIDRe.MatchString(s):
return strings.ToUpper(s)
case fbISOTimeRe.MatchString(s):
return strings.Replace(s, "T", " ", 1)
}
return v
}
var fbErrLogged sync.Map
// fbErr — ошибка Firebird; для разбора в журнал (раз на запрос) — исходный текст T-SQL и что ушло в Firebird.
func fbErr(orig, sent string, err error) error {
if err == nil {
return nil
}
var fe *firebirdsql.FbError
if errors.As(err, &fe) && fe.SQLCode == -104 { // синтаксис: перевод запроса не подошёл
if _, seen := fbErrLogged.LoadOrStore(orig, true); !seen {
log.Printf("depo-fb: запрос не принят Firebird: %v\n T-SQL: %s\n Firebird: %s", err, depoStmtHead(orig), sent)
}
}
return err
}