From 6fc0ff5d3d274dfee913cccd5f5be1a582c2dec0 Mon Sep 17 00:00:00 2001 From: fontvielle Date: Thu, 22 Oct 2026 11:14:22 +0300 Subject: [PATCH] =?UTF-8?q?=D0=90=D0=BD=D0=BA=D0=B5=D1=82=D0=B0=20=D1=83?= =?UTF-8?q?=D1=87=D0=B0=D1=81=D1=82=D0=BD=D0=B8=D0=BA=D0=B0:=20=D0=BE?= =?UTF-8?q?=D1=82=D0=B2=D0=B5=D1=82=20=D0=9D=D0=A0=D0=94=20=D0=BF=D1=80?= =?UTF-8?q?=D0=B8=D0=BD=D0=B8=D0=BC=D0=B0=D0=B5=D1=82=D1=81=D1=8F=20=D0=B0?= =?UTF-8?q?=D0=B2=D1=82=D0=BE=D0=BC=D0=B0=D1=82=D0=B8=D1=87=D0=B5=D1=81?= =?UTF-8?q?=D0=BA=D0=B8=20=E2=80=94=20=D0=BA=D0=B2=D0=B8=D1=82=D0=B0=D0=BD?= =?UTF-8?q?=D1=86=D0=B8=D1=8F=20=D0=B8=20=D0=BE=D1=82=D0=B2=D0=B5=D1=82=20?= =?UTF-8?q?=D1=81=D0=B5=D1=80=D0=B2=D0=B8=D1=81=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Квитанции ЭДО НРД (пакеты C, «Уведомление о получении Пакетов…») разбираются: для нашей анкеты (#M2MPF…) — отметка «доставлена в НРД» с временем, ошибка первичной обработки — отказ с текстом квитанции. Ответ сервиса: INFO — «принята в обработку», OK — принята, ERROR — отказ с кодом; документ-ответ M2MTransferParticipantForm — регистрация подтверждена. Всё видно в строке «Исходящих», на странице анкеты и в плашке раздела НРД. После запуска bj перечитывает квитанции за день отправки, если отметки ещё нет (22.10 на 9 квитанция пришла раньше). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Q7eREaYwUt1i4fNLZcGnjn --- internal/lkgateway/nsdpoller.go | 10 +- internal/lkgateway/nsdreceipt.go | 145 ++++++++++++++++++ internal/lkgateway/nsdreceipt_test.go | 23 +++ internal/lkgateway/participant_form.go | 2 + internal/lkgateway/participant_reg.go | 26 +++- .../lkgateway/web/templates/admin_claims.html | 2 +- 6 files changed, 202 insertions(+), 6 deletions(-) create mode 100644 internal/lkgateway/nsdreceipt.go create mode 100644 internal/lkgateway/nsdreceipt_test.go diff --git a/internal/lkgateway/nsdpoller.go b/internal/lkgateway/nsdpoller.go index a640a26..8afad86 100644 --- a/internal/lkgateway/nsdpoller.go +++ b/internal/lkgateway/nsdpoller.go @@ -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: diff --git a/internal/lkgateway/nsdreceipt.go b/internal/lkgateway/nsdreceipt.go new file mode 100644 index 0000000..31a1acc --- /dev/null +++ b/internal/lkgateway/nsdreceipt.go @@ -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 + } + } +} diff --git a/internal/lkgateway/nsdreceipt_test.go b/internal/lkgateway/nsdreceipt_test.go new file mode 100644 index 0000000..347af9e --- /dev/null +++ b/internal/lkgateway/nsdreceipt_test.go @@ -0,0 +1,23 @@ +package lkgateway + +import "testing" + +// Квитанция НРД о получении анкеты (пакет C, 22.10.2026, сервер 9) — текст после снятия тегов. +func TestParseNSDReceipt(t *testing.T) { + ok := `

Уведомление о получении Пакетов электронных документов

Отправитель

MC0413600000 ООО "ИК "Фонтвьель"

+

Тема сообщения

Тема входящего сообщения не указана

Дата и время получения

22.10.2026 11:07:31

+

Результаты первичной обработки Пакетов электронных документов:

Наименование Пакета электронных документов:

#M2MPF2210260000000000071.ZIP

+
Проверка типа документаУспешно
РасшифровкаУспешно
РаспаковкаУспешно
+

Перечень успешно обработанных вложенных файлов

#M2MPF2210260000000000071.XML

Составлено:

22.10.2026

` + 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 := `

Уведомление о получении Пакетов электронных документов

Дата и время получения

22.10.2026 11:07:31

Результаты первичной обработки

#M2MPF1.ZIP

РасшифровкаОшибка расшифровки

Составлено

` + if r, _ := parseNSDReceiptText(bad); r.OK || r.Text == "" { + t.Fatalf("ошибка не распознана: %+v", r) + } + if _, found := parseNSDReceiptText("

Справочник

"); found { + t.Fatal("не квитанция") + } +} diff --git a/internal/lkgateway/participant_form.go b/internal/lkgateway/participant_form.go index fe9e305..ac12b5d 100644 --- a/internal/lkgateway/participant_form.go +++ b/internal/lkgateway/participant_form.go @@ -469,6 +469,8 @@ func participantRegStatus(rc *RuntimeConfig) string { } out.WriteString(`. `) switch { + case reg.RespAt.IsZero() && !reg.DeliveredAt.IsZero(): + out.WriteString(`НРД получил анкету (квитанция ` + reg.DeliveredAt.In(listMSK).Format("02.01 15:04:05") + `, ошибок нет) — ответ по существу пока не пришёл.`) case reg.RespAt.IsZero(): out.WriteString(`Ответ НРД пока не получен.`) case strings.EqualFold(reg.Status, "ERROR"): diff --git a/internal/lkgateway/participant_reg.go b/internal/lkgateway/participant_reg.go index 55f17c8..4260666 100644 --- a/internal/lkgateway/participant_reg.go +++ b/internal/lkgateway/participant_reg.go @@ -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" diff --git a/internal/lkgateway/web/templates/admin_claims.html b/internal/lkgateway/web/templates/admin_claims.html index 3315d35..6d2b618 100644 --- a/internal/lkgateway/web/templates/admin_claims.html +++ b/internal/lkgateway/web/templates/admin_claims.html @@ -23,7 +23,7 @@ — {{index orgEff "Name"}} НРД - {{.StatusText}}{{if .RespText}}
{{.RespAt.Format "02.01 15:04"}} · {{.RespText}}
{{end}} + {{.StatusText}}{{if .RespText}}
{{.RespAt.Format "02.01 15:04"}} · {{.RespText}}
{{end}}{{if and .RespAt.IsZero (not .DeliveredAt.IsZero)}}
квитанция НРД {{.DeliveredAt.Format "02.01 15:04:05"}}: пакет получен, ошибок нет
{{end}} анкета → {{end}}{{else}}{{with .C}}