Files
Bridge-and-Join-s/internal/lkgateway/onec.go
T
fontvielle 21d5f33394 1С: проверка поручений МНП перед отправкой в НРД
Часть модуля onec-back, по умолчанию выключена, включается в настройках
(«Проверка поручений в 1С»). Исходящий перевод после блокировки бумаг в
МНП ждёт решения 1С и в НРД не уходит, пока 1С не подтвердит каждое
поручение; при отказе bj снимает поручения в QUIK с причиной из 1С и
отклоняет перевод в журнале и кабинете клиента.

- onec.instr / onec.instr_queue / onec.set_decision в базе bj: 1С читает
  очередь и пишет решение под логином onec_bj (только эти права); чтение
  очереди отмечает связь с 1С.
- bj-onec-admin (root, через bj-keymedia): логин onec_bj, вход только с
  IP 1С по TLS (pg_hba.conf), listen_addresses, ufw; revoke закрывает вход.
- onecLoop: отправка подтверждённых, отказы, снятие клиентом в QUIK,
  поручения из терминала, сроки решения и потеря связи — оповещения.
- Карточка заявки: блок «Проверка 1С», решение оператора вместо 1С.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q7eREaYwUt1i4fNLZcGnjn
2026-10-22 20:13:59 +03:00

131 lines
5.9 KiB
Go

package lkgateway
// База 1С бэк-офиса — модуль лицензии onec-back (07.10.2026, тестовый). Пока это подключение: адрес
// публикации базы на веб-сервере, пользователь и пароль 1С; bj проверяет их через стандартный интерфейс OData
// ($metadata). Обмен данными с базой — следующий шаг модуля.
import (
"context"
"errors"
"fmt"
"io"
"net"
"net/http"
"strings"
"time"
)
// OneCSettings — подключение к базе 1С бэк-офиса.
type OneCSettings struct {
URL string `json:"url,omitempty"` // адрес публикации: http://1c-server/middle
User string `json:"user,omitempty"`
Password string `json:"password,omitempty"`
CheckedAt time.Time `json:"checked_at,omitempty"`
CheckOK bool `json:"check_ok,omitempty"`
CheckMsg string `json:"check_msg,omitempty"`
// Check — проверка поручений МНП в 1С перед отправкой в НРД (onec_check.go).
Check OneCCheckSettings `json:"check,omitempty"`
}
func (r *RuntimeConfig) UpdateOneC(s OneCSettings) error {
r.mu.Lock()
r.data.OneC = s
r.data.UpdatedAt = time.Now().UTC()
r.mu.Unlock()
return r.save()
}
// oneCBase — адрес публикации без хвоста /odata/… и без завершающего «/».
func oneCBase(u string) string {
u = strings.TrimSpace(u)
if i := strings.Index(strings.ToLower(u), "/odata/"); i >= 0 {
u = u[:i]
}
u = strings.TrimRight(u, "/")
if u != "" && !strings.Contains(u, "://") {
u = "http://" + u
}
return u
}
// saveOneC — POST /admin/setup/onec: сохранить подключение и сразу проверить его.
func (h *setupHandlers) saveOneC(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method", http.StatusMethodNotAllowed)
return
}
s := h.rc.Snapshot().OneC
s.URL = oneCBase(r.FormValue("onec_url"))
s.User = strings.TrimSpace(r.FormValue("onec_user"))
if pw := r.FormValue("onec_password"); pw != "" {
s.Password = pw
}
var miss []string
for _, f := range []struct{ v, n string }{{s.URL, "адрес публикации базы"}, {s.User, "пользователь 1С"}, {s.Password, "пароль"}} {
if f.v == "" {
miss = append(miss, f.n)
}
}
if len(miss) > 0 {
setupFlash(w, r, "1С: не заполнено: "+strings.Join(miss, ", "))
return
}
ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second)
s.CheckOK, s.CheckMsg = oneCCheck(ctx, s)
cancel()
s.CheckedAt = time.Now()
if err := h.rc.UpdateOneC(s); err != nil {
setupFlash(w, r, "1С: настройки не сохранены — "+err.Error())
return
}
if s.CheckOK {
setupFlash(w, r, "1С: подключение сохранено — "+s.CheckMsg)
return
}
setupFlash(w, r, "1С: подключение сохранено, но проверка не прошла — "+s.CheckMsg)
}
// oneCCheck — база отвечает по стандартному интерфейсу OData и пускает пользователя.
func oneCCheck(ctx context.Context, s OneCSettings) (bool, string) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, oneCBase(s.URL)+"/odata/standard.odata/$metadata", nil)
if err != nil {
return false, "адрес не разобран: " + err.Error()
}
req.SetBasicAuth(s.User, s.Password)
cl := &http.Client{Transport: &http.Transport{Proxy: nil, DialContext: (&net.Dialer{Timeout: 8 * time.Second}).DialContext}}
resp, err := cl.Do(req)
if err != nil {
var ne net.Error
if errors.As(err, &ne) && ne.Timeout() {
return false, "сервер 1С не ответил вовремя — проверьте адрес и что веб-сервер с публикацией базы доступен с сервера bj"
}
if strings.Contains(err.Error(), "certificate") {
return false, "сертификат HTTPS веб-сервера 1С не доверенный — укажите адрес по http или выпустите доверенный сертификат (" + err.Error() + ")"
}
return false, "сервер 1С не отвечает: " + err.Error()
}
defer resp.Body.Close()
body, _ := io.ReadAll(io.LimitReader(resp.Body, 64<<20))
switch resp.StatusCode {
case http.StatusOK:
case http.StatusUnauthorized, http.StatusForbidden:
return false, "1С не пустила пользователя «" + s.User + "»: неверный логин или пароль, либо у пользователя нет права на внешнее подключение"
case http.StatusNotFound:
return false, "по адресу нет стандартного интерфейса OData: в конфигураторе 1С — Администрирование → Публикация на веб-сервере, отметьте «Публиковать стандартный интерфейс OData» и переопубликуйте базу"
default:
t := strings.TrimSpace(string(body))
if len([]rune(t)) > 200 {
t = string([]rune(t)[:200]) + "…"
}
return false, fmt.Sprintf("веб-сервер 1С ответил %d %s", resp.StatusCode, t)
}
if !strings.Contains(string(body), "edmx") {
return false, "по адресу отвечает не 1С (нет описания OData) — проверьте адрес публикации базы"
}
n := strings.Count(string(body), "<EntityType ")
if n == 0 {
return true, "база отвечает, пользователь входит; объекты для OData ещё не открыты — их состав задаётся в базе 1С (УстановитьСоставСтандартногоИнтерфейсаOData)"
}
return true, fmt.Sprintf("база отвечает, пользователь входит; объектов в OData — %d", n)
}