d2024b44ca
- ручных отметок перед отправкой нет: автор виден по учётной записи, клиент/счёт/остаток/
бумага проверены сверкой с DEPO при сохранении; блокировка в QUIK — необязательно,
по умолчанию не требуется (модуль неторговых поручений сейчас не используется)
- «Удалить черновик» в карточке (POST /admin/manual/{id}/delete), только для черновиков
- количество не больше остатка на торговом разделе: проверка в форме (по выбранной бумаге)
и на сервере при сохранении (по счёту DEPO) — поручение с превышением не сохраняется
- отчёт MS010 с отказом: orderResponse.Rejected, OutgoingDeal.Rejected; шаг «Операция
завершена отказом» и бейдж — красные в карточках исходящего и входящего; онлайн-статус
«поручение 16 завершено отказом НРД»
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q4YVo4UMopqT5DzicqpPjL
258 lines
9.5 KiB
Go
258 lines
9.5 KiB
Go
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 ""
|
|
}
|