Анкета участника: ответ НРД принимается автоматически — квитанция и ответ сервиса
Квитанции ЭДО НРД (пакеты 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:
@@ -75,6 +75,7 @@ func (s *Server) pollIncoming(ctx context.Context) {
|
|||||||
saveProcessed(processed)
|
saveProcessed(processed)
|
||||||
}
|
}
|
||||||
log.Printf("lk-gateway: поллер входящих ИШ запущен (канал %s, интервал %s, обработанных в памяти %d)", s.igwChannel, interval, len(processed))
|
log.Printf("lk-gateway: поллер входящих ИШ запущен (канал %s, интервал %s, обработанных в памяти %d)", s.igwChannel, interval, len(processed))
|
||||||
|
go s.participantReceiptBackfill(ctx)
|
||||||
|
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
@@ -146,7 +147,13 @@ func (s *Server) applyIncoming(ctx context.Context, p igw.Package) error {
|
|||||||
unpacked, err := igw.UnpackPackage(zipBytes)
|
unpacked, err := igw.UnpackPackage(zipBytes)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Нет основного .xml — служебный пакет (квитанция/конверт ЭДО).
|
// Нет основного .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)
|
log.Printf("lk-gateway: поллер пакет id=%d (%s) — служебный (квитанция/конверт), пропуск", p.ID, p.Type)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -279,6 +286,7 @@ func (s *Server) applyIncoming(ctx context.Context, p igw.Package) error {
|
|||||||
// Схему ответа фиксируем на боевом контуре — сохраняем подлинный XML, чтобы
|
// Схему ответа фиксируем на боевом контуре — сохраняем подлинный XML, чтобы
|
||||||
// затем встроить разбор в систему (для всех клиентов).
|
// затем встроить разбор в систему (для всех клиентов).
|
||||||
path, root := captureNSDReply(p, unpacked.DocXML, "participant-form")
|
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)
|
log.Printf("lk-gateway: поллер ОТВЕТ НРД на анкету участника id=%d (%s): корень <%s>, сохранён %s", p.ID, p.Type, root, path)
|
||||||
return nil
|
return nil
|
||||||
default:
|
default:
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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("не квитанция")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -469,6 +469,8 @@ func participantRegStatus(rc *RuntimeConfig) string {
|
|||||||
}
|
}
|
||||||
out.WriteString(`. `)
|
out.WriteString(`. `)
|
||||||
switch {
|
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():
|
case reg.RespAt.IsZero():
|
||||||
out.WriteString(`Ответ НРД пока не получен.`)
|
out.WriteString(`Ответ НРД пока не получен.`)
|
||||||
case strings.EqualFold(reg.Status, "ERROR"):
|
case strings.EqualFold(reg.Status, "ERROR"):
|
||||||
|
|||||||
@@ -28,6 +28,9 @@ type participantReg struct {
|
|||||||
Codes []string `json:"codes,omitempty"`
|
Codes []string `json:"codes,omitempty"`
|
||||||
RespText string `json:"resp_text,omitempty"`
|
RespText string `json:"resp_text,omitempty"`
|
||||||
RawFile string `json:"raw_file,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
|
var participantRegMu sync.Mutex
|
||||||
@@ -98,7 +101,7 @@ func saveParticipantReg(mut func(*participantReg)) {
|
|||||||
// StatusClass, StatusText — статус отправки анкеты для строк списков (класс бейджа и текст).
|
// StatusClass, StatusText — статус отправки анкеты для строк списков (класс бейджа и текст).
|
||||||
func (r participantReg) StatusClass() string {
|
func (r participantReg) StatusClass() string {
|
||||||
switch {
|
switch {
|
||||||
case r.RespAt.IsZero():
|
case r.RespAt.IsZero(), strings.EqualFold(r.Status, "INFO"):
|
||||||
return "awaiting_decision"
|
return "awaiting_decision"
|
||||||
case strings.EqualFold(r.Status, "ERROR"):
|
case strings.EqualFold(r.Status, "ERROR"):
|
||||||
return "rejected"
|
return "rejected"
|
||||||
@@ -108,8 +111,12 @@ func (r participantReg) StatusClass() string {
|
|||||||
|
|
||||||
func (r participantReg) StatusText() string {
|
func (r participantReg) StatusText() string {
|
||||||
switch {
|
switch {
|
||||||
|
case r.RespAt.IsZero() && !r.DeliveredAt.IsZero():
|
||||||
|
return "Доставлена в НРД · ждём ответ"
|
||||||
case r.RespAt.IsZero():
|
case r.RespAt.IsZero():
|
||||||
return "Ждёт ответа НРД"
|
return "Отправлена · ждём квитанцию НРД"
|
||||||
|
case strings.EqualFold(r.Status, "INFO"):
|
||||||
|
return "Принята НРД в обработку"
|
||||||
case strings.EqualFold(r.Status, "ERROR"):
|
case strings.EqualFold(r.Status, "ERROR"):
|
||||||
if len(r.Codes) > 0 {
|
if len(r.Codes) > 0 {
|
||||||
return "Отклонена НРД · " + strings.Join(r.Codes, ", ")
|
return "Отклонена НРД · " + strings.Join(r.Codes, ", ")
|
||||||
@@ -143,7 +150,16 @@ func recordParticipantReply(status string, resp *m2m.M2MTransferResponse, rawFil
|
|||||||
// participantAccepted — последняя отправленная анкета принята НРД.
|
// participantAccepted — последняя отправленная анкета принята НРД.
|
||||||
func participantAccepted() bool {
|
func participantAccepted() bool {
|
||||||
r := loadParticipantReg()
|
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 — плашка наверху раздела НРД: анкета не отправлена, ждёт ответа или отклонена.
|
// participantBanner — плашка наверху раздела НРД: анкета не отправлена, ждёт ответа или отклонена.
|
||||||
@@ -158,7 +174,9 @@ func participantBanner() template.HTML {
|
|||||||
"Без регистрации участника НРД не принимает запросы на переводы. Проверьте анкету и отправьте её."
|
"Без регистрации участника НРД не принимает запросы на переводы. Проверьте анкету и отправьте её."
|
||||||
switch {
|
switch {
|
||||||
case r.SentAt.IsZero():
|
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")+". Плашка пропадёт, когда НРД примет анкету."
|
cls, head, body = "stc-run", "Анкета участника M2M ждёт ответа НРД", "Отправлена "+r.SentAt.In(listMSK).Format("02.01.2006 15:04")+". Плашка пропадёт, когда НРД примет анкету."
|
||||||
case strings.EqualFold(r.Status, "ERROR"):
|
case strings.EqualFold(r.Status, "ERROR"):
|
||||||
cls, head = "stc-err", "НРД отклонил анкету участника M2M"
|
cls, head = "stc-err", "НРД отклонил анкету участника M2M"
|
||||||
|
|||||||
@@ -23,7 +23,7 @@
|
|||||||
<td><span class="muted">—</span></td>
|
<td><span class="muted">—</span></td>
|
||||||
<td><span style="font-size:11px">{{index orgEff "Name"}}</span></td>
|
<td><span style="font-size:11px">{{index orgEff "Name"}}</span></td>
|
||||||
<td><span style="font-size:11px">НРД</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>
|
<td><a href="/admin/setup/participant-form">анкета →</a></td>
|
||||||
</tr>{{end}}{{else}}{{with .C}}
|
</tr>{{end}}{{else}}{{with .C}}
|
||||||
<tr>
|
<tr>
|
||||||
|
|||||||
Reference in New Issue
Block a user