package lkgateway // manual_depo.go — подбор клиента и бумаг из DEPO для ручного ввода: поиск по ФИО или номеру // документа, открытые торговые счета, бумаги на торговом разделе с остатком. import ( "database/sql" "encoding/json" "fmt" "net/http" "strconv" "strings" ) // manualDepoAcc — открытый торговый счёт клиента. type manualDepoAcc struct { ClientID int64 `json:"client_id"` Number string `json:"number"` Contract string `json:"contract,omitempty"` IIS bool `json:"iis,omitempty"` } // manualDepoFace — клиент из картотеки DEPO. type manualDepoFace struct { LastName string `json:"last_name"` FirstName string `json:"first_name"` MiddleName string `json:"middle_name,omitempty"` Name string `json:"name"` Birth string `json:"birth,omitempty"` // ГГГГ-ММ-ДД DocType string `json:"doc_type,omitempty"` DocSer string `json:"doc_ser,omitempty"` DocNum string `json:"doc_num"` DocDate string `json:"doc_date,omitempty"` DocIssuer string `json:"doc_issuer,omitempty"` Phone string `json:"phone,omitempty"` Accounts []manualDepoAcc `json:"accounts"` } // manualDepoPos — бумага на торговом разделе клиента. type manualDepoPos struct { ISIN string `json:"isin"` Name string `json:"name"` Qty float64 `json:"qty"` Russian bool `json:"russian"` } // depoDocTypeNSD — вид документа DEPO → код справочника НРД (паспорт РФ в DEPO — 20). func depoDocTypeNSD(t int64) string { if t == 20 { return "21" } return "" } // manualDepoSearch — клиенты с открытым торговым счётом по ФИО (часть, без учёта регистра) или номеру документа. func manualDepoSearch(rc *RuntimeConfig, q string) ([]manualDepoFace, string) { q = strings.Join(strings.Fields(q), " ") if len([]rune(q)) < 3 { return nil, "введите не меньше 3 символов фамилии или номер документа" } if !manualDepoConfigured(rc) { return nil, "учётная система (DEPO) не подключена" } fs := rc.Snapshot().Fancy db, ctx, cancel, errS := depoOpen(fs) if errS != "" { return nil, errS } defer cancel() defer db.Close() digits := strings.ReplaceAll(q, " ", "") // Действующая версия карточки (LAST_FLAG=1); счета привязаны к исходной записи (SELF_ID). rows, err := db.QueryContext(ctx, `SELECT TOP 20 ISNULL(NULLIF(f.SELF_ID,0),f.ID), ISNULL(f.NAME,''), f.DATE_BIRTH, ISNULL(f.DOC_TYPE,0), ISNULL(f.DOC_SER,''), ISNULL(f.DOC_NUM,''), f.DOC_DAT, ISNULL(f.DOC_PLACE,''), ISNULL(f.DOC_CODE,''), ISNULL(f.PHONE,'') FROM OD_FACES f WHERE ISNULL(f.LAST_FLAG,1)=1 AND (UPPER(f.NAME) LIKE UPPER(@q) OR f.DOC_NUM=@d OR REPLACE(ISNULL(f.DOC_SER,''),' ','')+f.DOC_NUM=@d) AND EXISTS (SELECT 1 FROM OD_CLIENTS c WHERE c.FACE=ISNULL(NULLIF(f.SELF_ID,0),f.ID) AND c.NUMBER LIKE N'ВТНКЦ%' AND c.STATE=0 AND c.D_CLOSE IS NULL) ORDER BY f.NAME`, sql.Named("q", q+"%"), sql.Named("d", digits)) if err != nil { return nil, "поиск в DEPO: " + err.Error() } type faceRow struct { id int64 f manualDepoFace } var found []faceRow for rows.Next() { var r faceRow var birth, docDat sql.NullTime var docType int64 var place, code string if rows.Scan(&r.id, &r.f.Name, &birth, &docType, &r.f.DocSer, &r.f.DocNum, &docDat, &place, &code, &r.f.Phone) != nil { continue } p := strings.Fields(r.f.Name) if len(p) > 0 { r.f.LastName = p[0] } if len(p) > 1 { r.f.FirstName = p[1] } if len(p) > 2 { r.f.MiddleName = strings.Join(p[2:], " ") } if birth.Valid { r.f.Birth = birth.Time.Format("2006-01-02") } if docDat.Valid { r.f.DocDate = docDat.Time.Format("2006-01-02") } r.f.DocType = depoDocTypeNSD(docType) r.f.DocSer = strings.ReplaceAll(r.f.DocSer, " ", "") r.f.DocIssuer = strings.TrimSpace(place) if code = strings.TrimSpace(code); code != "" { r.f.DocIssuer += ", код подразделения " + code } found = append(found, r) } rows.Close() out := make([]manualDepoFace, 0, len(found)) for _, r := range found { ar, err := db.QueryContext(ctx, `SELECT c.ID, ISNULL(c.NUMBER,''), ISNULL((SELECT TOP 1 p.PORT_NUM FROM OD_CLIENTS_PORTS p WHERE p.CLIENT=c.ID ORDER BY p.PORT_DATE, p.PORT_NUM),'') FROM OD_CLIENTS c WHERE c.FACE=@f AND c.NUMBER LIKE N'ВТНКЦ%' AND c.STATE=0 AND c.D_CLOSE IS NULL ORDER BY c.ID`, sql.Named("f", r.id)) if err == nil { for ar.Next() { var a manualDepoAcc if ar.Scan(&a.ClientID, &a.Number, &a.Contract) == nil { r.f.Accounts = append(r.f.Accounts, a) } } ar.Close() } for i := range r.f.Accounts { ok, _ := depoAccountIIS(ctx, db, fs, r.f.Accounts[i].ClientID) r.f.Accounts[i].IIS = !ok } out = append(out, r.f) } if len(out) == 0 { return nil, "клиентов с открытым торговым счётом не найдено" } return out, "" } // manualDepoPositions — бумаги с положительным остатком на торговом разделе счёта (98070.04). func manualDepoPositions(rc *RuntimeConfig, clientID int64) ([]manualDepoPos, string) { if !manualDepoConfigured(rc) { return nil, "учётная система (DEPO) не подключена" } db, ctx, cancel, errS := depoOpen(rc.Snapshot().Fancy) if errS != "" { return nil, errS } defer cancel() defer db.Close() rows, err := db.QueryContext(ctx, `SELECT ISNULL(v.ISIN,''), ISNULL(v.NAME,''), SUM(CAST(r.K AS float)) FROM OD_RESTS r JOIN OD_VALUES v ON v.ID=r.VALUE_ID WHERE r.REG_1=@c AND r.BAL_ACC=@b GROUP BY v.ISIN, v.NAME HAVING SUM(CAST(r.K AS float)) > 0 ORDER BY v.NAME`, sql.Named("c", clientID), sql.Named("b", depoTradeBalAcc)) if err != nil { return nil, "остатки в DEPO: " + err.Error() } defer rows.Close() var out []manualDepoPos for rows.Next() { var p manualDepoPos if rows.Scan(&p.ISIN, &p.Name, &p.Qty) == nil { p.ISIN = strings.ToUpper(strings.TrimSpace(p.ISIN)) p.Russian = isRussianISIN(p.ISIN) out = append(out, p) } } return out, "" } // registerManualDepo — GET /admin/manual/depo/search?q= и /admin/manual/depo/positions?client=ID (JSON). func registerManualDepo(mux *http.ServeMux, rc *RuntimeConfig) { reply := func(w http.ResponseWriter, v any, errS string) { w.Header().Set("Content-Type", "application/json; charset=utf-8") w.Header().Set("Cache-Control", "no-store") _ = json.NewEncoder(w).Encode(map[string]any{"items": v, "error": errS}) } mux.HandleFunc("/admin/manual/depo/search", func(w http.ResponseWriter, r *http.Request) { items, errS := manualDepoSearch(rc, r.URL.Query().Get("q")) reply(w, items, errS) }) mux.HandleFunc("/admin/manual/depo/positions", func(w http.ResponseWriter, r *http.Request) { id, err := strconv.ParseInt(r.URL.Query().Get("client"), 10, 64) if err != nil || id <= 0 { reply(w, nil, "не указан счёт") return } items, errS := manualDepoPositions(rc, id) reply(w, items, errS) }) } // manualDepoClientByAccount — OD_CLIENTS.ID открытого торгового счёта по его номеру. func manualDepoClientByAccount(rc *RuntimeConfig, number string) (int64, string) { number = strings.TrimSpace(number) if number == "" || !manualDepoConfigured(rc) { return 0, "" } db, ctx, cancel, errS := depoOpen(rc.Snapshot().Fancy) if errS != "" { return 0, errS } defer cancel() defer db.Close() var id int64 err := db.QueryRowContext(ctx, `SELECT TOP 1 ID FROM OD_CLIENTS WHERE NUMBER=@n AND STATE=0 AND D_CLOSE IS NULL ORDER BY ID`, sql.Named("n", number)).Scan(&id) if err != nil { return 0, "" } return id, "" } // manualQtyOverAvailable — количество в поручении больше остатка на торговом разделе. // Пусто — всё в порядке или остаток проверить нельзя (DEPO не подключена, счёт не найден): // тогда решает сверка при сохранении. Смысл: поручение на 111 бумаг при 110 на счёте не // должно даже сохраниться — дальше по цепочке ему всё равно идти некуда. func manualQtyOverAvailable(rc *RuntimeConfig, depoClient string, o *ManualOrder) string { if !manualDepoConfigured(rc) || o == nil { return "" } id, _ := strconv.ParseInt(strings.TrimSpace(depoClient), 10, 64) if id <= 0 { id, _ = manualDepoClientByAccount(rc, o.Account) } if id <= 0 { return "" } pos, errS := manualDepoPositions(rc, id) if errS != "" { return "" } for _, sec := range o.Securities { isin := strings.ToUpper(strings.TrimSpace(sec.ISIN)) if isin == "" { continue } q, err := strconv.ParseFloat(strings.ReplaceAll(strings.TrimSpace(sec.Qty), ",", "."), 64) if err != nil { continue } for _, p := range pos { if p.ISIN == isin && q > p.Qty+1e-9 { return fmt.Sprintf("По бумаге %s на торговом разделе счёта %s доступно %s шт., а в поручении %s — сохранить нельзя: укажите не больше доступного.", isin, o.Account, strconv.FormatFloat(p.Qty, 'f', -1, 64), strings.TrimSpace(sec.Qty)) } } } return "" }