package lkgateway import ( "context" "crypto/sha1" "encoding/hex" "errors" "fmt" "strings" "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/igw" "git.zetit.ru/zuevav/Bridge-and-Join-s/internal/nsdxml" ) // lkInvestorUUID — стабильный UUID инвестора, выведенный из номера договора. // Нужен, потому что заявка (Deal) хранится в Postgres с investor_id типа UUID, // а в ЛК клиент идентифицируется договором (BR200) и логином. func lkInvestorUUID(contract string) string { sum := sha1.Sum([]byte("bj-lk:" + strings.ToUpper(strings.TrimSpace(contract)))) h := hex.EncodeToString(sum[:]) // оформляем как UUID v5-подобный (8-4-4-4-12) return fmt.Sprintf("%s-%s-5%s-a%s-%s", h[0:8], h[8:12], h[13:16], h[17:20], h[20:32]) } // Заявка на перевод бумаг, поданная клиентом из Личного кабинета. // Путь: ЛК → bj-server → запись в PostgreSQL (lk.transfer_requests) → M2M в НРД // (в тестовом режиме — имитатор НРД) → итог по ответу НРД (lkFinalizeByGUID). // LKTransferSecurity — одна бумага в заявке клиента. type LKTransferSecurity struct { ISIN string `json:"isin"` Quantity float64 `json:"quantity"` } // LKTransferRequest — запрос на перевод из личного кабинета. type LKTransferRequest struct { ReceivingINN string `json:"receiving_inn"` // ИНН принимающего депозитария (куда переводим) ReceivingName string `json:"receiving_name"` // наименование (для журнала) AccountAtRecv string `json:"account_at_recv"` // счёт депо клиента у принимающего брокера (если известен) Securities []LKTransferSecurity `json:"securities"` Comment string `json:"comment"` } // LKTransferResult — ответ клиенту. type LKTransferResult struct { OK bool `json:"ok"` RequestID int64 `json:"request_id"` GUID string `json:"guid"` Status string `json:"status"` Message string `json:"message"` } // LKClaimRow — заявка клиента для отображения в ЛК. type LKClaimRow struct { ID int64 `json:"id"` GUID string `json:"guid"` Status string `json:"status"` StatusRu string `json:"status_ru"` CreatedAt string `json:"created_at"` Securit int `json:"securities"` Reason string `json:"reason"` } // lkStatusRu — человекочитаемый статус заявки для клиента. func lkStatusRu(s string) string { switch s { case "new": return "Принята, ожидает проверки" case "checking": return "На проверке" case "ready": return "Проверена, отправляется в НРД" case "queued": return "Принята, ожидает отправки в НРД" case "sent": return "Отправлена в НРД" case "rejected": return "Отклонена" case "declined": return "Отклонена НРД" case "done": return "Завершена" case "error": return "Ошибка обработки" } return s } // CreateLKTransfer формирует заявку клиента, сохраняет её и отправляет в НРД. // Проверяет, что бумаги есть в портфеле клиента и количество доступно. func (s *Service) CreateLKTransfer(ctx context.Context, rc *RuntimeConfig, u LKUser, in LKTransferRequest) (LKTransferResult, error) { var res LKTransferResult fancy := rc.Snapshot().Fancy if _, err := lkDB(rc); err != nil { return res, errors.New("сервис временно недоступен (БД не настроена)") } if strings.TrimSpace(in.ReceivingINN) == "" { return res, errors.New("укажите принимающий депозитарий") } if len(in.Securities) == 0 { return res, errors.New("выберите хотя бы одну бумагу") } // Подача разрешена только клиентам с флагом can_transfer (тестовые); // реальные депозитарные клиенты работают в режиме просмотра. if !u.CanTransfer { return res, errors.New("для этого договора подача заявок недоступна (режим просмотра)") } // Портфель клиента — источник реквизитов и контроль доступного остатка. // Сначала реальный депозитарный учёт (тот же источник, что видит клиент в ЛК), // иначе — позиции модуля ЛК (lk.client_positions). var pos []ClientPosition if dp, derr := depoClientPositions(ctx, fancy, u.Contract); derr == nil && len(dp) > 0 { pos = depoPositionsAsClient(dp) } else { p2, err := lkClientPositions(ctx, rc, u.Contract) if err != nil { return res, fmt.Errorf("не удалось получить портфель: %w", err) } pos = p2 } pos = markTransferable(pos) byISIN := map[string]ClientPosition{} for _, p := range pos { byISIN[strings.ToUpper(p.ISIN)] = p } nsd := rc.Snapshot().NSD var secs []ClaimSec for _, want := range in.Securities { key := strings.ToUpper(strings.TrimSpace(want.ISIN)) p, ok := byISIN[key] if !ok { return res, fmt.Errorf("бумага %s не найдена в вашем портфеле", want.ISIN) } if !p.Transferable { return res, fmt.Errorf("бумага %s (%s) — иностранный выпуск, перевод через M2M недоступен: "+ "в MOEX МОСТ переводятся только российские ценные бумаги", p.ISIN, p.SecurityName) } if want.Quantity <= 0 { return res, fmt.Errorf("укажите количество для %s", want.ISIN) } if want.Quantity > p.Available { return res, fmt.Errorf("по %s доступно к переводу %.0f, запрошено %.0f", want.ISIN, p.Available, want.Quantity) } dep, acc, sect := p.DeponentCode, p.AccountID, p.SectionID if dep == "" { dep = nsd.DeponentCode } if acc == "" { acc = nsd.AccountID } if sect == "" { sect = nsd.SectionID } code := p.SecurityCode if code == "" { code = p.ISIN } // ИНН реквизитов расчётов есть только в таблице обмена; у депозитарных // позиций его нет — подставляем ИНН нашего депозитария (место расчётов НРД). setlINN := p.SettlementIN if setlINN == "" { setlINN = transferringINN(rc.Snapshot()) } secs = append(secs, ClaimSec{ SecurityCode: code, SecurityDetails: SecurityDetails{ISIN: p.ISIN}, Quantity: Quantity{Whole: uint64(want.Quantity)}, SettlementAccounts: []SettlementAccount{{ SettlementRequisitesINN: setlINN, SettlementLocation: SettlementLocation{ DeponentCode: dep, AccountID: acc, SectionID: sect, }, }}, }) } // ФИО/документ клиента — для M2MTransferRequest. last, first, middle := u.LastName, u.FirstName, u.MiddleName if last == "" && u.FullName != "" { parts := strings.Fields(u.FullName) if len(parts) > 0 { last = parts[0] } if len(parts) > 1 { first = parts[1] } if len(parts) > 2 { middle = parts[2] } } doc := Document{DocumentType: u.DocType, Series: u.DocSeries, Number: u.DocNumber} claim := CreateClaimRequest{ Investor: Investor{ ID: u.Contract, LastName: last, FirstName: first, MiddleName: middle, Document: doc, }, TransferringDepositoryINN: transferringINN(rc.Snapshot()), ReceivingDepositoryINN: strings.TrimSpace(in.ReceivingINN), CostInfo: CostInfo{Yes: &CostInfoYes{Code: nsd.DeponentCode}}, Securities: secs, SignatureFormat: "LK", } // ТЕСТОВЫЙ КЛИЕНТ: если серия ДУЛ совпадает с кодом сценария робота НРД // (1111/2001/2002/3333), заявка адресуется роботу-тестировщику, а серия/номер // документа задают сценарий. Так из ЛК можно прогнать полный цикл с НРД. // Только в режиме разработки: у реальных паспортов такие серии тоже встречаются. if _, isRobot := robotScenarios[u.DocSeries]; isRobot && devMode(rc) { claim.ReceiverCodeOverride = NSDRobotCode claim.InvestorDocumentOverride = &doc } // Регистрируем клиента ЛК в справочнике (анкета + депо-счета из портфеля), // иначе EnrichRequest не найдёт его данные. Депозитарным позициям (без своих // НРД-реквизитов) подставляем фиксированное место расчётов нашего депозитария. if ss, ok := s.store.(*SeedStore); ok { ss.EnsureLKClient(u, pos, m2mcore.DepoAccount{ DeponentCode: nsd.DeponentCode, AccountID: m2m.AccountID(nsd.AccountID), SectionID: nsd.SectionID, DepositoryINN: m2m.OrganizationINN(transferringINN(rc.Snapshot())), }) } req, err := s.BuildRequest(ctx, claim) if err != nil { return res, fmt.Errorf("не удалось сформировать поручение: %w", err) } xmlBytes, err := nsdxml.Marshal(req) if err != nil { return res, err } meta := LKRequestMeta{ Source: "lk", BJClaimID: string(req.Header.GUID), LKInvestorID: lkInvestorUUID(u.Contract), // UUID — его ждёт хранилище заявок ContractNumber: u.Contract, SignatureFormat: "LK", RequestXML: string(nsdxml.DecodeWindows1251(xmlBytes)), } id, err := lkInsertRequest(ctx, rc, req, meta) if err != nil { return res, fmt.Errorf("не удалось сохранить заявку: %w", err) } // Заявка сразу уходит в НРД; в тестовом режиме (боевой ЛК при тестовом ИШ) — имитатору НРД. sender := s.sender if emu, _ := lkEmulateNSD(rc); emu && lkEmuSender != nil { sender = lkEmuSender } if _, err := s.SendBuiltRequestVia(ctx, sender, req, lkInvestorUUID(u.Contract), nil); err != nil { var nd *igw.NotDeliveredError if errors.As(err, &nd) { // НРД сейчас не принимает пакеты: заявка сохранена и уйдёт сама, когда связь восстановится // (nsdhealth.go, автоповтор) — клиенту это не ошибка. _ = lkMarkStatus(ctx, rc, id, "queued") return LKTransferResult{ OK: true, RequestID: id, GUID: string(req.Header.GUID), Status: "queued", Message: "Заявка принята. Обмен с НРД сейчас временно недоступен — заявка будет отправлена автоматически, как только связь восстановится. Статус — в разделе «Мои заявки».", }, nil } _ = lkMarkStatus(ctx, rc, id, "error") return res, fmt.Errorf("заявка сохранена, но не отправлена: %w", err) } _ = lkMarkStatus(ctx, rc, id, "sent") return LKTransferResult{ OK: true, RequestID: id, GUID: string(req.Header.GUID), Status: "sent", Message: "Заявка принята и отправлена в НРД. Итог появится в разделе «Мои заявки».", }, nil }