Анкета участника: ответ НРД принимается автоматически — квитанция и ответ сервиса

Квитанции ЭДО НРД (пакеты C, «Уведомление о получении Пакетов…»)
разбираются: для нашей анкеты (#M2MPF…) — отметка «доставлена в НРД»
с временем, ошибка первичной обработки — отказ с текстом квитанции.
Ответ сервиса: INFO — «принята в обработку», OK — принята, ERROR —
отказ с кодом; документ-ответ M2MTransferParticipantForm — регистрация
подтверждена. Всё видно в строке «Исходящих», на странице анкеты и в
плашке раздела НРД. После запуска bj перечитывает квитанции за день
отправки, если отметки ещё нет (22.10 на 9 квитанция пришла раньше).

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:14:22 +03:00
parent f50b338294
commit 6fc0ff5d3d
6 changed files with 202 additions and 6 deletions
+9 -1
View File
@@ -75,6 +75,7 @@ func (s *Server) pollIncoming(ctx context.Context) {
saveProcessed(processed)
}
log.Printf("lk-gateway: поллер входящих ИШ запущен (канал %s, интервал %s, обработанных в памяти %d)", s.igwChannel, interval, len(processed))
go s.participantReceiptBackfill(ctx)
for {
select {
@@ -146,7 +147,13 @@ func (s *Server) applyIncoming(ctx context.Context, p igw.Package) error {
unpacked, err := igw.UnpackPackage(zipBytes)
if err != nil {
// Нет основного .xml — служебный пакет (квитанция/конверт ЭДО).
// Не ошибка: помечаем обработанным, чтобы не повторять.
// Не ошибка: помечаем обработанным, чтобы не повторять. Квитанция о получении нашей анкеты —
// отметка «доставлена в НРД» (nsdreceipt.go).
if r, ok := parseNSDReceipt(zipBytes); ok {
if applyNSDReceipt(r) {
log.Printf("lk-gateway: поллер квитанция id=%d: анкета %s получена НРД %s, без ошибок=%v %s", p.ID, r.Package, r.ReceivedAt.Format("02.01 15:04:05"), r.OK, r.Text)
}
}
log.Printf("lk-gateway: поллер пакет id=%d (%s) — служебный (квитанция/конверт), пропуск", p.ID, p.Type)
return nil
}
@@ -279,6 +286,7 @@ func (s *Server) applyIncoming(ctx context.Context, p igw.Package) error {
// Схему ответа фиксируем на боевом контуре — сохраняем подлинный XML, чтобы
// затем встроить разбор в систему (для всех клиентов).
path, root := captureNSDReply(p, unpacked.DocXML, "participant-form")
recordParticipantFormReply(path)
log.Printf("lk-gateway: поллер ОТВЕТ НРД на анкету участника id=%d (%s): корень <%s>, сохранён %s", p.ID, p.Type, root, path)
return nil
default:
+145
View File
@@ -0,0 +1,145 @@
package lkgateway
// nsdreceipt.go — квитанции ЭДО НРД («Уведомление о получении Пакетов электронных документов», пакеты
// типа C: HTML + подпись, без M2M-XML). По ним видно, что НРД получил наш пакет и как прошла первичная
// обработка (тип, расшифровка, распаковка). Для анкеты участника это первая отметка «доставлена»;
// ответ по существу (принята / M2M37) приходит отдельно.
import (
"archive/zip"
"bytes"
"context"
"io"
"log"
"regexp"
"strings"
"time"
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/nsdadapter/igw"
"golang.org/x/text/encoding/charmap"
)
// nsdReceipt — разобранная квитанция о получении пакета.
type nsdReceipt struct {
Package string // имя нашего пакета, напр. #M2MPF2210260000000000071.ZIP
ReceivedAt time.Time // когда НРД получил пакет
OK bool // первичная обработка без ошибок
Text string // кратко — что не прошло (если не OK)
}
var (
rcptTagRe = regexp.MustCompile(`(?s)<[^>]*>`)
rcptPkgRe = regexp.MustCompile(`#[A-Z0-9]+\.ZIP`)
rcptTimeRe = regexp.MustCompile(`Дата и время получения\s+(\d{2}\.\d{2}\.\d{4} \d{2}:\d{2}:\d{2})`)
rcptBadRe = regexp.MustCompile(`(?i)(ошибк\S*|не\s*успешно|неуспешно|отказ\S*|не\s+выполнен\S*)`)
)
// parseNSDReceipt — квитанция из ZIP пакета C. false — это не квитанция о получении.
func parseNSDReceipt(zipBytes []byte) (nsdReceipt, bool) {
zr, err := zip.NewReader(bytes.NewReader(zipBytes), int64(len(zipBytes)))
if err != nil {
return nsdReceipt{}, false
}
for _, f := range zr.File {
if !strings.HasSuffix(strings.ToLower(f.Name), ".html") && !strings.HasSuffix(strings.ToLower(f.Name), ".htm") {
continue
}
rc, err := f.Open()
if err != nil {
continue
}
raw, _ := io.ReadAll(io.LimitReader(rc, 1<<20))
rc.Close()
s := string(raw)
if !strings.Contains(s, "Уведомление") { // квитанции НРД — в windows-1251
if d, err := charmap.Windows1251.NewDecoder().Bytes(raw); err == nil {
s = string(d)
}
}
return parseNSDReceiptText(s)
}
return nsdReceipt{}, false
}
func parseNSDReceiptText(html string) (nsdReceipt, bool) {
text := strings.Join(strings.Fields(rcptTagRe.ReplaceAllString(html, " ")), " ")
if !strings.Contains(text, "Уведомление о получении") {
return nsdReceipt{}, false
}
r := nsdReceipt{Package: rcptPkgRe.FindString(text)}
if m := rcptTimeRe.FindStringSubmatch(text); m != nil {
r.ReceivedAt, _ = time.ParseInLocation("02.01.2006 15:04:05", m[1], listMSK)
}
res := text
if i := strings.Index(text, "Результаты первичной обработки"); i >= 0 {
res = text[i:]
}
if j := strings.Index(res, "Составлено"); j > 0 {
res = res[:j]
}
if m := rcptBadRe.FindStringIndex(res); m != nil {
a, b := m[0]-80, m[1]+120
if a < 0 {
a = 0
}
if b > len(res) {
b = len(res)
}
r.Text = strings.TrimSpace(strings.ToValidUTF8(res[a:b], ""))
} else {
r.OK = strings.Contains(res, "Успешно")
}
return r, r.Package != ""
}
// applyNSDReceipt — квитанция на нашу анкету (#M2MPF…): отметка «доставлена» в последней отправке;
// ошибка первичной обработки — отказ с текстом квитанции.
func applyNSDReceipt(r nsdReceipt) bool {
if !strings.HasPrefix(r.Package, "#M2MPF") {
return false
}
applied := false
saveParticipantReg(func(rg *participantReg) {
if rg.SentAt.IsZero() || !rg.DeliveredAt.IsZero() || (!r.ReceivedAt.IsZero() && r.ReceivedAt.Before(rg.SentAt.Add(-time.Minute))) {
return // квитанция на прежнюю отправку
}
applied = true
rg.DeliveredAt, rg.Package = r.ReceivedAt, r.Package
if rg.DeliveredAt.IsZero() {
rg.DeliveredAt = time.Now()
}
if !r.OK && rg.RespAt.IsZero() {
rg.RespAt, rg.Status, rg.RespText = time.Now(), "ERROR", "квитанция НРД: "+pick(r.Text, "пакет не прошёл первичную обработку")
}
})
return applied
}
// participantReceiptBackfill — после запуска: квитанция на анкету могла прийти, когда bj ещё
// пропускал квитанции (или был остановлен) — перечитываем входящие за день отправки.
func (s *Server) participantReceiptBackfill(ctx context.Context) {
rg := loadParticipantReg()
if rg.SentAt.IsZero() || !rg.DeliveredAt.IsZero() || time.Since(rg.SentAt) > 72*time.Hour || s.igwClient == nil {
return
}
cctx, cancel := context.WithTimeout(ctx, 60*time.Second)
defer cancel()
pkgs, err := s.igwClient.ListIncoming(cctx, igw.ListFilter{Channel: s.igwChannel, Date: rg.SentAt})
if err != nil {
log.Printf("lk-gateway: квитанция на анкету: ListIncoming: %v", err)
return
}
for _, p := range pkgs {
if !strings.EqualFold(p.Type, "C") {
continue
}
b, err := s.igwClient.GetPackage(cctx, p.ID)
if err != nil {
continue
}
if r, ok := parseNSDReceipt(b); ok && applyNSDReceipt(r) {
log.Printf("lk-gateway: квитанция на анкету найдена id=%d: %s получен НРД %s, без ошибок=%v", p.ID, r.Package, r.ReceivedAt.Format("02.01 15:04:05"), r.OK)
return
}
}
}
+23
View File
@@ -0,0 +1,23 @@
package lkgateway
import "testing"
// Квитанция НРД о получении анкеты (пакет C, 22.10.2026, сервер 9) — текст после снятия тегов.
func TestParseNSDReceipt(t *testing.T) {
ok := `<html><body><h1>Уведомление о получении Пакетов электронных документов</h1><p>Отправитель</p><p>MC0413600000 ООО "ИК "Фонтвьель"</p>
<p>Тема сообщения</p><p>Тема входящего сообщения не указана</p><p>Дата и время получения</p><p>22.10.2026 11:07:31</p>
<p>Результаты первичной обработки Пакетов электронных документов:</p><p>Наименование Пакета электронных документов:</p><p>#M2MPF2210260000000000071.ZIP</p>
<table><tr><td>Проверка типа документа</td><td>Успешно</td></tr><tr><td>Расшифровка</td><td>Успешно</td></tr><tr><td>Распаковка</td><td>Успешно</td></tr></table>
<p>Перечень успешно обработанных вложенных файлов</p><p>#M2MPF2210260000000000071.XML</p><p>Составлено:</p><p>22.10.2026</p></body></html>`
r, found := parseNSDReceiptText(ok)
if !found || !r.OK || r.Package != "#M2MPF2210260000000000071.ZIP" || r.ReceivedAt.Format("02.01.2006 15:04:05") != "22.10.2026 11:07:31" {
t.Fatalf("%+v", r)
}
bad := `<p>Уведомление о получении Пакетов электронных документов</p><p>Дата и время получения</p><p>22.10.2026 11:07:31</p><p>Результаты первичной обработки</p><p>#M2MPF1.ZIP</p><td>Расшифровка</td><td>Ошибка расшифровки</td><p>Составлено</p>`
if r, _ := parseNSDReceiptText(bad); r.OK || r.Text == "" {
t.Fatalf("ошибка не распознана: %+v", r)
}
if _, found := parseNSDReceiptText("<p>Справочник</p>"); found {
t.Fatal("не квитанция")
}
}
+2
View File
@@ -469,6 +469,8 @@ func participantRegStatus(rc *RuntimeConfig) string {
}
out.WriteString(`. `)
switch {
case reg.RespAt.IsZero() && !reg.DeliveredAt.IsZero():
out.WriteString(`<b>НРД получил анкету</b> (квитанция ` + reg.DeliveredAt.In(listMSK).Format("02.01 15:04:05") + `, ошибок нет) — ответ по существу пока не пришёл.`)
case reg.RespAt.IsZero():
out.WriteString(`Ответ НРД пока не получен.`)
case strings.EqualFold(reg.Status, "ERROR"):
+22 -4
View File
@@ -28,6 +28,9 @@ type participantReg struct {
Codes []string `json:"codes,omitempty"`
RespText string `json:"resp_text,omitempty"`
RawFile string `json:"raw_file,omitempty"`
// Квитанция ЭДО НРД о получении пакета (пакет C): НРД получил анкету, первичная обработка прошла.
DeliveredAt time.Time `json:"delivered_at,omitempty"`
Package string `json:"package,omitempty"` // имя пакета из квитанции (#M2MPF….ZIP)
}
var participantRegMu sync.Mutex
@@ -98,7 +101,7 @@ func saveParticipantReg(mut func(*participantReg)) {
// StatusClass, StatusText — статус отправки анкеты для строк списков (класс бейджа и текст).
func (r participantReg) StatusClass() string {
switch {
case r.RespAt.IsZero():
case r.RespAt.IsZero(), strings.EqualFold(r.Status, "INFO"):
return "awaiting_decision"
case strings.EqualFold(r.Status, "ERROR"):
return "rejected"
@@ -108,8 +111,12 @@ func (r participantReg) StatusClass() string {
func (r participantReg) StatusText() string {
switch {
case r.RespAt.IsZero() && !r.DeliveredAt.IsZero():
return "Доставлена в НРД · ждём ответ"
case r.RespAt.IsZero():
return "Ждёт ответа НРД"
return "Отправлена · ждём квитанцию НРД"
case strings.EqualFold(r.Status, "INFO"):
return "Принята НРД в обработку"
case strings.EqualFold(r.Status, "ERROR"):
if len(r.Codes) > 0 {
return "Отклонена НРД · " + strings.Join(r.Codes, ", ")
@@ -143,7 +150,16 @@ func recordParticipantReply(status string, resp *m2m.M2MTransferResponse, rawFil
// participantAccepted — последняя отправленная анкета принята НРД.
func participantAccepted() bool {
r := loadParticipantReg()
return !r.RespAt.IsZero() && !strings.EqualFold(r.Status, "ERROR")
return !r.RespAt.IsZero() && !strings.EqualFold(r.Status, "ERROR") && !strings.EqualFold(r.Status, "INFO")
}
// recordParticipantFormReply — НРД прислал документ-ответ на анкету (M2MTransferParticipantForm):
// регистрация подтверждена.
func recordParticipantFormReply(rawFile string) {
saveParticipantReg(func(r *participantReg) {
r.RespAt, r.Status, r.Codes = time.Now(), "OK", nil
r.RespText, r.RawFile = "НРД подтвердил регистрацию участника", rawFile
})
}
// participantBanner — плашка наверху раздела НРД: анкета не отправлена, ждёт ответа или отклонена.
@@ -158,7 +174,9 @@ func participantBanner() template.HTML {
"Без регистрации участника НРД не принимает запросы на переводы. Проверьте анкету и отправьте её."
switch {
case r.SentAt.IsZero():
case r.RespAt.IsZero():
case r.RespAt.IsZero() && !r.DeliveredAt.IsZero():
cls, head, body = "stc-run", "Анкета участника M2M доставлена в НРД — ждём ответ", "Квитанция НРД "+r.DeliveredAt.In(listMSK).Format("02.01.2006 15:04:05")+": пакет получен, ошибок нет. Плашка пропадёт, когда НРД примет анкету."
case r.RespAt.IsZero(), strings.EqualFold(r.Status, "INFO"):
cls, head, body = "stc-run", "Анкета участника M2M ждёт ответа НРД", "Отправлена "+r.SentAt.In(listMSK).Format("02.01.2006 15:04")+". Плашка пропадёт, когда НРД примет анкету."
case strings.EqualFold(r.Status, "ERROR"):
cls, head = "stc-err", "НРД отклонил анкету участника M2M"
@@ -23,7 +23,7 @@
<td><span class="muted">—</span></td>
<td><span style="font-size:11px">{{index orgEff "Name"}}</span></td>
<td><span style="font-size:11px">НРД</span></td>
<td><span class="badge {{.StatusClass}}">{{.StatusText}}</span>{{if .RespText}}<div class="live live-{{if eq .StatusClass "rejected"}}bad{{else}}ok{{end}}">{{.RespAt.Format "02.01 15:04"}} · {{.RespText}}</div>{{end}}</td>
<td><span class="badge {{.StatusClass}}">{{.StatusText}}</span>{{if .RespText}}<div class="live live-{{if eq .StatusClass "rejected"}}bad{{else}}ok{{end}}">{{.RespAt.Format "02.01 15:04"}} · {{.RespText}}</div>{{end}}{{if and .RespAt.IsZero (not .DeliveredAt.IsZero)}}<div class="live live-wait">квитанция НРД {{.DeliveredAt.Format "02.01 15:04:05"}}: пакет получен, ошибок нет</div>{{end}}</td>
<td><a href="/admin/setup/participant-form">анкета →</a></td>
</tr>{{end}}{{else}}{{with .C}}
<tr>