Мастер, «Организация»: одна кнопка «Сохранить»; в строке справочника — своя запись

- Организация и депозитарные реквизиты в НРД сохраняются одной кнопкой внизу шага
  (поля реквизитов — в общей форме org-form; saveOrg сохраняет и их, если они
  пришли; в настройках формы прежние). Повтор «Из DEPO: … подставлено» в мастере
  убран — то же в зелёной плашке.
- «Найти себя в справочнике участников M2M» не пустая: по коду и ИНН из полей
  показывается своя запись и «✓ Есть в справочнике участников M2M»; нет в
  справочнике — предупреждение.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014FsEKyJrYF69Cj9ZXzFLRi
This commit is contained in:
zuevav
2026-10-06 16:27:44 +03:00
parent db0c2fc048
commit c1f77df340
4 changed files with 103 additions and 33 deletions
+31
View File
@@ -613,3 +613,34 @@ func tsqlOutsideLiterals(s string) string {
}
return b.String()
}
// Шаг «Организация» мастера: одна кнопка «Сохранить» — организация и депозитарные реквизиты в НРД одной формой;
// без полей реквизитов (настройки) — только организация, реквизиты не трогаются.
func TestSaveOrgWithRequisites(t *testing.T) {
rc := testRC(t)
h := &setupHandlers{rc: rc}
post := func(form url.Values) *httptest.ResponseRecorder {
r := httptest.NewRequest(http.MethodPost, "/admin/setup/org", strings.NewReader(form.Encode()))
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
w := httptest.NewRecorder()
h.saveOrg(w, r)
return w
}
w := post(url.Values{"org_code": {"MC0413600000"}, "org_inn": {"7703807489"}, "org_name": {"ООО \"ИК \"Фонтвьель\""},
"deponent_code": {"MC0413600000"}, "account_id": {"HL171004001C"}, "section_id": {"36MC0413600000F00"}, "return_to": {"/admin/wizard?step=4"}})
if loc := w.Header().Get("Location"); !strings.HasPrefix(loc, "/admin/wizard?step=4") {
t.Fatalf("после сохранения — на тот же шаг: %q", loc)
}
s := rc.Snapshot().NSD
if s.OrgCode != "MC0413600000" || s.OrgINN != "7703807489" || s.AccountID != "HL171004001C" || s.SectionID != "36MC0413600000F00" || s.DeponentCode != "MC0413600000" {
t.Fatalf("организация и реквизиты одной кнопкой: %+v", s)
}
post(url.Values{"org_code": {"MC0413600000"}, "org_inn": {"7703807489"}, "org_name": {"Фонтвьель"}})
if s := rc.Snapshot().NSD; s.AccountID != "HL171004001C" || s.OrgName != "Фонтвьель" {
t.Fatalf("форма без реквизитов не должна их стирать: %+v", s)
}
post(url.Values{"org_code": {"MC0413600000"}, "deponent_code": {"MC0413600000"}, "account_id": {"HL 17"}, "section_id": {""}})
if s := rc.Snapshot().NSD; s.AccountID != "HL171004001C" {
t.Fatalf("неверный счёт — ничего не сохраняется: %+v", s)
}
}
+45 -21
View File
@@ -75,13 +75,39 @@ func orgName(s Settings) string {
return ""
}
// saveOrg — POST /admin/setup/org: организация выбрана из справочника (hb=код) или введена вручную.
// saveOrg — POST /admin/setup/org: организация выбрана из справочника (hb=код) или введена вручную. В мастере
// та же форма несёт и депозитарные реквизиты в НРД (deponent_code…): на шаге одна кнопка «Сохранить».
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
changedCode, bad := orgFromForm(r, &s)
if bad == "" && r.PostForm.Has("deponent_code") {
bad = nsdReqFromForm(r, &s)
}
if bad != "" {
setupFlash(w, r, bad)
return
}
if err := h.rc.UpdateNSD(s); err != nil {
setupFlash(w, r, "Организация: ошибка сохранения: "+err.Error())
return
}
msg := "Организация сохранена: " + pick(s.OrgName, "—") + ", ИНН " + pick(s.OrgINN, "—") + ", код " + pick(s.OrgCode, "—")
if r.PostForm.Has("deponent_code") {
msg += ". Реквизиты в НРД: код " + pick(s.DeponentCode, "—") + ", счёт " + pick(s.AccountID, "—") + ", раздел " + pick(s.SectionID, "—")
}
if changedCode {
msg += ". Код отправителя применится после перезапуска bj-server."
}
setupFlash(w, r, msg)
}
// orgFromForm — код, ИНН и наименование организации из формы (выбор из справочника или вручную).
// Возвращает, сменился ли код, и текст ошибки.
func orgFromForm(r *http.Request, s *NSDSettings) (bool, string) {
code := strings.ToUpper(strings.TrimSpace(r.FormValue("org_code")))
inn := strings.TrimSpace(r.FormValue("org_inn"))
name := strings.TrimSpace(r.FormValue("org_name"))
@@ -91,27 +117,17 @@ func (h *setupHandlers) saveOrg(w http.ResponseWriter, r *http.Request) {
}
}
if inn != "" && !manualINNRe.MatchString(inn) {
setupFlash(w, r, "Организация: ИНН юридического лица — 10 цифр")
return
return false, "Организация: ИНН юридического лица — 10 цифр"
}
if code != "" && !regexpMCCode.MatchString(code) {
setupFlash(w, r, "Организация: код участника — вида MC0000000000")
return
return false, "Организация: код участника — вида MC0000000000"
}
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)
return changedCode, ""
}
var nsdReqRe = regexp.MustCompile(`^[A-Z0-9]{4,30}$`)
@@ -124,19 +140,27 @@ func (h *setupHandlers) saveNSDRequisites(w http.ResponseWriter, r *http.Request
return
}
s := h.rc.Snapshot().NSD
if bad := nsdReqFromForm(r, &s); bad != "" {
setupFlash(w, r, bad)
return
}
if err := h.rc.UpdateNSD(s); err != nil {
setupFlash(w, r, "Реквизиты НРД: ошибка сохранения: "+err.Error())
return
}
setupFlash(w, r, "Реквизиты НРД сохранены: код "+pick(s.DeponentCode, "—")+", счёт "+pick(s.AccountID, "—")+", раздел "+pick(s.SectionID, "—"))
}
// nsdReqFromForm — депозитарный код, счёт и раздел организации в НРД из формы; текст ошибки.
func nsdReqFromForm(r *http.Request, s *NSDSettings) string {
dep := strings.ToUpper(strings.TrimSpace(r.FormValue("deponent_code")))
acc := strings.ToUpper(strings.TrimSpace(r.FormValue("account_id")))
sec := strings.ToUpper(strings.TrimSpace(r.FormValue("section_id")))
for _, v := range []struct{ name, val string }{{"депозитарный код", dep}, {"депозитарный счёт", acc}, {"раздел счёта", sec}} {
if v.val != "" && !nsdReqRe.MatchString(v.val) {
setupFlash(w, r, "Реквизиты НРД: "+v.name+" — латинские буквы и цифры, без пробелов")
return
return "Реквизиты НРД: " + v.name + " — латинские буквы и цифры, без пробелов"
}
}
s.DeponentCode, s.AccountID, s.SectionID = dep, acc, sec
if err := h.rc.UpdateNSD(s); err != nil {
setupFlash(w, r, "Реквизиты НРД: ошибка сохранения: "+err.Error())
return
}
setupFlash(w, r, "Реквизиты НРД сохранены: код "+pick(dep, "—")+", счёт "+pick(acc, "—")+", раздел "+pick(sec, "—"))
return ""
}
@@ -444,15 +444,14 @@
<h2><span class="dot {{if and .Settings.NSD.DeponentCode .Settings.NSD.AccountID .Settings.NSD.SectionID}}ok{{else}}err{{end}}"></span>Депозитарные реквизиты в НРД</h2>
<p class="muted" style="margin:0 0 8px">Где в НРД учитываются бумаги организации — из договора с НРД{{if .Settings.Fancy.Server}} или кнопкой «Определить из DEPO»{{end}}.</p>
{{if .Suggest}}{{if .Suggest.AccountID}}<div class="help-block" style="margin:0 0 8px"><strong>Найдено в DEPO:</strong> счёт {{.Suggest.AccountID}}, код {{.Suggest.DeponentCode}}, раздел {{.Suggest.SectionID}}.{{range .Suggest.Notes}}<br><span class="muted">⚠ {{.}}</span>{{end}}</div>{{end}}{{end}}
<form method="post" action="/admin/setup/nsd-requisites" style="display:grid;gap:10px;max-width:680px">
<input type="hidden" name="return_to" value="/admin/wizard?step=4">
<div style="display:grid;gap:10px;max-width:680px">{{/* поля — в общей форме шага (org-form): одна кнопка «Сохранить» */}}
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:10px">
<label style="margin:0">Депозитарный код<input type="text" name="deponent_code" value="{{if .Settings.NSD.DeponentCode}}{{.Settings.NSD.DeponentCode}}{{else if .Suggest}}{{.Suggest.DeponentCode}}{{end}}" placeholder="MC0000000000" style="width:100%;margin-top:4px"></label>
<label style="margin:0">Депозитарный счёт<input type="text" name="account_id" value="{{if .Settings.NSD.AccountID}}{{.Settings.NSD.AccountID}}{{else if .Suggest}}{{.Suggest.AccountID}}{{end}}" placeholder="HL000000000C" style="width:100%;margin-top:4px"></label>
<label style="margin:0">Раздел счёта<input type="text" name="section_id" value="{{if .Settings.NSD.SectionID}}{{.Settings.NSD.SectionID}}{{else if .Suggest}}{{.Suggest.SectionID}}{{end}}" placeholder="36MC0000000000F00" style="width:100%;margin-top:4px"></label>
<label style="margin:0">Депозитарный код<input type="text" name="deponent_code" form="org-form" value="{{if .Settings.NSD.DeponentCode}}{{.Settings.NSD.DeponentCode}}{{else if .Suggest}}{{.Suggest.DeponentCode}}{{end}}" placeholder="MC0000000000" style="width:100%;margin-top:4px"></label>
<label style="margin:0">Депозитарный счёт<input type="text" name="account_id" form="org-form" value="{{if .Settings.NSD.AccountID}}{{.Settings.NSD.AccountID}}{{else if .Suggest}}{{.Suggest.AccountID}}{{end}}" placeholder="HL000000000C" style="width:100%;margin-top:4px"></label>
<label style="margin:0">Раздел счёта<input type="text" name="section_id" form="org-form" value="{{if .Settings.NSD.SectionID}}{{.Settings.NSD.SectionID}}{{else if .Suggest}}{{.Suggest.SectionID}}{{end}}" placeholder="36MC0000000000F00" style="width:100%;margin-top:4px"></label>
</div>
<div style="display:flex;gap:8px;flex-wrap:wrap"><button type="submit" class="btn">Сохранить</button>{{if .Settings.Fancy.Server}}<button type="submit" class="btn" formaction="/admin/setup/fancy/detect" style="background:var(--card);color:var(--text);border:1px solid var(--border-strong,#c9cdd8)">Определить из DEPO</button>{{end}}</div>
</form>
<div style="display:flex;gap:8px;flex-wrap:wrap"><button type="submit" class="btn" form="org-form">Сохранить</button>{{if .Settings.Fancy.Server}}<button type="submit" class="btn" form="org-form" formaction="/admin/setup/fancy/detect" style="background:var(--card);color:var(--text);border:1px solid var(--border-strong,#c9cdd8)">Определить из DEPO</button>{{end}}</div>
</div>
</div>
{{template "wizNav" .}}
</div>
+21 -5
View File
@@ -5,11 +5,13 @@
<p class="muted">Депозитарий — участник MOEX МОСТ, от имени которого bj отправляет и принимает переводы: ИНН передающего и зачисляющего депозитария в пакетах{{if mod "quik-mnp"}}, код отправителя и код фирмы в QUIK{{end}}. Выберите себя из справочника участников НРД или введите данные вручную.</p>{{end}}
{{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 if not .InWizard}}<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">
<form method="post" action="/admin/setup/org" id="org-form" style="margin-top:12px;display:grid;gap:10px;max-width:680px">
<input type="hidden" name="section" value="sec-org">
{{if .InWizard}}<input type="hidden" name="return_to" value="/admin/wizard?step=4">{{end}}
<label>Найти себя в справочнике участников M2M</label>
<div class="hb-wrap"><input id="orgq" placeholder="Выберите из списка или введите ИНН, название, код" autocomplete="off"><button type="button" class="hb-toggle" title="Весь список">▾</button></div>
<div id="orgres" class="hb-res"></div>
<div id="orgmatch" class="hb-match" hidden></div>
<style>
.hb-box{position:relative}
.hb-res{margin-top:4px;max-height:320px;overflow:auto;border-radius:8px;border:1px solid var(--border);background:var(--card-2);padding:4px}
@@ -19,6 +21,7 @@
.hb-hit{display:block;width:100%;text-align:left;padding:7px 10px;margin:2px 0;border-radius:8px;border:1px solid var(--border);background:var(--card);color:var(--text);cursor:pointer;font-size:13px}
.hb-hit:hover{border-color:var(--accent)}
.hb-m{color:var(--muted);font-size:12px}
.hb-match{font-size:13px;margin-top:-2px}.hb-match.ok{color:var(--ok)}.hb-match.warn{color:var(--warn)}
</style>
<script>
// hbFind — подбор участника справочника НРД по ИНН, названию или коду (без учёта регистра и кавычек).
@@ -49,8 +52,21 @@ function hbBind(inp,box,list,onPick){
if(tg)tg.addEventListener('click',()=>{if(box.classList.contains('open'))close();else{inp.focus();draw()}});
}
{
const inp=document.getElementById('orgq'),f=inp.form;
hbBind(inp,document.getElementById('orgres'),{{.Participants}}||[],p=>{f.org_code.value=p.code;f.org_inn.value=p.inn;f.org_name.value=p.full_name||p.name;inp.value=p.name+' · ИНН '+p.inn})
const inp=document.getElementById('orgq'),f=inp.form,mt=document.getElementById('orgmatch'),list={{.Participants}}||[];
// Организация уже известна (сохранена или найдена в DEPO) — показать её запись из справочника, а не пустую строку.
const mark=()=>{
const code=f.org_code.value.trim().toUpperCase(),inn=f.org_inn.value.trim();
if(!code&&!inn||!list.length){mt.hidden=true;return null}
const p=list.find(x=>String(x.code).toUpperCase()===code&&(!inn||String(x.inn)===inn))||(code?list.find(x=>String(x.code).toUpperCase()===code):null);
mt.hidden=false;
if(p){inp.value=p.name+' · ИНН '+p.inn+' · '+p.code;inp.dataset.picked='1';mt.className='hb-match ok';mt.textContent='✓ Есть в справочнике участников M2M: '+(p.full_name||p.name)+(inn&&String(p.inn)!==inn?' — но ИНН в справочнике '+p.inn+', проверьте':'');}
else{mt.className='hb-match warn';mt.textContent='⚠ Участника с кодом '+(code||'—')+' в справочнике M2M нет — проверьте код или выберите себя из списка.';}
return p};
// поля кода и ИНН — ниже этого скрипта: связать, когда страница загружена
const init=()=>{mark();
hbBind(inp,document.getElementById('orgres'),list,p=>{f.org_code.value=p.code;f.org_inn.value=p.inn;f.org_name.value=p.full_name||p.name;mark()});
f.org_code.addEventListener('change',mark);f.org_inn.addEventListener('change',mark)};
if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
}
</script>
{{if not .Participants}}<span class="muted" style="font-size:12px">Справочник участников ещё не получен — запросите его в «Диагностике» или введите данные вручную.</span>{{end}}
@@ -60,8 +76,8 @@ function hbBind(inp,box,list,onPick){
<input type="text" name="org_inn" value="{{if .Settings.NSD.OrgINN}}{{.Settings.NSD.OrgINN}}{{else if .Suggest}}{{.Suggest.OrgINN}}{{end}}" maxlength="10" inputmode="numeric">
<label>Наименование</label>
<input type="text" name="org_name" value="{{if .Settings.NSD.OrgName}}{{.Settings.NSD.OrgName}}{{else if .Suggest}}{{.Suggest.OrgName}}{{end}}">
{{if .Suggest}}{{if .Suggest.OrgName}}<span class="muted" style="font-size:12px">Из DEPO: {{.Suggest.OrgName}}, ИНН {{.Suggest.OrgINN}}, код {{.Suggest.OrgCode}} — подставлено в пустые поля.</span>{{end}}{{end}}
<div><button type="submit" class="btn">Сохранить</button></div>
{{if and .Suggest (not .InWizard)}}{{if .Suggest.OrgName}}<span class="muted" style="font-size:12px">Из DEPO: {{.Suggest.OrgName}}, ИНН {{.Suggest.OrgINN}}, код {{.Suggest.OrgCode}} — подставлено в пустые поля.</span>{{end}}{{end}}
{{if not .InWizard}}<div><button type="submit" class="btn">Сохранить</button></div>{{end}}{{/* в мастере — одна кнопка внизу шага: организация и реквизиты НРД вместе */}}
</form>
</div>
{{end}}