Своя организация в настройках (справочник НРД или вручную) вместо зашитого ИНН; ручной ввод: поиск клиента и бумаг в DEPO, одна бумага на поручение
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -151,11 +151,8 @@ func answerIncoming(ctx context.Context, deps incomingDeps, id string, decisions
|
||||
return nil
|
||||
}
|
||||
|
||||
// transferringINN — ИНН нашей организации (принимающий депозитарий в решении).
|
||||
// ИНН ООО ИК «Фонтвьель» (участник M2M MC0413600000).
|
||||
func transferringINN(_ Settings) string {
|
||||
return "7703807489"
|
||||
}
|
||||
// transferringINN — ИНН нашей организации (передающий депозитарий в запросе, принимающий — в решении).
|
||||
func transferringINN(s Settings) string { return orgINN(s) }
|
||||
|
||||
// packageDecisions — пакет принимается только целиком (решение 16.09.2026): если хотя бы по одной бумаге
|
||||
// отказ, отказываем по всем; принятым бумагам ставится код первой отказной.
|
||||
|
||||
@@ -192,7 +192,10 @@ func lkInsertRequest(ctx context.Context, rc *RuntimeConfig, req *m2m.M2MTransfe
|
||||
if doc.DocumentSeries != nil {
|
||||
docSeries = string(*doc.DocumentSeries)
|
||||
}
|
||||
ourINN := transferringINN(Settings{})
|
||||
ourINN := ""
|
||||
if globalRC != nil {
|
||||
ourINN = transferringINN(globalRC.Snapshot())
|
||||
}
|
||||
opType := ""
|
||||
switch ourINN {
|
||||
case string(req.Data.TransferringDepository.INN):
|
||||
|
||||
@@ -329,8 +329,11 @@ func manualDataProblems(o *ManualOrder) []string {
|
||||
if !manualINNRe.MatchString(strings.TrimSpace(o.RecvINN)) {
|
||||
p = append(p, "ИНН принимающего депозитария — 10 цифр")
|
||||
}
|
||||
if len(o.Securities) == 0 {
|
||||
p = append(p, "нет ни одной бумаги")
|
||||
switch {
|
||||
case len(o.Securities) == 0:
|
||||
p = append(p, "не указана бумага")
|
||||
case len(o.Securities) > 1:
|
||||
p = append(p, "в одном поручении — одна бумага: на каждую бумагу оформите отдельное поручение")
|
||||
}
|
||||
for i, s := range o.Securities {
|
||||
isin := strings.ToUpper(strings.TrimSpace(s.ISIN))
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
package lkgateway
|
||||
|
||||
// manual_depo.go — подбор клиента и бумаг из DEPO для ручного ввода: поиск по ФИО или номеру
|
||||
// документа, открытые торговые счета, бумаги на торговом разделе с остатком.
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// manualDepoAcc — открытый торговый счёт клиента.
|
||||
type manualDepoAcc struct {
|
||||
ClientID int64 `json:"client_id"`
|
||||
Number string `json:"number"`
|
||||
Contract string `json:"contract,omitempty"`
|
||||
IIS bool `json:"iis,omitempty"`
|
||||
}
|
||||
|
||||
// manualDepoFace — клиент из картотеки DEPO.
|
||||
type manualDepoFace struct {
|
||||
LastName string `json:"last_name"`
|
||||
FirstName string `json:"first_name"`
|
||||
MiddleName string `json:"middle_name,omitempty"`
|
||||
Name string `json:"name"`
|
||||
Birth string `json:"birth,omitempty"` // ГГГГ-ММ-ДД
|
||||
DocType string `json:"doc_type,omitempty"`
|
||||
DocSer string `json:"doc_ser,omitempty"`
|
||||
DocNum string `json:"doc_num"`
|
||||
DocDate string `json:"doc_date,omitempty"`
|
||||
DocIssuer string `json:"doc_issuer,omitempty"`
|
||||
Phone string `json:"phone,omitempty"`
|
||||
Accounts []manualDepoAcc `json:"accounts"`
|
||||
}
|
||||
|
||||
// manualDepoPos — бумага на торговом разделе клиента.
|
||||
type manualDepoPos struct {
|
||||
ISIN string `json:"isin"`
|
||||
Name string `json:"name"`
|
||||
Qty float64 `json:"qty"`
|
||||
Russian bool `json:"russian"`
|
||||
}
|
||||
|
||||
// depoDocTypeNSD — вид документа DEPO → код справочника НРД (паспорт РФ в DEPO — 20).
|
||||
func depoDocTypeNSD(t int64) string {
|
||||
if t == 20 {
|
||||
return "21"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// manualDepoSearch — клиенты с открытым торговым счётом по ФИО (часть, без учёта регистра) или номеру документа.
|
||||
func manualDepoSearch(rc *RuntimeConfig, q string) ([]manualDepoFace, string) {
|
||||
q = strings.Join(strings.Fields(q), " ")
|
||||
if len([]rune(q)) < 3 {
|
||||
return nil, "введите не меньше 3 символов фамилии или номер документа"
|
||||
}
|
||||
if !manualDepoConfigured(rc) {
|
||||
return nil, "учётная система (DEPO) не подключена"
|
||||
}
|
||||
fs := rc.Snapshot().Fancy
|
||||
db, ctx, cancel, errS := depoOpen(fs)
|
||||
if errS != "" {
|
||||
return nil, errS
|
||||
}
|
||||
defer cancel()
|
||||
defer db.Close()
|
||||
digits := strings.ReplaceAll(q, " ", "")
|
||||
rows, err := db.QueryContext(ctx, `SELECT TOP 20 f.ID, ISNULL(f.NAME,''), f.DATE_BIRTH, ISNULL(f.DOC_TYPE,0), ISNULL(f.DOC_SER,''), ISNULL(f.DOC_NUM,''),
|
||||
f.DOC_DAT, ISNULL(f.DOC_PLACE,''), ISNULL(f.DOC_CODE,''), ISNULL(f.PHONE,'')
|
||||
FROM OD_FACES f
|
||||
WHERE ISNULL(f.LAST_FLAG,1)=1 AND (UPPER(f.NAME) LIKE UPPER(@q) OR f.DOC_NUM=@d OR REPLACE(ISNULL(f.DOC_SER,''),' ','')+f.DOC_NUM=@d)
|
||||
AND EXISTS (SELECT 1 FROM OD_CLIENTS c WHERE c.FACE=f.ID AND c.NUMBER LIKE N'ВТНКЦ%' AND c.STATE=0 AND c.D_CLOSE IS NULL)
|
||||
ORDER BY f.NAME`, sql.Named("q", q+"%"), sql.Named("d", digits))
|
||||
if err != nil {
|
||||
return nil, "поиск в DEPO: " + err.Error()
|
||||
}
|
||||
type faceRow struct {
|
||||
id int64
|
||||
f manualDepoFace
|
||||
}
|
||||
var found []faceRow
|
||||
for rows.Next() {
|
||||
var r faceRow
|
||||
var birth, docDat sql.NullTime
|
||||
var docType int64
|
||||
var place, code string
|
||||
if rows.Scan(&r.id, &r.f.Name, &birth, &docType, &r.f.DocSer, &r.f.DocNum, &docDat, &place, &code, &r.f.Phone) != nil {
|
||||
continue
|
||||
}
|
||||
p := strings.Fields(r.f.Name)
|
||||
if len(p) > 0 {
|
||||
r.f.LastName = p[0]
|
||||
}
|
||||
if len(p) > 1 {
|
||||
r.f.FirstName = p[1]
|
||||
}
|
||||
if len(p) > 2 {
|
||||
r.f.MiddleName = strings.Join(p[2:], " ")
|
||||
}
|
||||
if birth.Valid {
|
||||
r.f.Birth = birth.Time.Format("2006-01-02")
|
||||
}
|
||||
if docDat.Valid {
|
||||
r.f.DocDate = docDat.Time.Format("2006-01-02")
|
||||
}
|
||||
r.f.DocType = depoDocTypeNSD(docType)
|
||||
r.f.DocSer = strings.ReplaceAll(r.f.DocSer, " ", "")
|
||||
r.f.DocIssuer = strings.TrimSpace(place)
|
||||
if code = strings.TrimSpace(code); code != "" {
|
||||
r.f.DocIssuer += ", код подразделения " + code
|
||||
}
|
||||
found = append(found, r)
|
||||
}
|
||||
rows.Close()
|
||||
out := make([]manualDepoFace, 0, len(found))
|
||||
for _, r := range found {
|
||||
ar, err := db.QueryContext(ctx, `SELECT c.ID, ISNULL(c.NUMBER,''),
|
||||
ISNULL((SELECT TOP 1 p.PORT_NUM FROM OD_CLIENTS_PORTS p WHERE p.CLIENT=c.ID ORDER BY p.PORT_DATE, p.PORT_NUM),'')
|
||||
FROM OD_CLIENTS c WHERE c.FACE=@f AND c.NUMBER LIKE N'ВТНКЦ%' AND c.STATE=0 AND c.D_CLOSE IS NULL ORDER BY c.ID`, sql.Named("f", r.id))
|
||||
if err == nil {
|
||||
for ar.Next() {
|
||||
var a manualDepoAcc
|
||||
if ar.Scan(&a.ClientID, &a.Number, &a.Contract) == nil {
|
||||
r.f.Accounts = append(r.f.Accounts, a)
|
||||
}
|
||||
}
|
||||
ar.Close()
|
||||
}
|
||||
for i := range r.f.Accounts {
|
||||
ok, _ := depoAccountIIS(ctx, db, fs, r.f.Accounts[i].ClientID)
|
||||
r.f.Accounts[i].IIS = !ok
|
||||
}
|
||||
out = append(out, r.f)
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil, "клиентов с открытым торговым счётом не найдено"
|
||||
}
|
||||
return out, ""
|
||||
}
|
||||
|
||||
// manualDepoPositions — бумаги с положительным остатком на торговом разделе счёта (98070.04).
|
||||
func manualDepoPositions(rc *RuntimeConfig, clientID int64) ([]manualDepoPos, string) {
|
||||
if !manualDepoConfigured(rc) {
|
||||
return nil, "учётная система (DEPO) не подключена"
|
||||
}
|
||||
db, ctx, cancel, errS := depoOpen(rc.Snapshot().Fancy)
|
||||
if errS != "" {
|
||||
return nil, errS
|
||||
}
|
||||
defer cancel()
|
||||
defer db.Close()
|
||||
rows, err := db.QueryContext(ctx, `SELECT ISNULL(v.ISIN,''), ISNULL(v.NAME,''), SUM(CAST(r.K AS float))
|
||||
FROM OD_RESTS r JOIN OD_VALUES v ON v.ID=r.VALUE_ID
|
||||
WHERE r.REG_1=@c AND r.BAL_ACC=@b
|
||||
GROUP BY v.ISIN, v.NAME HAVING SUM(CAST(r.K AS float)) > 0 ORDER BY v.NAME`,
|
||||
sql.Named("c", clientID), sql.Named("b", depoTradeBalAcc))
|
||||
if err != nil {
|
||||
return nil, "остатки в DEPO: " + err.Error()
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []manualDepoPos
|
||||
for rows.Next() {
|
||||
var p manualDepoPos
|
||||
if rows.Scan(&p.ISIN, &p.Name, &p.Qty) == nil {
|
||||
p.ISIN = strings.ToUpper(strings.TrimSpace(p.ISIN))
|
||||
p.Russian = isRussianISIN(p.ISIN)
|
||||
out = append(out, p)
|
||||
}
|
||||
}
|
||||
return out, ""
|
||||
}
|
||||
|
||||
// registerManualDepo — GET /admin/manual/depo/search?q= и /admin/manual/depo/positions?client=ID (JSON).
|
||||
func registerManualDepo(mux *http.ServeMux, rc *RuntimeConfig) {
|
||||
reply := func(w http.ResponseWriter, v any, errS string) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"items": v, "error": errS})
|
||||
}
|
||||
mux.HandleFunc("/admin/manual/depo/search", func(w http.ResponseWriter, r *http.Request) {
|
||||
items, errS := manualDepoSearch(rc, r.URL.Query().Get("q"))
|
||||
reply(w, items, errS)
|
||||
})
|
||||
mux.HandleFunc("/admin/manual/depo/positions", func(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := strconv.ParseInt(r.URL.Query().Get("client"), 10, 64)
|
||||
if err != nil || id <= 0 {
|
||||
reply(w, nil, "не указан счёт")
|
||||
return
|
||||
}
|
||||
items, errS := manualDepoPositions(rc, id)
|
||||
reply(w, items, errS)
|
||||
})
|
||||
}
|
||||
@@ -4,13 +4,11 @@ package lkgateway
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -53,26 +51,6 @@ func manualActor(w http.ResponseWriter) string {
|
||||
return "без входа"
|
||||
}
|
||||
|
||||
func manualHandbook() []HandbookEntry {
|
||||
raw, err := os.ReadFile(handbookFile)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
var hb struct {
|
||||
Participants []HandbookEntry `json:"participants"`
|
||||
}
|
||||
if json.Unmarshal(raw, &hb) != nil {
|
||||
return nil
|
||||
}
|
||||
var out []HandbookEntry
|
||||
for _, p := range hb.Participants {
|
||||
if p.Kind != "place" && p.INN != "" {
|
||||
out = append(out, p)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func registerManual(mux *http.ServeMux, rc *RuntimeConfig, svc *Service) {
|
||||
tpl, err := template.New("layout.html").Funcs(templateFuncs).ParseFS(templatesFS,
|
||||
"web/templates/layout.html", "web/templates/admin_manual.html")
|
||||
@@ -127,12 +105,14 @@ func registerManual(mux *http.ServeMux, rc *RuntimeConfig, svc *Service) {
|
||||
render(w, tpl, d)
|
||||
})
|
||||
|
||||
registerManualDepo(mux, rc)
|
||||
|
||||
mux.HandleFunc("/admin/manual/new", func(w http.ResponseWriter, r *http.Request) {
|
||||
d := base("Новое поручение")
|
||||
d.Mode = "edit"
|
||||
d.O = &ManualOrder{Status: "draft", DocType: "21", SettlePlace: "nrd", PaperDate: time.Now().Format("2006-01-02"),
|
||||
ReceivedVia: "лично в офисе", Securities: []ManualSec{{}}}
|
||||
d.Handbook = manualHandbook()
|
||||
d.Handbook = handbookParticipants()
|
||||
render(w, tpl, d)
|
||||
})
|
||||
|
||||
@@ -220,10 +200,13 @@ func registerManual(mux *http.ServeMux, rc *RuntimeConfig, svc *Service) {
|
||||
d.Mode = "print"
|
||||
}
|
||||
if o.Status == "draft" {
|
||||
d.Handbook = manualHandbook()
|
||||
d.Handbook = handbookParticipants()
|
||||
d.Problems = manualDataProblems(o)
|
||||
d.Blockers = manualSendBlockers(rc, o)
|
||||
d.DepoStale = o.Depo != nil && o.Depo.Hash != o.dataHash()
|
||||
if len(o.Securities) == 0 { // форма показывает строку бумаги всегда
|
||||
o.Securities = []ManualSec{{}}
|
||||
}
|
||||
}
|
||||
d.Progress = manualProgressFor(r.Context(), svc, o)
|
||||
d.Auto = rc.Snapshot().M2MAuto.Enabled
|
||||
@@ -366,7 +349,7 @@ func manualFromForm(r *http.Request) *ManualOrder {
|
||||
if o.RecvINN != "" {
|
||||
o.RecvCode = handbookCodeByINN(o.RecvINN)
|
||||
if o.RecvName == "" {
|
||||
for _, p := range manualHandbook() {
|
||||
for _, p := range handbookParticipants() {
|
||||
if p.INN == o.RecvINN && (o.RecvCode == "" || p.Code == o.RecvCode) {
|
||||
o.RecvName = p.Name
|
||||
break
|
||||
|
||||
@@ -46,10 +46,14 @@ func TestManualSendBlockers(t *testing.T) {
|
||||
t.Fatalf("автоблокировка недоступна: %v", b)
|
||||
}
|
||||
o.QuikMode = "none"
|
||||
o.Securities = append(o.Securities, ManualSec{ISIN: "US0378331005", Qty: "0"})
|
||||
o.Securities = []ManualSec{{ISIN: "US0378331005", Qty: "0"}}
|
||||
if b = manualSendBlockers(rc, o); len(b) != 2 {
|
||||
t.Fatalf("иностранная бумага и нулевое количество: %v", b)
|
||||
}
|
||||
o.Securities = []ManualSec{{ISIN: "RU0009029540", Qty: "1"}, {ISIN: "RU000A0JUR61", Qty: "1"}}
|
||||
if b = manualSendBlockers(rc, o); len(b) != 1 || !strings.Contains(b[0], "одна бумага") {
|
||||
t.Fatalf("одно поручение — одна бумага: %v", b)
|
||||
}
|
||||
}
|
||||
|
||||
func TestManualQtyAndClaim(t *testing.T) {
|
||||
@@ -142,3 +146,15 @@ func TestManualPages(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestOrgIdentity(t *testing.T) {
|
||||
var s Settings
|
||||
s.NSD.DeponentCode = "MC0000000001"
|
||||
if orgCode(s) != "MC0000000001" {
|
||||
t.Fatalf("код по депозитарному коду: %q", orgCode(s))
|
||||
}
|
||||
s.NSD.OrgCode, s.NSD.OrgINN, s.NSD.OrgName = "mc0000000002", "7700000000", "Депозитарий"
|
||||
if orgCode(s) != "MC0000000002" || orgINN(s) != "7700000000" || transferringINN(s) != "7700000000" || orgName(s) != "Депозитарий" {
|
||||
t.Fatalf("организация из настроек: %q %q %q", orgCode(s), orgINN(s), orgName(s))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
package lkgateway
|
||||
|
||||
// org.go — наша организация (депозитарий) в M2M: код участника, ИНН, наименование.
|
||||
// Задаётся при настройке: выбором себя из справочника участников НРД или вручную. От неё зависят
|
||||
// ИНН передающего/зачисляющего депозитария в пакетах, код отправителя (канал ИШ) и код фирмы в QUIK.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var regexpMCCode = regexp.MustCompile(`^[A-Z]{2}[0-9A-Z]{10}$`)
|
||||
|
||||
// handbookParticipants — участники M2M из справочника НРД (без мест расчётов).
|
||||
func handbookParticipants() []HandbookEntry {
|
||||
raw, err := os.ReadFile(handbookFile)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
var hb struct {
|
||||
Participants []HandbookEntry `json:"participants"`
|
||||
}
|
||||
if json.Unmarshal(raw, &hb) != nil {
|
||||
return nil
|
||||
}
|
||||
var out []HandbookEntry
|
||||
for _, p := range hb.Participants {
|
||||
if p.Kind != "place" && p.INN != "" {
|
||||
out = append(out, p)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// handbookByCode — участник по MC-коду.
|
||||
func handbookByCode(code string) (HandbookEntry, bool) {
|
||||
code = strings.ToUpper(strings.TrimSpace(code))
|
||||
if code == "" {
|
||||
return HandbookEntry{}, false
|
||||
}
|
||||
for _, p := range handbookParticipants() {
|
||||
if strings.EqualFold(p.Code, code) {
|
||||
return p, true
|
||||
}
|
||||
}
|
||||
return HandbookEntry{}, false
|
||||
}
|
||||
|
||||
// orgCode — код участника M2M нашей организации: из настроек, иначе депозитарный код в НРД.
|
||||
func orgCode(s Settings) string {
|
||||
return strings.ToUpper(strings.TrimSpace(pick(s.NSD.OrgCode, s.NSD.DeponentCode)))
|
||||
}
|
||||
|
||||
// orgINN — ИНН нашей организации: из настроек, иначе по коду из справочника участников.
|
||||
func orgINN(s Settings) string {
|
||||
if inn := strings.TrimSpace(s.NSD.OrgINN); inn != "" {
|
||||
return inn
|
||||
}
|
||||
if p, ok := handbookByCode(orgCode(s)); ok {
|
||||
return p.INN
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// orgName — наименование нашей организации.
|
||||
func orgName(s Settings) string {
|
||||
if n := strings.TrimSpace(s.NSD.OrgName); n != "" {
|
||||
return n
|
||||
}
|
||||
if p, ok := handbookByCode(orgCode(s)); ok {
|
||||
return p.Name
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// saveOrg — POST /admin/setup/org: организация выбрана из справочника (hb=код) или введена вручную.
|
||||
func (h *setupHandlers) saveOrg(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
s := h.rc.Snapshot().NSD
|
||||
code := strings.ToUpper(strings.TrimSpace(r.FormValue("org_code")))
|
||||
inn := strings.TrimSpace(r.FormValue("org_inn"))
|
||||
name := strings.TrimSpace(r.FormValue("org_name"))
|
||||
if hb := strings.TrimSpace(r.FormValue("hb")); hb != "" {
|
||||
if p, ok := handbookByCode(hb); ok {
|
||||
code, inn, name = strings.ToUpper(p.Code), p.INN, pick(p.FullName, p.Name)
|
||||
}
|
||||
}
|
||||
if inn != "" && !manualINNRe.MatchString(inn) {
|
||||
setupFlash(w, r, "Организация: ИНН юридического лица — 10 цифр")
|
||||
return
|
||||
}
|
||||
if code != "" && !regexpMCCode.MatchString(code) {
|
||||
setupFlash(w, r, "Организация: код участника — вида MC0000000000")
|
||||
return
|
||||
}
|
||||
changedCode := code != s.OrgCode
|
||||
s.OrgCode, s.OrgINN, s.OrgName = code, inn, name
|
||||
if s.DeponentCode == "" {
|
||||
s.DeponentCode = code
|
||||
}
|
||||
if err := h.rc.UpdateNSD(s); err != nil {
|
||||
setupFlash(w, r, "Организация: ошибка сохранения: "+err.Error())
|
||||
return
|
||||
}
|
||||
msg := "Организация сохранена: " + pick(name, "—") + ", ИНН " + pick(inn, "—") + ", код " + pick(code, "—")
|
||||
if changedCode {
|
||||
msg += ". Код отправителя применится после перезапуска bj-server."
|
||||
}
|
||||
setupFlash(w, r, msg)
|
||||
}
|
||||
@@ -343,7 +343,7 @@ func quikExecNewInstr(ctx context.Context, s QuikMNPSettings, withdraw bool, ext
|
||||
var aid sql.NullInt64
|
||||
_, err = conn.ExecContext(cctx, quikNewInstrSQL,
|
||||
sql.Named("aid", sql.Out{Dest: &aid}), sql.Named("ext", nullStr(extID)), sql.Named("cid", clientID), sql.Named("it", itype),
|
||||
sql.Named("vol", qty), sql.Named("out", out), sql.Named("firm", quikFirmID), sql.Named("acc", quikAccount(s)),
|
||||
sql.Named("vol", qty), sql.Named("out", out), sql.Named("firm", quikFirmID()), sql.Named("acc", quikAccount(s)),
|
||||
sql.Named("cm", comment), sql.Named("itid", itid), sql.Named("inn", nullStr(inn)), sql.Named("pass", passExpenses), sql.Named("isin", isin), sql.Named("qs", qs))
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("quik: sp_NewInstrSec: %s", quikErrorDetail(err))
|
||||
@@ -372,7 +372,7 @@ func quikExecSign(ctx context.Context, s QuikMNPSettings, queryNo float64, clien
|
||||
@IntSecCode=@isec, @BlockS=NULL, @PutReason=NULL, @FirmID=@firm, @SignFlag=@sf, @DealDate=NULL, @PayDate=NULL, @CheckType=1, @DocComment=@cm,
|
||||
@AccountIn=NULL, @ClassCodeAfterTrans=NULL, @SecCodeAfterTrans=NULL, @QtyAfterTrans=NULL, @FirmAfterTrans=NULL, @BrokerComment=NULL`,
|
||||
sql.Named("aid", sql.Out{Dest: &aid}), sql.Named("no", queryNo), sql.Named("cid", clientID), sql.Named("vol", qty), sql.Named("isec", secOut),
|
||||
sql.Named("firm", quikFirmID), sql.Named("sf", quikSignFlagDepo), sql.Named("cm", comment))
|
||||
sql.Named("firm", quikFirmID()), sql.Named("sf", quikSignFlagDepo), sql.Named("cm", comment))
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("quik: sp_SignInstrSec: %s", quikErrorDetail(err))
|
||||
}
|
||||
|
||||
@@ -32,7 +32,6 @@ import (
|
||||
|
||||
// Идентификаторы M2M на стороне QUIK (фиксированы инструкцией + наши данные).
|
||||
const (
|
||||
quikFirmID = "MC0413600000" // наш код фирмы (таблица Firms, FirmId=2)
|
||||
quikTypeWithdrawM2M = 100 // InstrTypeID «Вывод ЦБ М2М»
|
||||
quikTypeDepositM2M = 101 // InstrTypeID «Ввод ЦБ М2М»
|
||||
quikBaseTypeWithdraw = 9 // базовый Type для вывода (Поставка ЦБ из ДКК)
|
||||
@@ -124,8 +123,8 @@ func quikRegisterWithdrawal(ctx context.Context, s QuikMNPSettings, p QuikWithdr
|
||||
sql.Named("ISIN", p.ISIN),
|
||||
sql.Named("QtyScale", nullInt(p.QtyScale)),
|
||||
sql.Named("Account", nullStr(p.Account)),
|
||||
sql.Named("QuikFirm", quikFirmID),
|
||||
sql.Named("FirmID", quikFirmID),
|
||||
sql.Named("QuikFirm", quikFirmID()),
|
||||
sql.Named("FirmID", quikFirmID()),
|
||||
sql.Named("CanSplit", 0),
|
||||
sql.Named("BlockS", p.Quantity),
|
||||
sql.Named("M2MDepoRecipientINN", nullStr(p.RecipientINN)),
|
||||
@@ -169,8 +168,8 @@ func quikRegisterDeposit(ctx context.Context, s QuikMNPSettings, clientID, isin,
|
||||
sql.Named("QuikClassCode", nil),
|
||||
sql.Named("ISIN", isin),
|
||||
sql.Named("Account", nullStr(account)),
|
||||
sql.Named("QuikFirm", quikFirmID),
|
||||
sql.Named("FirmID", quikFirmID),
|
||||
sql.Named("QuikFirm", quikFirmID()),
|
||||
sql.Named("FirmID", quikFirmID()),
|
||||
sql.Named("CanSplit", 0),
|
||||
sql.Named("BlockS", qty),
|
||||
sql.Named("CheckType", quikCheckViaAPI),
|
||||
@@ -203,7 +202,7 @@ func quikSignWithdrawal(ctx context.Context, s QuikMNPSettings, queryNo float64,
|
||||
sql.Named("ClientID", nullStr(clientID)),
|
||||
sql.Named("Volume", nil), // при подписи всегда NULL
|
||||
sql.Named("IntSecCode", quikSecWithdraw),
|
||||
sql.Named("FirmID", quikFirmID),
|
||||
sql.Named("FirmID", quikFirmID()),
|
||||
sql.Named("SignFlag", quikSignFlagDepo),
|
||||
sql.Named("CheckType", quikCheckViaAPI),
|
||||
)
|
||||
@@ -280,3 +279,11 @@ func nullInt(v int) any {
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// quikFirmID — код фирмы в QUIK (таблица Firms) = код участника нашей организации.
|
||||
func quikFirmID() string {
|
||||
if globalRC == nil {
|
||||
return ""
|
||||
}
|
||||
return orgCode(globalRC.Snapshot())
|
||||
}
|
||||
|
||||
@@ -221,7 +221,12 @@ type NSDSettings struct {
|
||||
|
||||
// Депозитарные реквизиты клиента — откуда списываются бумаги
|
||||
// (SettlementLocation в M2MTransferRequest). Из договора/письма НРД.
|
||||
DeponentCode string `json:"deponent_code"` // депкод, напр. MC0413600000
|
||||
// Наша организация — участник M2M (выбирается из справочника НРД или вводится при настройке).
|
||||
OrgCode string `json:"org_code,omitempty"` // код участника (отправитель пакетов), напр. MC0000000000
|
||||
OrgINN string `json:"org_inn,omitempty"` // ИНН — передающий/зачисляющий депозитарий в пакетах
|
||||
OrgName string `json:"org_name,omitempty"`
|
||||
|
||||
DeponentCode string `json:"deponent_code"` // депозитарный код в НРД (место хранения)
|
||||
AccountID string `json:"account_id"` // депозитарный счёт
|
||||
SectionID string `json:"section_id"` // раздел депозитарного счёта
|
||||
|
||||
|
||||
@@ -78,6 +78,11 @@ func NewServer(cfg ServerConfig) (*Server, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Код отправителя — наша организация из настроек (приоритет над BJ_M2M_SENDER).
|
||||
if code := orgCode(rc.Snapshot()); code != "" {
|
||||
cfg.DefaultSender = m2m.DeponentCode(code)
|
||||
}
|
||||
|
||||
// Выбор NSD-сендера: если в runtime-конфиге задан профиль ИШ и URL —
|
||||
// используем реальный nsdadapter поверх REST ИШ; иначе mock-эмуляция.
|
||||
// mockSender остаётся не-nil только в mock-режиме — на нём висит
|
||||
|
||||
@@ -84,6 +84,7 @@ func registerSetup(mux *http.ServeMux, a *admin, rc *RuntimeConfig, svc *Service
|
||||
mux.HandleFunc("/admin/setup/logo", h.saveLogo)
|
||||
mux.HandleFunc("/admin/setup/logo/reset", h.resetLogo)
|
||||
mux.HandleFunc("/admin/setup/nsd", h.saveNSD)
|
||||
mux.HandleFunc("/admin/setup/org", h.saveOrg)
|
||||
mux.HandleFunc("/admin/setup/lk", h.saveLK)
|
||||
mux.HandleFunc("/admin/setup/lk-integration", h.saveLKIntegration)
|
||||
mux.HandleFunc("/admin/setup/lk-check", h.checkLKConn)
|
||||
@@ -302,6 +303,9 @@ type SetupData struct {
|
||||
Health []HealthStatus // здоровье фоновых компонентов
|
||||
AdvancedUnlocked bool // открыты ли перспективные модули (QUIK, Fancy)
|
||||
NSDCerts NSDCertsView // сертификаты НРД в справочнике ИШ
|
||||
Participants []HandbookEntry // справочник участников M2M (выбор своей организации)
|
||||
OrgINN, OrgName string // наша организация (с учётом справочника)
|
||||
OrgCode string
|
||||
NotifyGroups []struct{ Key, Title string }
|
||||
NotifyKinds []struct{ Key, Title string }
|
||||
DepoJob DepoJobView
|
||||
@@ -362,6 +366,10 @@ func (h *setupHandlers) renderSetup(w http.ResponseWriter, req *http.Request, fl
|
||||
Health: HealthSnapshot(),
|
||||
AdvancedUnlocked: advancedUnlocked(req),
|
||||
NSDCerts: nsdCertsView(s),
|
||||
Participants: handbookParticipants(),
|
||||
OrgINN: orgINN(s),
|
||||
OrgName: orgName(s),
|
||||
OrgCode: orgCode(s),
|
||||
NotifyGroups: notifyGroups,
|
||||
NotifyKinds: notifyKinds,
|
||||
DepoJob: depoJobView(s.Fancy),
|
||||
@@ -520,6 +528,9 @@ func (h *setupHandlers) saveNSD(w http.ResponseWriter, r *http.Request) {
|
||||
Profile: strings.TrimSpace(r.FormValue("profile")),
|
||||
IGWBaseURL: strings.TrimSpace(r.FormValue("igw_base_url")),
|
||||
KeyContainer: strings.TrimSpace(r.FormValue("key_container")),
|
||||
OrgCode: h.rc.Snapshot().NSD.OrgCode, // организация — отдельной формой (saveOrg)
|
||||
OrgINN: h.rc.Snapshot().NSD.OrgINN,
|
||||
OrgName: h.rc.Snapshot().NSD.OrgName,
|
||||
DeponentCode: strings.TrimSpace(r.FormValue("deponent_code")),
|
||||
AccountID: strings.TrimSpace(r.FormValue("account_id")),
|
||||
SectionID: strings.TrimSpace(r.FormValue("section_id")),
|
||||
|
||||
@@ -150,7 +150,7 @@ ORDER BY e.ID;</pre>
|
||||
|
||||
<h2 id="handbook">Справочник участников M2M (M2MTH) → таблица FTV_M2M_HANDBOOK</h2>
|
||||
<p>НРД ведёт справочник участников переводов по MOEX МОСТ: кто депозитарий, кто брокер, их коды участника и ИНН, плюс места расчётов. bj получает его от НРД (сообщение M2MTransferHandbook) при старте и обновляет раз в сутки; по нему bj определяет контрагента по ИНН из перевода и наполняет справочник депозитариев в QUIK МНП. Предлагаем отдавать его в DEPO целиком в отдельную таблицу — тогда <code>CONTRAGENT_M2M_CODE</code> из FTV_M2M раскрывается в ИНН и наименование, и по нему можно заводить контрагентов в DEPO.</p>
|
||||
<p class="muted">Сейчас в справочнике 158 участников и 2 мест расчётов (получено 2026-09-09). Наш код участника — <code>MC0413600000</code>.</p>
|
||||
<p class="muted">Сейчас в справочнике 158 участников и 2 мест расчётов (получено 2026-09-09).</p>
|
||||
<table>
|
||||
<thead><tr><th style="width:190px">Поле таблицы</th><th style="width:130px">Тип</th><th>Откуда у bj / что означает</th></tr></thead>
|
||||
<tbody>
|
||||
@@ -201,7 +201,7 @@ ORDER BY e.ID;</pre>
|
||||
INSERT INTO dbo.FTV_M2M (GUID, DIRECTION, CONTRAGENT_M2M_CODE, DEPONENT_NAME, DEPONENT_DOC)
|
||||
VALUES ('5325B860-3265-49B6-986D-FA84FB41FE27', 'IN', 'MC0034900000', N'Иванов Иван Иванович', '0000 000000');
|
||||
INSERT INTO dbo.FTV_M2M_LINES (GUID, REFERENCE, ISIN, SEC_CODE, SHARE, AMOUNT, PLACE_NUMBER, DEPO_NUMBER)
|
||||
VALUES ('5325B860-3265-49B6-986D-FA84FB41FE27', 'M2M0000002675550', 'RU000A107UL4', 'RU000A107UL4', 18824, 1, '36MC0413600000F00', N'ВТНКЦ00000001');
|
||||
VALUES ('5325B860-3265-49B6-986D-FA84FB41FE27', 'M2M0000002675550', 'RU000A107UL4', 'RU000A107UL4', 18824, 1, '36MC0000000000F00', N'ВТНКЦ00000001');
|
||||
INSERT INTO dbo.FTV_M2M_EVENTS (GUID, REFERENCE, EVENT_TYPE, EVENT_DATE)
|
||||
VALUES ('5325B860-3265-49B6-986D-FA84FB41FE27', NULL, 'BJ_RECEIVED', '2026-09-17T09:21:26.000'); -- время получения M2MTR в bj
|
||||
COMMIT;
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
<ol>
|
||||
<li><b>Примите поручение.</b> Проверьте, что поручение подписано клиентом или представителем, датировано и содержит реквизиты бумаг и принимающего депозитария. Если действует представитель, проверьте доверенность.</li>
|
||||
<li><b>Создайте поручение в системе:</b> «Ручной ввод M2M» → «Новое поручение». Заполните поля по бумажному поручению (описание полей — ниже).</li>
|
||||
<li><b>Найдите клиента в учётной системе</b> кнопкой «Найти в DEPO по документу», если учётная система подключена. Пустые поля (ФИО, дата рождения, счёт, наименования бумаг) заполнятся из учётной системы. Сверьте их с поручением.</li>
|
||||
<li><b>Найдите клиента в учётной системе</b>, если она подключена: в поле поиска введите фамилию (или ФИО), либо серию и номер документа, и нажмите «Найти в DEPO». Показываются клиенты с открытым торговым счётом. Нажмите на нужный счёт: система подставит ФИО, дату рождения, документ, кем и когда он выдан, счёт, договор и телефон, а ниже покажет бумаги клиента на торговом разделе с остатками. Выберите бумагу и укажите количество. Сверьте подставленные данные с поручением клиента.</li>
|
||||
<li><b>Выполните сверку с DEPO</b> кнопкой «Сохранить и сверить с DEPO». Система проверяет:
|
||||
<ul>
|
||||
<li>клиента по документу, ФИО и дате рождения;</li>
|
||||
@@ -48,7 +48,7 @@
|
||||
<tr><td>ФИО, дата рождения</td><td>Как в документе. Дата рождения сверяется с анкетой депонента.</td></tr>
|
||||
<tr><td>Счёт депо клиента</td><td>Торговый счёт депо, с которого списываются бумаги. Он должен принадлежать клиенту и быть открыт.</td></tr>
|
||||
<tr><td>Договор, код в торговой системе</td><td>Для сведения и поиска клиента в торговой системе.</td></tr>
|
||||
<tr><td>Ценные бумаги</td><td>ISIN (только российские выпуски, начинаются с RU) и количество. Дробное количество допускается для паёв. Код НРД можно не указывать.</td></tr>
|
||||
<tr><td>Ценная бумага</td><td><b>Одно поручение — одна бумага</b>: если клиент переводит несколько бумаг, на каждую оформляется отдельное поручение. ISIN (только российские выпуски, начинаются с RU) и количество; дробное количество допускается для паёв. Код НРД можно не указывать.</td></tr>
|
||||
<tr><td>Принимающий депозитарий</td><td>Выбирается из справочника участников M2M, который bj получает от НРД. Если депозитария нет в справочнике, перевод через MOEX МОСТ невозможен.</td></tr>
|
||||
<tr><td>Счёт у принимающего</td><td>Для сведения: принимающий депозитарий сам находит клиента по документу.</td></tr>
|
||||
<tr><td>Место расчётов</td><td>НРД или СПБ Банк — как в поручении клиента.</td></tr>
|
||||
|
||||
@@ -56,7 +56,7 @@
|
||||
|
||||
<h2 id="s2">Шаг 2. Добавить классы в плагин МНП</h2>
|
||||
<p><em>Где:</em> QUIK Administrator → Модули → МНП → справочник <strong>«Классы инструментов»</strong>. <em>Документ:</em> МОСТ п. 4.1, шаг 2, стр. 11.</p>
|
||||
<p>Добавьте оба класса, созданных на шаге 1, и на каждом выберите фирму, по которой будет меняться позиция клиента: <code>MC0413600000</code> (Фондовый рынок).</p>
|
||||
<p>Добавьте оба класса, созданных на шаге 1, и на каждом выберите фирму, по которой будет меняться позиция клиента, — код участника вашей организации (Фондовый рынок).</p>
|
||||
|
||||
<h2 id="s3">Шаг 3. Создать инструменты МНП</h2>
|
||||
<p><em>Где:</em> QUIK Administrator → Модули → МНП → справочник <strong>«Инструменты»</strong>. <em>Документ:</em> МОСТ п. 4.2, стр. 14.</p>
|
||||
@@ -68,7 +68,7 @@
|
||||
</tbody>
|
||||
</table>
|
||||
<p>Коды берите <strong>ровно такие</strong>, как в документе: bj передаёт их в вызовах процедур. Если инструменты уже созданы под другими кодами (например, по имени класса), сообщите нам коды — мы подставим их в настройках.</p>
|
||||
<p>Затем на <strong>сервере QUIK</strong> в настройках Библиотеки расчёта лимитов по фирме <code>MC0413600000</code> добавьте эти инструменты и привяжите к валюте (рубли) и тегу расчётов, с которого списывается комиссия депозитария: раздел «Инструменты» / «Параметры учёта неторговых поручений» (МОСТ п. 4.2, стр. 14, второй абзац).</p>
|
||||
<p>Затем на <strong>сервере QUIK</strong> в настройках Библиотеки расчёта лимитов по фирме вашей организации добавьте эти инструменты и привяжите к валюте (рубли) и тегу расчётов, с которого списывается комиссия депозитария: раздел «Инструменты» / «Параметры учёта неторговых поручений» (МОСТ п. 4.2, стр. 14, второй абзац).</p>
|
||||
|
||||
<h2 id="s4">Шаг 4. Включить типы поручений M2M</h2>
|
||||
<p><em>Где:</em> QUIK Administrator → Модули → МНП → справочник <strong>«Типы поручений»</strong>. <em>Документ:</em> МОСТ п. 4.3, стр. 16–22.</p>
|
||||
@@ -122,7 +122,7 @@
|
||||
<tr><td>Клиент сам подал «Вывод ЦБ (M2M)» из QUIK</td><td>bj видит его в экспорте, оповещает оператора; после создания заявки поручение привязывается в карточке, дальше — как обычный исходящий</td><td>своё поручение «Активно», затем исполнено или снято с причиной</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p class="muted">Код клиента QUIK bj берёт из номера портфеля клиента в DEPO: «БР-859» → «BR859». Исключения задаются в той же форме настроек. Каждый шаг с ответом модуля виден в карточке перевода и на странице <a href="/admin/quik">QUIK МНП</a>; при ошибке приходит оповещение и есть кнопки для ручного повтора.</p>
|
||||
<p class="muted">Код клиента QUIK bj берёт из номера портфеля клиента в DEPO: «БР-001» → «BR001». Исключения задаются в той же форме настроек. Каждый шаг с ответом модуля виден в карточке перевода и на странице <a href="/admin/quik">QUIK МНП</a>; при ошибке приходит оповещение и есть кнопки для ручного повтора.</p>
|
||||
|
||||
<h2 id="verify">Как проверить, что всё готово</h2>
|
||||
<ol>
|
||||
|
||||
@@ -31,6 +31,12 @@
|
||||
.mf-kv{display:grid;grid-template-columns:minmax(150px,220px) 1fr;gap:4px 14px;font-size:13.5px}
|
||||
.mf-kv .k{color:var(--muted)}
|
||||
.dc-ok{color:var(--ok);font-weight:700}.dc-bad{color:var(--err);font-weight:700}.dc-warn{color:var(--warn);font-weight:700}
|
||||
.mf-find{background:var(--card-2);border:1px solid var(--border);border-radius:10px;padding:10px;margin-bottom:10px}
|
||||
.mf-find input{padding:7px 9px;border-radius:8px;border:1px solid var(--border);background:var(--card);color:var(--text);font-size:13.5px}
|
||||
.mf-res:empty{display:none}.mf-res{margin:8px 0}
|
||||
.mf-hit{display:flex;justify-content:space-between;gap:10px;flex-wrap:wrap;align-items:center;padding:7px 0;border-top:1px solid var(--border)}
|
||||
.mf-pos{display:block;width:100%;text-align:left;padding:7px 10px;margin:3px 0;border-radius:8px;border:1px solid var(--border);background:var(--card);color:var(--text);cursor:pointer;font-size:13px}
|
||||
.mf-pos:hover:not([disabled]){border-color:var(--accent)}.mf-pos[disabled]{opacity:.5;cursor:not-allowed}
|
||||
.mf-sign{display:grid;grid-template-columns:1fr 1fr;gap:40px;margin-top:40px}
|
||||
.mf-sign div{border-top:1px solid #000;padding-top:4px;font-size:12px}
|
||||
@media print{
|
||||
@@ -291,12 +297,20 @@ function mfRerun(){const f=document.getElementById('mf');if(f&&f.closest('detail
|
||||
</div>
|
||||
|
||||
<div class="mf-h">2. Клиент (как в поручении и анкете депонента)</div>
|
||||
{{if .DepoOn}}
|
||||
<div class="mf-find">
|
||||
<div style="display:flex;gap:8px;flex-wrap:wrap">
|
||||
<input id="dq" placeholder="Фамилия (или ФИО), либо серия и номер документа" style="flex:1;min-width:260px" onkeydown="if(event.key==='Enter'){event.preventDefault();mfFind()}">
|
||||
<button type="button" class="btn" onclick="mfFind()">Найти в DEPO</button>
|
||||
</div>
|
||||
<div id="dres" class="mf-res"></div>
|
||||
</div>
|
||||
{{end}}
|
||||
<div class="mf-grid">
|
||||
<div><label class="mf-req">Вид документа</label>
|
||||
<select name="doc_type">{{$dt := .O.DocType}}{{range .DocTypes}}<option value="{{.Code}}" {{if eq $dt .Code}}selected{{end}}>{{.Code}} — {{.Title}}</option>{{end}}</select></div>
|
||||
<div><label>Серия</label><input name="doc_series" value="{{.O.DocSeries}}" placeholder="4 цифры для паспорта РФ"></div>
|
||||
<div><label class="mf-req">Номер</label><input name="doc_number" value="{{.O.DocNumber}}"></div>
|
||||
<div style="display:flex;align-items:flex-end">{{if .DepoOn}}<button type="submit" name="do" value="lookup" class="btn btn-secondary" style="width:100%" title="Найти клиента в учётной системе по документу и заполнить пустые поля">Найти в DEPO по документу</button>{{end}}</div>
|
||||
<div><label>Дата выдачи</label><input type="date" name="doc_issue_date" value="{{.O.DocIssueDate}}"></div>
|
||||
<div class="wide"><label>Кем выдан</label><input name="doc_issuer" value="{{.O.DocIssuer}}"></div>
|
||||
<div><label class="mf-req">Фамилия</label><input name="last_name" value="{{.O.LastName}}"></div>
|
||||
@@ -304,20 +318,17 @@ function mfRerun(){const f=document.getElementById('mf');if(f&&f.closest('detail
|
||||
<div><label>Отчество</label><input name="middle_name" value="{{.O.MiddleName}}"></div>
|
||||
<div><label class="mf-req">Дата рождения</label><input type="date" name="birth_date" value="{{.O.BirthDate}}"></div>
|
||||
<div><label>Договор (брокерский / депозитарный)</label><input name="contract" value="{{.O.Contract}}"></div>
|
||||
<div><label class="mf-req">Счёт депо клиента</label><input name="account" value="{{.O.Account}}" placeholder="торговый счёт депо"></div>
|
||||
<div><label class="mf-req">Счёт депо клиента</label><input name="account" id="f_account" value="{{.O.Account}}" placeholder="торговый счёт депо"></div>
|
||||
<div><label>Код клиента в торговой системе</label><input name="quik_code" value="{{.O.QuikCode}}"></div>
|
||||
<div><label>Телефон для связи</label><input name="phone" value="{{.O.Phone}}"></div>
|
||||
</div>
|
||||
|
||||
<div class="mf-h">3. Ценные бумаги</div>
|
||||
<div class="mf-h">3. Ценная бумага</div>
|
||||
<div id="dpos" class="mf-res"></div>
|
||||
<div class="mf-sec mf-note" style="margin-bottom:2px"><span class="mf-req">ISIN</span><span>Код НРД</span><span class="nm">Наименование</span><span class="mf-req">Количество</span><span></span></div>
|
||||
<div id="secs">
|
||||
{{range .O.Securities}}
|
||||
<div class="mf-sec"><input name="sec_isin" value="{{.ISIN}}" placeholder="RU…" maxlength="12"><input name="sec_code" value="{{.Code}}" placeholder="необязательно"><input class="nm" name="sec_name" value="{{.Name}}" placeholder="заполнится из DEPO"><input name="sec_qty" value="{{.Qty}}" inputmode="decimal"><button type="button" class="x" onclick="mfDel(this)" title="Убрать">×</button></div>
|
||||
{{end}}
|
||||
</div>
|
||||
<button type="button" class="btn btn-secondary" onclick="mfAdd()" style="margin-top:4px">+ Бумага</button>
|
||||
<p class="mf-note">Через MOEX МОСТ переводятся только российские выпуски (ISIN начинается с RU). Дробное количество — для паёв, через точку или запятую.</p>
|
||||
{{$sec := index .O.Securities 0}}
|
||||
<div class="mf-sec"><input name="sec_isin" id="f_isin" value="{{$sec.ISIN}}" placeholder="RU…" maxlength="12"><input name="sec_code" value="{{$sec.Code}}" placeholder="необязательно"><input class="nm" name="sec_name" id="f_sname" value="{{$sec.Name}}" placeholder="заполнится из DEPO"><input name="sec_qty" id="f_qty" value="{{$sec.Qty}}" inputmode="decimal"><span></span></div>
|
||||
<p class="mf-note">Одно поручение — одна бумага: на каждую бумагу оформляется отдельное поручение. Через MOEX МОСТ переводятся только российские выпуски (ISIN начинается с RU). Дробное количество — для паёв, через точку или запятую.</p>
|
||||
|
||||
<div class="mf-h">4. Принимающая сторона</div>
|
||||
<div class="mf-grid">
|
||||
@@ -345,10 +356,37 @@ function mfRerun(){const f=document.getElementById('mf');if(f&&f.closest('detail
|
||||
<a href="/admin/manual" class="btn btn-secondary">К списку</a>
|
||||
</div>
|
||||
</form>
|
||||
<template id="secRow"><div class="mf-sec"><input name="sec_isin" placeholder="RU…" maxlength="12"><input name="sec_code" placeholder="необязательно"><input class="nm" name="sec_name" placeholder="заполнится из DEPO"><input name="sec_qty" inputmode="decimal"><button type="button" class="x" onclick="mfDel(this)" title="Убрать">×</button></div></template>
|
||||
<script>
|
||||
function mfAdd(){document.getElementById('secs').appendChild(document.getElementById('secRow').content.cloneNode(true))}
|
||||
function mfDel(b){const rows=document.querySelectorAll('#secs .mf-sec');if(rows.length>1){b.parentNode.remove()}else{b.parentNode.querySelectorAll('input').forEach(i=>i.value='')}}
|
||||
const mfEsc=s=>String(s??'').replace(/[&<>"]/g,c=>({'&':'&','<':'<','>':'>','"':'"'}[c]));
|
||||
let mfFaces=[];
|
||||
function mfSet(n,v){const e=document.querySelector('#mf [name="'+n+'"]');if(e&&v!==undefined&&v!==null)e.value=v}
|
||||
async function mfFind(){
|
||||
const q=document.getElementById('dq').value.trim(),box=document.getElementById('dres');
|
||||
box.innerHTML='<span class="mf-note">Поиск…</span>';
|
||||
try{const r=await (await fetch('/admin/manual/depo/search?q='+encodeURIComponent(q))).json();
|
||||
if(r.error){box.innerHTML='<span class="dc-bad">'+mfEsc(r.error)+'</span>';return}
|
||||
mfFaces=r.items||[];
|
||||
box.innerHTML=mfFaces.map((f,i)=>'<div class="mf-hit"><div><b>'+mfEsc(f.name)+'</b><div class="mf-note">'+(f.birth?'д.р. '+mfEsc(f.birth)+' · ':'')+'документ '+mfEsc(f.doc_ser)+' '+mfEsc(f.doc_num)+'</div></div><div style="display:flex;gap:6px;flex-wrap:wrap">'+
|
||||
(f.accounts||[]).map((a,j)=>'<button type="button" class="btn btn-secondary" onclick="mfPick('+i+','+j+')"'+(a.iis?' title="ИИС — перевод через M2M не выполняется"':'')+'>'+mfEsc(a.number)+(a.contract?' · '+mfEsc(a.contract):'')+(a.iis?' · ИИС':'')+'</button>').join('')+'</div></div>').join('');
|
||||
}catch(e){box.innerHTML='<span class="dc-bad">Поиск не выполнен: '+mfEsc(e)+'</span>'}
|
||||
}
|
||||
async function mfPick(i,j){
|
||||
const f=mfFaces[i],a=f.accounts[j];
|
||||
mfSet('last_name',f.last_name);mfSet('first_name',f.first_name);mfSet('middle_name',f.middle_name||'');mfSet('birth_date',f.birth||'');
|
||||
if(f.doc_type)mfSet('doc_type',f.doc_type);
|
||||
mfSet('doc_series',f.doc_ser||'');mfSet('doc_number',f.doc_num);mfSet('doc_issue_date',f.doc_date||'');mfSet('doc_issuer',f.doc_issuer||'');
|
||||
mfSet('account',a.number);mfSet('contract',a.contract||'');if(f.phone)mfSet('phone',f.phone);
|
||||
document.getElementById('dres').innerHTML='<span class="dc-ok">Подставлено из DEPO: '+mfEsc(f.name)+', счёт '+mfEsc(a.number)+'. Сверьте с поручением клиента.</span>';
|
||||
const box=document.getElementById('dpos');box.innerHTML='<span class="mf-note">Загрузка бумаг клиента…</span>';
|
||||
try{const r=await (await fetch('/admin/manual/depo/positions?client='+a.client_id)).json();
|
||||
if(r.error){box.innerHTML='<span class="dc-bad">'+mfEsc(r.error)+'</span>';return}
|
||||
const items=r.items||[];mfPos=items;
|
||||
if(!items.length){box.innerHTML='<span class="mf-note">На торговом разделе счёта '+mfEsc(a.number)+' бумаг нет.</span>';return}
|
||||
box.innerHTML='<div class="mf-note" style="margin-bottom:4px">Бумаги на торговом разделе '+mfEsc(a.number)+' — выберите одну:</div>'+items.map((p,k)=>'<button type="button" class="mf-pos"'+(p.russian?'':' disabled title="иностранный выпуск — через M2M не переводится"')+' onclick="mfSec('+k+')"><b>'+mfEsc(p.isin)+'</b> '+mfEsc(p.name)+' <span class="mf-note">· '+p.qty+' шт.</span></button>').join('');
|
||||
}catch(e){box.innerHTML='<span class="dc-bad">'+mfEsc(e)+'</span>'}
|
||||
}
|
||||
let mfPos=[];
|
||||
function mfSec(k){const p=mfPos[k];document.getElementById('f_isin').value=p.isin;document.getElementById('f_sname').value=p.name;const q=document.getElementById('f_qty');q.placeholder='доступно '+p.qty;q.focus()}
|
||||
function mfPickHB(s){const o=s.options[s.selectedIndex];if(!o.value)return;document.getElementById('recv_inn').value=o.value;document.getElementById('recv_name').value=o.dataset.name||''}
|
||||
</script>
|
||||
{{end}}
|
||||
|
||||
@@ -199,13 +199,13 @@
|
||||
<a href="/admin/help/quik"><strong>Инструкция для администратора QUIK</strong></a> — что настроить в QuikAdmin для M2M · <a href="/admin/docs/">документы ARQA</a>.</p>
|
||||
<form method="post" action="/admin/setup/quik" style="margin-top:12px;display:grid;gap:10px;max-width:560px">
|
||||
<label>Сервер (host или host\instance)
|
||||
<input type="text" name="server" value="{{.Settings.Quik.Server}}" placeholder="91.209.122.88" style="width:100%" required></label>
|
||||
<input type="text" name="server" value="{{.Settings.Quik.Server}}" placeholder="адрес SQL Server QUIK" style="width:100%" required></label>
|
||||
<label>Порт
|
||||
<input type="number" name="port" value="{{if .Settings.Quik.Port}}{{.Settings.Quik.Port}}{{else}}1433{{end}}" placeholder="8849" style="width:100%"></label>
|
||||
<label>Имя БД (если требуется, иначе оставьте пустым)
|
||||
<input type="text" name="database" value="{{.Settings.Quik.Database}}" placeholder="(по умолчанию)" style="width:100%"></label>
|
||||
<label>Логин (роль InstrEu)
|
||||
<input type="text" name="user" value="{{.Settings.Quik.User}}" placeholder="BROKER_88" style="width:100%" required></label>
|
||||
<input type="text" name="user" value="{{.Settings.Quik.User}}" placeholder="логин SQL" style="width:100%" required></label>
|
||||
<label>Пароль
|
||||
<input type="password" name="password" placeholder="{{if .Settings.Quik.Password}}•••••• — оставьте пустым, чтобы не менять{{else}}пароль SQL-логина{{end}}" autocomplete="off" style="width:100%"></label>
|
||||
<label>Шифрование канала
|
||||
@@ -254,7 +254,7 @@
|
||||
<label>МНП-инструмент ввода <span class="muted">(пусто = из класса INSTRINM2M)</span>
|
||||
<input type="text" name="sec_in" value="{{.Settings.Quik.SecIn}}" placeholder="RURINM2M" style="width:100%"></label>
|
||||
</div>
|
||||
<label>Исключения кодов клиентов <span class="muted">(по умолчанию «БР-859» → «BR859»; строка «номер портфеля=код QUIK»)</span>
|
||||
<label>Исключения кодов клиентов <span class="muted">(по умолчанию «БР-001» → «BR001»; строка «номер портфеля=код QUIK»)</span>
|
||||
<textarea name="client_code_map" rows="3" style="width:100%;font-family:ui-monospace,monospace;font-size:12px" placeholder="БР-4-4=BR44">{{quikCodeMap .Settings.Quik.ClientCodeMap}}</textarea></label>
|
||||
<div style="display:flex;gap:8px;flex-wrap:wrap"><button type="submit" class="btn btn-ok">Сохранить</button></div>
|
||||
</form>
|
||||
@@ -472,6 +472,28 @@
|
||||
<section class="settings-section" id="sec-nsd">
|
||||
<h1>НРД</h1>
|
||||
{{template "verline" (.Versions.Get "ish")}}
|
||||
<div class="card" id="sec-org">
|
||||
<h2><span class="dot {{if and .OrgINN .OrgCode}}ok{{else}}err{{end}}"></span>Наша организация</h2>
|
||||
<p class="muted">Депозитарий — участник MOEX МОСТ, от имени которого bj отправляет и принимает переводы: ИНН передающего и зачисляющего депозитария в пакетах, код отправителя и код фирмы в QUIK. Выберите себя из справочника участников НРД или введите данные вручную.</p>
|
||||
{{if and .OrgINN .OrgCode}}<p style="margin:8px 0 0"><b>{{if .OrgName}}{{.OrgName}}{{else}}—{{end}}</b> · ИНН <code>{{.OrgINN}}</code> · код <code>{{.OrgCode}}</code>{{if not .Settings.NSD.OrgINN}} <span class="muted">(ИНН взят из справочника по коду)</span>{{end}}</p>
|
||||
{{else}}<p style="margin:8px 0 0;color:var(--err)">Организация не задана — переводы не будут сформированы.</p>{{end}}
|
||||
<form method="post" action="/admin/setup/org" style="margin-top:12px;display:grid;gap:10px;max-width:680px">
|
||||
<input type="hidden" name="section" value="sec-org">
|
||||
<label>Из справочника участников M2M</label>
|
||||
<select name="hb" onchange="var o=this.options[this.selectedIndex];if(o.value){this.form.org_code.value=o.value;this.form.org_inn.value=o.dataset.inn;this.form.org_name.value=o.dataset.name}">
|
||||
<option value="">— выбрать себя —</option>
|
||||
{{$code := .OrgCode}}{{range .Participants}}<option value="{{.Code}}" data-inn="{{.INN}}" data-name="{{if .FullName}}{{.FullName}}{{else}}{{.Name}}{{end}}" {{if eq .Code $code}}selected{{end}}>{{.Name}} · ИНН {{.INN}} · {{.Code}}</option>{{end}}
|
||||
</select>
|
||||
{{if not .Participants}}<span class="muted" style="font-size:12px">Справочник участников ещё не получен — запросите его в «Диагностике» или введите данные вручную.</span>{{end}}
|
||||
<label>Код участника</label>
|
||||
<input type="text" name="org_code" value="{{.Settings.NSD.OrgCode}}" placeholder="MC0000000000" maxlength="12">
|
||||
<label>ИНН</label>
|
||||
<input type="text" name="org_inn" value="{{.Settings.NSD.OrgINN}}" maxlength="10" inputmode="numeric">
|
||||
<label>Наименование</label>
|
||||
<input type="text" name="org_name" value="{{.Settings.NSD.OrgName}}">
|
||||
<div><button type="submit" class="btn btn-ok">Сохранить</button></div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="card" id="m2m-auto">
|
||||
<h2><span class="dot {{if .Settings.M2MAuto.Enabled}}ok{{else}}warn{{end}}"></span>Автоматический режим M2M</h2>
|
||||
<p class="muted">Без участия оператора, чтобы соблюдать сроки: <strong>ответ на входящие переводы</strong> по свежей сверке с DEPO (пакет принимается только целиком; бумага для квалифицированных инвесторов при неполной или непрочитанной квалификации клиента — отказ M2M08), <strong>подача поручения 16/1</strong>, как только НРД пришлёт черновик после нашего согласия, и <strong>подача поручения 16</strong> по исходящим после согласия контрагента. Если DEPO или ИШ недоступны — bj повторяет каждые 3 минуты и шлёт оповещение; без сверки ничего не принимается. Обрабатывается только то, что пришло не раньше чем за сутки до включения. Кнопки в карточках остаются для ручной работы.</p>
|
||||
@@ -504,7 +526,7 @@
|
||||
<hr style="border:none;border-top:1px solid var(--border);margin:6px 0">
|
||||
<p class="muted" style="margin:0">Депозитарные реквизиты (откуда списываются бумаги) — из договора/письма НРД. Нужны для формирования заявки на перевод.</p>
|
||||
<label>Депозитарный код</label>
|
||||
<input type="text" name="deponent_code" value="{{.Settings.NSD.DeponentCode}}" placeholder="напр. MC0413600000">
|
||||
<input type="text" name="deponent_code" value="{{.Settings.NSD.DeponentCode}}" placeholder="MC0000000000">
|
||||
<label>Депозитарный счёт</label>
|
||||
<input type="text" name="account_id" value="{{.Settings.NSD.AccountID}}" placeholder="депозитарный счёт">
|
||||
<label>Раздел счёта</label>
|
||||
@@ -549,14 +571,14 @@
|
||||
<div>
|
||||
<label>Адрес ЛК</label>
|
||||
<div style="display:flex;gap:8px;align-items:center;margin-top:4px">
|
||||
<input type="text" name="lk_public_url" value="{{.Settings.LK.PublicURL}}" placeholder="https://fontvielle.ef.winr.pro" style="flex:1">
|
||||
<input type="text" name="lk_public_url" value="{{.Settings.LK.PublicURL}}" placeholder="https://lk.example.ru" style="flex:1">
|
||||
{{if .Settings.LK.PublicURL}}<a class="btn btn-secondary" href="{{.Settings.LK.PublicURL}}" target="_blank" rel="noopener">Открыть →</a>{{end}}
|
||||
</div>
|
||||
<span class="muted" style="font-size:12px">{{if .DevMode}}Показывается ссылкой на дашборде для первичного теста.{{else}}Адрес, по которому клиенты входят в личный кабинет.{{end}}</span>
|
||||
</div>
|
||||
<div>
|
||||
<label>Callback URL <span class="muted" style="font-weight:400">(куда bj шлёт статус заявки)</span></label>
|
||||
<input type="text" name="callback_url" value="{{.Settings.LK.CallbackURL}}" placeholder="https://fontvielle.ef.winr.pro" style="margin-top:4px">
|
||||
<input type="text" name="callback_url" value="{{.Settings.LK.CallbackURL}}" placeholder="https://lk.example.ru" style="margin-top:4px">
|
||||
<span class="muted" style="font-size:12px">{{if not .Settings.LK.CallbackURL}}Не настроен — уведомления в ЛК отключены. Для обмена с НРД не требуется.{{end}}</span>
|
||||
</div>
|
||||
<div><button type="submit" class="btn">Сохранить и проверить</button></div>
|
||||
@@ -577,7 +599,7 @@
|
||||
</div>
|
||||
<form method="post" action="/admin/setup/lk-integration" style="display:grid;gap:9px">
|
||||
<input type="hidden" name="env" value="test">
|
||||
<div><label>Адрес API</label><input type="text" name="base_url" value="{{.Settings.LK.Test.BaseURL}}" placeholder="https://fontvielle.ef.winr.pro/manager/api" style="margin-top:3px"></div>
|
||||
<div><label>Адрес API</label><input type="text" name="base_url" value="{{.Settings.LK.Test.BaseURL}}" placeholder="https://lk.example.ru/manager/api" style="margin-top:3px"></div>
|
||||
<div><label>Логин</label><input type="text" name="login" value="{{.Settings.LK.Test.Login}}" autocomplete="off" style="margin-top:3px"></div>
|
||||
<div><label>Пароль</label><input type="password" name="password" value="" placeholder="{{if .Settings.LK.Test.Password}}•••••• (не менять — оставьте пустым){{end}}" autocomplete="new-password" style="margin-top:3px"></div>
|
||||
<div style="display:flex;gap:8px;flex-wrap:wrap;margin-top:2px">
|
||||
|
||||
Reference in New Issue
Block a user