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+" закрыт") }