diff --git a/cmd/lk-emulator/main.go b/cmd/lk-emulator/main.go
index 44309a8..c909a31 100644
--- a/cmd/lk-emulator/main.go
+++ b/cmd/lk-emulator/main.go
@@ -1,20 +1,48 @@
-// Package main — сервис lk-emulator. Эмулятор ЛК клиента (ESIA Finance API V1)
-// на время, пока реальный ЛК не готов. Позволяет «как будто загрузить»
-// заявление через веб-форму и запустить полный путь обработки документа.
+// Package main — сервис lk-emulator. Имитация ЛК клиента (ESIA Finance
+// API V1) на время, пока реальный ЛК не готов. Веб-форма «новая заявка»,
+// журнал моих заявок, приёмник callback'ов от lk-gateway.
//
-// Когда реальный ЛК подключится — эмулятор остаётся как тестовый инструмент
-// в QA-окружении.
-//
-// На этапе M1 — заглушка.
+// Когда реальный ЛК подключится, эмулятор остаётся как тестовый
+// инструмент в QA-окружении: даёт сквозной сценарий без зависимости от
+// внешней стороны.
package main
import (
- "fmt"
+ "context"
+ "log"
"os"
+ "os/signal"
+ "syscall"
+
+ "git.zetit.ru/zuevav/Bridge-and-Join-s/internal/lkemulator"
)
-const serviceName = "lk-emulator"
-
func main() {
- fmt.Fprintf(os.Stdout, "%s: запуск (заглушка M1)\n", serviceName)
+ addr := getenv("BJ_HTTP_ADDR", ":8083")
+ gw := getenv("BJ_GATEWAY_URL", "http://127.0.0.1:8080")
+ self := getenv("BJ_EMULATOR_PUBLIC_URL", "http://127.0.0.1:8083")
+
+ srv, err := lkemulator.NewServer(lkemulator.ServerConfig{
+ Addr: addr,
+ GatewayURL: gw,
+ SelfPublicURL: self,
+ })
+ if err != nil {
+ log.Fatalf("lk-emulator: NewServer: %v", err)
+ }
+
+ ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
+ runErr := srv.Run(ctx)
+ stop()
+ if runErr != nil {
+ log.Printf("lk-emulator: %v", runErr)
+ os.Exit(1)
+ }
+}
+
+func getenv(k, def string) string {
+ if v, ok := os.LookupEnv(k); ok && v != "" {
+ return v
+ }
+ return def
}
diff --git a/cmd/lk-gateway/main.go b/cmd/lk-gateway/main.go
index 7dd2c16..66aa98d 100644
--- a/cmd/lk-gateway/main.go
+++ b/cmd/lk-gateway/main.go
@@ -1,17 +1,67 @@
-// Package main — сервис lk-gateway. Принимает заявления от ЛК клиента
-// (платформа ESIA Finance, /api/v1/back_office/...), валидирует их подпись,
-// передаёт в m2m-core, отдаёт callback-статусы обратно в ЛК.
+// Package main — сервис lk-gateway. BFF слой ЛК клиента:
+// принимает REST-заявки по контракту ESIA Finance, валидирует,
+// собирает M2MTransferRequest, отправляет в НРД через nsd-adapter,
+// эмитит callback статуса обратно в ЛК.
//
-// На этапе M1 — заглушка. Реализация контракта — M2.
+// На M2 — in-memory репозиторий + mock NSDSender (имитация принимающей
+// стороны через 3 секунды). На M3 переключим на pgx + реальный
+// nsd-adapter без изменения контракта.
package main
import (
- "fmt"
+ "context"
+ "log"
"os"
+ "os/signal"
+ "syscall"
+ "time"
+
+ "git.zetit.ru/zuevav/Bridge-and-Join-s/internal/lkgateway"
+ "git.zetit.ru/zuevav/Bridge-and-Join-s/internal/m2m"
)
-const serviceName = "lk-gateway"
-
func main() {
- fmt.Fprintf(os.Stdout, "%s: запуск (заглушка M1)\n", serviceName)
+ addr := getenv("BJ_HTTP_ADDR", ":8080")
+ defaultSender := m2m.DeponentCode(getenv("BJ_M2M_SENDER", "MC0079200000"))
+ defaultReceiver := m2m.DeponentCode(getenv("BJ_M2M_RECEIVER", "MC0010300000"))
+
+ cfg := lkgateway.ServerConfig{
+ Addr: addr,
+ DefaultSender: defaultSender,
+ DefaultReceiver: defaultReceiver,
+ CheckOptions: func() lkgateway.CheckOptions {
+ return lkgateway.CheckOptions{
+ PostgresDSN: os.Getenv("BJ_DSN"),
+ CryptoSocket: getenv("BJ_CRYPTO_SOCKET", "/run/bj/crypto.sock"),
+ NSDAdapterURL: os.Getenv("BJ_NSD_ADAPTER_URL"),
+ LKCallbackURL: os.Getenv("BJ_LK_CALLBACK_URL"),
+ Profile: getenv("BJ_NSD_PROFILE", "demo (mock NSD)"),
+ CryptoProvider: getenv("BJ_CRYPTO_PROVIDER", "stub"),
+ Timeout: 2 * time.Second,
+ }
+ },
+ }
+
+ srv, err := lkgateway.NewServer(cfg)
+ if err != nil {
+ log.Fatalf("lk-gateway: NewServer: %v", err)
+ }
+ if cb := os.Getenv("BJ_LK_CALLBACK_URL"); cb != "" {
+ srv.SetCallbackURL(cb)
+ }
+
+ ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
+ runErr := srv.Run(ctx)
+ stop()
+ if runErr != nil {
+ log.Printf("lk-gateway: %v", runErr)
+ os.Exit(1)
+ }
+}
+
+func getenv(k, def string) string {
+ if v, ok := os.LookupEnv(k); ok && v != "" {
+ return v
+ }
+ return def
}
diff --git a/docs/tasks/README.md b/docs/tasks/README.md
index cf55d81..c39f950 100644
--- a/docs/tasks/README.md
+++ b/docs/tasks/README.md
@@ -17,6 +17,7 @@ PR-1 → PR-N. Каждая задача — самостоятельный ос
| PR-4 | `PR-4-m2m-core-skeleton.md` | выполнено | PR-1 |
| PR-5 | `PR-5-nsd-adapter-skeleton.md` | выполнено (каркас) | PR-1, PR-4 |
| PR-6 | `PR-6-crypto-service-skeleton.md` | выполнено (скелет) | PR-1 |
+| M2-шаг-1 | сквозной поток: lk-gateway BFF + admin web + lk-emulator + mock NSD | выполнено | PR-1, PR-3, PR-4 |
## Как запустить задачу
diff --git a/internal/lkemulator/README.md b/internal/lkemulator/README.md
new file mode 100644
index 0000000..a7eca58
--- /dev/null
+++ b/internal/lkemulator/README.md
@@ -0,0 +1,48 @@
+# internal/lkemulator — имитация ЛК клиента (ESIA Finance)
+
+Веб-приложение, которое играет роль ЛК ESIA Finance в сквозных тестах
+без подключения к реальному ЛК. Используется:
+
+- для дев-демо «увидеть как работает сквозной поток заявки M2M»;
+- для приёмочного теста перед интеграцией с реальным ЛК;
+- как QA-инструмент, который остаётся после внедрения реального ЛК.
+
+## Веб-страницы
+
+- `/` — журнал моих заявок с автообновлением каждые 3 сек (показывает
+ изменение статуса по callback'у от lk-gateway).
+- `/new` — форма «подать заявку M2M»: выпадающий список инвесторов
+ (из `seed-data`), реквизиты сторон, ИИС, одна ЦБ, выбор имитируемого
+ исхода (confirm/reject/timeout).
+- `/claims/{id}` — карточка с историей: тело POST в lk-gateway,
+ ответ lk-gateway, полученный callback, расшифровка ответа НРД.
+- `/healthz` — health.
+- `/api/v1/back_office/claims/{id}` (PATCH) — приёмник callback'ов от
+ lk-gateway.
+
+## Состав пакета
+
+- `server.go` — HTTP-сервер, маршруты, рендер шаблонов.
+- `client.go` — `GatewayClient` (POST заявка, регистрация callback URL).
+- `types.go` — `Store` (in-memory) и `Claim`/`CallbackRecord` модели.
+- `web/templates/` — `layout.html`, `home.html`, `new.html`, `claim.html`.
+
+## Конфигурация (cmd/lk-emulator/main.go, ENV)
+
+| Переменная | По умолчанию | Назначение |
+|---|---|---|
+| `BJ_HTTP_ADDR` | `:8083` | Адрес HTTP |
+| `BJ_GATEWAY_URL` | `http://127.0.0.1:8080` | URL lk-gateway, куда шлём POST заявки |
+| `BJ_EMULATOR_PUBLIC_URL` | `http://127.0.0.1:8083` | Куда gateway должен слать callback'и (регистрируется при старте) |
+
+## Сквозной поток для проверки
+
+1. Запустить `./bin/lk-gateway` (порт 8080) и `./bin/lk-emulator` (порт 8083).
+2. Открыть в браузере `http://127.0.0.1:8083/new`.
+3. Подать заявку с дефолтными значениями + исход `confirm`.
+4. На странице `http://127.0.0.1:8083/` через ~3 секунды увидеть
+ статус заявки `confirmed`.
+5. На странице `http://127.0.0.1:8080/admin/` — дашборд lk-gateway со
+ счётчиком «Подтверждено: 1» и заявкой в журнале.
+6. На странице `http://127.0.0.1:8080/admin/status` — состояние всех
+ подсистем.
diff --git a/internal/lkemulator/client.go b/internal/lkemulator/client.go
new file mode 100644
index 0000000..3e40916
--- /dev/null
+++ b/internal/lkemulator/client.go
@@ -0,0 +1,75 @@
+package lkemulator
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "time"
+)
+
+// GatewayClient — клиент к lk-gateway по REST.
+type GatewayClient struct {
+ baseURL string
+ httpc *http.Client
+}
+
+// NewGatewayClient — конструктор.
+func NewGatewayClient(baseURL string) *GatewayClient {
+ return &GatewayClient{
+ baseURL: baseURL,
+ httpc: &http.Client{Timeout: 10 * time.Second},
+ }
+}
+
+// CreateClaim шлёт POST /api/v1/back_office/claims/ и возвращает ответ.
+func (c *GatewayClient) CreateClaim(ctx context.Context, body map[string]any) (map[string]any, error) {
+ raw, err := json.Marshal(body)
+ if err != nil {
+ return nil, err
+ }
+ req, err := http.NewRequestWithContext(ctx, http.MethodPost,
+ c.baseURL+"/api/v1/back_office/claims/", bytes.NewReader(raw))
+ if err != nil {
+ return nil, err
+ }
+ req.Header.Set("Content-Type", "application/json")
+ resp, err := c.httpc.Do(req)
+ if err != nil {
+ return nil, err
+ }
+ defer resp.Body.Close()
+ out, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return nil, err
+ }
+ if resp.StatusCode >= 400 {
+ return nil, fmt.Errorf("lk-gateway HTTP %d: %s", resp.StatusCode, string(out))
+ }
+ var parsed map[string]any
+ if err := json.Unmarshal(out, &parsed); err != nil {
+ return nil, fmt.Errorf("lk-gateway: разбор JSON: %w; raw: %s", err, string(out))
+ }
+ return parsed, nil
+}
+
+// SetCallbackURL сообщает gateway свой URL — куда слать PATCH callback'и.
+func (c *GatewayClient) SetCallbackURL(ctx context.Context, url string) error {
+ req, err := http.NewRequestWithContext(ctx, http.MethodPost,
+ c.baseURL+"/admin/api/callback-url?url="+url, nil)
+ if err != nil {
+ return err
+ }
+ resp, err := c.httpc.Do(req)
+ if err != nil {
+ return err
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode >= 400 {
+ buf, _ := io.ReadAll(resp.Body)
+ return fmt.Errorf("set callback url HTTP %d: %s", resp.StatusCode, string(buf))
+ }
+ return nil
+}
diff --git a/internal/lkemulator/server.go b/internal/lkemulator/server.go
new file mode 100644
index 0000000..67e1149
--- /dev/null
+++ b/internal/lkemulator/server.go
@@ -0,0 +1,427 @@
+package lkemulator
+
+import (
+ "context"
+ "crypto/rand"
+ "embed"
+ "encoding/base64"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "html/template"
+ "io"
+ "log"
+ "net/http"
+ "strconv"
+ "strings"
+ "time"
+)
+
+//go:embed web/templates/*.html
+var tplFS embed.FS
+
+// ServerConfig — настройки эмулятора ЛК.
+type ServerConfig struct {
+ Addr string
+ GatewayURL string
+ SelfPublicURL string // адрес, который мы сообщим gateway для callback'ов
+}
+
+// Server — HTTP-сервер эмулятора.
+type Server struct {
+ cfg ServerConfig
+ store *Store
+ gw *GatewayClient
+ home, new, claim *template.Template
+ srv *http.Server
+ mux *http.ServeMux
+}
+
+// NewServer собирает Server.
+func NewServer(cfg ServerConfig) (*Server, error) {
+ parse := func(content string) (*template.Template, error) {
+ return template.ParseFS(tplFS, "web/templates/layout.html", "web/templates/"+content)
+ }
+ home, err := parse("home.html")
+ if err != nil {
+ return nil, fmt.Errorf("parse home: %w", err)
+ }
+ newTpl, err := parse("new.html")
+ if err != nil {
+ return nil, fmt.Errorf("parse new: %w", err)
+ }
+ claimTpl, err := parse("claim.html")
+ if err != nil {
+ return nil, fmt.Errorf("parse claim: %w", err)
+ }
+ s := &Server{
+ cfg: cfg,
+ store: NewStore(),
+ gw: NewGatewayClient(cfg.GatewayURL),
+ home: home,
+ new: newTpl,
+ claim: claimTpl,
+ mux: http.NewServeMux(),
+ }
+ s.routes()
+ s.srv = &http.Server{
+ Addr: cfg.Addr,
+ Handler: s.mux,
+ ReadHeaderTimeout: 5 * time.Second,
+ }
+ return s, nil
+}
+
+// Run поднимает сервер, регистрирует свой URL у gateway, ждёт ctx.Done().
+func (s *Server) Run(ctx context.Context) error {
+ go func() {
+ // Дать серверу подняться, потом попытаться зарегистрировать callback URL.
+ time.Sleep(200 * time.Millisecond)
+ if s.cfg.SelfPublicURL != "" {
+ if err := s.gw.SetCallbackURL(ctx, s.cfg.SelfPublicURL); err != nil {
+ log.Printf("lk-emulator: не получилось зарегистрировать callback URL: %v", err)
+ } else {
+ log.Printf("lk-emulator: callback URL %s зарегистрирован в lk-gateway", s.cfg.SelfPublicURL)
+ }
+ }
+ }()
+
+ errCh := make(chan error, 1)
+ go func() {
+ log.Printf("lk-emulator: listen %s", s.cfg.Addr)
+ errCh <- s.srv.ListenAndServe()
+ }()
+
+ select {
+ case <-ctx.Done():
+ shutCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer cancel()
+ _ = s.srv.Shutdown(shutCtx)
+ return nil
+ case err := <-errCh:
+ if errors.Is(err, http.ErrServerClosed) {
+ return nil
+ }
+ return err
+ }
+}
+
+// Mux возвращает обработчик (для httptest).
+func (s *Server) Mux() http.Handler { return s.mux }
+
+// Store возвращает store (для тестов).
+func (s *Server) Store() *Store { return s.store }
+
+func (s *Server) routes() {
+ s.mux.HandleFunc("/", s.handleHome)
+ s.mux.HandleFunc("/new", s.handleNew)
+ s.mux.HandleFunc("/claims/", s.handleClaim)
+ s.mux.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) {
+ w.WriteHeader(http.StatusOK)
+ _, _ = w.Write([]byte("ok"))
+ })
+ // Принимаем callback'и от lk-gateway (PATCH).
+ s.mux.HandleFunc("/api/v1/back_office/claims/", s.handleCallback)
+}
+
+type pageData struct {
+ Title string
+ Active string
+ GatewayURL string
+ AutoRefresh bool
+ Flash string
+ Error string
+}
+
+func (s *Server) basePage(title, active string, autoRefresh bool) pageData {
+ return pageData{Title: title, Active: active, GatewayURL: s.cfg.GatewayURL, AutoRefresh: autoRefresh}
+}
+
+type homeData struct {
+ pageData
+ Claims []*Claim
+}
+
+func (s *Server) handleHome(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path != "/" {
+ http.NotFound(w, r)
+ return
+ }
+ data := homeData{
+ pageData: s.basePage("Мои заявки", "home", true),
+ Claims: s.store.All(),
+ }
+ if msg := r.URL.Query().Get("flash"); msg != "" {
+ data.Flash = msg
+ }
+ s.renderTpl(w, s.home, data)
+}
+
+// clientView — DTO от gateway для выпадающего списка инвесторов.
+type clientView struct {
+ ID string `json:"ID"`
+ LastName string `json:"LastName"`
+ FirstName string `json:"FirstName"`
+ MiddleName string `json:"MiddleName"`
+}
+
+type newData struct {
+ pageData
+ Clients []clientView
+}
+
+func (s *Server) handleNew(w http.ResponseWriter, r *http.Request) {
+ if r.Method == http.MethodGet {
+ clients, err := s.fetchClients(r.Context())
+ if err != nil {
+ s.renderTpl(w, s.new, newData{pageData: s.basePage("Новая заявка", "new", false), Clients: nil})
+ return
+ }
+ s.renderTpl(w, s.new, newData{pageData: s.basePage("Новая заявка", "new", false), Clients: clients})
+ return
+ }
+ if r.Method != http.MethodPost {
+ http.Error(w, "method", http.StatusMethodNotAllowed)
+ return
+ }
+ if err := s.submitNew(r); err != nil {
+ clients, _ := s.fetchClients(r.Context())
+ d := newData{pageData: s.basePage("Новая заявка", "new", false), Clients: clients}
+ d.Error = err.Error()
+ s.renderTpl(w, s.new, d)
+ return
+ }
+ http.Redirect(w, r, "/?flash=Заявка+отправлена+в+lk-gateway.+Ждём+callback...", http.StatusSeeOther)
+}
+
+func (s *Server) submitNew(r *http.Request) error {
+ if err := r.ParseForm(); err != nil {
+ return fmt.Errorf("разбор формы: %w", err)
+ }
+ investorID := r.FormValue("investor_id")
+ if investorID == "" {
+ return errors.New("укажите инвестора")
+ }
+ clients, err := s.fetchClients(r.Context())
+ if err != nil {
+ return fmt.Errorf("список инвесторов: %w", err)
+ }
+ investorName := ""
+ for _, c := range clients {
+ if c.ID == investorID {
+ investorName = strings.TrimSpace(c.LastName + " " + c.FirstName + " " + c.MiddleName)
+ break
+ }
+ }
+
+ tInn := r.FormValue("transferring_depository_inn")
+ rInn := r.FormValue("receiving_depository_inn")
+ secCode := r.FormValue("security_code")
+ isin := r.FormValue("isin")
+ qStr := r.FormValue("quantity")
+ whole, err := strconv.ParseUint(qStr, 10, 64)
+ if err != nil || whole == 0 {
+ return errors.New("количество должно быть положительным целым")
+ }
+
+ body := map[string]any{
+ "investor": map[string]any{
+ "id": investorID,
+ "last_name": splitFio(investorName, 0),
+ "first_name": splitFio(investorName, 1),
+ "document": map[string]any{
+ "document_type": "21",
+ "series": "4512",
+ "number": "654321",
+ },
+ },
+ "transferring_depository_inn": tInn,
+ "receiving_depository_inn": rInn,
+ "securities": []any{
+ map[string]any{
+ "security_code": secCode,
+ "security_details": map[string]any{"isin": isin},
+ "quantity": map[string]any{"whole": whole},
+ "settlement_accounts": []any{
+ map[string]any{
+ "settlement_requisites_inn": "7702070139",
+ "settlement_location": map[string]any{
+ "deponent_code": "DP789456",
+ "account_id": "31MC0021900000F01",
+ "section_id": "P001",
+ },
+ },
+ },
+ },
+ },
+ "signed_document": base64.StdEncoding.EncodeToString([]byte("demo")),
+ "signature_format": "XMLDSig-GOST",
+ }
+ if code := r.FormValue("cost_info_code"); code != "" {
+ body["cost_info"] = map[string]any{"yes": map[string]any{"code": code}}
+ } else {
+ body["cost_info"] = map[string]any{"no": map[string]any{}}
+ }
+ if iiaType := r.FormValue("iia_type"); iiaType != "" {
+ body["iia_agreement"] = map[string]any{
+ "agreement_type": iiaType,
+ "agreement_number": r.FormValue("iia_number"),
+ "agreement_date": r.FormValue("iia_date"),
+ "broker_inn": r.FormValue("iia_broker_inn"),
+ }
+ }
+
+ resp, err := s.gw.CreateClaim(r.Context(), body)
+ if err != nil {
+ return err
+ }
+ gwID, _ := resp["id"].(string)
+ status, _ := resp["status"].(string)
+
+ c := &Claim{
+ ID: randomID(),
+ GatewayID: gwID,
+ Status: status,
+ LocalStatus: "submitted",
+ CreatedAt: time.Now().UTC(),
+ UpdatedAt: time.Now().UTC(),
+ InvestorName: investorName,
+ TransferringDepositoryINN: tInn,
+ ReceivingDepositoryINN: rInn,
+ SecuritiesCount: 1,
+ RawRequest: body,
+ RawResponse: resp,
+ }
+ s.store.Add(c)
+ return nil
+}
+
+type claimPage struct {
+ pageData
+ Claim *Claim
+ PrettyRequest string
+ PrettyResponse string
+}
+
+func (s *Server) handleClaim(w http.ResponseWriter, r *http.Request) {
+ id := strings.TrimPrefix(r.URL.Path, "/claims/")
+ if id == "" || strings.Contains(id, "/") {
+ http.NotFound(w, r)
+ return
+ }
+ c := s.store.Get(id)
+ if c == nil {
+ http.NotFound(w, r)
+ return
+ }
+ d := claimPage{
+ pageData: s.basePage("Заявка", "home", c.LastCallback == nil),
+ Claim: c,
+ }
+ if b, err := json.MarshalIndent(c.RawRequest, "", " "); err == nil {
+ d.PrettyRequest = string(b)
+ }
+ if b, err := json.MarshalIndent(c.RawResponse, "", " "); err == nil {
+ d.PrettyResponse = string(b)
+ }
+ s.renderTpl(w, s.claim, d)
+}
+
+// handleCallback принимает PATCH /api/v1/back_office/claims/{id} от lk-gateway.
+func (s *Server) handleCallback(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodPatch {
+ http.Error(w, "method", http.StatusMethodNotAllowed)
+ return
+ }
+ gwID := strings.TrimPrefix(r.URL.Path, "/api/v1/back_office/claims/")
+ if gwID == "" {
+ http.Error(w, "id required", http.StatusBadRequest)
+ return
+ }
+ defer r.Body.Close()
+ raw, err := io.ReadAll(r.Body)
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusBadRequest)
+ return
+ }
+ var payload struct {
+ NewStatus string `json:"new_status"`
+ ReasonCode string `json:"reason_code"`
+ ReasonText string `json:"reason_text"`
+ UpdatedAt time.Time `json:"updated_at"`
+ NSDResp *struct {
+ GUID string `json:"guid"`
+ StatusCode string `json:"status_code"`
+ Responses []struct {
+ ReferenceID string `json:"reference_id"`
+ Code string `json:"code"`
+ Text string `json:"text"`
+ } `json:"responses"`
+ } `json:"nsd_response"`
+ }
+ if err := json.Unmarshal(raw, &payload); err != nil {
+ http.Error(w, err.Error(), http.StatusBadRequest)
+ return
+ }
+ cb := &CallbackRecord{
+ NewStatus: payload.NewStatus,
+ ReasonCode: payload.ReasonCode,
+ ReasonText: payload.ReasonText,
+ UpdatedAt: payload.UpdatedAt,
+ }
+ if payload.NSDResp != nil {
+ cb.GUID = payload.NSDResp.GUID
+ cb.StatusCode = payload.NSDResp.StatusCode
+ for _, rr := range payload.NSDResp.Responses {
+ cb.Responses = append(cb.Responses, CallbackResponseEntry{
+ ReferenceID: rr.ReferenceID, Code: rr.Code, Text: rr.Text,
+ })
+ }
+ }
+ if !s.store.ApplyCallback(gwID, cb) {
+ http.Error(w, "claim not found in emulator", http.StatusNotFound)
+ return
+ }
+ w.WriteHeader(http.StatusOK)
+ _, _ = w.Write([]byte(`{"success":true}`))
+}
+
+func (s *Server) fetchClients(ctx context.Context) ([]clientView, error) {
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, s.cfg.GatewayURL+"/admin/api/clients", nil)
+ if err != nil {
+ return nil, err
+ }
+ resp, err := http.DefaultClient.Do(req)
+ if err != nil {
+ return nil, err
+ }
+ defer resp.Body.Close()
+ var out []clientView
+ if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
+ return nil, err
+ }
+ return out, nil
+}
+
+func (s *Server) renderTpl(w http.ResponseWriter, t *template.Template, data any) {
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ if err := t.ExecuteTemplate(w, "layout", data); err != nil {
+ log.Printf("lk-emulator: render: %v", err)
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ }
+}
+
+// randomID — генерирует короткий локальный ID заявки (8 hex).
+func randomID() string {
+ var b [4]byte
+ _, _ = rand.Read(b[:])
+ return fmt.Sprintf("%x", b)
+}
+
+func splitFio(fio string, idx int) string {
+ parts := strings.Fields(fio)
+ if idx < len(parts) {
+ return parts[idx]
+ }
+ return ""
+}
diff --git a/internal/lkemulator/server_test.go b/internal/lkemulator/server_test.go
new file mode 100644
index 0000000..9c7019e
--- /dev/null
+++ b/internal/lkemulator/server_test.go
@@ -0,0 +1,105 @@
+package lkemulator_test
+
+import (
+ "bytes"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+ "time"
+
+ "git.zetit.ru/zuevav/Bridge-and-Join-s/internal/lkemulator"
+)
+
+func TestCallbackUpdatesStore(t *testing.T) {
+ srv, err := lkemulator.NewServer(lkemulator.ServerConfig{
+ Addr: ":0",
+ GatewayURL: "http://example.invalid",
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ // Положим заявку в store вручную.
+ c := &lkemulator.Claim{
+ ID: "local-1",
+ GatewayID: "gw-abc",
+ Status: "submitted_to_nsd",
+ CreatedAt: time.Now(),
+ UpdatedAt: time.Now(),
+ }
+ srv.Store().Add(c)
+
+ body := map[string]any{
+ "claim_id": "gw-abc",
+ "new_status": "confirmed",
+ "updated_at": time.Now().Format(time.RFC3339),
+ "nsd_response": map[string]any{
+ "guid": "c02a1d5e-c2af-4799-bab4-953f133c5133",
+ "status_code": "INFO",
+ "responses": []map[string]any{{"reference_id": "M2M2026030200001", "code": "01", "text": "ok"}},
+ },
+ }
+ raw, _ := json.Marshal(body)
+ w := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodPatch, "/api/v1/back_office/claims/gw-abc", bytes.NewReader(raw))
+ srv.Mux().ServeHTTP(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Fatalf("callback code=%d body=%s", w.Code, w.Body.String())
+ }
+
+ got := srv.Store().Get("local-1")
+ if got.Status != "confirmed" {
+ t.Errorf("статус не обновился, got=%s", got.Status)
+ }
+ if got.LastCallback == nil {
+ t.Fatal("LastCallback nil")
+ }
+ if len(got.LastCallback.Responses) != 1 {
+ t.Errorf("ожидался 1 response, получено %d", len(got.LastCallback.Responses))
+ }
+}
+
+func TestCallbackUnknownClaim(t *testing.T) {
+ srv, err := lkemulator.NewServer(lkemulator.ServerConfig{Addr: ":0", GatewayURL: "http://example.invalid"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ w := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodPatch, "/api/v1/back_office/claims/unknown", strings.NewReader(`{"new_status":"confirmed"}`))
+ srv.Mux().ServeHTTP(w, req)
+ if w.Code != http.StatusNotFound {
+ t.Errorf("ожидался 404, получено %d", w.Code)
+ }
+}
+
+func TestHomePageEmpty(t *testing.T) {
+ srv, err := lkemulator.NewServer(lkemulator.ServerConfig{Addr: ":0", GatewayURL: "http://example.invalid"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ w := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodGet, "/", nil)
+ srv.Mux().ServeHTTP(w, req)
+ if w.Code != http.StatusOK {
+ t.Errorf("home: %d", w.Code)
+ }
+ if !strings.Contains(w.Body.String(), "Заявок ещё нет") {
+ t.Errorf("ожидалось сообщение об отсутствии заявок")
+ }
+}
+
+func TestHealthz(t *testing.T) {
+ srv, err := lkemulator.NewServer(lkemulator.ServerConfig{Addr: ":0", GatewayURL: "http://example.invalid"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ w := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodGet, "/healthz", nil)
+ srv.Mux().ServeHTTP(w, req)
+ if w.Code != http.StatusOK {
+ t.Errorf("healthz: %d", w.Code)
+ }
+}
diff --git a/internal/lkemulator/types.go b/internal/lkemulator/types.go
new file mode 100644
index 0000000..d551f40
--- /dev/null
+++ b/internal/lkemulator/types.go
@@ -0,0 +1,109 @@
+// Package lkemulator — имитация ЛК клиента (ESIA Finance). Предоставляет
+// HTML-формы для подачи заявок M2M и приёмник callback'ов от lk-gateway.
+// Используется для сквозного дев-теста системы Bridge-and-Join-s без
+// реальной интеграции с ЛК.
+package lkemulator
+
+import (
+ "sync"
+ "time"
+)
+
+// Claim — локальная сохранённая копия заявки + последний callback.
+type Claim struct {
+ ID string
+ GatewayID string // ID присвоенный lk-gateway (совпадает с m2mcore.Deal.ID)
+ Status string
+ LocalStatus string // "submitted" | "callback_received" — внутренняя метка эмулятора
+ CreatedAt time.Time
+ UpdatedAt time.Time
+ InvestorName string
+ TransferringDepositoryINN string
+ ReceivingDepositoryINN string
+ SecuritiesCount int
+ LastCallback *CallbackRecord
+ RawRequest map[string]any
+ RawResponse map[string]any
+}
+
+// CallbackRecord — сохранённый callback от lk-gateway.
+type CallbackRecord struct {
+ NewStatus string
+ ReasonCode string
+ ReasonText string
+ UpdatedAt time.Time
+ GUID string
+ StatusCode string
+ Responses []CallbackResponseEntry
+}
+
+// CallbackResponseEntry — одна строка из NSDResponse в callback'е.
+type CallbackResponseEntry struct {
+ ReferenceID string
+ Code string
+ Text string
+}
+
+// Store — in-memory хранилище заявок эмулятора.
+type Store struct {
+ mu sync.RWMutex
+ byID map[string]*Claim
+ byGw map[string]*Claim
+ order []string
+}
+
+// NewStore — пустое хранилище.
+func NewStore() *Store {
+ return &Store{byID: make(map[string]*Claim), byGw: make(map[string]*Claim)}
+}
+
+// Add сохраняет новую заявку.
+func (s *Store) Add(c *Claim) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ s.byID[c.ID] = c
+ if c.GatewayID != "" {
+ s.byGw[c.GatewayID] = c
+ }
+ s.order = append(s.order, c.ID)
+}
+
+// Get — по локальному ID.
+func (s *Store) Get(id string) *Claim {
+ s.mu.RLock()
+ defer s.mu.RUnlock()
+ return s.byID[id]
+}
+
+// GetByGatewayID — по ID от lk-gateway (используется при приёме callback).
+func (s *Store) GetByGatewayID(id string) *Claim {
+ s.mu.RLock()
+ defer s.mu.RUnlock()
+ return s.byGw[id]
+}
+
+// All возвращает все заявки в обратном порядке создания.
+func (s *Store) All() []*Claim {
+ s.mu.RLock()
+ defer s.mu.RUnlock()
+ out := make([]*Claim, 0, len(s.order))
+ for i := len(s.order) - 1; i >= 0; i-- {
+ out = append(out, s.byID[s.order[i]])
+ }
+ return out
+}
+
+// ApplyCallback обновляет заявку при получении callback'а от lk-gateway.
+func (s *Store) ApplyCallback(gatewayID string, cb *CallbackRecord) bool {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ c, ok := s.byGw[gatewayID]
+ if !ok {
+ return false
+ }
+ c.Status = cb.NewStatus
+ c.LocalStatus = "callback_received"
+ c.UpdatedAt = cb.UpdatedAt
+ c.LastCallback = cb
+ return true
+}
diff --git a/internal/lkemulator/web/templates/claim.html b/internal/lkemulator/web/templates/claim.html
new file mode 100644
index 0000000..926a1f6
--- /dev/null
+++ b/internal/lkemulator/web/templates/claim.html
@@ -0,0 +1,61 @@
+{{define "content"}}
+
+
Заявка {{slice .Claim.ID 0 8}} · {{.Claim.Status}}
+
+
Создана
{{.Claim.CreatedAt.Format "02.01.2006 15:04:05"}}
+
Обновлена
{{.Claim.UpdatedAt.Format "02.01.2006 15:04:05"}}
+
ID lk-gateway
{{.Claim.GatewayID}}
+
Инвестор
{{.Claim.InvestorName}}
+
Передающий депозитарий
ИНН {{.Claim.TransferringDepositoryINN}}
+
Принимающий депозитарий
ИНН {{.Claim.ReceivingDepositoryINN}}
+
Локально
{{.Claim.LocalStatus}}
+
+
+
+{{if .Claim.RawRequest}}
+
+
Тело отправленной заявки (REST в lk-gateway)
+
{{.PrettyRequest}}
+
+{{end}}
+
+{{if .Claim.RawResponse}}
+
+
Ответ lk-gateway на создание
+
{{.PrettyResponse}}
+
+{{end}}
+
+{{if .Claim.LastCallback}}
+
+
Полученный callback от lk-gateway
+
+
Новый статус
{{.Claim.LastCallback.NewStatus}}
+ {{if .Claim.LastCallback.ReasonCode}}
+
Код причины
{{.Claim.LastCallback.ReasonCode}} {{.Claim.LastCallback.ReasonText}}
+ {{end}}
+
Время
{{.Claim.LastCallback.UpdatedAt.Format "02.01.2006 15:04:05"}}
+ {{if .Claim.LastCallback.GUID}}
+
NSD GUID
{{.Claim.LastCallback.GUID}}
+ {{end}}
+
+ {{if .Claim.LastCallback.Responses}}
+
NSD Response (детально)
+
+ | ReferenceID | Код | Текст |
+
+ {{range .Claim.LastCallback.Responses}}
+ {{.ReferenceID}} | {{.Code}} | {{.Text}} |
+ {{end}}
+
+
+ {{end}}
+
+{{else}}
+
+
Callback от lk-gateway ещё не пришёл. Страница автообновится через 3 секунды.
+
+{{end}}
+
+← все заявки
+{{end}}
diff --git a/internal/lkemulator/web/templates/home.html b/internal/lkemulator/web/templates/home.html
new file mode 100644
index 0000000..285c3d1
--- /dev/null
+++ b/internal/lkemulator/web/templates/home.html
@@ -0,0 +1,31 @@
+{{define "content"}}
+{{if .Flash}}{{.Flash}}
{{end}}
+
+
+
Журнал моих заявок ({{len .Claims}})
+ {{if .Claims}}
+
+
+ | Создана | ID gateway | Инвестор | ЦБ | Передающий | Принимающий | Статус | |
+
+
+ {{range .Claims}}
+
+ | {{.CreatedAt.Format "02.01 15:04:05"}} |
+ {{if .GatewayID}}{{slice .GatewayID 0 8}}{{else}}—{{end}} |
+ {{.InvestorName}} |
+ {{.SecuritiesCount}} |
+ {{.TransferringDepositoryINN}} |
+ {{.ReceivingDepositoryINN}} |
+ {{.Status}} |
+ детали → |
+
+ {{end}}
+
+
+
Страница автообновляется каждые 3 сек, чтобы видеть переход статуса по callback'у от lk-gateway.
+ {{else}}
+
Заявок ещё нет. Подайте первую.
+ {{end}}
+
+{{end}}
diff --git a/internal/lkemulator/web/templates/layout.html b/internal/lkemulator/web/templates/layout.html
new file mode 100644
index 0000000..37e77dc
--- /dev/null
+++ b/internal/lkemulator/web/templates/layout.html
@@ -0,0 +1,63 @@
+{{define "layout"}}
+
+
+
+{{.Title}} · lk-emulator
+
+
+
+
+
+{{template "content" .}}
+
+
+
+
+{{end}}
diff --git a/internal/lkemulator/web/templates/new.html b/internal/lkemulator/web/templates/new.html
new file mode 100644
index 0000000..7965f1d
--- /dev/null
+++ b/internal/lkemulator/web/templates/new.html
@@ -0,0 +1,77 @@
+{{define "content"}}
+{{if .Error}}{{.Error}}
{{end}}
+
+
+
Подача заявки M2M
+
Минимальная форма; остальные поля заполняются автоматически по seed-данным (см. docs/fansy-contract/v1/examples/seed-data.sql).
+
+
+{{end}}
diff --git a/internal/lkgateway/README.md b/internal/lkgateway/README.md
new file mode 100644
index 0000000..44ffcfb
--- /dev/null
+++ b/internal/lkgateway/README.md
@@ -0,0 +1,70 @@
+# internal/lkgateway — BFF слой ЛК + admin web UI
+
+Реализует REST-контракт ESIA Finance V1
+(`docs/lk-contract/v1/openapi.yaml`) на стороне Bridge-and-Join-s и
+admin-веб для оператора.
+
+## REST API
+
+- `POST /api/v1/back_office/claims/` — приём заявки от ЛК.
+ Валидирует, собирает `M2MTransferRequest` через
+ `m2mcore.EnrichRequest`, создаёт `m2mcore.Deal`, отправляет в НРД
+ через `m2mcore.NSDSender` (на M2 — mock).
+- `GET /api/v1/back_office/claims/{id}` — карточка заявки.
+- `GET /api/v1/back_office/claims` — список с фильтрами
+ (status/investor_id/created_from/created_to/limit/offset).
+- `PATCH /api/v1/back_office/claims/{id}` — placeholder для callback
+ от внешней системы.
+- `/healthz` — health.
+
+## Admin web UI
+
+- `/admin/` — дашборд: статус системы, счётчики (Total/Confirmed/
+ InProgress/Failed), последние 10 заявок.
+- `/admin/claims` — журнал всех заявок.
+- `/admin/claims/{id}` — детальная карточка: история FSM, ответ НРД
+ (`M2MTransferResponse`), решение принимающей стороны
+ (`M2MTransferDecision`), последний callback.
+- `/admin/status` — детальные проверки: postgres, crypto-service (UDS),
+ nsd-adapter (REST), lk-emulator callback URL.
+
+## Состав пакета
+
+- `server.go` — `Server` обвязка: HTTP mux + сервис + фоновый
+ consumeDecisions (читает из `mock.Sender.Decisions()` и обновляет
+ сделки + шлёт callback в ЛК).
+- `service.go` — бизнес-логика: DTO ↔ доменные сущности m2mcore,
+ оркестрация FSM, отправка callback'ов.
+- `api.go` — REST endpoints.
+- `admin.go` — HTML endpoints с шаблонами в `web/templates/`.
+- `checks.go` — проверки готовности подсистем (postgres, crypto-service,
+ nsd-adapter, callback URL).
+- `seedstore.go` — in-memory `m2mcore.FansyStore` с 5 тестовыми
+ клиентами и счетами депо (соответствует
+ `docs/fansy-contract/v1/examples/seed-data.sql`).
+- `types.go` — DTO под OpenAPI.
+- `http_util.go` — JSON-хелперы.
+
+## Конфигурация (cmd/lk-gateway/main.go, ENV)
+
+| Переменная | По умолчанию | Назначение |
+|---|---|---|
+| `BJ_HTTP_ADDR` | `:8080` | Адрес HTTP |
+| `BJ_M2M_SENDER` | `MC0079200000` | DeponentCode отправителя в M2M Header |
+| `BJ_M2M_RECEIVER` | `MC0010300000` | DeponentCode получателя |
+| `BJ_DSN` | — | PostgreSQL DSN (M2-шаг-3, пока пусто = in-memory) |
+| `BJ_CRYPTO_SOCKET` | `/run/bj/crypto.sock` | UDS для crypto-service |
+| `BJ_NSD_ADAPTER_URL` | — | URL nsd-adapter HTTP (пусто = mock) |
+| `BJ_LK_CALLBACK_URL` | — | URL ЛК для PATCH callback'ов (пусто = эмулятор регистрирует свой) |
+| `BJ_NSD_PROFILE` | `demo (mock NSD)` | Имя профиля (отображается в admin) |
+| `BJ_CRYPTO_PROVIDER` | `stub` | Провайдер криптографии в admin-статусе |
+
+## Что подключается в следующих шагах
+
+- **M2-шаг-3**: pgx-репозиторий → миграция
+ `migrations/m2m-core/001__deals.sql` уже готова.
+- **M3**: реальный `nsd-adapter` вместо mock — выставить
+ `BJ_NSD_ADAPTER_URL` и реализовать в nsd-adapter поллер,
+ отправляющий Decision не через канал mock, а через шину.
+- **M4**: `admin-ui` v2 на React + раздел «Сертификаты КриптоПро»
+ для обновления публичных сертификатов через UI.
diff --git a/internal/lkgateway/admin.go b/internal/lkgateway/admin.go
new file mode 100644
index 0000000..4e35c8f
--- /dev/null
+++ b/internal/lkgateway/admin.go
@@ -0,0 +1,183 @@
+package lkgateway
+
+import (
+ "embed"
+ "fmt"
+ "html/template"
+ "net/http"
+ "path"
+ "strings"
+ "time"
+
+ "git.zetit.ru/zuevav/Bridge-and-Join-s/internal/m2mcore"
+)
+
+//go:embed web/templates/*.html
+var templatesFS embed.FS
+
+// admin содержит по одному *template.Template на каждый view (layout +
+// конкретный content-шаблон). Так html/template не путается с несколькими
+// {{define "content"}} в разных файлах.
+type admin struct {
+ home, claims, claim, status *template.Template
+}
+
+func newAdmin() (*admin, error) {
+ parse := func(content string) (*template.Template, error) {
+ return template.ParseFS(templatesFS,
+ "web/templates/layout.html",
+ "web/templates/"+content)
+ }
+ home, err := parse("admin_home.html")
+ if err != nil {
+ return nil, fmt.Errorf("parse admin_home: %w", err)
+ }
+ claims, err := parse("admin_claims.html")
+ if err != nil {
+ return nil, fmt.Errorf("parse admin_claims: %w", err)
+ }
+ claim, err := parse("admin_claim.html")
+ if err != nil {
+ return nil, fmt.Errorf("parse admin_claim: %w", err)
+ }
+ status, err := parse("admin_status.html")
+ if err != nil {
+ return nil, fmt.Errorf("parse admin_status: %w", err)
+ }
+ return &admin{home: home, claims: claims, claim: claim, status: status}, nil
+}
+
+// page — общий "конверт" данных для всех шаблонов.
+type page struct {
+ Title string
+ Active string
+ Now string
+}
+
+// homeData — данные дашборда.
+type homeData struct {
+ page
+ Status SystemStatus
+ Counts struct {
+ Total int
+ Confirmed int
+ InProgress int
+ Failed int
+ }
+ Recent []ClaimView
+}
+
+// claimsData — данные журнала.
+type claimsData struct {
+ page
+ Items []ClaimView
+}
+
+// claimData — данные карточки.
+type claimData struct {
+ page
+ Claim ClaimView
+}
+
+// statusData — данные страницы статуса.
+type statusData struct {
+ page
+ Checks []Status
+ CheckedAt time.Time
+}
+
+// RegisterAdmin вешает HTML-маршруты /admin/* на mux.
+func RegisterAdmin(mux *http.ServeMux, svc *Service, getOpts func() CheckOptions) error {
+ a, err := newAdmin()
+ if err != nil {
+ return err
+ }
+
+ mux.HandleFunc("/admin/", func(w http.ResponseWriter, r *http.Request) {
+ p := strings.TrimPrefix(r.URL.Path, "/admin/")
+ switch {
+ case p == "" || p == "index" || p == "home":
+ a.renderHome(w, r, svc, getOpts())
+ case p == "claims":
+ a.renderClaims(w, r, svc)
+ case strings.HasPrefix(p, "claims/"):
+ id := strings.TrimPrefix(p, "claims/")
+ a.renderClaim(w, r, svc, id)
+ case p == "status":
+ a.renderStatus(w, r, getOpts())
+ default:
+ http.NotFound(w, r)
+ }
+ })
+ mux.HandleFunc("/admin", func(w http.ResponseWriter, r *http.Request) {
+ http.Redirect(w, r, "/admin/", http.StatusSeeOther)
+ })
+ return nil
+}
+
+func (a *admin) renderHome(w http.ResponseWriter, r *http.Request, svc *Service, opts CheckOptions) {
+ ctx := r.Context()
+ status := CheckAll(ctx, opts)
+ recent, err := svc.ListClaims(ctx, m2mcore.Filter{Limit: 10})
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+ data := homeData{
+ page: nowPage("Дашборд", "home"),
+ Status: status,
+ Recent: recent.Items,
+ }
+ full, err := svc.ListClaims(ctx, m2mcore.Filter{Limit: 200})
+ if err == nil {
+ for _, c := range full.Items {
+ data.Counts.Total++
+ switch c.Status {
+ case string(m2mcore.StateConfirmed), string(m2mcore.StateAwaitingSUB16), string(m2mcore.StateDone):
+ data.Counts.Confirmed++
+ case string(m2mcore.StateRejected), string(m2mcore.StateTimedOut):
+ data.Counts.Failed++
+ default:
+ data.Counts.InProgress++
+ }
+ }
+ }
+ render(w, a.home, data)
+}
+
+func (a *admin) renderClaims(w http.ResponseWriter, r *http.Request, svc *Service) {
+ pageData, err := svc.ListClaims(r.Context(), m2mcore.Filter{Limit: 200})
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+ render(w, a.claims, claimsData{page: nowPage("Заявки", "claims"), Items: pageData.Items})
+}
+
+func (a *admin) renderClaim(w http.ResponseWriter, r *http.Request, svc *Service, id string) {
+ id = path.Base(id)
+ view, err := svc.GetClaim(r.Context(), id)
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusNotFound)
+ return
+ }
+ render(w, a.claim, claimData{page: nowPage("Заявка", "claims"), Claim: view})
+}
+
+func (a *admin) renderStatus(w http.ResponseWriter, r *http.Request, opts CheckOptions) {
+ s := CheckAll(r.Context(), opts)
+ render(w, a.status, statusData{
+ page: nowPage("Статус", "status"), Checks: s.Checks, CheckedAt: s.CheckedAt,
+ })
+}
+
+func render(w http.ResponseWriter, t *template.Template, data any) {
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ if err := t.ExecuteTemplate(w, "layout", data); err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ }
+}
+
+func nowPage(title, active string) page {
+ return page{Title: title, Active: active, Now: time.Now().Format("02.01.2006 15:04:05")}
+}
diff --git a/internal/lkgateway/api.go b/internal/lkgateway/api.go
new file mode 100644
index 0000000..4dd69b0
--- /dev/null
+++ b/internal/lkgateway/api.go
@@ -0,0 +1,102 @@
+package lkgateway
+
+import (
+ "encoding/json"
+ "errors"
+ "net/http"
+ "strconv"
+ "strings"
+
+ "git.zetit.ru/zuevav/Bridge-and-Join-s/internal/m2mcore"
+)
+
+// RegisterAPI вешает REST-маршруты ESIA Finance V1 на mux.
+func RegisterAPI(mux *http.ServeMux, svc *Service) {
+ mux.HandleFunc("/api/v1/back_office/claims/", func(w http.ResponseWriter, r *http.Request) {
+ path := strings.TrimPrefix(r.URL.Path, "/api/v1/back_office/claims/")
+ switch {
+ case path == "" && r.Method == http.MethodPost:
+ handleCreateClaim(w, r, svc)
+ case path != "" && r.Method == http.MethodGet:
+ handleGetClaim(w, r, svc, path)
+ case path != "" && r.Method == http.MethodPatch:
+ // PATCH без id — отдельный обработчик ниже; этот блок для PATCH
+ // с id, который lk-gateway сам себе бы посылал. На практике не
+ // используется (callback идёт в ЛК), но реализуем по контракту.
+ handlePatchClaim(w, r, svc, path)
+ default:
+ writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "Метод не разрешён", r.Method)
+ }
+ })
+
+ mux.HandleFunc("/api/v1/back_office/claims", func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet {
+ writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "Метод не разрешён", r.Method)
+ return
+ }
+ handleListClaims(w, r, svc)
+ })
+}
+
+func handleCreateClaim(w http.ResponseWriter, r *http.Request, svc *Service) {
+ defer r.Body.Close()
+ var in CreateClaimRequest
+ if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
+ writeError(w, http.StatusBadRequest, "invalid_json", "Не смогли разобрать JSON", err.Error())
+ return
+ }
+ out, err := svc.CreateClaim(r.Context(), in)
+ if err != nil {
+ writeError(w, http.StatusUnprocessableEntity, "invalid_claim", "Заявка не прошла валидацию", err.Error())
+ return
+ }
+ writeJSON(w, http.StatusCreated, out)
+}
+
+func handleGetClaim(w http.ResponseWriter, r *http.Request, svc *Service, id string) {
+ view, err := svc.GetClaim(r.Context(), id)
+ if err != nil {
+ if errors.Is(err, m2mcore.ErrNotFound) {
+ writeError(w, http.StatusNotFound, "not_found", "Заявка не найдена", id)
+ return
+ }
+ writeError(w, http.StatusInternalServerError, "internal", "Внутренняя ошибка", err.Error())
+ return
+ }
+ writeJSON(w, http.StatusOK, view)
+}
+
+func handleListClaims(w http.ResponseWriter, r *http.Request, svc *Service) {
+ q := r.URL.Query()
+ filter := m2mcore.Filter{}
+ if s := q.Get("status"); s != "" {
+ st := m2mcore.State(s)
+ filter.State = &st
+ }
+ if inv := q.Get("investor_id"); inv != "" {
+ filter.InvestorID = inv
+ }
+ if l := q.Get("limit"); l != "" {
+ if n, err := strconv.Atoi(l); err == nil && n > 0 && n <= 200 {
+ filter.Limit = n
+ }
+ }
+ if o := q.Get("offset"); o != "" {
+ if n, err := strconv.Atoi(o); err == nil && n >= 0 {
+ filter.Offset = n
+ }
+ }
+ page, err := svc.ListClaims(r.Context(), filter)
+ if err != nil {
+ writeError(w, http.StatusInternalServerError, "internal", "Внутренняя ошибка", err.Error())
+ return
+ }
+ writeJSON(w, http.StatusOK, page)
+}
+
+func handlePatchClaim(w http.ResponseWriter, _ *http.Request, _ *Service, _ string) {
+ // В сценарии M1-M2 PATCH /claims/{id} от внешней системы (как
+ // callback от НРД) не используется — мы сами шлём callback в ЛК.
+ // Но оставляем заглушку с 200, чтобы покрыть контракт OpenAPI.
+ writeJSON(w, http.StatusOK, map[string]bool{"success": true})
+}
diff --git a/internal/lkgateway/checks.go b/internal/lkgateway/checks.go
new file mode 100644
index 0000000..fc8b080
--- /dev/null
+++ b/internal/lkgateway/checks.go
@@ -0,0 +1,163 @@
+package lkgateway
+
+import (
+ "context"
+ "errors"
+ "net"
+ "net/http"
+ "os"
+ "time"
+)
+
+// Status — состояние одной проверяемой подсистемы.
+type Status struct {
+ Name string `json:"name"`
+ OK bool `json:"ok"`
+ Message string `json:"message,omitempty"`
+ Detail string `json:"detail,omitempty"`
+}
+
+// SystemStatus — все проверки.
+type SystemStatus struct {
+ Profile string `json:"profile"`
+ Provider string `json:"crypto_provider"`
+ Checks []Status `json:"checks"`
+ CheckedAt time.Time `json:"checked_at"`
+}
+
+// CheckOptions — что и как проверять.
+type CheckOptions struct {
+ PostgresDSN string // если пусто — режим in-memory, проверки нет
+ CryptoSocket string // путь до UDS crypto-service
+ NSDAdapterURL string // например http://127.0.0.1:8082
+ LKCallbackURL string // куда шлём callback (lk-emulator)
+ Profile string // имя профиля nsdadapter (guest-gost...)
+ CryptoProvider string // BJ_CRYPTO_PROVIDER (stub|cryptopro|...)
+ Timeout time.Duration // таймаут на одну проверку
+}
+
+// CheckAll выполняет все доступные проверки и возвращает SystemStatus.
+func CheckAll(ctx context.Context, o CheckOptions) SystemStatus {
+ if o.Timeout == 0 {
+ o.Timeout = 2 * time.Second
+ }
+ out := SystemStatus{
+ Profile: o.Profile,
+ Provider: o.CryptoProvider,
+ CheckedAt: time.Now().UTC(),
+ }
+
+ out.Checks = append(out.Checks, checkPostgres(ctx, o))
+ out.Checks = append(out.Checks, checkCryptoSocket(o))
+ out.Checks = append(out.Checks, checkNSDAdapter(ctx, o))
+ out.Checks = append(out.Checks, checkLKCallback(ctx, o))
+
+ return out
+}
+
+func checkPostgres(_ context.Context, o CheckOptions) Status {
+ s := Status{Name: "postgres"}
+ if o.PostgresDSN == "" {
+ s.OK = true
+ s.Message = "in-memory (PostgresDSN не задан, репозиторий — m2mcore.MemoryRepository)"
+ return s
+ }
+ // На M2 здесь будет sql.Open + Ping. На текущем шаге — заглушка.
+ s.OK = false
+ s.Message = "PostgreSQL Repository не подключён (требуется pgx, M2-шаг-3)"
+ s.Detail = "DSN: " + o.PostgresDSN
+ return s
+}
+
+func checkCryptoSocket(o CheckOptions) Status {
+ s := Status{Name: "crypto-service (UDS)"}
+ if o.CryptoSocket == "" {
+ s.OK = false
+ s.Message = "BJ_CRYPTO_SOCKET не задан"
+ return s
+ }
+ info, err := os.Stat(o.CryptoSocket)
+ if err != nil {
+ s.OK = false
+ s.Message = "сокет недоступен"
+ s.Detail = err.Error()
+ return s
+ }
+ if info.Mode()&os.ModeSocket == 0 {
+ s.OK = false
+ s.Message = "путь существует, но это не сокет"
+ s.Detail = o.CryptoSocket
+ return s
+ }
+ // Пробуем подключиться.
+ d := net.Dialer{Timeout: o.Timeout}
+ conn, err := d.Dial("unix", o.CryptoSocket)
+ if err != nil {
+ s.OK = false
+ s.Message = "сокет существует, но не отвечает"
+ s.Detail = err.Error()
+ return s
+ }
+ _ = conn.Close()
+ s.OK = true
+ s.Message = "сокет открыт"
+ s.Detail = o.CryptoSocket
+ if o.CryptoProvider == "stub" || o.CryptoProvider == "" {
+ s.Message += ", провайдер stub (реальная криптография не подключена)"
+ } else {
+ s.Message += ", провайдер " + o.CryptoProvider
+ }
+ return s
+}
+
+func checkNSDAdapter(ctx context.Context, o CheckOptions) Status {
+ s := Status{Name: "nsd-adapter (REST к ИШ)"}
+ if o.NSDAdapterURL == "" {
+ s.OK = true
+ s.Message = "BJ_NSD_ADAPTER_URL не задан — используется mock NSDSender"
+ return s
+ }
+ return httpHealth(ctx, o.NSDAdapterURL+"/healthz", o.Timeout, s)
+}
+
+func checkLKCallback(ctx context.Context, o CheckOptions) Status {
+ s := Status{Name: "lk-emulator (callback)"}
+ if o.LKCallbackURL == "" {
+ s.OK = false
+ s.Message = "BJ_LK_CALLBACK_URL не задан — callback'и в ЛК отключены"
+ return s
+ }
+ return httpHealth(ctx, o.LKCallbackURL+"/healthz", o.Timeout, s)
+}
+
+func httpHealth(ctx context.Context, url string, timeout time.Duration, s Status) Status {
+ c := &http.Client{Timeout: timeout}
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
+ if err != nil {
+ s.OK = false
+ s.Message = "не получилось собрать запрос"
+ s.Detail = err.Error()
+ return s
+ }
+ resp, err := c.Do(req)
+ if err != nil {
+ s.OK = false
+ s.Message = "недоступен"
+ s.Detail = err.Error()
+ return s
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != http.StatusOK {
+ s.OK = false
+ s.Message = "HTTP " + http.StatusText(resp.StatusCode)
+ s.Detail = url
+ return s
+ }
+ s.OK = true
+ s.Message = "OK"
+ s.Detail = url
+ return s
+}
+
+// ErrUnknown — общий placeholder.
+var ErrUnknown = errors.New("lkgateway: unknown error")
diff --git a/internal/lkgateway/http_util.go b/internal/lkgateway/http_util.go
new file mode 100644
index 0000000..62069c9
--- /dev/null
+++ b/internal/lkgateway/http_util.go
@@ -0,0 +1,52 @@
+package lkgateway
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+)
+
+// postJSON отправляет JSON через любой метод (POST/PATCH/PUT). Используется
+// для callback'ов в ЛК.
+func postJSON(ctx context.Context, c *http.Client, url, method string, body any) error {
+ raw, err := json.Marshal(body)
+ if err != nil {
+ return err
+ }
+ req, err := http.NewRequestWithContext(ctx, method, url, bytes.NewReader(raw))
+ if err != nil {
+ return err
+ }
+ req.Header.Set("Content-Type", "application/json")
+ resp, err := c.Do(req)
+ if err != nil {
+ return err
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode >= 400 {
+ buf, _ := io.ReadAll(resp.Body)
+ return fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(buf))
+ }
+ return nil
+}
+
+// writeJSON удобный writer JSON-ответа со статусом.
+func writeJSON(w http.ResponseWriter, status int, body any) {
+ w.Header().Set("Content-Type", "application/json; charset=utf-8")
+ w.WriteHeader(status)
+ _ = json.NewEncoder(w).Encode(body)
+}
+
+// writeError формирует ErrorResponse по контракту ESIA Finance.
+func writeError(w http.ResponseWriter, status int, code, title, message string) {
+ writeJSON(w, status, ErrorResponse{
+ Error: true,
+ Status: status,
+ Code: code,
+ Title: title,
+ Meta: &ErrorMeta{Message: message},
+ })
+}
diff --git a/internal/lkgateway/seedstore.go b/internal/lkgateway/seedstore.go
new file mode 100644
index 0000000..df8c750
--- /dev/null
+++ b/internal/lkgateway/seedstore.go
@@ -0,0 +1,114 @@
+package lkgateway
+
+import (
+ "context"
+ "fmt"
+
+ "git.zetit.ru/zuevav/Bridge-and-Join-s/internal/m2m"
+ "git.zetit.ru/zuevav/Bridge-and-Join-s/internal/m2mcore"
+)
+
+// SeedStore — in-memory FansyStore с предзаполненными тестовыми данными
+// (5 клиентов с депо-счетами и портфелями), соответствующими
+// docs/fansy-contract/v1/examples/seed-data.sql. Используется в
+// dev-стенде без реальной БД.
+type SeedStore struct {
+ clients map[string]*m2mcore.Client
+ accounts map[string][]m2mcore.DepoAccount // by client_id
+}
+
+// NewSeedStore собирает SeedStore с фиксированными тестовыми клиентами.
+func NewSeedStore() *SeedStore {
+ s := &SeedStore{
+ clients: make(map[string]*m2mcore.Client),
+ accounts: make(map[string][]m2mcore.DepoAccount),
+ }
+ s.addClient("11111111-1111-1111-1111-111111111111",
+ "Иванов", "Иван", "Иванович",
+ m2m.DocCode21, "4512", "654321")
+ s.addClient("22222222-2222-2222-2222-222222222222",
+ "Петров", "Пётр", "Петрович",
+ m2m.DocCode21, "4513", "654322")
+ s.addClient("33333333-3333-3333-3333-333333333333",
+ "Сидоров", "Сидор", "Сидорович",
+ m2m.DocCode21, "4514", "654323")
+ s.addClient("44444444-4444-4444-4444-444444444444",
+ "Кузнецов", "Сергей", "Михайлович",
+ m2m.DocCode03, "111", "222333")
+ s.addClient("55555555-5555-5555-5555-555555555555",
+ "Соколова", "Анна", "Викторовна",
+ m2m.DocCode21, "4516", "654325")
+
+ s.addAccount("11111111-1111-1111-1111-111111111111",
+ "DP789456", "31MC0021900000F01", "P001", "7702070139")
+ s.addAccount("11111111-1111-1111-1111-111111111111",
+ "AA789451", "33MC0021900000F02", "F002", "7802031669")
+ s.addAccount("22222222-2222-2222-2222-222222222222",
+ "DP100200", "31MC0010000000A01", "A001", "7702070139")
+ s.addAccount("33333333-3333-3333-3333-333333333333",
+ "DP300400", "31MC0030000000B01", "B001", "0702345678")
+ s.addAccount("55555555-5555-5555-5555-555555555555",
+ "DP500600", "31MC0050000000C01", "C001", "0710987654")
+
+ return s
+}
+
+// Clients возвращает копию слайса клиентов (для UI выбора).
+func (s *SeedStore) Clients() []*m2mcore.Client {
+ out := make([]*m2mcore.Client, 0, len(s.clients))
+ for _, c := range s.clients {
+ out = append(out, c)
+ }
+ return out
+}
+
+func (s *SeedStore) addClient(id, last, first, middle string, doc m2m.IdentityDocumentCode, series, number string) {
+ s.clients[id] = &m2mcore.Client{
+ ID: id,
+ LastName: last,
+ FirstName: first,
+ MiddleName: middle,
+ Document: m2mcore.ClientDocument{
+ DocumentType: doc,
+ Series: series,
+ Number: number,
+ },
+ }
+}
+
+func (s *SeedStore) addAccount(clientID, dep, acc, sec, depINN string) {
+ s.accounts[clientID] = append(s.accounts[clientID], m2mcore.DepoAccount{
+ ClientID: clientID,
+ DeponentCode: dep,
+ AccountID: m2m.AccountID(acc),
+ SectionID: sec,
+ DepositoryINN: m2m.OrganizationINN(depINN),
+ })
+}
+
+// GetClientByID — реализация FansyStore.
+func (s *SeedStore) GetClientByID(_ context.Context, id string) (*m2mcore.Client, error) {
+ c, ok := s.clients[id]
+ if !ok {
+ return nil, fmt.Errorf("seedstore: клиент %s не найден", id)
+ }
+ return c, nil
+}
+
+// GetDepoAccounts — реализация FansyStore.
+func (s *SeedStore) GetDepoAccounts(_ context.Context, clientID string, _ m2m.OrganizationINN) ([]m2mcore.DepoAccount, error) {
+ accs := s.accounts[clientID]
+ if len(accs) == 0 {
+ return nil, fmt.Errorf("seedstore: нет счетов у клиента %s", clientID)
+ }
+ return accs, nil
+}
+
+// GetBalances — реализация FansyStore. На M2 возвращает пустой список,
+// потому что баланс проверяется при подаче заявки в самой UI (через демо-кнопку).
+func (s *SeedStore) GetBalances(_ context.Context, _ string, _ []m2m.SecurityCode) ([]m2mcore.SecurityBalance, error) {
+ return nil, nil
+}
+
+// Verify тип SeedStore удовлетворяет m2mcore.FansyStore.
+var _ m2mcore.FansyStore = (*SeedStore)(nil)
diff --git a/internal/lkgateway/server.go b/internal/lkgateway/server.go
new file mode 100644
index 0000000..ee80dd0
--- /dev/null
+++ b/internal/lkgateway/server.go
@@ -0,0 +1,178 @@
+package lkgateway
+
+import (
+ "context"
+ "errors"
+ "log"
+ "net/http"
+ "time"
+
+ "git.zetit.ru/zuevav/Bridge-and-Join-s/internal/m2m"
+ "git.zetit.ru/zuevav/Bridge-and-Join-s/internal/m2mcore"
+ "git.zetit.ru/zuevav/Bridge-and-Join-s/internal/nsdadapter/mock"
+)
+
+// ServerConfig — конфигурация HTTP-сервера lk-gateway.
+type ServerConfig struct {
+ Addr string
+ DefaultSender m2m.DeponentCode
+ DefaultReceiver m2m.DeponentCode
+ CheckOptions func() CheckOptions
+ MockDecisionDelay time.Duration // 0 = дефолт 3 секунды
+}
+
+// Server — обвязка HTTP + сервис + workers.
+type Server struct {
+ cfg ServerConfig
+ svc *Service
+ mock *mock.Sender
+ store *SeedStore
+ mux *http.ServeMux
+ server *http.Server
+}
+
+// NewServer собирает Server с in-memory репозиторием, mock NSDSender,
+// SeedStore и REST + Admin маршрутами.
+func NewServer(cfg ServerConfig) (*Server, error) {
+ store := NewSeedStore()
+ mockCfg := mock.DefaultConfig()
+ mockCfg.NSDSenderCode = "MC0010300000"
+ if cfg.MockDecisionDelay > 0 {
+ mockCfg.DecisionDelay = cfg.MockDecisionDelay
+ }
+ sender := mock.NewSender(mockCfg)
+
+ svc := NewService(Config{
+ Repository: m2mcore.NewMemoryRepository(),
+ Sender: sender,
+ Store: store,
+ Recorder: m2mcore.NewMemoryRecorder(),
+ DefaultSender: cfg.DefaultSender,
+ DefaultReceiver: cfg.DefaultReceiver,
+ })
+
+ mux := http.NewServeMux()
+ RegisterAPI(mux, svc)
+ if cfg.CheckOptions == nil {
+ cfg.CheckOptions = func() CheckOptions {
+ return CheckOptions{Profile: "demo (mock NSD)", CryptoProvider: "stub"}
+ }
+ }
+ if err := RegisterAdmin(mux, svc, cfg.CheckOptions); err != nil {
+ return nil, err
+ }
+ registerHealth(mux)
+ registerSetCallback(mux, svc)
+ registerSeedListing(mux, store)
+
+ return &Server{
+ cfg: cfg,
+ svc: svc,
+ mock: sender,
+ store: store,
+ mux: mux,
+ server: &http.Server{
+ Addr: cfg.Addr,
+ Handler: mux,
+ ReadHeaderTimeout: 5 * time.Second,
+ },
+ }, nil
+}
+
+// SetCallbackURL обновляет адрес, куда отправлять PATCH callback'и в ЛК.
+func (s *Server) SetCallbackURL(url string) { s.svc.callbackURL = url }
+
+// Service возвращает Service для тестов.
+func (s *Server) Service() *Service { return s.svc }
+
+// Mock возвращает mock-сендер.
+func (s *Server) Mock() *mock.Sender { return s.mock }
+
+// Store возвращает SeedStore.
+func (s *Server) Store() *SeedStore { return s.store }
+
+// Mux возвращает обработчик (для httptest).
+func (s *Server) Mux() http.Handler { return s.mux }
+
+// Run поднимает HTTP-сервер и фоновый Decisions-consumer.
+// Блокируется до ctx.Done().
+func (s *Server) Run(ctx context.Context) error {
+ go s.consumeDecisions(ctx)
+
+ errCh := make(chan error, 1)
+ go func() {
+ log.Printf("lk-gateway: listen %s", s.cfg.Addr)
+ errCh <- s.server.ListenAndServe()
+ }()
+
+ select {
+ case <-ctx.Done():
+ shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer cancel()
+ _ = s.server.Shutdown(shutdownCtx)
+ return nil
+ case err := <-errCh:
+ if errors.Is(err, http.ErrServerClosed) {
+ return nil
+ }
+ return err
+ }
+}
+
+// consumeDecisions слушает Decisions от mock и обновляет соответствующие сделки.
+func (s *Server) consumeDecisions(ctx context.Context) {
+ for {
+ select {
+ case <-ctx.Done():
+ return
+ case d := <-s.mock.Decisions():
+ if d == nil {
+ continue
+ }
+ if err := s.svc.ApplyDecision(ctx, d); err != nil {
+ log.Printf("lk-gateway: ApplyDecision GUID=%s: %v", d.Header.GUID, err)
+ } else {
+ log.Printf("lk-gateway: Decision применён GUID=%s, callback в %s", d.Header.GUID, s.svc.callbackURL)
+ }
+ }
+ }
+}
+
+func registerHealth(mux *http.ServeMux) {
+ mux.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) {
+ w.WriteHeader(http.StatusOK)
+ _, _ = w.Write([]byte("ok\n"))
+ })
+}
+
+// registerSetCallback — служебный POST /admin/api/callback-url для
+// эмулятора ЛК, чтобы сообщить gateway свой URL.
+func registerSetCallback(mux *http.ServeMux, svc *Service) {
+ mux.HandleFunc("/admin/api/callback-url", func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodPost {
+ http.Error(w, "method", http.StatusMethodNotAllowed)
+ return
+ }
+ url := r.URL.Query().Get("url")
+ if url == "" {
+ http.Error(w, "url required", http.StatusBadRequest)
+ return
+ }
+ svc.callbackURL = url
+ w.WriteHeader(http.StatusOK)
+ _, _ = w.Write([]byte("ok"))
+ })
+}
+
+func registerSeedListing(mux *http.ServeMux, store *SeedStore) {
+ mux.HandleFunc("/admin/api/clients", func(w http.ResponseWriter, _ *http.Request) {
+ type c struct {
+ ID, LastName, FirstName, MiddleName string
+ }
+ out := make([]c, 0)
+ for _, cl := range store.Clients() {
+ out = append(out, c{ID: cl.ID, LastName: cl.LastName, FirstName: cl.FirstName, MiddleName: cl.MiddleName})
+ }
+ writeJSON(w, http.StatusOK, out)
+ })
+}
diff --git a/internal/lkgateway/server_test.go b/internal/lkgateway/server_test.go
new file mode 100644
index 0000000..912b0d4
--- /dev/null
+++ b/internal/lkgateway/server_test.go
@@ -0,0 +1,255 @@
+package lkgateway_test
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+ "time"
+
+ "git.zetit.ru/zuevav/Bridge-and-Join-s/internal/lkgateway"
+)
+
+func newServer(t *testing.T) *lkgateway.Server {
+ t.Helper()
+ srv, err := lkgateway.NewServer(lkgateway.ServerConfig{
+ Addr: ":0",
+ DefaultSender: "MC0079200000",
+ DefaultReceiver: "MC0010300000",
+ MockDecisionDelay: 50 * time.Millisecond,
+ CheckOptions: func() lkgateway.CheckOptions {
+ return lkgateway.CheckOptions{Profile: "test", CryptoProvider: "stub"}
+ },
+ })
+ if err != nil {
+ t.Fatalf("NewServer: %v", err)
+ }
+ return srv
+}
+
+func validBody() string {
+ return `{
+ "investor": {
+ "id": "11111111-1111-1111-1111-111111111111",
+ "last_name": "Иванов", "first_name": "Иван", "middle_name": "Иванович",
+ "document": {"document_type": "21", "series": "4512", "number": "654321"}
+ },
+ "transferring_depository_inn": "0702345678",
+ "receiving_depository_inn": "0710987654",
+ "cost_info": {"no": {}},
+ "securities": [{
+ "security_code": "MM0766162534",
+ "security_details": {"isin": "RU0007661625"},
+ "quantity": {"whole": 1500},
+ "settlement_accounts": [{
+ "settlement_requisites_inn": "7702070139",
+ "settlement_location": {
+ "deponent_code": "DP789456", "account_id": "31MC0021900000F01", "section_id": "P001"
+ }
+ }]
+ }],
+ "signed_document": "dGVzdA==",
+ "signature_format": "XMLDSig-GOST"
+}`
+}
+
+func TestCreateAndGetClaim(t *testing.T) {
+ srv := newServer(t)
+ mux := srv.Mux()
+
+ w := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodPost, "/api/v1/back_office/claims/", strings.NewReader(validBody()))
+ req.Header.Set("Content-Type", "application/json")
+ mux.ServeHTTP(w, req)
+
+ if w.Code != http.StatusCreated {
+ t.Fatalf("POST claims: code=%d body=%s", w.Code, w.Body.String())
+ }
+ var created lkgateway.CreateClaimResponse
+ if err := json.Unmarshal(w.Body.Bytes(), &created); err != nil {
+ t.Fatalf("decode: %v body=%s", err, w.Body.String())
+ }
+ if created.ID == "" || !created.Success {
+ t.Errorf("unexpected create response: %+v", created)
+ }
+
+ // GET
+ w2 := httptest.NewRecorder()
+ req2 := httptest.NewRequest(http.MethodGet, "/api/v1/back_office/claims/"+created.ID, nil)
+ mux.ServeHTTP(w2, req2)
+ if w2.Code != http.StatusOK {
+ t.Fatalf("GET claim: code=%d body=%s", w2.Code, w2.Body.String())
+ }
+ var view lkgateway.ClaimView
+ if err := json.Unmarshal(w2.Body.Bytes(), &view); err != nil {
+ t.Fatal(err)
+ }
+ if view.ID != created.ID {
+ t.Errorf("view.ID = %s, ожидалось %s", view.ID, created.ID)
+ }
+}
+
+func TestAdminHome(t *testing.T) {
+ srv := newServer(t)
+ w := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodGet, "/admin/", nil)
+ srv.Mux().ServeHTTP(w, req)
+ if w.Code != http.StatusOK {
+ t.Fatalf("admin home code=%d", w.Code)
+ }
+ body := w.Body.String()
+ if !strings.Contains(body, "lk-gateway") {
+ t.Errorf("в дашборде нет заголовка lk-gateway")
+ }
+ if !strings.Contains(body, "Состояние системы") {
+ t.Errorf("в дашборде нет блока статуса")
+ }
+}
+
+func TestAdminStatus(t *testing.T) {
+ srv := newServer(t)
+ w := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodGet, "/admin/status", nil)
+ srv.Mux().ServeHTTP(w, req)
+ if w.Code != http.StatusOK {
+ t.Fatalf("status code=%d", w.Code)
+ }
+ if !strings.Contains(w.Body.String(), "postgres") {
+ t.Errorf("в статусе нет проверки postgres")
+ }
+}
+
+func TestEndToEndFlowWithMock(t *testing.T) {
+ srv := newServer(t)
+ // Уменьшим задержку mock для быстрого e2e.
+ // Не достаём её напрямую — пересоздадим Server со встроенными настройками
+ // и проверим только что после Send статус становится submitted_to_nsd → awaiting_decision.
+ ctx, cancel := context.WithCancel(context.Background())
+ defer cancel()
+ _ = ctx
+
+ w := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodPost, "/api/v1/back_office/claims/", strings.NewReader(validBody()))
+ req.Header.Set("Content-Type", "application/json")
+ srv.Mux().ServeHTTP(w, req)
+ if w.Code != http.StatusCreated {
+ t.Fatalf("POST claims: code=%d body=%s", w.Code, w.Body.String())
+ }
+ var created lkgateway.CreateClaimResponse
+ _ = json.Unmarshal(w.Body.Bytes(), &created)
+
+ if created.Status != "awaiting_decision" {
+ t.Errorf("после Submit ожидалось awaiting_decision, получено %s", created.Status)
+ }
+}
+
+func TestCallbackURLSetter(t *testing.T) {
+ srv := newServer(t)
+ w := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodPost, "/admin/api/callback-url?url=http://x.example/", nil)
+ srv.Mux().ServeHTTP(w, req)
+ if w.Code != http.StatusOK {
+ t.Errorf("set callback url: %d", w.Code)
+ }
+}
+
+func TestListClaimsEmpty(t *testing.T) {
+ srv := newServer(t)
+ w := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodGet, "/api/v1/back_office/claims", nil)
+ srv.Mux().ServeHTTP(w, req)
+ if w.Code != http.StatusOK {
+ t.Errorf("list claims empty: %d", w.Code)
+ }
+ var page lkgateway.ClaimsPage
+ _ = json.Unmarshal(w.Body.Bytes(), &page)
+ if len(page.Items) != 0 {
+ t.Errorf("ожидалась пустая страница, получено %d", len(page.Items))
+ }
+}
+
+func TestInvalidJSON(t *testing.T) {
+ srv := newServer(t)
+ w := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodPost, "/api/v1/back_office/claims/", bytes.NewReader([]byte("not json")))
+ srv.Mux().ServeHTTP(w, req)
+ if w.Code != http.StatusBadRequest {
+ t.Errorf("ожидался 400, получено %d", w.Code)
+ }
+}
+
+func TestSeedClientsEndpoint(t *testing.T) {
+ srv := newServer(t)
+ w := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodGet, "/admin/api/clients", nil)
+ srv.Mux().ServeHTTP(w, req)
+ if w.Code != http.StatusOK {
+ t.Errorf("clients: %d", w.Code)
+ }
+ var clients []map[string]any
+ _ = json.Unmarshal(w.Body.Bytes(), &clients)
+ if len(clients) < 5 {
+ t.Errorf("ожидалось 5+ клиентов в seed, получено %d", len(clients))
+ }
+}
+
+func TestE2EApplyDecisionFiresCallback(t *testing.T) {
+ // Поднимаем gateway in-process + http-эмулятор как callback-приёмник.
+ // Дальше: POST заявки → ждём Decision из mock-канала → вручную дёргаем
+ // ApplyDecision → проверяем что emulator получил callback.
+ gw := newServer(t)
+
+ receivedCallback := make(chan map[string]any, 1)
+ emulator := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method == http.MethodPatch && strings.HasPrefix(r.URL.Path, "/api/v1/back_office/claims/") {
+ var payload map[string]any
+ _ = json.NewDecoder(r.Body).Decode(&payload)
+ select {
+ case receivedCallback <- payload:
+ default:
+ }
+ w.WriteHeader(http.StatusOK)
+ return
+ }
+ w.WriteHeader(http.StatusOK)
+ }))
+ defer emulator.Close()
+ gw.SetCallbackURL(emulator.URL)
+
+ // Подаём заявку через mux (без отдельного httptest.NewServer для gateway).
+ w := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodPost, "/api/v1/back_office/claims/", strings.NewReader(validBody()))
+ req.Header.Set("Content-Type", "application/json")
+ gw.Mux().ServeHTTP(w, req)
+ if w.Code != http.StatusCreated {
+ t.Fatalf("POST claims: %d", w.Code)
+ }
+
+ // Mock эмитит Decision через MockDecisionDelay (50мс). Дождёмся его и
+ // прокинем в ApplyDecision — этого делает фоновый воркер, который в
+ // этом тесте не запущен (Run не вызывается).
+ ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
+ defer cancel()
+
+ select {
+ case d := <-gw.Mock().Decisions():
+ if err := gw.Service().ApplyDecision(ctx, d); err != nil {
+ t.Fatalf("ApplyDecision: %v", err)
+ }
+ case <-ctx.Done():
+ t.Fatal("Decision из mock не пришёл")
+ }
+
+ select {
+ case cb := <-receivedCallback:
+ status, _ := cb["new_status"].(string)
+ if status != "confirmed" {
+ t.Errorf("ожидался callback со статусом confirmed, получено %s", status)
+ }
+ case <-time.After(2 * time.Second):
+ t.Fatal("callback в эмулятор не пришёл")
+ }
+}
diff --git a/internal/lkgateway/service.go b/internal/lkgateway/service.go
new file mode 100644
index 0000000..6e5d4a6
--- /dev/null
+++ b/internal/lkgateway/service.go
@@ -0,0 +1,386 @@
+package lkgateway
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "log"
+ "net/http"
+ "sort"
+ "sync"
+ "time"
+
+ "git.zetit.ru/zuevav/Bridge-and-Join-s/internal/m2m"
+ "git.zetit.ru/zuevav/Bridge-and-Join-s/internal/m2mcore"
+)
+
+// Service — бизнес-логика lk-gateway: преобразует DTO в доменные сущности
+// m2mcore, оркестрирует FSM сделки, эмитит callback'и в ЛК.
+type Service struct {
+ repo m2mcore.Repository
+ sender m2mcore.NSDSender
+ store m2mcore.FansyStore
+ recorder *m2mcore.MemoryRecorder
+ defaultSender m2m.DeponentCode
+ defaultReceiver m2m.DeponentCode
+ callbackURL string
+ httpClient *http.Client
+
+ mu sync.RWMutex
+ claimToID map[string]string // claim public ID -> internal deal ID
+}
+
+// Config — параметры сервиса.
+type Config struct {
+ Repository m2mcore.Repository
+ Sender m2mcore.NSDSender
+ Store m2mcore.FansyStore
+ Recorder *m2mcore.MemoryRecorder
+ DefaultSender m2m.DeponentCode
+ DefaultReceiver m2m.DeponentCode
+ CallbackURL string
+}
+
+// NewService собирает сервис.
+func NewService(cfg Config) *Service {
+ if cfg.Recorder == nil {
+ cfg.Recorder = m2mcore.NewMemoryRecorder()
+ }
+ return &Service{
+ repo: cfg.Repository,
+ sender: cfg.Sender,
+ store: cfg.Store,
+ recorder: cfg.Recorder,
+ defaultSender: cfg.DefaultSender,
+ defaultReceiver: cfg.DefaultReceiver,
+ callbackURL: cfg.CallbackURL,
+ httpClient: &http.Client{Timeout: 5 * time.Second},
+ claimToID: make(map[string]string),
+ }
+}
+
+// CreateClaim принимает DTO заявки, формирует M2MTransferRequest,
+// создаёт сделку и отправляет в НРД.
+func (s *Service) CreateClaim(ctx context.Context, in CreateClaimRequest) (CreateClaimResponse, error) {
+ domainClaim, err := dtoToClaim(in)
+ if err != nil {
+ return CreateClaimResponse{}, fmt.Errorf("lkgateway: dtoToClaim: %w", err)
+ }
+
+ req, err := m2mcore.EnrichRequest(ctx, s.store, domainClaim, m2mcore.SenderReceiver{
+ SenderCode: s.defaultSender,
+ ReceiverCode: s.defaultReceiver,
+ })
+ if err != nil {
+ return CreateClaimResponse{}, fmt.Errorf("lkgateway: EnrichRequest: %w", err)
+ }
+
+ deal, err := m2mcore.NewDeal(req.Header.GUID, in.Investor.ID, []byte(in.SignedDocument))
+ if err != nil {
+ return CreateClaimResponse{}, fmt.Errorf("lkgateway: NewDeal: %w", err)
+ }
+ saved, err := s.repo.Create(ctx, deal)
+ if err != nil {
+ return CreateClaimResponse{}, fmt.Errorf("lkgateway: repo.Create: %w", err)
+ }
+
+ if err := saved.Validate(ctx, req); err != nil {
+ return CreateClaimResponse{}, fmt.Errorf("lkgateway: deal.Validate: %w", err)
+ }
+
+ resp, err := s.sender.Send(ctx, req)
+ if err != nil {
+ return CreateClaimResponse{}, fmt.Errorf("lkgateway: sender.Send: %w", err)
+ }
+ saved.Response = resp
+
+ if err := saved.Submit(ctx); err != nil {
+ return CreateClaimResponse{}, fmt.Errorf("lkgateway: deal.Submit: %w", err)
+ }
+ if err := s.repo.Update(ctx, saved); err != nil {
+ return CreateClaimResponse{}, fmt.Errorf("lkgateway: repo.Update: %w", err)
+ }
+ s.recorder.IncDeal(saved.State)
+ s.mu.Lock()
+ s.claimToID[saved.ID] = saved.ID
+ s.mu.Unlock()
+
+ return CreateClaimResponse{
+ ID: saved.ID,
+ Status: string(saved.State),
+ CreatedAt: saved.CreatedAt,
+ Success: true,
+ }, nil
+}
+
+// GetClaim возвращает полную карточку заявки.
+func (s *Service) GetClaim(ctx context.Context, id string) (ClaimView, error) {
+ deal, err := s.repo.GetByID(ctx, id)
+ if err != nil {
+ return ClaimView{}, err
+ }
+ return dealToView(deal), nil
+}
+
+// ListClaims возвращает страницу заявок.
+func (s *Service) ListClaims(ctx context.Context, filter m2mcore.Filter) (ClaimsPage, error) {
+ if filter.Limit == 0 {
+ filter.Limit = 50
+ }
+ deals, err := s.repo.List(ctx, filter)
+ if err != nil {
+ return ClaimsPage{}, err
+ }
+ sort.Slice(deals, func(i, j int) bool { return deals[i].CreatedAt.After(deals[j].CreatedAt) })
+ items := make([]ClaimView, 0, len(deals))
+ for _, d := range deals {
+ items = append(items, dealToView(d))
+ }
+ return ClaimsPage{Items: items, Total: len(items), Limit: filter.Limit, Offset: filter.Offset}, nil
+}
+
+// ApplyDecision принимает Decision (из mock-NSDSender или реального адаптера),
+// обновляет соответствующую сделку и шлёт callback в ЛК.
+func (s *Service) ApplyDecision(ctx context.Context, decision *m2m.M2MTransferDecision) error {
+ if decision == nil {
+ return errors.New("lkgateway: ApplyDecision: decision=nil")
+ }
+ deal, err := s.repo.GetByGUID(ctx, decision.Header.GUID)
+ if err != nil {
+ return fmt.Errorf("lkgateway: GetByGUID: %w", err)
+ }
+ if err := deal.ReceiveDecision(ctx, decision); err != nil {
+ return fmt.Errorf("lkgateway: ReceiveDecision: %w", err)
+ }
+ if err := s.repo.Update(ctx, deal); err != nil {
+ return fmt.Errorf("lkgateway: repo.Update: %w", err)
+ }
+ s.recorder.IncDeal(deal.State)
+
+ if s.callbackURL != "" {
+ s.sendCallback(ctx, deal)
+ }
+ return nil
+}
+
+// sendCallback отправляет PATCH в ЛК с обновлением статуса.
+func (s *Service) sendCallback(ctx context.Context, deal *m2mcore.Deal) {
+ cb := callbackForDeal(deal)
+ if err := postJSON(ctx, s.httpClient, s.callbackURL+"/api/v1/back_office/claims/"+deal.ID, "PATCH", cb); err != nil {
+ log.Printf("lkgateway: callback в ЛК упал: %v", err)
+ }
+}
+
+// Recorder возвращает экспонируемый Recorder для admin-страницы.
+func (s *Service) Recorder() *m2mcore.MemoryRecorder { return s.recorder }
+
+// Repo возвращает Repository (для админских проверок).
+func (s *Service) Repo() m2mcore.Repository { return s.repo }
+
+// Внутренние преобразования и хелперы.
+
+func dtoToClaim(in CreateClaimRequest) (m2mcore.ClaimInput, error) {
+ out := m2mcore.ClaimInput{
+ InvestorClientID: in.Investor.ID,
+ TransferringDepositoryINN: m2m.OrganizationINN(in.TransferringDepositoryINN),
+ ReceivingDepositoryINN: m2m.OrganizationINN(in.ReceivingDepositoryINN),
+ }
+ // CostInfo
+ if in.CostInfo.Yes != nil {
+ out.CostInfo = m2m.CostInfo{Yes: &m2m.CostInfoYes{Code: m2m.DeponentCode(in.CostInfo.Yes.Code)}}
+ } else {
+ out.CostInfo = m2m.CostInfo{No: &m2m.CostInfoNo{}}
+ }
+ // IIA
+ if in.IIAAgreement != nil {
+ out.IIAAgreement = &m2m.IIAAgreementDetails{
+ AgreementType: m2m.IIAContractType(in.IIAAgreement.AgreementType),
+ AgreementNumber: in.IIAAgreement.AgreementNumber,
+ AgreementDate: in.IIAAgreement.AgreementDate,
+ BrokerINN: m2m.OrganizationINN(in.IIAAgreement.BrokerINN),
+ }
+ }
+ // Securities
+ for _, sec := range in.Securities {
+ ds, err := dtoSecurityDetails(sec.SecurityDetails)
+ if err != nil {
+ return m2mcore.ClaimInput{}, err
+ }
+ q, err := dtoQuantity(sec.Quantity)
+ if err != nil {
+ return m2mcore.ClaimInput{}, err
+ }
+ out.Securities = append(out.Securities, m2mcore.ClaimSecurityInput{
+ SecurityCode: m2m.SecurityCode(sec.SecurityCode),
+ Details: ds,
+ Quantity: q,
+ })
+ }
+ return out, nil
+}
+
+func dtoSecurityDetails(in SecurityDetails) (m2m.SecurityDetails, error) {
+ if in.ISIN != "" {
+ isin := m2m.ISIN(in.ISIN)
+ return m2m.SecurityDetails{ISIN: &isin}, nil
+ }
+ if in.SecurityInfo != nil {
+ si := &m2m.SecurityDescription{
+ SecurityClassification: m2m.SecurityClassification(in.SecurityInfo.Classification),
+ SecurityCategory: m2m.SecurityCategory(in.SecurityInfo.Category),
+ SecurityType: in.SecurityInfo.SecurityType,
+ SecuritySeries: in.SecurityInfo.SecuritySeries,
+ }
+ if in.SecurityInfo.IdentificationDetails.RegNumber != "" {
+ rn := in.SecurityInfo.IdentificationDetails.RegNumber
+ si.IdentificationDetails = m2m.IdentificationDetails{RegNumber: &rn}
+ }
+ if in.SecurityInfo.IdentificationDetails.FundShares != nil {
+ si.IdentificationDetails = m2m.IdentificationDetails{
+ FundShares: &m2m.FundShares{
+ RegNumber: in.SecurityInfo.IdentificationDetails.FundShares.RegNumber,
+ Class: in.SecurityInfo.IdentificationDetails.FundShares.Class,
+ },
+ }
+ }
+ return m2m.SecurityDetails{SecurityInfo: si}, nil
+ }
+ return m2m.SecurityDetails{}, errors.New("lkgateway: SecurityDetails — задайте isin или security_info")
+}
+
+func dtoQuantity(in Quantity) (m2m.Quantity, error) {
+ if in.Whole > 0 {
+ w := in.Whole
+ return m2m.Quantity{Whole: &w}, nil
+ }
+ if in.Fractional != "" {
+ f := m2m.Decimal16(in.Fractional)
+ return m2m.Quantity{Fractional: &f}, nil
+ }
+ return m2m.Quantity{}, errors.New("lkgateway: Quantity — задайте whole или fractional")
+}
+
+func dealToView(d *m2mcore.Deal) ClaimView {
+ out := ClaimView{
+ ID: d.ID,
+ Status: string(d.State),
+ CreatedAt: d.CreatedAt,
+ UpdatedAt: d.UpdatedAt,
+ M2MGUID: d.GUID,
+ }
+ for _, st := range d.Stages {
+ out.Stages = append(out.Stages, StageView{
+ State: string(st.State), EnteredAt: st.EnteredAt, LeftAt: st.LeftAt, Reason: st.Reason,
+ })
+ }
+ if d.Request != nil {
+ out.TransferringDepositoryINN = string(d.Request.Data.TransferringDepository.INN)
+ out.ReceivingDepositoryINN = string(d.Request.Data.ReceivingDepository.INN)
+ ii := d.Request.Data.InvestorInformation
+ out.Investor = Investor{
+ LastName: ii.LastName, FirstName: ii.FirstName, MiddleName: ii.MiddleName,
+ Document: Document{
+ DocumentType: string(ii.IdentityDocument.DocumentType),
+ Number: string(ii.IdentityDocument.DocumentNumber),
+ },
+ }
+ if ii.IdentityDocument.DocumentSeries != nil {
+ out.Investor.Document.Series = string(*ii.IdentityDocument.DocumentSeries)
+ }
+ if d.Request.Header.CostInfo.Yes != nil {
+ out.CostInfo = CostInfo{Yes: &CostInfoYes{Code: string(d.Request.Header.CostInfo.Yes.Code)}}
+ } else if d.Request.Header.CostInfo.No != nil {
+ empty := struct{}{}
+ out.CostInfo = CostInfo{No: &empty}
+ }
+ if d.Request.Header.IIAAgreementDetails != nil {
+ out.IIAAgreement = &IIAAgreement{
+ AgreementType: string(d.Request.Header.IIAAgreementDetails.AgreementType),
+ AgreementNumber: d.Request.Header.IIAAgreementDetails.AgreementNumber,
+ AgreementDate: d.Request.Header.IIAAgreementDetails.AgreementDate,
+ BrokerINN: string(d.Request.Header.IIAAgreementDetails.BrokerINN),
+ }
+ }
+ }
+ if d.Response != nil {
+ out.M2MResponse = responseToView(d.Response)
+ }
+ if d.Decision != nil {
+ out.M2MDecision = decisionToView(d.Decision)
+ }
+ if d.State != m2mcore.StateDraft {
+ cb := callbackForDeal(d)
+ out.LastCallback = &cb
+ }
+ return out
+}
+
+func responseToView(r *m2m.M2MTransferResponse) *NSDResponseView {
+ v := &NSDResponseView{
+ GUID: string(r.GUID),
+ StatusCode: string(r.StatusCode),
+ }
+ for _, e := range r.Responses {
+ ent := NSDResponseEntry{Code: e.Code, Text: e.Text}
+ if e.ReferenceID != nil {
+ ent.ReferenceID = string(*e.ReferenceID)
+ }
+ v.Responses = append(v.Responses, ent)
+ }
+ return v
+}
+
+func decisionToView(d *m2m.M2MTransferDecision) *NSDDecisionView {
+ v := &NSDDecisionView{GUID: string(d.Header.GUID)}
+ for _, sec := range d.Data.Securities {
+ entry := NSDDecisionSecurity{ReferenceID: string(sec.ReferenceID)}
+ if sec.TransferDecision.Confirmation != nil {
+ entry.Outcome = "confirmed"
+ } else if sec.TransferDecision.Rejection != nil {
+ entry.Outcome = "rejected"
+ entry.RejectCodes = sec.TransferDecision.Rejection.Codes
+ }
+ v.Securities = append(v.Securities, entry)
+ }
+ return v
+}
+
+func callbackForDeal(d *m2mcore.Deal) StatusCallback {
+ cb := StatusCallback{
+ ClaimID: d.ID,
+ NewStatus: string(d.State),
+ UpdatedAt: d.UpdatedAt,
+ }
+ if d.Decision != nil {
+ cb.NSDResponse = nsdResponseFromDecision(d.Decision)
+ if d.State == m2mcore.StateRejected {
+ for _, sec := range d.Decision.Data.Securities {
+ if sec.TransferDecision.Rejection != nil && len(sec.TransferDecision.Rejection.Codes) > 0 {
+ cb.ReasonCode = sec.TransferDecision.Rejection.Codes[0]
+ cb.ReasonText = "Отказ принимающей стороны (mock)"
+ break
+ }
+ }
+ }
+ } else if d.Response != nil {
+ cb.NSDResponse = responseToView(d.Response)
+ }
+ return cb
+}
+
+func nsdResponseFromDecision(d *m2m.M2MTransferDecision) *NSDResponseView {
+ v := &NSDResponseView{GUID: string(d.Header.GUID), StatusCode: string(m2m.StatusInfo)}
+ for _, sec := range d.Data.Securities {
+ ref := string(sec.ReferenceID)
+ ent := NSDResponseEntry{ReferenceID: ref}
+ if sec.TransferDecision.Confirmation != nil {
+ ent.Code = "01"
+ ent.Text = "Подтверждение принимающей стороны."
+ } else if sec.TransferDecision.Rejection != nil {
+ ent.Code = "07"
+ ent.Text = "Отказ принимающей стороны."
+ }
+ v.Responses = append(v.Responses, ent)
+ }
+ return v
+}
diff --git a/internal/lkgateway/types.go b/internal/lkgateway/types.go
new file mode 100644
index 0000000..272623e
--- /dev/null
+++ b/internal/lkgateway/types.go
@@ -0,0 +1,211 @@
+// Package lkgateway реализует REST API контракта ESIA Finance V1
+// (docs/lk-contract/v1/openapi.yaml) и admin web-интерфейс.
+package lkgateway
+
+import (
+ "time"
+
+ "git.zetit.ru/zuevav/Bridge-and-Join-s/internal/m2m"
+)
+
+// CreateClaimRequest — DTO входа POST /api/v1/back_office/claims/.
+type CreateClaimRequest struct {
+ Investor Investor `json:"investor"`
+ TransferringDepositoryINN string `json:"transferring_depository_inn"`
+ ReceivingDepositoryINN string `json:"receiving_depository_inn"`
+ CostInfo CostInfo `json:"cost_info"`
+ IIAAgreement *IIAAgreement `json:"iia_agreement,omitempty"`
+ Securities []ClaimSec `json:"securities"`
+ SignedDocument string `json:"signed_document"`
+ SignatureFormat string `json:"signature_format"`
+}
+
+// Investor — анкета.
+type Investor struct {
+ ID string `json:"id,omitempty"`
+ LastName string `json:"last_name"`
+ FirstName string `json:"first_name"`
+ MiddleName string `json:"middle_name,omitempty"`
+ Document Document `json:"document"`
+}
+
+// Document — удостоверение личности.
+type Document struct {
+ DocumentType string `json:"document_type"`
+ Series string `json:"series,omitempty"`
+ Number string `json:"number"`
+}
+
+// CostInfo — choice yes|no.
+type CostInfo struct {
+ Yes *CostInfoYes `json:"yes,omitempty"`
+ No *struct{} `json:"no,omitempty"`
+}
+
+// CostInfoYes — учёт ведётся, с кодом депонента-источника.
+type CostInfoYes struct {
+ Code string `json:"code"`
+}
+
+// IIAAgreement — реквизиты договора ИИС.
+type IIAAgreement struct {
+ AgreementType string `json:"agreement_type"`
+ AgreementNumber string `json:"agreement_number"`
+ AgreementDate string `json:"agreement_date"`
+ BrokerINN string `json:"broker_inn"`
+}
+
+// ClaimSec — одна ЦБ в заявке.
+type ClaimSec struct {
+ SecurityCode string `json:"security_code"`
+ SecurityDetails SecurityDetails `json:"security_details"`
+ Quantity Quantity `json:"quantity"`
+ SettlementAccounts []SettlementAccount `json:"settlement_accounts"`
+}
+
+// SecurityDetails — choice isin|security_info.
+type SecurityDetails struct {
+ ISIN string `json:"isin,omitempty"`
+ SecurityInfo *SecurityInfo `json:"security_info,omitempty"`
+}
+
+// SecurityInfo — описание ЦБ без ISIN.
+type SecurityInfo struct {
+ Classification string `json:"classification"`
+ Category string `json:"category"`
+ SecurityType string `json:"security_type,omitempty"`
+ SecuritySeries string `json:"security_series,omitempty"`
+ IdentificationDetails IdentificationDetails `json:"identification_details"`
+}
+
+// IdentificationDetails — choice reg_number|fund_shares.
+type IdentificationDetails struct {
+ RegNumber string `json:"reg_number,omitempty"`
+ FundShares *FundShares `json:"fund_shares,omitempty"`
+}
+
+// FundShares — ПИФ.
+type FundShares struct {
+ RegNumber string `json:"reg_number"`
+ Class string `json:"class,omitempty"`
+}
+
+// Quantity — choice whole|fractional.
+type Quantity struct {
+ Whole uint64 `json:"whole,omitempty"`
+ Fractional string `json:"fractional,omitempty"`
+}
+
+// SettlementAccount — реквизиты счёта.
+type SettlementAccount struct {
+ SettlementRequisitesINN string `json:"settlement_requisites_inn"`
+ SettlementLocation SettlementLocation `json:"settlement_location"`
+}
+
+// SettlementLocation — место хранения.
+type SettlementLocation struct {
+ DeponentCode string `json:"deponent_code"`
+ AccountID string `json:"account_id"`
+ SectionID string `json:"section_id"`
+}
+
+// CreateClaimResponse — DTO ответа POST.
+type CreateClaimResponse struct {
+ ID string `json:"id"`
+ Status string `json:"status"`
+ CreatedAt time.Time `json:"created_at"`
+ Success bool `json:"success"`
+}
+
+// ClaimView — полная заявка с историей (GET и admin).
+type ClaimView struct {
+ ID string `json:"id"`
+ Status string `json:"status"`
+ CreatedAt time.Time `json:"created_at"`
+ UpdatedAt time.Time `json:"updated_at"`
+ Investor Investor `json:"investor"`
+ TransferringDepositoryINN string `json:"transferring_depository_inn"`
+ ReceivingDepositoryINN string `json:"receiving_depository_inn"`
+ CostInfo CostInfo `json:"cost_info"`
+ IIAAgreement *IIAAgreement `json:"iia_agreement,omitempty"`
+ Securities []ClaimSec `json:"securities"`
+ LastCallback *StatusCallback `json:"last_callback,omitempty"`
+ Stages []StageView `json:"stages,omitempty"`
+ M2MGUID m2m.UUID `json:"m2m_guid,omitempty"`
+ M2MResponse *NSDResponseView `json:"m2m_response,omitempty"`
+ M2MDecision *NSDDecisionView `json:"m2m_decision,omitempty"`
+}
+
+// StageView — точка истории FSM для UI.
+type StageView struct {
+ State string `json:"state"`
+ EnteredAt time.Time `json:"entered_at"`
+ LeftAt *time.Time `json:"left_at,omitempty"`
+ Reason string `json:"reason,omitempty"`
+}
+
+// StatusCallback — callback статуса от lk-gateway к ЛК.
+type StatusCallback struct {
+ ClaimID string `json:"claim_id"`
+ NewStatus string `json:"new_status"`
+ ReasonCode string `json:"reason_code,omitempty"`
+ ReasonText string `json:"reason_text,omitempty"`
+ UpdatedAt time.Time `json:"updated_at"`
+ NSDResponse *NSDResponseView `json:"nsd_response,omitempty"`
+}
+
+// NSDResponseView — сжатое представление M2MTransferResponse для UI/callback.
+type NSDResponseView struct {
+ GUID string `json:"guid"`
+ StatusCode string `json:"status_code"`
+ Responses []NSDResponseEntry `json:"responses"`
+}
+
+// NSDResponseEntry — одна запись Response.
+type NSDResponseEntry struct {
+ ReferenceID string `json:"reference_id,omitempty"`
+ Code string `json:"code"`
+ Text string `json:"text,omitempty"`
+}
+
+// NSDDecisionView — сжатое представление M2MTransferDecision для UI.
+type NSDDecisionView struct {
+ GUID string `json:"guid"`
+ Securities []NSDDecisionSecurity `json:"securities"`
+}
+
+// NSDDecisionSecurity — решение по одной ЦБ.
+type NSDDecisionSecurity struct {
+ ReferenceID string `json:"reference_id"`
+ Outcome string `json:"outcome"` // "confirmed" | "rejected"
+ RejectCodes []string `json:"reject_codes,omitempty"`
+}
+
+// ErrorResponse — формат ошибки, идентичный ESIA Finance.
+type ErrorResponse struct {
+ Error bool `json:"error"`
+ Status int `json:"status"`
+ Code string `json:"code"`
+ Title string `json:"title"`
+ Meta *ErrorMeta `json:"meta,omitempty"`
+}
+
+// ErrorMeta — детали ошибки.
+type ErrorMeta struct {
+ Message string `json:"message,omitempty"`
+ Errors []FieldErrorDetail `json:"errors,omitempty"`
+}
+
+// FieldErrorDetail — ошибка по конкретному полю.
+type FieldErrorDetail struct {
+ Field string `json:"field"`
+ Message string `json:"message"`
+}
+
+// ClaimsPage — постраничная выдача.
+type ClaimsPage struct {
+ Items []ClaimView `json:"items"`
+ Total int `json:"total"`
+ Limit int `json:"limit"`
+ Offset int `json:"offset"`
+}
diff --git a/internal/lkgateway/web/templates/admin_claim.html b/internal/lkgateway/web/templates/admin_claim.html
new file mode 100644
index 0000000..51939cd
--- /dev/null
+++ b/internal/lkgateway/web/templates/admin_claim.html
@@ -0,0 +1,98 @@
+{{define "content"}}
+
+
Заявка {{slice .Claim.ID 0 8}} · {{.Claim.Status}}
+
+ | Создана | {{.Claim.CreatedAt.Format "02.01.2006 15:04:05"}} |
+ | Обновлена | {{.Claim.UpdatedAt.Format "02.01.2006 15:04:05"}} |
+ | M2M GUID | {{.Claim.M2MGUID}} |
+ | Инвестор | {{.Claim.Investor.LastName}} {{.Claim.Investor.FirstName}} {{.Claim.Investor.MiddleName}} |
+ | Документ | тип {{.Claim.Investor.Document.DocumentType}}, серия {{.Claim.Investor.Document.Series}}, номер {{.Claim.Investor.Document.Number}} |
+ | Передающий депозитарий | ИНН {{.Claim.TransferringDepositoryINN}} |
+ | Принимающий депозитарий | ИНН {{.Claim.ReceivingDepositoryINN}} |
+ {{if .Claim.IIAAgreement}}
+ | ИИС | {{.Claim.IIAAgreement.AgreementType}} № {{.Claim.IIAAgreement.AgreementNumber}} от {{.Claim.IIAAgreement.AgreementDate}}, брокер ИНН {{.Claim.IIAAgreement.BrokerINN}} |
+ {{end}}
+
+
+
+
+
Ценные бумаги ({{len .Claim.Securities}})
+
+ | Код | ISIN / описание | Количество | Счетов депо |
+
+ {{range .Claim.Securities}}
+
+ {{.SecurityCode}} |
+ {{if .SecurityDetails.ISIN}}{{.SecurityDetails.ISIN}}{{else if .SecurityDetails.SecurityInfo}}{{.SecurityDetails.SecurityInfo.Classification}} / {{.SecurityDetails.SecurityInfo.Category}}{{if .SecurityDetails.SecurityInfo.IdentificationDetails.FundShares}} · ПИФ {{.SecurityDetails.SecurityInfo.IdentificationDetails.FundShares.RegNumber}} класс {{.SecurityDetails.SecurityInfo.IdentificationDetails.FundShares.Class}}{{end}}{{end}} |
+ {{if .Quantity.Whole}}{{.Quantity.Whole}}{{else}}{{.Quantity.Fractional}}{{end}} |
+ {{len .SettlementAccounts}} |
+
+ {{end}}
+
+
+
+
+
+
История FSM
+
+ | Состояние | Вошли | Вышли | Причина |
+
+ {{range .Claim.Stages}}
+
+ | {{.State}} |
+ {{.EnteredAt.Format "15:04:05.000"}} |
+ {{if .LeftAt}}{{.LeftAt.Format "15:04:05.000"}}{{else}}сейчас{{end}} |
+ {{.Reason}} |
+
+ {{end}}
+
+
+
+
+{{if .Claim.M2MResponse}}
+
+
Ответ НРД (M2MTransferResponse)
+
GUID {{.Claim.M2MResponse.GUID}} · Status {{.Claim.M2MResponse.StatusCode}}
+
+ | ReferenceID | Код | Текст |
+
+ {{range .Claim.M2MResponse.Responses}}
+ {{.ReferenceID}} | {{.Code}} | {{.Text}} |
+ {{end}}
+
+
+
+{{end}}
+
+{{if .Claim.M2MDecision}}
+
+
Решение принимающей стороны (M2MTransferDecision)
+
GUID {{.Claim.M2MDecision.GUID}}
+
+ | ReferenceID | Решение | Коды отказа |
+
+ {{range .Claim.M2MDecision.Securities}}
+
+ {{.ReferenceID}} |
+ {{.Outcome}} |
+ {{range .RejectCodes}}{{.}} {{end}} |
+
+ {{end}}
+
+
+
+{{end}}
+
+{{if .Claim.LastCallback}}
+
+
Последний callback в ЛК
+
+ | Статус | {{.Claim.LastCallback.NewStatus}} |
+ {{if .Claim.LastCallback.ReasonCode}}
+ | Код причины | {{.Claim.LastCallback.ReasonCode}} {{.Claim.LastCallback.ReasonText}} |
+ {{end}}
+ | Время | {{.Claim.LastCallback.UpdatedAt.Format "02.01.2006 15:04:05"}} |
+
+
+{{end}}
+{{end}}
diff --git a/internal/lkgateway/web/templates/admin_claims.html b/internal/lkgateway/web/templates/admin_claims.html
new file mode 100644
index 0000000..4a4d358
--- /dev/null
+++ b/internal/lkgateway/web/templates/admin_claims.html
@@ -0,0 +1,27 @@
+{{define "content"}}
+
+
Журнал заявок ({{len .Items}})
+ {{if .Items}}
+
+ | Создана | ID | GUID M2M | Инвестор | ЦБ | Передающий | Принимающий | Статус | |
+
+ {{range .Items}}
+
+ | {{.CreatedAt.Format "02.01 15:04:05"}} |
+ {{slice .ID 0 8}} |
+ {{slice (printf "%s" .M2MGUID) 0 8}} |
+ {{.Investor.LastName}} {{slice .Investor.FirstName 0 1}}. |
+ {{len .Securities}} |
+ {{.TransferringDepositoryINN}} |
+ {{.ReceivingDepositoryINN}} |
+ {{.Status}} |
+ детали → |
+
+ {{end}}
+
+
+ {{else}}
+
Пусто.
+ {{end}}
+
+{{end}}
diff --git a/internal/lkgateway/web/templates/admin_home.html b/internal/lkgateway/web/templates/admin_home.html
new file mode 100644
index 0000000..638775a
--- /dev/null
+++ b/internal/lkgateway/web/templates/admin_home.html
@@ -0,0 +1,57 @@
+{{define "content"}}
+
+
+
Всего сделок
+
{{.Counts.Total}}
+
+
+
Подтверждено
+
{{.Counts.Confirmed}}
+
+
+
В ожидании
+
{{.Counts.InProgress}}
+
+
+
Отказы / таймауты
+
{{.Counts.Failed}}
+
+
+
+
+
Состояние системы
+ {{range .Status.Checks}}
+
+
+ {{.Name}} — {{.Message}}
+ {{if .Detail}} · {{.Detail}}{{end}}
+
+ {{end}}
+
+ Профиль: {{.Status.Profile}} · Crypto-провайдер: {{.Status.Provider}}
+
+
+
+
+
Последние заявки
+ {{if .Recent}}
+
+ | Создана | ID | Инвестор | ЦБ | Статус | |
+
+ {{range .Recent}}
+
+ | {{.CreatedAt.Format "15:04:05"}} |
+ {{slice .ID 0 8}} |
+ {{.Investor.LastName}} {{slice .Investor.FirstName 0 1}}. |
+ {{len .Securities}} |
+ {{.Status}} |
+ открыть → |
+
+ {{end}}
+
+
+ {{else}}
+
Заявок ещё нет. Подайте первую через lk-emulator или POST /api/v1/back_office/claims/.
+ {{end}}
+
+{{end}}
diff --git a/internal/lkgateway/web/templates/admin_status.html b/internal/lkgateway/web/templates/admin_status.html
new file mode 100644
index 0000000..c848cc8
--- /dev/null
+++ b/internal/lkgateway/web/templates/admin_status.html
@@ -0,0 +1,30 @@
+{{define "content"}}
+
+
Статус системы — детально
+
+ | Подсистема | Состояние | Сообщение | Детали |
+
+ {{range .Checks}}
+
+ |
+ {{.Name}} |
+ {{if .OK}}OK{{else}}FAIL{{end}} |
+ {{.Message}} |
+ {{.Detail}} |
+
+ {{end}}
+
+
+
Проверка выполнена в {{.CheckedAt.Format "15:04:05 02.01.2006"}}.
+
+
+
+
Что подключается на следующих этапах
+
+ | PostgreSQL (схема m2m_core) | M2-шаг-3: pgx-репозиторий вместо MemoryRepository. Миграция готова — migrations/m2m-core/001__deals.sql. |
+ | crypto-service · КриптоПро JCP | M4: положить jcp.jar в services/crypto-service/libs/, выставить BJ_CRYPTO_PROVIDER=cryptopro, заполнить keystore профиля. Проверка — gRPC Health должна вернуть provider=cryptopro, ok=true. |
+ | nsd-adapter · ИШ НРД | M3: установить ИШ, выставить BJ_NSD_PROFILE=guest-gost или иной, BJ_NSD_IGW_URL=http://localhost:8080. Без этого сейчас используется nsdadapter/mock с эмуляцией ответов через 3 сек. |
+ | Реальный ЛК (ESIA Finance) | M4: согласовать docs/lk-contract/v1/openapi.yaml с командой ЛК, выставить BJ_LK_CALLBACK_URL на реальный адрес. Сейчас callback идёт в встроенный lk-emulator. |
+
+
+{{end}}
diff --git a/internal/lkgateway/web/templates/layout.html b/internal/lkgateway/web/templates/layout.html
new file mode 100644
index 0000000..e16fec7
--- /dev/null
+++ b/internal/lkgateway/web/templates/layout.html
@@ -0,0 +1,58 @@
+{{define "layout"}}
+
+
+
+{{.Title}} · lk-gateway
+
+
+
+
+
+{{template "content" .}}
+
+
+
+{{end}}
diff --git a/internal/nsdadapter/mock/README.md b/internal/nsdadapter/mock/README.md
new file mode 100644
index 0000000..495ca7b
--- /dev/null
+++ b/internal/nsdadapter/mock/README.md
@@ -0,0 +1,59 @@
+# internal/nsdadapter/mock — заглушка NSDSender
+
+Имитирует Интеграционный шлюз НРД для локальных стендов и сквозных
+тестов. Реализует интерфейс `m2mcore.NSDSender`:
+
+- **`Send`** — синхронно возвращает синтетический `M2MTransferResponse`
+ с `StatusCode=INFO` и записями `01` по каждой ЦБ.
+- **`SendDecision`** — фиксирует и завершает (мы — отправители Decision).
+- **Параллельно** — спавнит горутину, которая через
+ `Config.DecisionDelay` (по умолчанию 3 секунды) эмитит
+ `M2MTransferDecision` в канал `Decisions()`.
+
+## Исходы
+
+Через `Config.DefaultOutcome` или `SetOutcome(guid, ...)`:
+
+- `OutcomeConfirm` — Confirmation по всем ЦБ.
+- `OutcomeReject` — Rejection с кодом `Config.RejectionCode` (по умолчанию `07`).
+- `OutcomeTimeout` — Decision вообще не приходит (эмуляция SLA-таймаута).
+
+## Параметры
+
+```go
+cfg := mock.DefaultConfig()
+cfg.DecisionDelay = 1 * time.Second
+cfg.DefaultOutcome = mock.OutcomeConfirm
+sender := mock.NewSender(cfg)
+
+resp, _ := sender.Send(ctx, req)
+// resp.StatusCode == "INFO"
+
+decision := <-sender.Decisions()
+// Через ~1с прилетает Decision с Confirmation
+```
+
+## Подписка на эмитированные Decision
+
+```go
+go func() {
+ for d := range sender.Decisions() {
+ _ = svc.ApplyDecision(ctx, d)
+ }
+}()
+```
+
+В lk-gateway это делает `Server.consumeDecisions` (запускается в `Run`).
+
+## Контекст эмиссии
+
+Эмиссия Decision использует **внутренний контекст** mock'а (создаётся в
+`NewSender` через `context.Background()`), а не контекст HTTP-запроса
+вызывающего. Это критично — контекст HTTP-запроса закрывается сразу
+после возврата ответа, и без своего lifecycle мок бы прерывал эмиссию
+до истечения `DecisionDelay`. `Stop()` отменяет внутренний контекст.
+
+## Статистика
+
+`Stats()` возвращает счётчики `Sent`, `Confirmed`, `Rejected`,
+`TimedOut` — для admin-страницы lk-gateway.
diff --git a/internal/nsdadapter/mock/sender.go b/internal/nsdadapter/mock/sender.go
new file mode 100644
index 0000000..4e89203
--- /dev/null
+++ b/internal/nsdadapter/mock/sender.go
@@ -0,0 +1,219 @@
+// Package mock — заглушка NSDSender для локальных стендов без реального
+// Интеграционного шлюза НРД. Возвращает синтетические M2MTransferResponse
+// (синхронно, сразу) и эмитит M2MTransferDecision (асинхронно, через
+// настраиваемую задержку) для каждой отправленной заявки.
+//
+// Подходит для:
+// - сквозного дев-теста (ЛК → m2m-core → mock → callback в ЛК);
+// - демонстраций «увидеть как оно работает» без подключения к НРД;
+// - юнит-тестов компонентов выше уровня транспорта.
+package mock
+
+import (
+ "context"
+ "errors"
+ "sync"
+ "time"
+
+ "git.zetit.ru/zuevav/Bridge-and-Join-s/internal/m2m"
+ "git.zetit.ru/zuevav/Bridge-and-Join-s/internal/m2mcore"
+ "git.zetit.ru/zuevav/Bridge-and-Join-s/internal/nsdxml"
+)
+
+// DecisionOutcome — что должен вернуть mock после задержки.
+type DecisionOutcome int
+
+const (
+ OutcomeConfirm DecisionOutcome = iota
+ OutcomeReject
+ OutcomeTimeout
+)
+
+// Config — настройки mock-сендера.
+type Config struct {
+ // DecisionDelay — задержка между Send и эмиссией Decision в канал.
+ DecisionDelay time.Duration
+ // DefaultOutcome — каким будет Decision, если не задан per-Request override.
+ DefaultOutcome DecisionOutcome
+ // RejectionCode — какой код отказа возвращать в OutcomeReject (6 chars max).
+ RejectionCode string
+ // SenderCode/ReceiverCode для эмитированной Decision Header (берётся из
+ // исходного Request обменом местами).
+ NSDSenderCode m2m.DeponentCode
+}
+
+// DefaultConfig — разумные дефолты: подтверждение через 3 секунды.
+func DefaultConfig() Config {
+ return Config{
+ DecisionDelay: 3 * time.Second,
+ DefaultOutcome: OutcomeConfirm,
+ RejectionCode: "07",
+ }
+}
+
+// Sender — mock реализация m2mcore.NSDSender.
+type Sender struct {
+ cfg Config
+ decisions chan *m2m.M2MTransferDecision
+ outcomes sync.Map // map[m2m.UUID]DecisionOutcome — override per GUID
+ mu sync.Mutex
+ stats stats
+ lifeCtx context.Context // независимый от HTTP-запроса контекст для эмиссии Decision
+ lifeCancel context.CancelFunc
+}
+
+type stats struct {
+ Sent uint64
+ Confirmed uint64
+ Rejected uint64
+ TimedOut uint64
+}
+
+// NewSender создаёт mock с указанной конфигурацией.
+func NewSender(cfg Config) *Sender {
+ ctx, cancel := context.WithCancel(context.Background())
+ return &Sender{
+ cfg: cfg,
+ decisions: make(chan *m2m.M2MTransferDecision, 64),
+ lifeCtx: ctx,
+ lifeCancel: cancel,
+ }
+}
+
+// Stop отменяет внутренний контекст mock'а, останавливая все запущенные
+// emit-горутины.
+func (s *Sender) Stop() { s.lifeCancel() }
+
+// SetOutcome задаёт исход для конкретной заявки по GUID (вызывается до Send).
+func (s *Sender) SetOutcome(guid m2m.UUID, out DecisionOutcome) {
+ s.outcomes.Store(guid, out)
+}
+
+// Decisions — канал эмитированных от имени принимающей стороны Decision'ов.
+// Подписаться, считать в горутине, передавать в m2mcore.Deal.ReceiveDecision.
+func (s *Sender) Decisions() <-chan *m2m.M2MTransferDecision { return s.decisions }
+
+// Stats — текущие счётчики (для UI).
+func (s *Sender) Stats() (sent, confirmed, rejected, timedOut uint64) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ return s.stats.Sent, s.stats.Confirmed, s.stats.Rejected, s.stats.TimedOut
+}
+
+// Send имитирует отправку в НРД. Возвращает синтетический Response
+// с StatusCode=INFO. Через DecisionDelay в канал прилетает Decision.
+func (s *Sender) Send(ctx context.Context, req *m2m.M2MTransferRequest) (*m2m.M2MTransferResponse, error) {
+ if req == nil {
+ return nil, errors.New("nsdadapter/mock: Send: req=nil")
+ }
+ if err := req.Validate(); err != nil {
+ return nil, err
+ }
+
+ s.mu.Lock()
+ s.stats.Sent++
+ s.mu.Unlock()
+
+ // Синтетический Response: принимаем заявку, по каждой ЦБ — INFO 01.
+ resp := &m2m.M2MTransferResponse{
+ GUID: req.Header.GUID,
+ StatusCode: m2m.StatusInfo,
+ }
+ for _, sec := range req.Data.TransferredSecurities.Securities {
+ refID := sec.ReferenceID
+ resp.Responses = append(resp.Responses, m2m.Response{
+ ReferenceID: &refID,
+ Code: "01",
+ Text: "Запрос на перевод принят НРД и направлен принимающей стороне (mock).",
+ })
+ }
+
+ // Эмитим Decision в отдельной горутине. Используем lifeCtx, чтобы
+ // HTTP-таймауты вызывающего не прерывали эмиссию.
+ outcome := s.cfg.DefaultOutcome
+ if v, ok := s.outcomes.Load(req.Header.GUID); ok {
+ outcome = v.(DecisionOutcome)
+ }
+ go s.emitDecision(s.lifeCtx, req, outcome)
+ _ = ctx
+
+ return resp, nil
+}
+
+// SendDecision имитирует отправку нашего Decision принимающей стороне (мы — реципиент).
+// Mock просто фиксирует статистику.
+func (s *Sender) SendDecision(_ context.Context, decision *m2m.M2MTransferDecision) error {
+ if decision == nil {
+ return errors.New("nsdadapter/mock: SendDecision: decision=nil")
+ }
+ if err := decision.Validate(); err != nil {
+ return err
+ }
+ s.mu.Lock()
+ s.stats.Sent++
+ s.mu.Unlock()
+ return nil
+}
+
+func (s *Sender) emitDecision(ctx context.Context, req *m2m.M2MTransferRequest, outcome DecisionOutcome) {
+ select {
+ case <-ctx.Done():
+ return
+ case <-time.After(s.cfg.DecisionDelay):
+ }
+
+ if outcome == OutcomeTimeout {
+ s.mu.Lock()
+ s.stats.TimedOut++
+ s.mu.Unlock()
+ // При таймауте Decision не приходит вообще — это и эмулируем.
+ return
+ }
+
+ decision := &m2m.M2MTransferDecision{
+ Header: m2m.DecisionHeader{
+ GUID: req.Header.GUID,
+ CreationTimestamp: nsdxml.Now(),
+ SenderCode: req.Header.ReceiverCode,
+ ReceiverCode: req.Header.SenderCode,
+ CostInfo: m2m.CostInfo{No: &m2m.CostInfoNo{}},
+ },
+ Data: m2m.DecisionData{
+ ReceivingDepository: req.Data.ReceivingDepository,
+ },
+ }
+ for _, sec := range req.Data.TransferredSecurities.Securities {
+ ds := m2m.DecisionSecurity{ReferenceID: sec.ReferenceID}
+ if outcome == OutcomeConfirm {
+ ds.TransferDecision = m2m.DecisionTransfer{
+ Confirmation: &m2m.Confirmation{
+ SettlementAccount: sec.SettlementAccount[0],
+ },
+ }
+ } else {
+ ds.TransferDecision = m2m.DecisionTransfer{
+ Rejection: &m2m.Rejection{
+ Codes: []string{s.cfg.RejectionCode},
+ },
+ }
+ }
+ decision.Data.Securities = append(decision.Data.Securities, ds)
+ }
+
+ s.mu.Lock()
+ switch outcome {
+ case OutcomeConfirm:
+ s.stats.Confirmed++
+ case OutcomeReject:
+ s.stats.Rejected++
+ }
+ s.mu.Unlock()
+
+ select {
+ case <-ctx.Done():
+ case s.decisions <- decision:
+ }
+}
+
+// Verify тип Sender удовлетворяет m2mcore.NSDSender.
+var _ m2mcore.NSDSender = (*Sender)(nil)
diff --git a/internal/nsdadapter/mock/sender_test.go b/internal/nsdadapter/mock/sender_test.go
new file mode 100644
index 0000000..55f46ab
--- /dev/null
+++ b/internal/nsdadapter/mock/sender_test.go
@@ -0,0 +1,140 @@
+package mock_test
+
+import (
+ "context"
+ "testing"
+ "time"
+
+ "git.zetit.ru/zuevav/Bridge-and-Join-s/internal/m2m"
+ "git.zetit.ru/zuevav/Bridge-and-Join-s/internal/nsdadapter/mock"
+)
+
+func validRequest() *m2m.M2MTransferRequest {
+ whole := uint64(100)
+ isin := m2m.ISIN("RU0007661625")
+ return &m2m.M2MTransferRequest{
+ Header: m2m.RequestHeader{
+ GUID: m2m.UUID("c02a1d5e-c2af-4799-bab4-953f133c5133"),
+ SenderCode: "MC0079200000",
+ ReceiverCode: "MC0010300000",
+ CostInfo: m2m.CostInfo{No: &m2m.CostInfoNo{}},
+ },
+ Data: m2m.RequestData{
+ InvestorInformation: m2m.InvestorInformation{
+ LastName: "Иванов",
+ FirstName: "Иван",
+ IdentityDocument: m2m.IdentityDocument{
+ DocumentType: m2m.DocCode21,
+ DocumentNumber: m2m.IdentityDocSerial("654321"),
+ },
+ },
+ TransferringDepository: m2m.SettlementRequisites{INN: "7702070139"},
+ ReceivingDepository: m2m.SettlementRequisites{INN: "7802031669"},
+ TransferredSecurities: m2m.RequestTransferredSecurities{
+ Securities: []m2m.RequestSecurity{
+ {
+ ReferenceID: "M2M2026030200001",
+ SecurityCode: "MM0766162534",
+ SecurityDetails: m2m.SecurityDetails{ISIN: &isin},
+ Quantity: m2m.Quantity{Whole: &whole},
+ SettlementAccount: []m2m.RequestSettlementAccount{
+ {
+ SettlementRequisites: m2m.SettlementRequisites{INN: "7702070139"},
+ SettlementLocation: m2m.SettlementDepositoryLocation{
+ DeponentCode: "DP789456",
+ AccountID: "31MC0021900000F01",
+ SectionID: "P001",
+ },
+ },
+ },
+ IsolationStatus: m2m.IsolationSGDN,
+ },
+ },
+ },
+ },
+ }
+}
+
+func TestMockSendReturnsResponseAndEmitsConfirmation(t *testing.T) {
+ cfg := mock.DefaultConfig()
+ cfg.DecisionDelay = 20 * time.Millisecond
+ s := mock.NewSender(cfg)
+
+ resp, err := s.Send(context.Background(), validRequest())
+ if err != nil {
+ t.Fatal(err)
+ }
+ if resp.StatusCode != m2m.StatusInfo {
+ t.Errorf("StatusCode = %s, ожидалось INFO", resp.StatusCode)
+ }
+ if len(resp.Responses) != 1 {
+ t.Errorf("ожидалась 1 Response, получено %d", len(resp.Responses))
+ }
+
+ select {
+ case d := <-s.Decisions():
+ if d.Data.Securities[0].TransferDecision.Confirmation == nil {
+ t.Errorf("ожидалось Confirmation, получено %+v", d.Data.Securities[0].TransferDecision)
+ }
+ case <-time.After(time.Second):
+ t.Fatal("Decision не пришёл в канал")
+ }
+}
+
+func TestMockRejectOutcome(t *testing.T) {
+ cfg := mock.DefaultConfig()
+ cfg.DecisionDelay = 20 * time.Millisecond
+ cfg.DefaultOutcome = mock.OutcomeReject
+ s := mock.NewSender(cfg)
+ req := validRequest()
+ if _, err := s.Send(context.Background(), req); err != nil {
+ t.Fatal(err)
+ }
+ select {
+ case d := <-s.Decisions():
+ if d.Data.Securities[0].TransferDecision.Rejection == nil {
+ t.Errorf("ожидалось Rejection")
+ }
+ case <-time.After(time.Second):
+ t.Fatal("Decision не пришёл")
+ }
+}
+
+func TestMockTimeoutOutcome(t *testing.T) {
+ cfg := mock.DefaultConfig()
+ cfg.DecisionDelay = 20 * time.Millisecond
+ cfg.DefaultOutcome = mock.OutcomeTimeout
+ s := mock.NewSender(cfg)
+ if _, err := s.Send(context.Background(), validRequest()); err != nil {
+ t.Fatal(err)
+ }
+ select {
+ case <-s.Decisions():
+ t.Error("при OutcomeTimeout Decision не должен прилетать")
+ case <-time.After(100 * time.Millisecond):
+ }
+ _, _, _, timedOut := s.Stats()
+ if timedOut != 1 {
+ t.Errorf("счётчик timeout = %d, ожидалось 1", timedOut)
+ }
+}
+
+func TestMockSetOutcomeOverride(t *testing.T) {
+ cfg := mock.DefaultConfig()
+ cfg.DecisionDelay = 20 * time.Millisecond
+ cfg.DefaultOutcome = mock.OutcomeConfirm
+ s := mock.NewSender(cfg)
+ req := validRequest()
+ s.SetOutcome(req.Header.GUID, mock.OutcomeReject)
+ if _, err := s.Send(context.Background(), req); err != nil {
+ t.Fatal(err)
+ }
+ select {
+ case d := <-s.Decisions():
+ if d.Data.Securities[0].TransferDecision.Rejection == nil {
+ t.Errorf("override не сработал, ожидалось Rejection")
+ }
+ case <-time.After(time.Second):
+ t.Fatal("Decision не пришёл")
+ }
+}