Files
Bridge-and-Join-s/internal/lkgateway/onec_check.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

1158 lines
48 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
// Проверка поручений в 1С (22.10.2026) — часть модуля лицензии onec-back, включается отдельно в
// настройках («Проверка поручений в 1С»). Исходящий перевод уходит в НРД только после того, как 1С
// подтвердила каждое поручение МНП («Вывод ЦБ (M2M)») этого перевода. Обмен — через базу PostgreSQL bj:
// - bj кладёт поручения в onec.instr (ключ — номер поручения МНП) и держит перевод «Не отправлена»;
// - 1С читает представление onec.instr_queue и записывает решение функцией onec.set_decision под
// своим логином onec_bj (логин, вход по IP и TLS заводит root-скрипт bj-onec-admin);
// - onecLoop по решению отправляет перевод в НРД или снимает поручения в МНП с причиной из 1С.
// Пока проверка выключена (или модуля нет в лицензии), перевод идёт как раньше, объекты для 1С не
// создаются, вход onec_bj закрыт. Инструкция программисту 1С — документ «Bridge&Join × 1С: проверка
// поручений МНП».
import (
"context"
"crypto/rand"
"database/sql"
"encoding/json"
"errors"
"fmt"
"log"
"math/big"
"net"
"net/http"
"strconv"
"strings"
"sync"
"time"
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/m2m"
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/m2mcore"
)
// OneCCheckSettings — проверка поручений в 1С.
type OneCCheckSettings struct {
Enabled bool `json:"enabled,omitempty"`
EnabledAt time.Time `json:"enabled_at,omitempty"` // когда включили (поручения из терминала — с этого момента)
ClientIPs string `json:"client_ips,omitempty"` // IP сервера 1С через запятую: вход в базу bj только с них
TimeoutMin int `json:"timeout_min,omitempty"` // срок решения 1С, мин (0 — 30): дольше — оповещение оператору
AccessAt time.Time `json:"access_at,omitempty"` // когда bj последний раз открыл вход логину onec_bj
AccessOK bool `json:"access_ok,omitempty"`
AccessMsg string `json:"access_msg,omitempty"`
}
// Timeout — срок решения 1С.
func (s OneCCheckSettings) Timeout() time.Duration {
if s.TimeoutMin <= 0 {
return 30 * time.Minute
}
return time.Duration(s.TimeoutMin) * time.Minute
}
const (
onecWaitStatus = "onec_wait" // ответ SendBuiltRequestVia: перевод ждёт решения 1С
onecLostAfter = 15 * time.Minute // 1С не читала очередь дольше — связь с 1С потеряна
onecLogin = "onec_bj" // логин 1С в PostgreSQL bj
onecKeepDays = 45 // сверка bj_state — по строкам не старше
onecTermPrefix = "terminal-" // поручения, поданные клиентом из терминала QUIK (quikhooks.go)
onecPwLen = 24 // длина пароля onec_bj
onecPwAlphabet = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789"
)
// onecCheckActive — проверка 1С включена, модуль в лицензии, база bj настроена.
func onecCheckActive(rc *RuntimeConfig) bool {
if rc == nil {
return false
}
s := rc.Snapshot()
return s.OneC.Check.Enabled && strings.TrimSpace(s.Postgres.DSN) != "" && moduleAllowed(rc, "onec-back")
}
// onecSchemaDDL — объекты для 1С в базе bj (владелец — bj). Совместимо с PostgreSQL 11.
var onecSchemaDDL = []string{
`CREATE SCHEMA IF NOT EXISTS onec`,
`CREATE TABLE IF NOT EXISTS onec.instr (
id bigserial PRIMARY KEY,
guid text NOT NULL,
isin text NOT NULL DEFAULT '',
instr_no bigint,
source text NOT NULL DEFAULT 'lk',
created_at timestamptz NOT NULL DEFAULT now(),
direction text NOT NULL DEFAULT 'OUT',
client_code text,
contract text,
sec_code text,
qty numeric(20,7),
depo_account text,
counterparty_code text,
counterparty_name text,
status text NOT NULL DEFAULT 'NEW' CHECK (status IN ('NEW','CONFIRMED','REJECTED','CANCELLED')),
reason text,
checked_by text,
checked_at timestamptz,
onec_doc text,
by_operator boolean NOT NULL DEFAULT false,
bj_state text NOT NULL DEFAULT 'WAIT_1C',
bj_state_at timestamptz NOT NULL DEFAULT now(),
bj_note text,
alerted_at timestamptz
)`,
`CREATE UNIQUE INDEX IF NOT EXISTS instr_no_uq ON onec.instr (instr_no) WHERE instr_no IS NOT NULL`,
`CREATE UNIQUE INDEX IF NOT EXISTS instr_nomnp_uq ON onec.instr (guid, isin) WHERE instr_no IS NULL`,
`CREATE INDEX IF NOT EXISTS instr_guid ON onec.instr (guid)`,
`CREATE TABLE IF NOT EXISTS onec.reads (
id int PRIMARY KEY DEFAULT 1 CHECK (id = 1),
read_at timestamptz NOT NULL,
reads bigint NOT NULL DEFAULT 0
)`,
// Очередь для 1С: чтение отмечает время (по нему bj видит, что 1С на связи).
`CREATE OR REPLACE FUNCTION onec.queue() RETURNS TABLE (
instr_no bigint, created_at timestamptz, direction text, client_code text, contract text,
isin text, sec_code text, qty numeric, depo_account text, counterparty_code text,
counterparty_name text, status text, reason text, checked_by text, checked_at timestamptz,
onec_doc text, bj_state text, bj_state_at timestamptz)
LANGUAGE plpgsql SECURITY DEFINER SET search_path = pg_catalog, pg_temp AS $f$
BEGIN
INSERT INTO onec.reads AS r (id, read_at, reads) VALUES (1, now(), 1)
ON CONFLICT (id) DO UPDATE SET read_at = now(), reads = r.reads + 1;
RETURN QUERY SELECT i.instr_no, i.created_at, i.direction, i.client_code, i.contract,
i.isin, i.sec_code, i.qty::numeric, i.depo_account, i.counterparty_code,
i.counterparty_name, i.status, i.reason, i.checked_by, i.checked_at,
i.onec_doc, CASE WHEN i.bj_state = 'WAIT_OPER' THEN 'WAIT_1C' ELSE i.bj_state END, i.bj_state_at
FROM onec.instr i WHERE i.instr_no IS NOT NULL ORDER BY i.created_at, i.id;
END
$f$`,
`CREATE OR REPLACE VIEW onec.instr_queue AS SELECT * FROM onec.queue()`,
// Решение 1С: окончательное; повтор того же решения — ALREADY, другое — CONFLICT.
`CREATE OR REPLACE FUNCTION onec.set_decision(p_instr_no bigint, p_status text, p_reason text,
p_checked_by text, p_onec_doc text) RETURNS text
LANGUAGE plpgsql SECURITY DEFINER SET search_path = pg_catalog, pg_temp AS $f$
DECLARE
st text := upper(btrim(coalesce(p_status, '')));
rs text := nullif(btrim(coalesce(p_reason, '')), '');
cur onec.instr%ROWTYPE;
BEGIN
IF st NOT IN ('CONFIRMED', 'REJECTED') THEN
RETURN 'ERROR: статус — CONFIRMED или REJECTED';
END IF;
IF st = 'REJECTED' AND rs IS NULL THEN
RETURN 'ERROR: при отказе нужна причина';
END IF;
IF char_length(rs) > 255 THEN
RETURN 'ERROR: причина длиннее 255 символов';
END IF;
SELECT * INTO cur FROM onec.instr WHERE instr_no = p_instr_no FOR UPDATE;
IF NOT FOUND THEN
RETURN 'NOT_FOUND';
END IF;
IF cur.status <> 'NEW' THEN
IF cur.status = st THEN
RETURN 'ALREADY';
END IF;
RETURN 'CONFLICT';
END IF;
UPDATE onec.instr SET status = st,
reason = CASE WHEN st = 'REJECTED' THEN rs END,
checked_by = left(nullif(btrim(coalesce(p_checked_by, '')), ''), 200),
checked_at = now(),
onec_doc = left(nullif(btrim(coalesce(p_onec_doc, '')), ''), 500)
WHERE id = cur.id;
RETURN 'OK';
END
$f$`,
`REVOKE ALL ON SCHEMA onec FROM PUBLIC`,
`REVOKE ALL ON ALL TABLES IN SCHEMA onec FROM PUBLIC`,
`REVOKE ALL ON FUNCTION onec.queue() FROM PUBLIC`,
`REVOKE ALL ON FUNCTION onec.set_decision(bigint, text, text, text, text) FROM PUBLIC`,
}
// onecGrantSQL — права логину 1С (выдаёт bj как владелец объектов, когда логин заведён).
var onecGrantSQL = []string{
`GRANT USAGE ON SCHEMA onec TO ` + onecLogin,
`GRANT SELECT ON onec.instr_queue TO ` + onecLogin,
`GRANT EXECUTE ON FUNCTION onec.queue(), onec.set_decision(bigint, text, text, text, text) TO ` + onecLogin,
}
var (
onecMu sync.Mutex
onecSchemaAt string // DSN, в котором объекты для 1С созданы этим процессом
onecGranted bool // права логину onec_bj выданы
)
// onecDB — база bj с объектами для 1С (создаёт их при первом обращении).
func onecDB(ctx context.Context, rc *RuntimeConfig) (*sql.DB, error) {
db, err := lkDB(rc)
if err != nil {
return nil, err
}
onecMu.Lock()
defer onecMu.Unlock()
dsn := rc.Snapshot().Postgres.DSN
cctx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
if onecSchemaAt != dsn {
for _, q := range onecSchemaDDL {
if _, err := db.ExecContext(cctx, q); err != nil {
return nil, fmt.Errorf("объекты для 1С в базе bj: %w", err)
}
}
onecSchemaAt, onecGranted = dsn, false
}
if !onecGranted {
var n int
if err := db.QueryRowContext(cctx, `SELECT count(*) FROM pg_roles WHERE rolname = $1`, onecLogin).Scan(&n); err != nil {
return nil, fmt.Errorf("логин 1С в базе bj: %w", err)
}
if n > 0 {
for _, q := range onecGrantSQL {
if _, err := db.ExecContext(cctx, q); err != nil {
return nil, fmt.Errorf("права логину %s: %w", onecLogin, err)
}
}
onecGranted = true
}
}
return db, nil
}
// onecResetGrants — логин 1С заведён заново: выдать права при следующем обращении.
func onecResetGrants() {
onecMu.Lock()
onecGranted = false
onecMu.Unlock()
}
// ---- проверка перед отправкой в НРД ----
// onecItem — бумага перевода и её поручение МНП.
type onecItem struct {
ISIN, SecCode, DepoAccount, Client, Contract string
Qty float64
InstrNo int64 // 0 — поручения МНП нет
}
// onecInstrNo — номер поручения МНП числом (0 — нет).
func onecInstrNo(s string) int64 {
s = strings.TrimSpace(s)
if n, err := strconv.ParseInt(s, 10, 64); err == nil && n > 0 {
return n
}
if f, err := strconv.ParseFloat(s, 64); err == nil && f >= 1 && f == float64(int64(f)) {
return int64(f)
}
return 0
}
func onecItemsFor(req *m2m.M2MTransferRequest) []onecItem {
guid := string(req.Header.GUID)
var out []onecItem
for _, sec := range req.Data.TransferredSecurities.Securities {
it := onecItem{ISIN: quikSecISIN(sec), SecCode: string(sec.SecurityCode), Qty: quikSecQty(sec.Quantity)}
if len(sec.SettlementAccount) > 0 {
it.DepoAccount = string(sec.SettlementAccount[0].SettlementLocation.AccountID)
}
if l := globalQuikLinks.Get(guid, it.ISIN); l != nil {
it.Client, it.Contract = l.ClientID, l.Contract
if l.State == "registered" || l.State == "signed" {
it.InstrNo = onecInstrNo(l.InstrNo)
}
}
out = append(out, it)
}
return out
}
// onecCounterparty — депозитарий получателя: код участника НРД и наименование по справочнику.
func onecCounterparty(req *m2m.M2MTransferRequest) (code, name string) {
inn := strings.TrimSpace(string(req.Data.ReceivingDepository.INN))
if e, ok := handbookByINN(inn); ok {
return pick(e.Code, inn), pick(e.DisplayName, e.Name)
}
return inn, ""
}
func onecNull(s string) any {
if s = strings.TrimSpace(s); s == "" {
return nil
}
return s
}
// onecUpsert — поручения перевода в очередь 1С. Поручение, уже стоящее в очереди (подано клиентом
// из терминала и привязано к заявке), переходит к этому переводу вместе с решением 1С.
func onecUpsert(ctx context.Context, db *sql.DB, req *m2m.M2MTransferRequest) error {
guid := string(req.Header.GUID)
cpCode, cpName := onecCounterparty(req)
for _, it := range onecItemsFor(req) {
args := []any{guid, it.ISIN, onecNull(it.Client), onecNull(it.Contract), onecNull(it.SecCode), it.Qty, onecNull(it.DepoAccount), onecNull(cpCode), onecNull(cpName)}
if it.InstrNo == 0 {
if _, err := db.ExecContext(ctx, `INSERT INTO onec.instr (guid, isin, client_code, contract, sec_code, qty, depo_account, counterparty_code, counterparty_name, bj_state, bj_note)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, 'WAIT_OPER', 'нет поручения МНП — 1С проверить нечего, решает оператор bj')
ON CONFLICT (guid, isin) WHERE instr_no IS NULL DO NOTHING`, args...); err != nil {
return err
}
continue
}
if _, err := db.ExecContext(ctx, `DELETE FROM onec.instr WHERE guid = $1 AND isin = $2 AND instr_no IS NULL AND status = 'NEW'`, guid, it.ISIN); err != nil {
return err
}
if _, err := db.ExecContext(ctx, `INSERT INTO onec.instr AS t (guid, isin, client_code, contract, sec_code, qty, depo_account, counterparty_code, counterparty_name, instr_no)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
ON CONFLICT (instr_no) WHERE instr_no IS NOT NULL DO UPDATE SET guid = EXCLUDED.guid, isin = EXCLUDED.isin,
client_code = COALESCE(EXCLUDED.client_code, t.client_code), contract = COALESCE(EXCLUDED.contract, t.contract),
sec_code = COALESCE(EXCLUDED.sec_code, t.sec_code), depo_account = COALESCE(EXCLUDED.depo_account, t.depo_account),
counterparty_code = COALESCE(EXCLUDED.counterparty_code, t.counterparty_code),
counterparty_name = COALESCE(EXCLUDED.counterparty_name, t.counterparty_name)`, append(args, it.InstrNo)...); err != nil {
return err
}
}
return nil
}
// onecVerdict — сводное решение по строкам перевода: wait (ждём), reject (отказ по любой бумаге)
// или pass (все поручения подтверждены).
func onecVerdict(statuses []string) string {
if len(statuses) == 0 {
return "wait"
}
all := true
for _, st := range statuses {
switch st {
case "REJECTED", "CANCELLED":
return "reject"
case "CONFIRMED":
default:
all = false
}
}
if all {
return "pass"
}
return "wait"
}
// onecRequestStatuses — статусы строк очереди по бумагам перевода.
func onecRequestStatuses(ctx context.Context, db *sql.DB, req *m2m.M2MTransferRequest) ([]string, error) {
guid := string(req.Header.GUID)
var out []string
for _, it := range onecItemsFor(req) {
var st string
var err error
if it.InstrNo == 0 {
err = db.QueryRowContext(ctx, `SELECT status FROM onec.instr WHERE guid = $1 AND isin = $2 AND instr_no IS NULL`, guid, it.ISIN).Scan(&st)
} else {
err = db.QueryRowContext(ctx, `SELECT status FROM onec.instr WHERE instr_no = $1`, it.InstrNo).Scan(&st)
}
if errors.Is(err, sql.ErrNoRows) {
st, err = "NEW", nil
}
if err != nil {
return nil, err
}
out = append(out, st)
}
return out, nil
}
// onecGate — хук перед отправкой в НРД (service.go): true — перевод ждёт решения 1С (в НРД не
// отправлять). Ручной ввод с блокировкой бумаг сотрудником не проверяется: решение принял оператор.
func onecGate(ctx context.Context, rc *RuntimeConfig, req *m2m.M2MTransferRequest) (bool, error) {
if req == nil || !onecCheckActive(rc) || manualQuikSkipped(ctx) {
return false, nil
}
db, err := onecDB(ctx, rc)
if err != nil {
return false, fmt.Errorf("проверка 1С: %w", err)
}
cctx, cancel := context.WithTimeout(ctx, 15*time.Second)
defer cancel()
if err := onecUpsert(cctx, db, req); err != nil {
return false, fmt.Errorf("проверка 1С: очередь: %w", err)
}
sts, err := onecRequestStatuses(cctx, db, req)
if err != nil {
return false, fmt.Errorf("проверка 1С: %w", err)
}
guid := string(req.Header.GUID)
switch onecVerdict(sts) {
case "pass":
_, _ = db.ExecContext(cctx, `UPDATE onec.instr SET bj_state = 'WAIT_SEND', bj_state_at = now() WHERE guid = $1 AND bj_state IN ('WAIT_1C', 'WAIT_OPER')`, guid)
log.Printf("onec: %s подтверждён 1С — отправка в НРД", shortGUID(guid))
return false, nil
case "reject":
onecKickNow() // отказ обработает onecLoop
}
globalLKFlows.stepIfExists(guid, "onec", "Проверка 1С: ждём решения", "Поручения МНП переданы на проверку в 1С. Перевод уйдёт в НРД после подтверждения.")
log.Printf("onec: %s ждёт решения 1С", shortGUID(guid))
return true, nil
}
// ---- обработка решений ----
var onecKick = make(chan struct{}, 1)
// onecKickNow — разбудить onecLoop (решение оператора, отказ).
func onecKickNow() {
select {
case onecKick <- struct{}{}:
default:
}
}
// onecAlerted — оповещения, которые уже ушли (повтор — только после восстановления).
var onecAlerted = struct {
sync.Mutex
m map[string]bool
}{m: map[string]bool{}}
func onecAlertOnce(rc *RuntimeConfig, key, title, body string) {
onecAlerted.Lock()
seen := onecAlerted.m[key]
onecAlerted.m[key] = true
onecAlerted.Unlock()
if !seen {
raiseSystemAlert(rc, title, body)
}
}
func onecAlertClear(key string) {
onecAlerted.Lock()
delete(onecAlerted.m, key)
onecAlerted.Unlock()
}
// onecLoop — решения 1С: отправка в НРД, снятие в МНП, сроки, связь с 1С. Раз в минуту и по сигналу.
func (s *Server) onecLoop(ctx context.Context) {
t := time.NewTicker(time.Minute)
defer t.Stop()
for {
if onecCheckActive(s.rc) {
onecTick(ctx, s.rc, s.svc, time.Now())
}
healthBeat("onec-check")
select {
case <-ctx.Done():
return
case <-t.C:
case <-onecKick:
}
}
}
// OneCRow — строка очереди 1С (для обработки и показа).
type OneCRow struct {
ID int64
GUID, ISIN, Source, Client, SecCode string
InstrNo int64
Qty float64
Status, Reason, CheckedBy, OneCDoc string
BJState, Note string
ByOperator bool
CreatedAt, CheckedAt, BJStateAt, Alerted time.Time
}
const onecRowCols = `id, guid, isin, source, COALESCE(client_code, ''), COALESCE(sec_code, ''), COALESCE(instr_no, 0), COALESCE(qty, 0)::float8,
status, COALESCE(reason, ''), COALESCE(checked_by, ''), COALESCE(onec_doc, ''), bj_state, COALESCE(bj_note, ''), by_operator,
created_at, checked_at, bj_state_at, alerted_at`
func onecScan(rows *sql.Rows) ([]OneCRow, error) {
defer rows.Close()
var out []OneCRow
for rows.Next() {
var r OneCRow
var ch, al sql.NullTime
if err := rows.Scan(&r.ID, &r.GUID, &r.ISIN, &r.Source, &r.Client, &r.SecCode, &r.InstrNo, &r.Qty,
&r.Status, &r.Reason, &r.CheckedBy, &r.OneCDoc, &r.BJState, &r.Note, &r.ByOperator,
&r.CreatedAt, &ch, &r.BJStateAt, &al); err != nil {
return out, err
}
r.CheckedAt, r.Alerted = ch.Time, al.Time
out = append(out, r)
}
return out, rows.Err()
}
func onecQuery(ctx context.Context, db *sql.DB, where string, args ...any) ([]OneCRow, error) {
rows, err := db.QueryContext(ctx, `SELECT `+onecRowCols+` FROM onec.instr `+where, args...)
if err != nil {
return nil, err
}
return onecScan(rows)
}
func onecSetState(ctx context.Context, db *sql.DB, id int64, state, note string) {
if _, err := db.ExecContext(ctx, `UPDATE onec.instr SET bj_state = $2, bj_state_at = now(), bj_note = COALESCE($3, bj_note) WHERE id = $1 AND bj_state <> $2`, id, state, onecNull(note)); err != nil {
log.Printf("onec: строка %d → %s: %v", id, state, err)
}
}
func onecTick(ctx context.Context, rc *RuntimeConfig, svc *Service, now time.Time) {
db, err := onecDB(ctx, rc)
if err != nil {
onecAlertOnce(rc, "db", "Проверка 1С: база bj недоступна", "Проверка поручений в 1С не работает: "+err.Error()+". Переводы ждут, в НРД не уходят.")
return
}
onecAlertClear("db")
cctx, cancel := context.WithTimeout(ctx, 4*time.Minute)
defer cancel()
onecEnqueueTerminal(cctx, rc, db)
onecRefreshNoMNP(cctx, svc, db)
onecCheckCancelled(cctx, rc, db)
onecApply(cctx, rc, svc, db)
onecSync(cctx, svc, db)
onecWatch(cctx, rc, db, now)
}
// onecEnqueueTerminal — поручения «Вывод ЦБ (M2M)», поданные клиентом из терминала (quikhooks.go):
// тоже на проверку 1С. Перевод по ним оформляет оператор; отказ 1С снимает поручение в МНП.
func onecEnqueueTerminal(ctx context.Context, rc *RuntimeConfig, db *sql.DB) {
since := rc.Snapshot().OneC.Check.EnabledAt
for _, l := range globalQuikLinks.List(0) {
if !l.IsTerminal() || l.State != "manual" || l.Direction != "out" || l.UpdatedAt.Before(since) {
continue
}
no := onecInstrNo(l.InstrNo)
if no == 0 {
continue
}
if _, err := db.ExecContext(ctx, `INSERT INTO onec.instr (guid, isin, instr_no, source, client_code, contract, qty)
VALUES ($1, $2, $3, 'terminal', $4, $5, $6) ON CONFLICT (instr_no) WHERE instr_no IS NOT NULL DO NOTHING`,
l.GUID, l.ISIN, no, onecNull(l.ClientID), onecNull(l.Contract), l.Qty); err != nil {
log.Printf("onec: поручение из терминала №%d: %v", no, err)
}
}
}
// onecRefreshNoMNP — перевод ждал без поручения МНП, а оператор зарегистрировал или привязал его:
// теперь проверяет 1С.
func onecRefreshNoMNP(ctx context.Context, svc *Service, db *sql.DB) {
rows, err := onecQuery(ctx, db, `WHERE instr_no IS NULL AND status = 'NEW' AND bj_state = 'WAIT_OPER'`)
if err != nil {
return
}
done := map[string]bool{}
for _, r := range rows {
if done[r.GUID] {
continue
}
done[r.GUID] = true
l := globalQuikLinks.Get(r.GUID, r.ISIN)
if l == nil || onecInstrNo(l.InstrNo) == 0 || (l.State != "registered" && l.State != "signed") {
continue
}
if d, err := svc.Repo().GetByGUID(ctx, m2m.UUID(r.GUID)); err == nil && d != nil && d.Request != nil {
if err := onecUpsert(ctx, db, d.Request); err != nil {
log.Printf("onec: %s: %v", shortGUID(r.GUID), err)
}
}
}
}
// onecCheckCancelled — клиент снял поручение в QUIK до решения 1С: решение больше не нужно.
func onecCheckCancelled(ctx context.Context, rc *RuntimeConfig, db *sql.DB) {
q := rc.Snapshot().Quik
if !quikActive(q) || q.Emulate {
return
}
rows, err := onecQuery(ctx, db, `WHERE status = 'NEW' AND instr_no IS NOT NULL`)
if err != nil || len(rows) == 0 {
return
}
items, err := quikListM2MInstructions(ctx, q, 500, false)
if err != nil {
return
}
st := map[int64]int{}
for _, it := range items {
st[onecInstrNo(it.Number)] = it.Status
}
for _, r := range rows {
if s, ok := st[r.InstrNo]; ok && (s == 2 || s == 3) {
if l := globalQuikLinks.ByInstrNo(strconv.FormatInt(r.InstrNo, 10)); l != nil && l.State == "denied" {
continue // сняли мы — разберёт onecSync
}
_, _ = db.ExecContext(ctx, `UPDATE onec.instr SET status = 'CANCELLED', reason = 'Поручение снято в QUIK', checked_by = 'bj', checked_at = now() WHERE id = $1 AND status = 'NEW'`, r.ID)
log.Printf("onec: поручение МНП №%d снято в QUIK до решения 1С", r.InstrNo)
}
}
}
// onecApply — решения 1С (и оператора), по которым bj ещё ничего не сделал.
func onecApply(ctx context.Context, rc *RuntimeConfig, svc *Service, db *sql.DB) {
rows, err := onecQuery(ctx, db, `WHERE guid IN (SELECT guid FROM onec.instr WHERE status <> 'NEW' AND bj_state IN ('WAIT_1C', 'WAIT_OPER'))
AND bj_state IN ('WAIT_1C', 'WAIT_OPER') ORDER BY created_at, id`)
if err != nil {
log.Printf("onec: решения: %v", err)
return
}
byGUID := map[string][]OneCRow{}
var order []string
for _, r := range rows {
if _, ok := byGUID[r.GUID]; !ok {
order = append(order, r.GUID)
}
byGUID[r.GUID] = append(byGUID[r.GUID], r)
}
for _, guid := range order {
if ctx.Err() != nil {
return
}
if strings.HasPrefix(guid, onecTermPrefix) {
onecApplyTerminal(ctx, rc, db, byGUID[guid])
continue
}
onecApplyDeal(ctx, rc, svc, db, guid, byGUID[guid])
}
}
// onecApplyTerminal — поручение из терминала: отказ — снять в МНП; подтверждение — оператору
// оформить перевод (bj сам такие поручения в НРД не отправляет).
func onecApplyTerminal(ctx context.Context, rc *RuntimeConfig, db *sql.DB, rows []OneCRow) {
for _, r := range rows {
switch r.Status {
case "CONFIRMED":
onecSetState(ctx, db, r.ID, "WAIT_SEND", "подтверждено 1С — перевод оформляет оператор bj")
raiseSystemAlert(rc, "Проверка 1С: поручение из терминала подтверждено",
fmt.Sprintf("1С подтвердила поручение МНП №%d (клиент %s, %s × %v), поданное клиентом из терминала QUIK. Оформите перевод в bj: ручной ввод или «Привязать» в карточке заявки.", r.InstrNo, r.Client, r.ISIN, r.Qty))
case "REJECTED":
if _, err := QuikOutDeny(ctx, rc, r.GUID, r.ISIN, "Отказ: "+r.Reason); err != nil {
onecAlertOnce(rc, "deny-"+r.GUID, "Проверка 1С: поручение не снято в QUIK",
fmt.Sprintf("1С отказала по поручению МНП №%d (%s), но снять его в QUIK не удалось: %v. bj повторит.", r.InstrNo, r.Reason, err))
continue
}
onecAlertClear("deny-" + r.GUID)
onecSetState(ctx, db, r.ID, "DENIED", "")
case "CANCELLED":
onecSetState(ctx, db, r.ID, "DENIED", "")
}
}
}
// onecApplyDeal — перевод ждёт решения 1С: все бумаги подтверждены — в НРД; отказ по любой —
// снять поручения в МНП и отклонить перевод с причиной для клиента.
func onecApplyDeal(ctx context.Context, rc *RuntimeConfig, svc *Service, db *sql.DB, guid string, rows []OneCRow) {
var sts []string
reason := ""
for _, r := range rows {
sts = append(sts, r.Status)
if reason == "" && (r.Status == "REJECTED" || r.Status == "CANCELLED") {
reason = r.Reason
}
}
d, err := svc.Repo().GetByGUID(ctx, m2m.UUID(guid))
if err != nil || d == nil {
return
}
switch onecVerdict(sts) {
case "pass":
if d.State != m2mcore.StateValidated || d.Request == nil {
return // уже отправлен или закрыт — разберёт onecSync
}
if err := onecRelease(ctx, rc, svc, d); err != nil {
onecAlertOnce(rc, "send-"+guid, "Проверка 1С: подтверждённый перевод не отправлен",
fmt.Sprintf("Перевод %s подтверждён 1С, но отправить его в НРД не удалось: %v. bj повторит; можно «Отправить повторно» в карточке заявки.", shortGUID(guid), err))
return
}
onecAlertClear("send-" + guid)
case "reject":
if err := onecRejectDeal(ctx, rc, svc, d, reason); err != nil {
onecAlertOnce(rc, "deny-"+guid, "Проверка 1С: отказ не выполнен",
fmt.Sprintf("1С отказала по переводу %s (%s), но снять поручение в QUIK не удалось: %v. bj повторит.", shortGUID(guid), reason, err))
return
}
onecAlertClear("deny-" + guid)
_, _ = db.ExecContext(ctx, `UPDATE onec.instr SET status = 'CANCELLED', reason = 'Перевод отклонён по другой бумаге', checked_by = 'bj', checked_at = now() WHERE guid = $1 AND status = 'NEW'`, guid)
_, _ = db.ExecContext(ctx, `UPDATE onec.instr SET bj_state = 'DENIED', bj_state_at = now() WHERE guid = $1 AND bj_state IN ('WAIT_1C', 'WAIT_OPER', 'WAIT_SEND')`, guid)
}
}
// onecRelease — подтверждённый перевод в НРД тем же путём, что «Отправить повторно».
func onecRelease(ctx context.Context, rc *RuntimeConfig, svc *Service, d *m2mcore.Deal) error {
guid := string(d.Request.Header.GUID)
sender := realTestSenderFor(rc, guid, d.Request, svc.sender)
if nsdEmuMode(rc) == "imitate" && lkEmuSender != nil {
sender = lkEmuSender
}
resp, err := svc.SendBuiltRequestVia(ctx, sender, d.Request, d.InvestorID, d.SignedClaim)
if err != nil {
if sendFailGet(guid) != nil {
return nil // НРД сейчас не принимает пакеты — повтор автоматически (sendfail.go)
}
return err
}
if resp.Status == onecWaitStatus {
return errors.New("1С ещё не подтвердила все бумаги перевода")
}
globalLKFlows.stepIfExists(guid, "onec", "Проверка 1С: подтверждено", "1С подтвердила поручения МНП — перевод отправлен в НРД.")
if db, err := lkDB(rc); err == nil { // кабинет клиента (bj-lk): «На проверке» → «Отправлена в НРД»
_, _ = db.ExecContext(ctx, `UPDATE lk.transfer_requests SET status = 'sent' WHERE guid = $1 AND status = 'checking'`, guid)
}
log.Printf("onec: %s подтверждён 1С и отправлен в НРД", shortGUID(guid))
return nil
}
// onecRejectDeal — отказ 1С: снять поручения в МНП (клиент видит причину в QUIK), отклонить перевод
// в журнале bj и в кабинете клиента.
func onecRejectDeal(ctx context.Context, rc *RuntimeConfig, svc *Service, d *m2mcore.Deal, reason string) error {
guid := string(d.GUID)
reason = pick(strings.TrimSpace(reason), "отказ по проверке бэк-офиса")
for _, l := range globalQuikLinks.ForGUID(guid) {
if l.State == "registered" {
if _, err := QuikOutDeny(ctx, rc, guid, l.ISIN, "Отказ: "+reason); err != nil {
return err
}
}
}
short, full := "Отказ: "+reason, "Отказ по проверке 1С: "+reason
if id, ok := LKClaimIDByGUID(guid); ok && d.Request != nil {
globalLKFlows.step(guid, id, "reject", "Проверка 1С: отказ", reason)
lkGatewayReject(ctx, rc, svc, id, d.InvestorID, d.Request, "1C", short, full)
} else if !m2mcore.IsTerminal(d.State) {
if err := d.RejectManually(ctx, "1С", "1C", full); err != nil {
log.Printf("onec: %s: отказ по сделке: %v", shortGUID(guid), err)
} else if err := svc.Repo().Update(ctx, d); err != nil {
log.Printf("onec: %s: сохранение отказа: %v", shortGUID(guid), err)
}
}
if err := lkFinalizeByGUID(ctx, rc, guid, "rejected", "rejected", short); err != nil {
log.Printf("onec: %s: статус в кабинете: %v", shortGUID(guid), err)
}
log.Printf("onec: %s отклонён по проверке 1С: %s", shortGUID(guid), reason)
return nil
}
// onecSync — ход перевода для 1С (bj_state): отправлен, исполнен, не состоялся, снят.
func onecSync(ctx context.Context, svc *Service, db *sql.DB) {
rows, err := onecQuery(ctx, db, fmt.Sprintf(`WHERE bj_state IN ('WAIT_1C', 'WAIT_OPER', 'WAIT_SEND', 'SENT_TO_NSD') AND created_at > now() - interval '%d days'`, onecKeepDays))
if err != nil {
return
}
deals := map[string]*m2mcore.Deal{}
for _, r := range rows {
l := globalQuikLinks.Get(r.GUID, r.ISIN)
var d *m2mcore.Deal
if !strings.HasPrefix(r.GUID, onecTermPrefix) {
var ok bool
if d, ok = deals[r.GUID]; !ok {
d, _ = svc.Repo().GetByGUID(ctx, m2m.UUID(r.GUID))
deals[r.GUID] = d
}
}
switch {
case l != nil && l.State == "signed":
onecSetState(ctx, db, r.ID, "EXECUTED", "")
case r.BJState == "SENT_TO_NSD" && ((l != nil && l.State == "denied") || (d != nil && (d.State == m2mcore.StateRejected || d.State == m2mcore.StateTimedOut))):
onecSetState(ctx, db, r.ID, "FAILED", "")
case r.BJState != "SENT_TO_NSD" && ((l != nil && l.State == "denied") || (d != nil && m2mcore.IsTerminal(d.State) && d.State != m2mcore.StateDone)):
// сняли или отклонили в bj до отправки в НРД (оператор, отказ по другой бумаге)
if r.Status == "NEW" {
_, _ = db.ExecContext(ctx, `UPDATE onec.instr SET status = 'CANCELLED', reason = 'Перевод отклонён в bj', checked_by = 'bj', checked_at = now() WHERE id = $1 AND status = 'NEW'`, r.ID)
}
onecSetState(ctx, db, r.ID, "DENIED", "")
case r.BJState == "WAIT_SEND" && d != nil && d.State != m2mcore.StateValidated && d.State != m2mcore.StateDraft:
onecSetState(ctx, db, r.ID, "SENT_TO_NSD", "")
}
}
}
// onecWatch — сроки решения и связь с 1С.
func onecWatch(ctx context.Context, rc *RuntimeConfig, db *sql.DB, now time.Time) {
s := rc.Snapshot()
cs := s.OneC.Check
rows, err := onecQuery(ctx, db, `WHERE status = 'NEW' AND alerted_at IS NULL AND bj_state IN ('WAIT_1C', 'WAIT_OPER')`)
if err == nil {
for _, r := range rows {
var title, body string
switch {
case r.BJState == "WAIT_OPER":
title = "Проверка 1С: перевод без поручения МНП ждёт оператора"
body = fmt.Sprintf("Перевод %s (%s × %v): в QUIK МНП нет поручения, 1С проверить его не может. Зарегистрируйте поручение в карточке заявки или решите сами: «Подтвердить» / «Отклонить» в блоке «Проверка 1С».", shortGUID(r.GUID), r.ISIN, r.Qty)
case now.Sub(r.CreatedAt) >= cs.Timeout() && s.WorkHours.Open(now):
title = "Проверка 1С: нет решения по поручению МНП"
body = fmt.Sprintf("Поручение МНП №%d (клиент %s, %s × %v) ждёт решения 1С с %s. Перевод в НРД не отправлен. Проверьте 1С или решите в карточке заявки.", r.InstrNo, r.Client, r.ISIN, r.Qty, r.CreatedAt.In(time.Local).Format("02.01 15:04"))
default:
continue
}
raiseSystemAlert(rc, title, body)
_, _ = db.ExecContext(ctx, `UPDATE onec.instr SET alerted_at = now() WHERE id = $1`, r.ID)
}
}
// Связь: 1С читает очередь раз в минуту. Считаем с момента, когда вход открыт.
start := cs.AccessAt
if cs.EnabledAt.After(start) {
start = cs.EnabledAt
}
if !cs.AccessOK || now.Sub(start) < onecLostAfter {
return
}
last := onecLastRead(ctx, db)
if !last.IsZero() && now.Sub(last) < onecLostAfter {
onecAlertClear("lost")
return
}
since := "ни разу не читала очередь"
if !last.IsZero() {
since = "последнее чтение очереди " + last.In(time.Local).Format("02.01 15:04")
}
onecAlertOnce(rc, "lost", "Проверка 1С: нет связи с 1С", "1С не забирает поручения на проверку: "+since+". Переводы ждут решения и в НРД не уходят.")
}
func onecLastRead(ctx context.Context, db *sql.DB) time.Time {
var t time.Time
if err := db.QueryRowContext(ctx, `SELECT read_at FROM onec.reads WHERE id = 1`).Scan(&t); err != nil {
return time.Time{}
}
return t
}
// ---- решение оператора ----
// onecOperatorDecide — оператор bj решает за 1С (по всем бумагам перевода, ещё ждущим решения).
func onecOperatorDecide(ctx context.Context, rc *RuntimeConfig, guid, decision, reason, who string) (int64, error) {
db, err := onecDB(ctx, rc)
if err != nil {
return 0, err
}
st := "CONFIRMED"
reason = strings.TrimSpace(reason)
if decision == "reject" {
st = "REJECTED"
if reason == "" {
return 0, errors.New("при отказе нужна причина — её увидит клиент")
}
if len([]rune(reason)) > 255 {
return 0, errors.New("причина длиннее 255 символов")
}
} else {
reason = ""
}
res, err := db.ExecContext(ctx, `UPDATE onec.instr SET status = $2, reason = $3, checked_by = $4, checked_at = now(), by_operator = true
WHERE guid = $1 AND status = 'NEW' AND bj_state IN ('WAIT_1C', 'WAIT_OPER')`, guid, st, onecNull(reason), "оператор bj: "+pick(who, "—"))
if err != nil {
return 0, err
}
n, _ := res.RowsAffected()
if n > 0 {
log.Printf("onec: %s: решение оператора %s (%s)", shortGUID(guid), st, who)
onecKickNow()
}
return n, nil
}
func registerOneCOps(mux *http.ServeMux, rc *RuntimeConfig) {
mux.HandleFunc("/admin/onec/decide", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method", http.StatusMethodNotAllowed)
return
}
guid := strings.TrimSpace(r.FormValue("guid"))
back := r.FormValue("back")
if !strings.HasPrefix(back, "/admin/") {
back = "/admin/claims"
}
who := ""
if u := currentUser(w); u != nil {
who = pick(u.Name, u.Login)
}
ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
defer cancel()
n, err := onecOperatorDecide(ctx, rc, guid, r.FormValue("decision"), r.FormValue("reason"), who)
msg := "Проверка 1С: решение записано — bj выполнит его в течение минуты"
switch {
case err != nil:
msg = "Проверка 1С: " + err.Error()
case n == 0:
msg = "Проверка 1С: по переводу нет поручений, ждущих решения"
}
sep := "?"
if strings.Contains(back, "?") {
sep = "&"
}
http.Redirect(w, r, back+sep+"flash="+urlQ(msg), http.StatusSeeOther)
})
}
// ---- показ ----
// StatusRu — решение по-русски.
func (r OneCRow) StatusRu() string {
switch r.Status {
case "NEW":
if r.BJState == "WAIT_OPER" {
return "ждёт оператора"
}
return "ждёт решения 1С"
case "CONFIRMED":
return "подтверждено"
case "REJECTED":
return "отказано"
case "CANCELLED":
return "снято"
}
return r.Status
}
// BJStateRu — что сделал bj.
func (r OneCRow) BJStateRu() string {
switch r.BJState {
case "WAIT_1C":
return "ждёт решения"
case "WAIT_OPER":
return "нет поручения МНП — решает оператор"
case "WAIT_SEND":
if strings.HasPrefix(r.GUID, onecTermPrefix) {
return "перевод оформляет оператор"
}
return "отправляется в НРД"
case "SENT_TO_NSD":
return "отправлен в НРД"
case "EXECUTED":
return "исполнен, бумаги списаны"
case "DENIED":
return "снят в МНП"
case "FAILED":
return "перевод не состоялся"
}
return r.BJState
}
// Waiting — строка ждёт решения (кнопки оператора).
func (r OneCRow) Waiting() bool {
return r.Status == "NEW" && (r.BJState == "WAIT_1C" || r.BJState == "WAIT_OPER")
}
// OneCWaiting — по переводу есть поручения, ждущие решения (карточка заявки).
func (d claimData) OneCWaiting() bool {
for _, r := range d.OneC {
if r.Waiting() {
return true
}
}
return false
}
// onecRowsFor — строки очереди 1С по переводу (карточка заявки).
func onecRowsFor(ctx context.Context, rc *RuntimeConfig, guid string) []OneCRow {
if guid == "" || !moduleAllowed(rc, "onec-back") || !rc.Snapshot().OneC.Check.Enabled {
return nil
}
db, err := onecDB(ctx, rc)
if err != nil {
return nil
}
cctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
rows, _ := onecQuery(cctx, db, `WHERE guid = $1 ORDER BY id`, guid)
return rows
}
// OneCCheckView — блок «Проверка поручений в 1С» в настройках.
type OneCCheckView struct {
QuikOK bool // QUIK МНП подключён и сам регистрирует «Вывод ЦБ (M2M)»
DBErr string
Waiting int // ждут решения
LastRead time.Time
Rows []OneCRow
Password, Host string // выданный пароль onec_bj (показывается один раз)
}
var onecPwShow struct {
sync.Mutex
pw, host string
at time.Time
}
func onecCheckView(ctx context.Context, rc *RuntimeConfig) OneCCheckView {
s := rc.Snapshot()
v := OneCCheckView{QuikOK: quikActive(s.Quik) && s.Quik.AutoOut}
onecPwShow.Lock()
if onecPwShow.pw != "" && time.Since(onecPwShow.at) < 15*time.Minute {
v.Password, v.Host = onecPwShow.pw, onecPwShow.host
}
onecPwShow.pw = ""
onecPwShow.Unlock()
if !s.OneC.Check.Enabled {
return v
}
db, err := onecDB(ctx, rc)
if err != nil {
v.DBErr = err.Error()
return v
}
cctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
_ = db.QueryRowContext(cctx, `SELECT count(*) FROM onec.instr WHERE status = 'NEW' AND bj_state IN ('WAIT_1C', 'WAIT_OPER')`).Scan(&v.Waiting)
v.LastRead = onecLastRead(cctx, db)
v.Rows, _ = onecQuery(cctx, db, `ORDER BY id DESC LIMIT 15`)
return v
}
// ---- настройки ----
// onecParseIPs — IP или подсети сервера 1С (IPv4), через запятую или пробел.
func onecParseIPs(raw string) ([]string, error) {
var out []string
for _, f := range strings.FieldsFunc(raw, func(r rune) bool { return r == ',' || r == ';' || r == ' ' || r == '\n' || r == '\t' }) {
p := f
if !strings.Contains(p, "/") {
p += "/32"
}
ip, n, err := net.ParseCIDR(p)
if err != nil || ip.To4() == nil {
return nil, fmt.Errorf("«%s» — не IPv4-адрес", f)
}
if ones, _ := n.Mask.Size(); ones < 24 {
return nil, fmt.Errorf("«%s» — слишком широкая подсеть, нужен адрес сервера 1С (не шире /24)", f)
}
out = append(out, n.String())
}
if len(out) > 8 {
return nil, errors.New("не больше 8 адресов")
}
return out, nil
}
func onecPassword() (string, error) {
b := make([]byte, onecPwLen)
max := big.NewInt(int64(len(onecPwAlphabet)))
for i := range b {
n, err := rand.Int(rand.Reader, max)
if err != nil {
return "", err
}
b[i] = onecPwAlphabet[n.Int64()]
}
return string(b), nil
}
// saveOneCCheck — POST /admin/setup/onec/check: включить/выключить проверку, IP 1С, срок.
func (h *setupHandlers) saveOneCCheck(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method", http.StatusMethodNotAllowed)
return
}
o := h.rc.Snapshot().OneC
c := o.Check
on := r.FormValue("enabled") != ""
ips, err := onecParseIPs(r.FormValue("client_ips"))
if err != nil {
setupFlash(w, r, "Проверка 1С: "+err.Error())
return
}
c.ClientIPs = strings.Join(ips, ", ")
c.TimeoutMin, _ = strconv.Atoi(strings.TrimSpace(r.FormValue("timeout_min")))
if c.TimeoutMin < 5 || c.TimeoutMin > 24*60 {
c.TimeoutMin = 30
}
msg := "Проверка 1С: настройки сохранены"
ctx, cancel := context.WithTimeout(r.Context(), 90*time.Second)
defer cancel()
switch {
case on && !c.Enabled:
c.Enabled, c.EnabledAt = true, time.Now()
msg = "Проверка 1С включена: исходящие переводы уходят в НРД только после решения 1С"
case !on && c.Enabled:
c.Enabled = false
msg = "Проверка 1С выключена: переводы идут в НРД без решения 1С"
if db, err := onecDB(ctx, h.rc); err == nil {
var n int
_ = db.QueryRowContext(ctx, `SELECT count(DISTINCT guid) FROM onec.instr WHERE status = 'NEW' AND bj_state IN ('WAIT_1C', 'WAIT_OPER') AND guid NOT LIKE 'terminal-%'`).Scan(&n)
if n > 0 {
msg += fmt.Sprintf(". Переводов, ждавших решения, — %d: они остались «Не отправлена», отправьте их из карточки заявки («Отправить повторно») или отклоните", n)
}
}
if c.AccessOK {
if _, err := runKeymediaOp(ctx, "onec-revoke", map[string]string{}); err != nil {
msg += "; вход логином " + onecLogin + " закрыть не удалось: " + err.Error()
} else {
c.AccessOK, c.AccessMsg = false, "вход закрыт при выключении проверки"
msg += "; вход логином " + onecLogin + " закрыт"
}
}
}
o.Check = c
if err := h.rc.UpdateOneC(o); err != nil {
setupFlash(w, r, "Проверка 1С: настройки не сохранены — "+err.Error())
return
}
if c.Enabled {
if _, err := onecDB(ctx, h.rc); err != nil {
msg += "; объекты для 1С в базе bj не созданы: " + err.Error()
}
}
setupFlash(w, r, msg)
}
// saveOneCAccess — POST /admin/setup/onec/access: завести логин onec_bj с новым паролем и открыть
// вход в базу bj с IP сервера 1С (root-скрипт bj-onec-admin). Пароль показывается один раз.
func (h *setupHandlers) saveOneCAccess(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method", http.StatusMethodNotAllowed)
return
}
o := h.rc.Snapshot().OneC
c := o.Check
if !c.Enabled {
setupFlash(w, r, "Проверка 1С: сначала включите проверку")
return
}
ips, err := onecParseIPs(c.ClientIPs)
if err != nil || len(ips) == 0 {
setupFlash(w, r, "Проверка 1С: укажите IP-адрес сервера 1С и сохраните")
return
}
pw, err := onecPassword()
if err != nil {
setupFlash(w, r, "Проверка 1С: пароль не создан — "+err.Error())
return
}
extra, _ := json.Marshal(map[string]string{"password": pw, "ips": strings.Join(ips, ",")})
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Minute)
defer cancel()
res, err := runKeymediaOp(ctx, "onec-access", map[string]string{"extra": string(extra)})
c.AccessAt = time.Now()
if err != nil {
c.AccessOK, c.AccessMsg = false, err.Error()
o.Check = c
_ = h.rc.UpdateOneC(o)
setupFlash(w, r, "Проверка 1С: вход для 1С не открыт — "+err.Error())
return
}
out, _ := res["output"].(string)
host, _ := res["host"].(string)
c.AccessOK, c.AccessMsg = true, out
o.Check = c
if err := h.rc.UpdateOneC(o); err != nil {
setupFlash(w, r, "Проверка 1С: настройки не сохранены — "+err.Error())
return
}
onecResetGrants()
msg := "Проверка 1С: вход для 1С открыт — " + out + ". Пароль показан ниже один раз"
if _, err := onecDB(ctx, h.rc); err != nil {
msg += "; права логину " + onecLogin + " не выданы: " + err.Error()
}
onecPwShow.Lock()
onecPwShow.pw, onecPwShow.host, onecPwShow.at = pw, host, time.Now()
onecPwShow.Unlock()
setupFlash(w, r, msg)
}
// saveOneCRevoke — POST /admin/setup/onec/revoke: закрыть вход логином onec_bj.
func (h *setupHandlers) saveOneCRevoke(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method", http.StatusMethodNotAllowed)
return
}
ctx, cancel := context.WithTimeout(r.Context(), time.Minute)
defer cancel()
if _, err := runKeymediaOp(ctx, "onec-revoke", map[string]string{}); err != nil {
setupFlash(w, r, "Проверка 1С: вход не закрыт — "+err.Error())
return
}
o := h.rc.Snapshot().OneC
o.Check.AccessOK, o.Check.AccessMsg, o.Check.AccessAt = false, "вход закрыт", time.Now()
_ = h.rc.UpdateOneC(o)
setupFlash(w, r, "Проверка 1С: вход логином "+onecLogin+" закрыт")
}