21d5f33394
Часть модуля 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
1158 lines
48 KiB
Go
1158 lines
48 KiB
Go
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+" закрыт")
|
||
}
|