Справочник участников — своего контура; регистрация — по справочнику НРД; поиск клиентов портфеля
readHandbook брал самый свежий из своего, общего (с сервера лицензий) и встроенного — общий был тестовым (TEST3, 166 участников, прислан с 28), и на боевом 9 bj считал «регистрация подтверждена», хотя в боевом справочнике НРД (89) нас нет. Теперь при своём справочнике от НРД — только он; на сервер лицензий справочник уходит только с боевого контура. Регистрация участника проверяется по справочнику, полученному от НРД; появились в нём — анкета отмечается принятой автоматически, плашка уходит. Портфель ЛК: клиенты DEPO списком по клику (первые 50 по алфавиту), «не найдено» вместо «поиск недоступен» (пустой ответ был null). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q7eREaYwUt1i4fNLZcGnjn
This commit is contained in:
@@ -33,10 +33,15 @@ func handbookAt(b []byte) time.Time {
|
|||||||
return h.At
|
return h.At
|
||||||
}
|
}
|
||||||
|
|
||||||
// readHandbook — самый свежий из доступных справочников.
|
// readHandbook — справочник своего контура: полученный этим bj от НРД, если он есть (боевой и
|
||||||
|
// тестовый справочники разные — в боевом меньше участников, не все подключились; копия с сервера
|
||||||
|
// лицензий может быть с другого контура). Нет своего — самый свежий из копии и встроенного снимка.
|
||||||
func readHandbook() ([]byte, error) {
|
func readHandbook() ([]byte, error) {
|
||||||
|
if b, err := os.ReadFile(handbookFile); err == nil && len(b) > 0 && !handbookAt(b).IsZero() {
|
||||||
|
return b, nil
|
||||||
|
}
|
||||||
best := handbookSnapshot
|
best := handbookSnapshot
|
||||||
for _, p := range []string{handbookFile, handbookCloudFile} {
|
for _, p := range []string{handbookCloudFile} {
|
||||||
if b, err := os.ReadFile(p); err == nil && len(b) > 0 && handbookAt(b).After(handbookAt(best)) {
|
if b, err := os.ReadFile(p); err == nil && len(b) > 0 && handbookAt(b).After(handbookAt(best)) {
|
||||||
best = b
|
best = b
|
||||||
}
|
}
|
||||||
@@ -58,6 +63,9 @@ func pushHandbookAsync(rc *RuntimeConfig, raw []byte) {
|
|||||||
if rc == nil || strings.TrimSpace(rc.Snapshot().License.Key) == "" {
|
if rc == nil || strings.TrimSpace(rc.Snapshot().License.Key) == "" {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if contourOf(currentNSDProfile()) != "prod" {
|
||||||
|
return // общий справочник — только боевой: тестовый (TEST3) шире и для клиентов неверен
|
||||||
|
}
|
||||||
go func() {
|
go func() {
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|||||||
@@ -385,11 +385,12 @@ func lkContractByAccount(ps LKPortfolioSyncSettings, acc string) string {
|
|||||||
return strings.ReplaceAll(ps.contractTemplate(), "{n}", n)
|
return strings.ReplaceAll(ps.contractTemplate(), "{n}", n)
|
||||||
}
|
}
|
||||||
|
|
||||||
// lkPortfolioClients — поиск клиентов DEPO по ФИО или номеру портфеля (открытые торговые счета).
|
// lkPortfolioClients — клиенты DEPO по ФИО, портфелю или счёту (открытые торговые счета); пустой
|
||||||
|
// запрос — первые по алфавиту, чтобы выбрать из списка без ввода.
|
||||||
func lkPortfolioClients(ctx context.Context, rc *RuntimeConfig, q string) ([]LKPortfolioClient, error) {
|
func lkPortfolioClients(ctx context.Context, rc *RuntimeConfig, q string) ([]LKPortfolioClient, error) {
|
||||||
q = strings.TrimSpace(q)
|
q = strings.TrimSpace(q)
|
||||||
if len([]rune(q)) < 2 {
|
if len([]rune(q)) == 1 {
|
||||||
return nil, nil
|
return []LKPortfolioClient{}, nil
|
||||||
}
|
}
|
||||||
fs := rc.Snapshot().Fancy
|
fs := rc.Snapshot().Fancy
|
||||||
ps := rc.Snapshot().LK.PortfolioSync
|
ps := rc.Snapshot().LK.PortfolioSync
|
||||||
@@ -400,7 +401,7 @@ func lkPortfolioClients(ctx context.Context, rc *RuntimeConfig, q string) ([]LKP
|
|||||||
defer db.Close()
|
defer db.Close()
|
||||||
cctx, cancel := context.WithTimeout(ctx, 15*time.Second)
|
cctx, cancel := context.WithTimeout(ctx, 15*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
rows, err := db.QueryContext(cctx, `SELECT TOP 20 ISNULL(p.PORT_NUM,''), ISNULL(f.NAME,''), c.NUMBER
|
rows, err := db.QueryContext(cctx, `SELECT TOP 50 ISNULL(p.PORT_NUM,''), ISNULL(f.NAME,''), c.NUMBER
|
||||||
FROM OD_CLIENTS c JOIN OD_FACES f ON f.ID=c.FACE LEFT JOIN OD_CLIENTS_PORTS p ON p.CLIENT=c.ID
|
FROM OD_CLIENTS c JOIN OD_FACES f ON f.ID=c.FACE LEFT JOIN OD_CLIENTS_PORTS p ON p.CLIENT=c.ID
|
||||||
WHERE c.NUMBER LIKE @pre AND c.STATE=0 AND c.D_CLOSE IS NULL AND (f.NAME LIKE @q OR p.PORT_NUM LIKE @q OR c.NUMBER LIKE @q)
|
WHERE c.NUMBER LIKE @pre AND c.STATE=0 AND c.D_CLOSE IS NULL AND (f.NAME LIKE @q OR p.PORT_NUM LIKE @q OR c.NUMBER LIKE @q)
|
||||||
ORDER BY f.NAME`, sql.Named("q", "%"+likeEscape(q)+"%"), sql.Named("pre", likeEscape(ps.accountPrefix())+"%"))
|
ORDER BY f.NAME`, sql.Named("q", "%"+likeEscape(q)+"%"), sql.Named("pre", likeEscape(ps.accountPrefix())+"%"))
|
||||||
@@ -408,7 +409,7 @@ func lkPortfolioClients(ctx context.Context, rc *RuntimeConfig, q string) ([]LKP
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
var out []LKPortfolioClient
|
out := []LKPortfolioClient{}
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var c LKPortfolioClient
|
var c LKPortfolioClient
|
||||||
if rows.Scan(&c.Port, &c.Name, &c.Account) == nil && c.Port != "" {
|
if rows.Scan(&c.Port, &c.Name, &c.Account) == nil && c.Port != "" {
|
||||||
|
|||||||
@@ -268,7 +268,9 @@ func (s *Server) applyIncoming(ctx context.Context, p igw.Package) error {
|
|||||||
}
|
}
|
||||||
nsdProbeReply(time.Now())
|
nsdProbeReply(time.Now())
|
||||||
writeHandbookRawXML(unpacked.DocXML) // подлинный XML НРД — для выгрузки со страницы справочника
|
writeHandbookRawXML(unpacked.DocXML) // подлинный XML НРД — для выгрузки со страницы справочника
|
||||||
if !saveHandbook(&hb) {
|
changed := saveHandbook(&hb)
|
||||||
|
participantHandbookCheck(s.rc) // появились в справочнике — анкета принята
|
||||||
|
if !changed {
|
||||||
log.Printf("lk-gateway: поллер справочник M2M id=%d: без изменений (%d участников) — связь с НРД подтверждена", p.ID, len(hb.Participants.Participants))
|
log.Printf("lk-gateway: поллер справочник M2M id=%d: без изменений (%d участников) — связь с НРД подтверждена", p.ID, len(hb.Participants.Participants))
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -488,21 +488,19 @@ func participantRegStatus(rc *RuntimeConfig) string {
|
|||||||
if contour == "" {
|
if contour == "" {
|
||||||
contour = "текущего"
|
contour = "текущего"
|
||||||
}
|
}
|
||||||
hb, ok := handbookByCode(orgCode(s))
|
_ = s
|
||||||
if !ok {
|
in, known, at, n := nsdHandbookHasUs(rc)
|
||||||
if inn := orgINN(s); inn != "" {
|
switch {
|
||||||
hb, ok = handbookByINN(inn)
|
case in:
|
||||||
}
|
out.WriteString(`<div class="ok">В справочнике участников M2M, полученном от НРД (` + contour + ` контур, ` +
|
||||||
|
at.In(listMSK).Format("02.01 15:04") + `), наша организация <b>есть</b>. Регистрация подтверждена.</div>`)
|
||||||
|
case known:
|
||||||
|
out.WriteString(fmt.Sprintf(`<div class="note">В справочнике участников M2M, полученном от НРД (%s контур, %s, участников %d), нашей организации пока <b>нет</b>. `+
|
||||||
|
`После приёма анкеты НРД добавит её в справочник — bj получает его каждые несколько минут и сам отметит регистрацию.</div>`,
|
||||||
|
contour, at.In(listMSK).Format("02.01 15:04"), n))
|
||||||
|
default:
|
||||||
|
out.WriteString(`<div class="note">Справочник участников M2M от НРД ещё не получен — проверить регистрацию по нему пока нельзя.</div>`)
|
||||||
}
|
}
|
||||||
if ok {
|
|
||||||
out.WriteString(`<div class="ok">В справочнике участников M2M ` + contour + ` контура: <b>есть</b> — ` +
|
|
||||||
html.EscapeString(pickStr(hb.Name, hb.FullName)) + `, код ` + html.EscapeString(hb.Code) +
|
|
||||||
`, ИНН ` + html.EscapeString(hb.INN) + `. Регистрация подтверждена.</div>`)
|
|
||||||
return out.String()
|
|
||||||
}
|
|
||||||
out.WriteString(`<div class="note">В справочнике участников M2M ` + contour + ` контура нашей организации пока <b>нет</b>. ` +
|
|
||||||
`После приёма анкеты НРД организация появится в справочнике (обновляется каждые ~10 минут) — тогда здесь будет «есть». ` +
|
|
||||||
`Регистрацию подтверждает также ответный документ от НРД.</div>`)
|
|
||||||
return out.String()
|
return out.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -149,6 +149,9 @@ func recordParticipantReply(status string, resp *m2m.M2MTransferResponse, rawFil
|
|||||||
|
|
||||||
// participantAccepted — последняя отправленная анкета принята НРД.
|
// participantAccepted — последняя отправленная анкета принята НРД.
|
||||||
func participantAccepted() bool {
|
func participantAccepted() bool {
|
||||||
|
if in, _, _, _ := nsdHandbookHasUs(globalRC); in {
|
||||||
|
return true // уже в справочнике участников НРД — зарегистрированы (в т.ч. без анкеты из bj)
|
||||||
|
}
|
||||||
r := loadParticipantReg()
|
r := loadParticipantReg()
|
||||||
return !r.RespAt.IsZero() && !strings.EqualFold(r.Status, "ERROR") && !strings.EqualFold(r.Status, "INFO")
|
return !r.RespAt.IsZero() && !strings.EqualFold(r.Status, "ERROR") && !strings.EqualFold(r.Status, "INFO")
|
||||||
}
|
}
|
||||||
@@ -185,3 +188,45 @@ func participantBanner() template.HTML {
|
|||||||
return template.HTML(`<div class="stc ` + cls + `" style="margin:0 0 14px"><span class="stc-ic"></span><div class="stc-b"><b>` + esc(head) +
|
return template.HTML(`<div class="stc ` + cls + `" style="margin:0 0 14px"><span class="stc-ic"></span><div class="stc-b"><b>` + esc(head) +
|
||||||
`</b><small>` + esc(body) + ` <a href="#nsd-participant" onclick="var d=document.getElementById('pf-box');if(d)d.open=true">Открыть анкету →</a></small></div></div>`)
|
`</b><small>` + esc(body) + ` <a href="#nsd-participant" onclick="var d=document.getElementById('pf-box');if(d)d.open=true">Открыть анкету →</a></small></div></div>`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// nsdHandbookHasUs — наша организация в справочнике участников M2M, который этот bj получил от НРД
|
||||||
|
// (handbookFile, текущий контур). known=false — справочника от НРД ещё нет. Общий справочник с
|
||||||
|
// сервера лицензий здесь не годится: он собран с других установок и контуров.
|
||||||
|
func nsdHandbookHasUs(rc *RuntimeConfig) (in, known bool, at time.Time, n int) {
|
||||||
|
b, err := os.ReadFile(handbookFile)
|
||||||
|
if err != nil || rc == nil {
|
||||||
|
return false, false, time.Time{}, 0
|
||||||
|
}
|
||||||
|
var h struct {
|
||||||
|
At time.Time `json:"at"`
|
||||||
|
Participants []HandbookEntry `json:"participants"`
|
||||||
|
}
|
||||||
|
if json.Unmarshal(b, &h) != nil {
|
||||||
|
return false, false, time.Time{}, 0
|
||||||
|
}
|
||||||
|
s := rc.Snapshot()
|
||||||
|
code, inn := strings.ToUpper(strings.TrimSpace(orgCode(s))), strings.TrimSpace(orgINN(s))
|
||||||
|
for _, p := range h.Participants {
|
||||||
|
if (code != "" && strings.EqualFold(p.Code, code)) || (code == "" && inn != "" && p.INN == inn) {
|
||||||
|
return true, true, h.At, len(h.Participants)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false, true, h.At, len(h.Participants)
|
||||||
|
}
|
||||||
|
|
||||||
|
// participantHandbookCheck — после каждого справочника от НРД: появились в нём, а анкета ещё не
|
||||||
|
// отмечена принятой — регистрация подтверждена справочником.
|
||||||
|
func participantHandbookCheck(rc *RuntimeConfig) {
|
||||||
|
in, known, at, _ := nsdHandbookHasUs(rc)
|
||||||
|
if !in || !known {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
r := loadParticipantReg()
|
||||||
|
if r.SentAt.IsZero() || (!r.RespAt.IsZero() && !strings.EqualFold(r.Status, "INFO") && !strings.EqualFold(r.Status, "ERROR")) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
saveParticipantReg(func(r *participantReg) {
|
||||||
|
r.RespAt, r.Status, r.Codes = at, "OK", nil
|
||||||
|
r.RespText = "организация появилась в справочнике участников M2M НРД"
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -708,7 +708,7 @@
|
|||||||
<div style="font-size:13px;font-weight:600;margin-bottom:4px">Залить одному клиенту (для теста)</div>
|
<div style="font-size:13px;font-weight:600;margin-bottom:4px">Залить одному клиенту (для теста)</div>
|
||||||
<div style="display:flex;gap:10px;align-items:flex-end;flex-wrap:wrap">
|
<div style="display:flex;gap:10px;align-items:flex-end;flex-wrap:wrap">
|
||||||
<label style="display:flex;flex-direction:column;gap:3px;font-size:12px;min-width:320px">Клиент DEPO — ФИО, портфель или счёт
|
<label style="display:flex;flex-direction:column;gap:3px;font-size:12px;min-width:320px">Клиент DEPO — ФИО, портфель или счёт
|
||||||
<input id="lkpf-q" autocomplete="off" placeholder="например, Погонщикова или БР-859"></label>
|
<input id="lkpf-q" autocomplete="off" placeholder="нажмите — список клиентов; или ФИО, портфель, счёт"></label>
|
||||||
<input type="hidden" name="client_port" id="lkpf-port"><input type="hidden" name="client_lk" id="lkpf-lk">
|
<input type="hidden" name="client_port" id="lkpf-port"><input type="hidden" name="client_lk" id="lkpf-lk">
|
||||||
<button class="btn" type="submit" name="action" value="client_push" id="lkpf-push" disabled>Залить сейчас</button>
|
<button class="btn" type="submit" name="action" value="client_push" id="lkpf-push" disabled>Залить сейчас</button>
|
||||||
<button class="btn" type="submit" name="action" value="client_add" id="lkpf-add" disabled>Добавить в постоянное заполнение</button>
|
<button class="btn" type="submit" name="action" value="client_add" id="lkpf-add" disabled>Добавить в постоянное заполнение</button>
|
||||||
@@ -726,20 +726,23 @@
|
|||||||
sel.innerHTML=ok?('Выбран: <b>'+esc(c.name)+'</b> · портфель DEPO '+esc(c.port)+' · счёт '+esc(c.account)+' → договор в ЛК <b>'+esc(c.lk)+'</b>'):'У клиента нет торгового счёта — договор в ЛК не определить';
|
sel.innerHTML=ok?('Выбран: <b>'+esc(c.name)+'</b> · портфель DEPO '+esc(c.port)+' · счёт '+esc(c.account)+' → договор в ЛК <b>'+esc(c.lk)+'</b>'):'У клиента нет торгового счёта — договор в ЛК не определить';
|
||||||
list.innerHTML='';
|
list.innerHTML='';
|
||||||
}
|
}
|
||||||
q.addEventListener('input',function(){
|
function load(){
|
||||||
clearTimeout(t); var v=q.value.trim(); if(v.length<2){list.innerHTML='';return}
|
clearTimeout(t); var v=q.value.trim(); if(v.length===1){list.innerHTML='';return}
|
||||||
|
list.innerHTML='<span class="muted">ищу в DEPO…</span>';
|
||||||
t=setTimeout(function(){
|
t=setTimeout(function(){
|
||||||
fetch('/admin/setup/lk-portfolio/clients?q='+encodeURIComponent(v),{cache:'no-store'}).then(function(r){return r.json()}).then(function(d){
|
fetch('/admin/setup/lk-portfolio/clients?q='+encodeURIComponent(v),{cache:'no-store'}).then(function(r){if(!r.ok) throw new Error(r.status); return r.json()}).then(function(d){
|
||||||
if(d.error){list.innerHTML='<span style="color:var(--err)">'+esc(d.error)+'</span>';return}
|
if(d&&d.error){list.innerHTML='<span style="color:var(--err)">DEPO: '+esc(d.error)+'</span>';return}
|
||||||
if(!d||!d.length){list.innerHTML='<span class="muted">не найдено</span>';return}
|
if(!d||!d.length){list.innerHTML='<span class="muted">не найдено</span>';return}
|
||||||
list.innerHTML=''; d.forEach(function(c){
|
list.innerHTML=''; d.forEach(function(c){
|
||||||
var a=document.createElement('a'); a.href='#'; a.style.display='block'; a.style.padding='2px 0';
|
var a=document.createElement('a'); a.href='#'; a.style.display='block'; a.style.padding='2px 0';
|
||||||
a.innerHTML=esc(c.name)+' · '+esc(c.port)+' · '+esc(c.account)+(c.lk?' → '+esc(c.lk):'');
|
a.innerHTML=esc(c.name)+' · '+esc(c.port)+' · '+esc(c.account)+(c.lk?' → '+esc(c.lk):'');
|
||||||
a.onclick=function(e){e.preventDefault(); pick(c)}; list.appendChild(a);
|
a.onclick=function(e){e.preventDefault(); pick(c)}; list.appendChild(a);
|
||||||
});
|
});
|
||||||
}).catch(function(){list.innerHTML='<span style="color:var(--err)">поиск недоступен</span>'});
|
}).catch(function(e){list.innerHTML='<span style="color:var(--err)">поиск не ответил ('+esc(e&&e.message||'сеть')+') — повторите</span>'});
|
||||||
},300);
|
},300);
|
||||||
});
|
}
|
||||||
|
q.addEventListener('input',load);
|
||||||
|
q.addEventListener('focus',function(){ if(!list.innerHTML) load(); });
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
{{range lkPortfolioStates}}<div class="muted" style="font-size:12.5px;margin-top:8px"><b>{{.LK}}</b> ← {{.Depo}}: {{if .Err}}<span style="color:var(--err)">ошибка — {{.Err}}</span>{{else if .PushedAt.IsZero}}сверено {{.CheckedAt.Format "15:04:05"}}, отправки ещё не было{{else}}отправлено {{.PushedAt.Format "02.01 15:04:05"}} ({{.Lines}} бумаг), сверено {{.CheckedAt.Format "15:04:05"}}{{end}}{{range .Skipped}}<br>пропущено: {{.}}{{end}}</div>{{end}}
|
{{range lkPortfolioStates}}<div class="muted" style="font-size:12.5px;margin-top:8px"><b>{{.LK}}</b> ← {{.Depo}}: {{if .Err}}<span style="color:var(--err)">ошибка — {{.Err}}</span>{{else if .PushedAt.IsZero}}сверено {{.CheckedAt.Format "15:04:05"}}, отправки ещё не было{{else}}отправлено {{.PushedAt.Format "02.01 15:04:05"}} ({{.Lines}} бумаг), сверено {{.CheckedAt.Format "15:04:05"}}{{end}}{{range .Skipped}}<br>пропущено: {{.}}{{end}}</div>{{end}}
|
||||||
|
|||||||
Reference in New Issue
Block a user