diff --git a/deploy/release/component-versions.json b/deploy/release/component-versions.json
index 488b109..d65446e 100644
--- a/deploy/release/component-versions.json
+++ b/deploy/release/component-versions.json
@@ -20,8 +20,8 @@
"version": "2026.10.21.6"
},
"bj-server": {
- "fingerprint": "bbbd830ed300ae81eee05d87ad0fe59d0756a794f1d157c37bc36fe67c2e0f25",
- "version": "2026.10.21.12"
+ "fingerprint": "729ea3d2cdb6ba9d3f5e56d8acb967d7a5620771a6949d723482ee14d882a83c",
+ "version": "2026.10.21.13"
},
"crypto-service.jar": {
"fingerprint": "2c4fd536bc9b1c20c69a70e8dcd374a61847b263aeb2e474c45debb01fbd8233",
diff --git a/internal/lkgateway/nsdpoller.go b/internal/lkgateway/nsdpoller.go
index a4491c9..a640a26 100644
--- a/internal/lkgateway/nsdpoller.go
+++ b/internal/lkgateway/nsdpoller.go
@@ -176,6 +176,16 @@ func (s *Server) applyIncoming(ctx context.Context, p igw.Package) error {
}
log.Printf("lk-gateway: поллер M2MTransferResponse id=%d: статус=%s коды=[%s] GUID=%s",
p.ID, resp.StatusCode, strings.TrimSpace(codes), resp.GUID)
+ if gs := string(resp.GUID); gs == "" || gs == zeroGUID {
+ // Нулевой GUID — ответ не на перевод (сделки нет), а на регистрацию участника
+ // (анкета #M2MPF) или служебное сообщение сервиса МОСТ. Фиксируем и показываем
+ // на странице анкеты, а не отбрасываем как «сделка не найдена».
+ raw, _ := captureNSDReply(p, unpacked.DocXML, "participant-form")
+ recordParticipantReply(string(resp.StatusCode), resp, raw)
+ log.Printf("lk-gateway: поллер ОТВЕТ НРД на анкету участника id=%d: %s [%s] %q (сохранён %s)",
+ p.ID, resp.StatusCode, strings.TrimSpace(codes), strings.Join(serviceErrorTexts(resp), "; "), raw)
+ return nil
+ }
if err := s.svc.ApplyServiceResponse(ctx, resp, unpacked.DocXML); err != nil {
// Сделка может быть не найдена (ответ на чужой/старый GUID) —
// логируем, но помечаем обработанным, чтобы не зациклиться.
diff --git a/internal/lkgateway/participant_form.go b/internal/lkgateway/participant_form.go
index 1038c9f..593ce70 100644
--- a/internal/lkgateway/participant_form.go
+++ b/internal/lkgateway/participant_form.go
@@ -355,6 +355,9 @@ func registerParticipantForm(mux *http.ServeMux, rc *RuntimeConfig, igwClient *i
break
}
flash, flashKind = "Анкета отправлена через ИШ ("+string(pkg)+", пакет id="+id+"). Ждём подтверждения регистрации от НРД.", "ok"
+ saveParticipantReg(func(rg *participantReg) {
+ *rg = participantReg{SentAt: time.Now(), PkgID: id, Name: d.FullName, Channel: igwChannel}
+ })
}
}
@@ -370,6 +373,24 @@ func participantRegStatus(rc *RuntimeConfig) string {
if rc == nil {
return ""
}
+ var out strings.Builder
+ // Последняя отправка анкеты и ответ НРД на неё (учёт в participant-reg.json).
+ if reg := loadParticipantReg(); !reg.SentAt.IsZero() {
+ out.WriteString(`
Анкета отправлена через ИШ ` + reg.SentAt.Format("02.01.2006 15:04") + `, пакет ` + html.EscapeString(reg.PkgID))
+ if reg.Name != "" {
+ out.WriteString(`, наименование «` + html.EscapeString(reg.Name) + `»`)
+ }
+ out.WriteString(`. `)
+ switch {
+ case reg.RespAt.IsZero():
+ out.WriteString(`Ответ НРД пока не получен.`)
+ case strings.EqualFold(reg.Status, "ERROR"):
+ out.WriteString(`НРД отклонил ` + reg.RespAt.Format("02.01 15:04") + `: ` + html.EscapeString(reg.RespText) + `. Исправьте указанное и отправьте снова.`)
+ default:
+ out.WriteString(`НРД принял ` + reg.RespAt.Format("02.01 15:04") + `. ` + html.EscapeString(reg.RespText))
+ }
+ out.WriteString(`
`)
+ }
s := rc.Snapshot()
contour := map[string]string{"prod": "боевого", "guest": "гостевого", "test3": "тестового"}[contourOf(currentNSDProfile())]
if contour == "" {
@@ -382,13 +403,15 @@ func participantRegStatus(rc *RuntimeConfig) string {
}
}
if ok {
- return `В справочнике участников M2M ` + contour + ` контура:
есть — ` +
+ out.WriteString(`
В справочнике участников M2M ` + contour + ` контура: есть — ` +
html.EscapeString(pickStr(hb.Name, hb.FullName)) + `, код ` + html.EscapeString(hb.Code) +
- `, ИНН ` + html.EscapeString(hb.INN) + `. Регистрация подтверждена.
`
+ `, ИНН ` + html.EscapeString(hb.INN) + `. Регистрация подтверждена.
`)
+ return out.String()
}
- return `В справочнике участников M2M ` + contour + ` контура нашей организации пока
нет. ` +
+ out.WriteString(`
В справочнике участников M2M ` + contour + ` контура нашей организации пока нет. ` +
`После приёма анкеты НРД организация появится в справочнике (обновляется каждые ~10 минут) — тогда здесь будет «есть». ` +
- `Регистрацию подтверждает также ответный документ от НРД.
`
+ `Регистрацию подтверждает также ответный документ от НРД.
`)
+ return out.String()
}
func participantFormRender(w http.ResponseWriter, d participantFormData, errs []string, action, flash, flashKind, xmlPreview, regHTML string, igwConfigured, igwProd bool) {
diff --git a/internal/lkgateway/participant_reg.go b/internal/lkgateway/participant_reg.go
new file mode 100644
index 0000000..deb53b3
--- /dev/null
+++ b/internal/lkgateway/participant_reg.go
@@ -0,0 +1,82 @@
+package lkgateway
+
+// participant_reg.go — учёт регистрации участника M2M: что и когда отправили
+// анкетой (#M2MPF) и какой ответ дал НРД (сервис МОСТ, M2MTransferResponse с
+// нулевым GUID — он не относится к переводу). Показывается на странице анкеты.
+
+import (
+ "encoding/json"
+ "os"
+ "strings"
+ "sync"
+ "time"
+
+ "git.zetit.ru/zuevav/Bridge-and-Join-s/internal/m2m"
+)
+
+const participantRegFile = "/var/lib/bj/.bj/participant-reg.json"
+
+// participantReg — последняя отправка анкеты и ответ НРД на неё.
+type participantReg struct {
+ SentAt time.Time `json:"sent_at,omitempty"`
+ PkgID string `json:"pkg_id,omitempty"`
+ Name string `json:"name,omitempty"` // отправленное полное наименование
+ Channel string `json:"channel,omitempty"`
+ RespAt time.Time `json:"resp_at,omitempty"`
+ Status string `json:"status,omitempty"` // OK | ERROR
+ Codes []string `json:"codes,omitempty"`
+ RespText string `json:"resp_text,omitempty"`
+ RawFile string `json:"raw_file,omitempty"`
+}
+
+var participantRegMu sync.Mutex
+
+func loadParticipantReg() participantReg {
+ participantRegMu.Lock()
+ defer participantRegMu.Unlock()
+ var r participantReg
+ if b, err := os.ReadFile(participantRegFile); err == nil {
+ _ = json.Unmarshal(b, &r)
+ }
+ return r
+}
+
+// saveParticipantReg читает текущую запись, даёт изменить и атомарно пишет назад.
+func saveParticipantReg(mut func(*participantReg)) {
+ participantRegMu.Lock()
+ defer participantRegMu.Unlock()
+ var r participantReg
+ if b, err := os.ReadFile(participantRegFile); err == nil {
+ _ = json.Unmarshal(b, &r)
+ }
+ mut(&r)
+ b, err := json.MarshalIndent(&r, "", " ")
+ if err != nil {
+ return
+ }
+ tmp := participantRegFile + ".tmp"
+ if os.WriteFile(tmp, b, 0o640) == nil {
+ _ = os.Rename(tmp, participantRegFile)
+ }
+}
+
+// recordParticipantReply сохраняет ответ НРД на анкету (статус, коды, тексты, файл XML).
+func recordParticipantReply(status string, resp *m2m.M2MTransferResponse, rawFile string) {
+ var codes []string
+ seen := map[string]bool{}
+ for _, rr := range resp.Responses {
+ c := strings.TrimSpace(rr.Code)
+ if c != "" && !seen[c] {
+ seen[c] = true
+ codes = append(codes, c)
+ }
+ }
+ text := strings.Join(serviceErrorTexts(resp), "; ")
+ saveParticipantReg(func(r *participantReg) {
+ r.RespAt = time.Now()
+ r.Status = status
+ r.Codes = codes
+ r.RespText = text
+ r.RawFile = rawFile
+ })
+}