Справочник участников — своего контура; регистрация — по справочнику НРД; поиск клиентов портфеля

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:
fontvielle
2026-10-22 11:28:13 +03:00
parent 6fc0ff5d3d
commit f296ac5ad8
6 changed files with 86 additions and 29 deletions
+10 -2
View File
@@ -33,10 +33,15 @@ func handbookAt(b []byte) time.Time {
return h.At
}
// readHandbook — самый свежий из доступных справочников.
// readHandbook — справочник своего контура: полученный этим bj от НРД, если он есть (боевой и
// тестовый справочники разные — в боевом меньше участников, не все подключились; копия с сервера
// лицензий может быть с другого контура). Нет своего — самый свежий из копии и встроенного снимка.
func readHandbook() ([]byte, error) {
if b, err := os.ReadFile(handbookFile); err == nil && len(b) > 0 && !handbookAt(b).IsZero() {
return b, nil
}
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)) {
best = b
}
@@ -58,6 +63,9 @@ func pushHandbookAsync(rc *RuntimeConfig, raw []byte) {
if rc == nil || strings.TrimSpace(rc.Snapshot().License.Key) == "" {
return
}
if contourOf(currentNSDProfile()) != "prod" {
return // общий справочник — только боевой: тестовый (TEST3) шире и для клиентов неверен
}
go func() {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
+6 -5
View File
@@ -385,11 +385,12 @@ func lkContractByAccount(ps LKPortfolioSyncSettings, acc string) string {
return strings.ReplaceAll(ps.contractTemplate(), "{n}", n)
}
// lkPortfolioClients — поиск клиентов DEPO по ФИО или номеру портфеля (открытые торговые счета).
// lkPortfolioClients — клиенты DEPO по ФИО, портфелю или счёту (открытые торговые счета); пустой
// запрос — первые по алфавиту, чтобы выбрать из списка без ввода.
func lkPortfolioClients(ctx context.Context, rc *RuntimeConfig, q string) ([]LKPortfolioClient, error) {
q = strings.TrimSpace(q)
if len([]rune(q)) < 2 {
return nil, nil
if len([]rune(q)) == 1 {
return []LKPortfolioClient{}, nil
}
fs := rc.Snapshot().Fancy
ps := rc.Snapshot().LK.PortfolioSync
@@ -400,7 +401,7 @@ func lkPortfolioClients(ctx context.Context, rc *RuntimeConfig, q string) ([]LKP
defer db.Close()
cctx, cancel := context.WithTimeout(ctx, 15*time.Second)
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
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())+"%"))
@@ -408,7 +409,7 @@ func lkPortfolioClients(ctx context.Context, rc *RuntimeConfig, q string) ([]LKP
return nil, err
}
defer rows.Close()
var out []LKPortfolioClient
out := []LKPortfolioClient{}
for rows.Next() {
var c LKPortfolioClient
if rows.Scan(&c.Port, &c.Name, &c.Account) == nil && c.Port != "" {
+3 -1
View File
@@ -268,7 +268,9 @@ func (s *Server) applyIncoming(ctx context.Context, p igw.Package) error {
}
nsdProbeReply(time.Now())
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))
return nil
}
+12 -14
View File
@@ -488,21 +488,19 @@ func participantRegStatus(rc *RuntimeConfig) string {
if contour == "" {
contour = "текущего"
}
hb, ok := handbookByCode(orgCode(s))
if !ok {
if inn := orgINN(s); inn != "" {
hb, ok = handbookByINN(inn)
}
_ = s
in, known, at, n := nsdHandbookHasUs(rc)
switch {
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()
}
+45
View File
@@ -149,6 +149,9 @@ func recordParticipantReply(status string, resp *m2m.M2MTransferResponse, rawFil
// participantAccepted — последняя отправленная анкета принята НРД.
func participantAccepted() bool {
if in, _, _, _ := nsdHandbookHasUs(globalRC); in {
return true // уже в справочнике участников НРД — зарегистрированы (в т.ч. без анкеты из bj)
}
r := loadParticipantReg()
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) +
`</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="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 — ФИО, портфель или счёт
<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">
<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>
@@ -726,20 +726,23 @@
sel.innerHTML=ok?('Выбран: <b>'+esc(c.name)+'</b> · портфель DEPO '+esc(c.port)+' · счёт '+esc(c.account)+' → договор в ЛК <b>'+esc(c.lk)+'</b>'):'У клиента нет торгового счёта — договор в ЛК не определить';
list.innerHTML='';
}
q.addEventListener('input',function(){
clearTimeout(t); var v=q.value.trim(); if(v.length<2){list.innerHTML='';return}
function load(){
clearTimeout(t); var v=q.value.trim(); if(v.length===1){list.innerHTML='';return}
list.innerHTML='<span class="muted">ищу в DEPO…</span>';
t=setTimeout(function(){
fetch('/admin/setup/lk-portfolio/clients?q='+encodeURIComponent(v),{cache:'no-store'}).then(function(r){return r.json()}).then(function(d){
if(d.error){list.innerHTML='<span style="color:var(--err)">'+esc(d.error)+'</span>';return}
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&&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}
list.innerHTML=''; d.forEach(function(c){
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.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);
});
}
q.addEventListener('input',load);
q.addEventListener('focus',function(){ if(!list.innerHTML) load(); });
})();
</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}}