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 }