Ручной ввод: без обязательных галок, удаление черновика, запрет количества больше остатка; отказ НРД по поручению — красным

- ручных отметок перед отправкой нет: автор виден по учётной записи, клиент/счёт/остаток/
  бумага проверены сверкой с 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
This commit is contained in:
zuevav
2026-09-18 14:39:24 +03:00
parent b9da2ca0ff
commit d2024b44ca
11 changed files with 171 additions and 54 deletions
+5 -3
View File
@@ -165,20 +165,21 @@ func registerIncoming(mux *http.ServeMux, a *admin, deps incomingDeps) {
return
}
var orderResps []orderResponse
var orderDone bool
var orderDone, orderRejected bool
var orderDoneAt string
if it.OrderSubmitted {
orderResps = findOrderResponses(it.OrderContrDID)
for _, rp := range orderResps {
if rp.Done { // отчёт MS010 — операция проведена
if rp.Done { // отчёт MS010 — операция проведена или завершена отказом
orderDone = true
orderDoneAt = rp.Time
orderRejected = orderRejected || rp.Rejected
}
}
}
rejected, codes := incomingRejectedAll(it)
data := incomingDetailData{page: nowPage("Входящий перевод", "incoming"), It: it, Flash: r.URL.Query().Get("flash"),
OrderResponses: orderResps, OrderDone: orderDone, OrderDoneAt: orderDoneAt, DraftReceived: draftReceivedTime(it.GUID),
OrderResponses: orderResps, OrderDone: orderDone, OrderRejected: orderRejected, OrderDoneAt: orderDoneAt, DraftReceived: draftReceivedTime(it.GUID),
Quik: globalQuikLinks.ForGUID(it.GUID), QuikOn: quikActive(deps.rc.Snapshot().Quik), Watch: globalOrderWatch.Get(it.GUID), DepoL: globalDepoLinks.Get(it.GUID),
Rejected: rejected, RejectCodes: codes, AutoMode: deps.rc.Snapshot().M2MAuto.Enabled}
// Сверка выполняется при открытии карточки; итог сохраняем для бейджа в списке.
@@ -236,6 +237,7 @@ type incomingDetailData struct {
Flash string
OrderResponses []orderResponse
OrderDone bool
OrderRejected bool // MS010 с отказом
OrderDoneAt string
DraftReceived string
Depo depoResult // сверка с DEPO — рекомендация по ответу, ничего не отправляется
+10 -2
View File
@@ -198,7 +198,9 @@ type orderResponse struct {
Kind string // человекочитаемый тип (квитанция / принятие / отчёт MS010)
Time string
Text string // краткое содержимое
Done bool // true — это отчёт MS010 (операция проведена)
Done bool // true — это отчёт MS010 (операция проведена или завершена отказом)
// Rejected — MS010 с отказом: операция завершилась, но не исполнена. Показывается красным.
Rejected bool
}
// findOrderResponses ищет в INBOX ИШ ответы НРД по нашему поручению (по contr_d_id).
@@ -222,7 +224,7 @@ func findOrderResponses(contrDID string) []orderResponse {
}
kind, done := orderRespKind(dir, body)
tmp = append(tmp, rr{orderResponse{
Pkg: dir, Kind: kind, Done: done,
Pkg: dir, Kind: kind, Done: done, Rejected: done && respTextRejected(body),
Time: mt.In(time.FixedZone("МСК", 3*3600)).Format("02.01.2006 15:04:05"),
Text: orderRespSummary(body),
}, mt})
@@ -453,3 +455,9 @@ func orderRespSummary(body string) string {
}
return txt
}
// respTextRejected — в отчёте НРД сказано об отказе, а не об исполнении.
func respTextRejected(body string) bool {
low := strings.ToLower(body)
return strings.Contains(low, "отказ") || strings.Contains(low, "reject") || strings.Contains(low, "не исполнен")
}
+3
View File
@@ -58,6 +58,7 @@ func liveOutgoing(c ClaimView) LiveStatus {
cp := *d
if cp.Submitted && len(cp.Responses) == 0 && !cp.Done {
cp.Responses, cp.Done = outgoingResponses(cp.ContrDIDs) // ответы ищем только для этой, живой сделки
_, cp.Rejected, _ = orderRespVerdict(cp.Responses)
}
depo = &cp
}
@@ -89,6 +90,8 @@ func liveOutgoing(c ClaimView) LiveStatus {
return LiveStatus{Text: "принята контрагентом, ожидает черновика поручения 16 от НРД", Kind: "wait", Active: true}
case !depo.Submitted:
return LiveStatus{Text: "черновик поручения 16 получен, ожидает подачи в НРД", Kind: "warn", Active: true}
case depo.Done && depo.Rejected:
return LiveStatus{Text: "поручение 16 завершено отказом НРД (MS010)", Kind: "bad"}
case depo.Done:
return LiveStatus{Text: "поручение 16 исполнено: MS010 получен", Kind: "ok"}
case watch != nil:
+19 -8
View File
@@ -266,6 +266,21 @@ func (s *manualStore) Create(o *ManualOrder) error {
}
// Update — изменение под замком: fn получает текущую запись.
// Delete — удалить черновик. Отправленные и отклонённые поручения не удаляются: они след.
func (s *manualStore) Delete(id int) error {
s.mu.Lock()
defer s.mu.Unlock()
o, ok := s.items[id]
if !ok {
return fmt.Errorf("поручение не найдено")
}
if o.Status != "draft" {
return fmt.Errorf("удалить можно только черновик, а поручение уже %s", manualStatusRu(o.Status))
}
delete(s.items, id)
return s.saveLocked()
}
func (s *manualStore) Update(id int, fn func(o *ManualOrder) error) error {
s.mu.Lock()
defer s.mu.Unlock()
@@ -382,11 +397,8 @@ func manualSendBlockers(rc *RuntimeConfig, o *ManualOrder) []string {
b = append(b, "сверка с DEPO не пройдена: "+o.Depo.Summary)
}
}
for _, c := range manualPreChecks {
if !o.Checks[c.Key].Done {
b = append(b, "нет отметки: "+c.Title)
}
}
// Ручных отметок перед отправкой нет: кто создал поручение — видно по учётной записи,
// а клиент, счёт, остаток и бумага проверены сверкой с учётной системой при сохранении.
switch o.QuikMode {
case "auto":
if !manualQuikAutoAvailable(rc) {
@@ -396,9 +408,8 @@ func manualSendBlockers(rc *RuntimeConfig, o *ManualOrder) []string {
if strings.TrimSpace(o.QuikInstr) == "" {
b = append(b, "укажите номер поручения на блокировку в торговой системе")
}
case "none":
default:
b = append(b, "не выбрано, как заблокированы бумаги в торговой системе")
case "none", "":
// модуль неторговых поручений QUIK не используется — блокировка не требуется
}
return b
}
+60
View File
@@ -6,6 +6,7 @@ package lkgateway
import (
"database/sql"
"encoding/json"
"fmt"
"net/http"
"strconv"
"strings"
@@ -195,3 +196,62 @@ func registerManualDepo(mux *http.ServeMux, rc *RuntimeConfig) {
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 ""
}
+17
View File
@@ -127,6 +127,16 @@ func registerManual(mux *http.ServeMux, rc *RuntimeConfig, svc *Service) {
id, _ := strconv.Atoi(r.FormValue("id"))
actor := manualActor(w)
msg := "Поручение сохранено"
if over := manualQtyOverAvailable(rc, r.FormValue("depo_client"), in); over != "" {
if id == 0 {
d := base("Новое поручение")
d.Mode, d.O, d.Handbook, d.Flash = "edit", in, handbookParticipants(), over
render(w, tpl, d)
return
}
back(w, r, fmt.Sprintf("/admin/manual/%d", id), over, "#mf")
return
}
if id == 0 {
in.Status, in.CreatedBy = "draft", actor
if err := globalManual.Create(in); err != nil {
@@ -265,6 +275,13 @@ func registerManual(mux *http.ServeMux, rc *RuntimeConfig, svc *Service) {
})
log.Printf("lk-gateway: ручной ввод %s (%s): отправка — %s", o.Number(), o.FullName(), msg)
back(w, r, card, msg, "")
case "delete":
if err := globalManual.Delete(id); err != nil {
back(w, r, card, err.Error(), "")
return
}
back(w, r, "/admin/manual", fmt.Sprintf("Черновик %d удалён", id), "")
return
case "decline":
reason := strings.TrimSpace(r.FormValue("reason"))
err := globalManual.Update(id, func(o *ManualOrder) error {
+19 -16
View File
@@ -25,19 +25,16 @@ func TestManualSendBlockers(t *testing.T) {
t.Fatal(err)
}
o := manualTestOrder()
b := manualSendBlockers(rc, o)
if len(b) != len(manualPreChecks)+1 { // все отметки + способ блокировки в QUIK
t.Fatalf("без отметок: %v", b)
}
o.Checks = map[string]ManualMark{}
for _, c := range manualPreChecks {
o.Checks[c.Key] = ManualMark{Done: true}
// Ручных отметок нет, блокировка в QUIK по умолчанию не требуется: черновик готов сразу.
if b := manualSendBlockers(rc, o); len(b) != 0 {
t.Fatalf("готовый черновик: %v", b)
}
o.QuikMode = "manual"
if b = manualSendBlockers(rc, o); len(b) != 1 || !strings.Contains(b[0], "номер поручения") {
if b := manualSendBlockers(rc, o); len(b) != 1 || !strings.Contains(b[0], "номер поручения") {
t.Fatalf("ручная блокировка без номера: %v", b)
}
o.QuikInstr = "123"
var b []string
if b = manualSendBlockers(rc, o); len(b) != 0 {
t.Fatalf("всё заполнено: %v", b)
}
@@ -113,19 +110,16 @@ func TestManualPages(t *testing.T) {
if o.DocSeries != "0000" || o.Account != "СЧЁТ-1" || len(o.Securities) != 1 || o.Securities[0].ISIN != "RU0009029540" {
t.Fatalf("данные: %+v", o)
}
if rec = do("GET", "/admin/manual/1", nil); rec.Code != 200 || !strings.Contains(rec.Body.String(), "Отправить пока нельзя") {
if rec = do("GET", "/admin/manual/1", nil); rec.Code != 200 || !strings.Contains(rec.Body.String(), "Сверка пройдена, поручение готово") {
t.Fatalf("карточка: %d %s", rec.Code, rec.Body.String())
}
chk := url.Values{"quik_mode": {"none"}}
for _, c := range manualPreChecks {
chk.Set("check_"+c.Key, "1")
}
chk := url.Values{"quik_mode": {"manual"}, "quik_instr": {"77"}}
do("POST", "/admin/manual/1/checks", chk)
o, _ = globalManual.Get(1)
if len(manualSendBlockers(rc, o)) != 0 || o.Checks["paper"].At.IsZero() {
t.Fatalf("отметки: %+v %v", o.Checks, manualSendBlockers(rc, o))
if len(manualSendBlockers(rc, o)) != 0 || o.QuikMode != "manual" {
t.Fatalf("блокировка вручную: %+v %v", o.QuikMode, manualSendBlockers(rc, o))
}
if rec = do("GET", "/admin/manual/1", nil); !strings.Contains(rec.Body.String(), "Все проверки и отметки пройдены") {
if rec = do("GET", "/admin/manual/1", nil); !strings.Contains(rec.Body.String(), "Сверка пройдена, поручение готово") {
t.Fatalf("готово к отправке: %s", rec.Body.String())
}
// Изменение данных сбрасывает отметки.
@@ -135,6 +129,15 @@ func TestManualPages(t *testing.T) {
if o, _ = globalManual.Get(1); len(o.Checks) != 0 {
t.Fatalf("отметки должны сброситься: %+v", o.Checks)
}
// Черновик можно удалить; отклонённое — нет.
do("POST", "/admin/manual/save", url.Values{"paper_date": {"2026-09-17"}, "last_name": {"Пётр"}, "first_name": {"П"}, "birth_date": {"1980-01-02"},
"doc_type": {"21"}, "doc_series": {"0000"}, "doc_number": {"000001"}, "account": {"счёт-2"}, "recv_inn": {"7702165310"}, "sec_isin": {"ru0009029540"}, "sec_qty": {"1"}, "do": {"save"}})
if rec = do("POST", "/admin/manual/2/delete", nil); rec.Code != http.StatusSeeOther || !strings.Contains(rec.Header().Get("Location"), "/admin/manual?") {
t.Fatalf("удаление черновика: %d %s", rec.Code, rec.Header().Get("Location"))
}
if _, ok := globalManual.Get(2); ok {
t.Fatal("черновик не удалён")
}
do("POST", "/admin/manual/1/decline", url.Values{"reason": {"нет подписи клиента"}})
o, _ = globalManual.Get(1)
if o.Status != "declined" || o.DeclinedAt.Before(time.Now().Add(-time.Minute)) {
+2
View File
@@ -148,6 +148,7 @@ type OutgoingDeal struct {
// ответ НРД:
Responses []orderResponse
Done bool // получен MS010 по всем поручениям
Rejected bool // MS010 с отказом: операция завершена, но не исполнена
}
// scanForm16Drafts живьём читает INBOX и собирает исходящие сделки: все черновики
@@ -247,6 +248,7 @@ func scanForm16DraftsOpt(store *OutgoingStore, withResponses bool) []*OutgoingDe
d.ContrDIDs = sub.ContrDIDs
if withResponses {
d.Responses, d.Done = outgoingResponses(sub.ContrDIDs)
_, d.Rejected, _ = orderRespVerdict(d.Responses)
}
}
}
@@ -162,12 +162,17 @@
.ord-step.wait .dot{background:#f59e0b;color:#fff;box-shadow:0 0 0 4px color-mix(in srgb,#f59e0b 28%,transparent)}
.ord-step.wait .s{color:#b45309}
.ord-step.todo{opacity:.55}
.ord-step.bad{border-color:#dc2626;background:color-mix(in srgb,#dc2626 12%,var(--card))}
.ord-step.bad .dot{background:#dc2626;color:#fff}
.ord-step.bad .s{color:#b3261e}
.ord-dl.bad{border-color:#dc2626;color:#b3261e}
.ord-dl{display:inline-block;margin-top:9px;font-size:11.5px;font-weight:700;padding:4px 10px;border-radius:8px;background:var(--card);border:1px solid #16a34a;color:#0f7a37;text-decoration:none;white-space:nowrap}
.ord-dl:hover{background:color-mix(in srgb,#16a34a 12%,var(--card))}
</style>
<div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap">
<h2 style="margin:0">Депозитарное поручение 16 (списание)</h2>
{{if .Depo.Done}}<span class="badge confirmed">проведено · MS010</span>
{{if and .Depo.Done .Depo.Rejected}}<span class="badge rejected">завершено отказом · MS010</span>
{{else if .Depo.Done}}<span class="badge confirmed">проведено · MS010</span>
{{else if .Depo.Submitted}}<span class="badge awaiting_decision">подано, ждём MS010</span>
{{else}}<span class="badge" style="background:var(--card-2);color:var(--muted)">требует подачи</span>{{end}}
</div>
@@ -186,10 +191,10 @@
<div><div class="t">Поручение 16 подано</div><div class="s">{{if .Depo.Submitted}}{{msk .Depo.OrderAt}}{{else}}нужно подать в НРД{{end}}</div>
{{if .Depo.OrderXML}}<a class="ord-dl" href="/admin/outgoing/{{.Depo.GUID}}/order.xml">⬇ Отправленная форма 16</a>{{end}}</div>
</div>
<div class="ord-step {{if .Depo.Done}}done{{else if .Depo.Submitted}}wait{{else}}todo{{end}}">
<span class="dot">{{if .Depo.Done}}✓{{else}}3{{end}}</span>
<div><div class="t">Списание проведено · MS010</div><div class="s">{{if .Depo.Done}}отчёт получен от НРД{{else if .Depo.Submitted}}ждём встречный 16/1 + MS010{{else}}после подачи{{end}}</div>
{{if .Depo.Responses}}<a class="ord-dl" href="/admin/outgoing/{{.Depo.GUID}}/ms010.zip">⬇ Ответ НРД (MS010)</a>{{end}}</div>
<div class="ord-step {{if and .Depo.Done .Depo.Rejected}}bad{{else if .Depo.Done}}done{{else if .Depo.Submitted}}wait{{else}}todo{{end}}">
<span class="dot">{{if and .Depo.Done .Depo.Rejected}}✕{{else if .Depo.Done}}✓{{else}}3{{end}}</span>
<div><div class="t">{{if and .Depo.Done .Depo.Rejected}}Операция завершена отказом · MS010{{else}}Списание проведено · MS010{{end}}</div><div class="s">{{if and .Depo.Done .Depo.Rejected}}НРД отказал в исполнении — см. отчёт{{else if .Depo.Done}}отчёт получен от НРД{{else if .Depo.Submitted}}ждём встречный 16/1 + MS010{{else}}после подачи{{end}}</div>
{{if .Depo.Responses}}<a class="ord-dl{{if .Depo.Rejected}} bad{{end}}" href="/admin/outgoing/{{.Depo.GUID}}/ms010.zip">⬇ Ответ НРД (MS010)</a>{{end}}</div>
</div>
</div>
@@ -226,6 +226,10 @@
.ord-step.wait .dot{background:#f59e0b;color:#fff;box-shadow:0 0 0 4px color-mix(in srgb,#f59e0b 28%,transparent)}
.ord-step.wait .s{color:#b45309}
.ord-step.todo{opacity:.55}
.ord-step.bad{border-color:#dc2626;background:color-mix(in srgb,#dc2626 12%,var(--card))}
.ord-step.bad .dot{background:#dc2626;color:#fff}
.ord-step.bad .s{color:#b3261e}
.ord-dl.bad{border-color:#dc2626;color:#b3261e}
.ord-dl{display:inline-block;margin-top:9px;font-size:11.5px;font-weight:700;padding:4px 10px;border-radius:8px;background:var(--card);border:1px solid #16a34a;color:#0f7a37;text-decoration:none;white-space:nowrap}
.ord-dl:hover{background:color-mix(in srgb,#16a34a 12%,var(--card))}
</style>
@@ -265,9 +269,9 @@
<div><div class="t">Поручение подано в НРД</div><div class="s">{{if .It.OrderSubmitted}}{{msk .It.OrderAt}}{{else if .AutoMode}}bj подаст автоматически по черновику{{else if .DraftReceived}}нужно подать{{else}}после черновика{{end}}</div>
{{if .It.OrderXML}}<a class="ord-dl" href="/admin/incoming/{{.It.ID}}/order.xml">⬇ Отправленная форма 16/1</a>{{end}}</div>
</div>
<div class="ord-step {{if .OrderDone}}done{{else if .It.OrderSubmitted}}wait{{else}}todo{{end}}">
<span class="dot">{{if .OrderDone}}✓{{else}}3{{end}}</span>
<div><div class="t">Проведено · MS010</div><div class="s">{{if .OrderDone}}{{if .OrderDoneAt}}{{.OrderDoneAt}}{{else}}отчёт получен от НРД{{end}}{{else if .It.OrderSubmitted}}ждём MS010 от НРД{{else}}после подачи{{end}}</div>
<div class="ord-step {{if and .OrderDone .OrderRejected}}bad{{else if .OrderDone}}done{{else if .It.OrderSubmitted}}wait{{else}}todo{{end}}">
<span class="dot">{{if and .OrderDone .OrderRejected}}✕{{else if .OrderDone}}✓{{else}}3{{end}}</span>
<div><div class="t">{{if and .OrderDone .OrderRejected}}Операция завершена отказом · MS010{{else}}Проведено · MS010{{end}}</div><div class="s">{{if and .OrderDone .OrderRejected}}НРД отказал в исполнении{{if .OrderDoneAt}} · {{.OrderDoneAt}}{{end}}{{else if .OrderDone}}{{if .OrderDoneAt}}{{.OrderDoneAt}}{{else}}отчёт получен от НРД{{end}}{{else if .It.OrderSubmitted}}ждём MS010 от НРД{{else}}после подачи{{end}}</div>
{{if .OrderResponses}}<a class="ord-dl" href="/admin/incoming/{{.It.ID}}/ms010.zip">⬇ Ответ НРД (MS010)</a>{{end}}</div>
</div>
</div>
@@ -286,7 +290,7 @@
<table style="margin-top:8px">
<thead><tr><th style="width:150px">Время</th><th>Ответ НРД</th></tr></thead>
<tbody>
{{range .OrderResponses}}<tr><td style="white-space:nowrap">{{.Time}}</td><td>{{.Kind}}</td></tr>{{end}}
{{range .OrderResponses}}<tr><td style="white-space:nowrap">{{.Time}}</td><td{{if .Rejected}} style="color:#b3261e;font-weight:600"{{end}}>{{.Kind}}</td></tr>{{end}}
</tbody>
</table>
{{else}}
@@ -349,7 +353,7 @@
<div class="st st-nrd">
<div class="tm">{{.Time}}</div>
<div class="ti">Ответ НРД</div>
<div class="de">{{.Kind}}</div>
<div class="de"{{if .Rejected}} style="color:#b3261e;font-weight:600"{{end}}>{{.Kind}}</div>
</div>
{{end}}
</aside>
@@ -176,19 +176,13 @@
<div class="card"><h2>Сверка с учётной системой</h2><p class="muted" style="margin:0">Учётная система депозитария не подключена к bj — сверку выполните вручную и подтвердите отметками ниже.</p></div>
{{end}}
{{/* ---- Обязательные отметки ---- */}}
{{if or (eq $o.Status "draft") $o.Checks}}
{{/* ---- Торговая система: блокировка (необязательно) ---- */}}
{{if or (eq $o.Status "draft") $o.QuikMode}}
<div class="card" id="checks">
<h2>Обязательные отметки перед отправкой</h2>
<h2>Блокировка в торговой системе <span class="muted" style="font-size:13px;font-weight:500">· необязательно</span></h2>
<p class="muted" style="margin:0 0 8px">Клиент, счёт, остаток и бумага проверяются сверкой с учётной системой при сохранении — отдельных отметок не нужно. Здесь только отметьте, если бумаги заблокированы в торговой системе; по умолчанию блокировка не требуется.</p>
{{$editable := and (eq $o.Status "draft") (eq .Mode "card") (can "operate")}}
<form method="post" action="/admin/manual/{{$o.ID}}/checks" id="chkForm">
{{$grp := ""}}
{{range .PreChecks}}
{{if ne .Group $grp}}<div class="mf-grp">{{.Group}}</div>{{$grp = .Group}}{{end}}
{{$m := index $o.Checks .Key}}
<label class="mf-chk"><input type="checkbox" name="check_{{.Key}}" value="1" {{if $m.Done}}checked{{end}} {{if not $editable}}disabled{{end}} onchange="this.form.submit()">
<span><div class="t">{{.Title}}</div><div class="h">{{.Hint}}</div>{{if $m.Done}}<div class="who">✓ {{$m.By}} · {{msk $m.At}}</div>{{end}}</span></label>
{{end}}
<div class="mf-grp">Торговая система (QUIK)</div>
<label class="mf-chk"><input type="radio" name="quik_mode" value="auto" {{if eq $o.QuikMode "auto"}}checked{{end}} {{if or (not $editable) (not .QuikAuto)}}disabled{{end}} onchange="this.form.submit()">
<span><div class="t">Заблокировать бумаги автоматически при отправке</div><div class="h">{{if .QuikAuto}}bj зарегистрирует в модуле неторговых поручений вывод бумаг (лимит уменьшится), по исполнению подпишет списание, по отказу снимет блокировку.{{else}}Недоступно: автоматический цикл в торговой системе не включён в настройках.{{end}}</div></span></label>
@@ -196,7 +190,7 @@
<span><div class="t">Бумаги заблокированы вручную (лимит уменьшен)</div><div class="h">Позиция клиента проверена, поручение на вывод зарегистрировано сотрудником. После ответа отметьте списание или снятие блокировки.</div>
{{if eq $o.QuikMode "manual"}}<div style="margin-top:6px;display:flex;gap:8px;align-items:center;flex-wrap:wrap"><span class="mf-note">№ поручения в торговой системе:</span><input name="quik_instr" value="{{$o.QuikInstr}}" {{if not $editable}}disabled{{end}} style="max-width:200px;padding:5px 8px;border-radius:7px;border:1px solid var(--border);background:var(--card);color:var(--text)">{{if $editable}}<button type="submit" class="btn btn-secondary" style="padding:5px 10px">Сохранить</button>{{end}}</div>{{end}}
</span></label>
<label class="mf-chk"><input type="radio" name="quik_mode" value="none" {{if eq $o.QuikMode "none"}}checked{{end}} {{if not $editable}}disabled{{end}} onchange="this.form.submit()">
<label class="mf-chk"><input type="radio" name="quik_mode" value="none" {{if or (eq $o.QuikMode "none") (eq $o.QuikMode "")}}checked{{end}} {{if not $editable}}disabled{{end}} onchange="this.form.submit()">
<span><div class="t">Блокировка в торговой системе не требуется</div><div class="h">Бумаги не учитываются в торговой системе (не на торговом разделе, клиент не пользуется брокерским обслуживанием).</div></span></label>
{{if and $editable (ne $o.QuikMode "manual")}}<input type="hidden" name="quik_instr" value="{{$o.QuikInstr}}">{{end}}
</form>
@@ -211,13 +205,16 @@
<p class="muted" style="margin:0">Отправить пока нельзя:</p>
<ul class="mf-block">{{range .Blockers}}<li>{{.}}</li>{{end}}</ul>
{{else}}
<p style="margin:0"><span class="dc-ok">Все проверки и отметки пройдены.</span> Поручение уйдёт в НРД тем же путём, что и заявка из личного кабинета{{if eq $o.QuikMode "auto"}}, бумаги будут заблокированы в торговой системе перед отправкой{{end}}.</p>
<p style="margin:0"><span class="dc-ok">Сверка пройдена, поручение готово.</span> Поручение уйдёт в НРД тем же путём, что и заявка из личного кабинета{{if eq $o.QuikMode "auto"}}, бумаги будут заблокированы в торговой системе перед отправкой{{end}}.</p>
{{end}}
{{if can "operate"}}
<div style="display:flex;gap:10px;flex-wrap:wrap;margin-top:14px;align-items:flex-start">
<form method="post" action="/admin/manual/{{$o.ID}}/send" onsubmit="return confirm('Отправить поручение {{$o.Number}} ({{$o.FullName}}) в НРД? Отменить отправку будет нельзя.')">
<button type="submit" class="btn btn-ok" {{if .Blockers}}disabled style="opacity:.5;cursor:not-allowed"{{end}}>Отправить в НРД</button>
</form>
<form method="post" action="/admin/manual/{{$o.ID}}/delete" style="margin:0" onsubmit="return confirm('Удалить черновик {{$o.Number}}? Восстановить его будет нельзя.')">
<button type="submit" class="btn btn-secondary">Удалить черновик</button>
</form>
<details><summary class="btn btn-secondary" style="list-style:none;cursor:pointer">Отклонить поручение…</summary>
<form method="post" action="/admin/manual/{{$o.ID}}/decline" style="margin-top:8px;display:flex;gap:8px;flex-wrap:wrap" onsubmit="return confirm('Отклонить поручение без отправки в НРД?')">
<input name="reason" required placeholder="причина отказа клиенту" style="min-width:320px;padding:7px 9px;border-radius:8px;border:1px solid var(--border);background:var(--card);color:var(--text)">
@@ -296,8 +293,8 @@ function mfRerun(){const f=document.getElementById('mf');if(f&&f.closest('detail
{{/* ======================= ФОРМА (новое / черновик) ======================= */}}
{{define "manualForm"}}
<form method="post" action="/admin/manual/save" id="mf">
<input type="hidden" name="id" value="{{.O.ID}}">
<form method="post" action="/admin/manual/save" id="mf" onsubmit="return mfQtyOK()">
<input type="hidden" name="id" value="{{.O.ID}}"><input type="hidden" name="depo_client" id="f_depo_client" value="">
<div class="mf-h" style="margin-top:0">1. Основание</div>
<div class="mf-grid">
<div><label class="mf-req">Дата поручения</label><input type="date" name="paper_date" value="{{.O.PaperDate}}" required></div>
@@ -384,6 +381,7 @@ async function mfPick(i,j){
if(f.doc_type)mfSet('doc_type',f.doc_type);
mfSet('doc_series',f.doc_ser||'');mfSet('doc_number',f.doc_num);mfSet('doc_issue_date',f.doc_date||'');mfSet('doc_issuer',f.doc_issuer||'');
mfSet('account',a.number);mfSet('contract',a.contract||'');if(f.phone)mfSet('phone',f.phone);
const dc=document.getElementById('f_depo_client');if(dc)dc.value=a.client_id||'';
document.getElementById('dres').innerHTML='<span class="dc-ok">Подставлено из DEPO: '+mfEsc(f.name)+', счёт '+mfEsc(a.number)+'. Сверьте с поручением клиента.</span>';
const box=document.getElementById('dpos');box.innerHTML='<span class="mf-note">Загрузка бумаг клиента…</span>';
try{const r=await (await fetch('/admin/manual/depo/positions?client='+a.client_id)).json();
@@ -394,7 +392,11 @@ async function mfPick(i,j){
}catch(e){box.innerHTML='<span class="dc-bad">'+mfEsc(e)+'</span>'}
}
let mfPos=[];
function mfSec(k){const p=mfPos[k];document.getElementById('f_isin').value=p.isin;document.getElementById('f_sname').value=p.name;const q=document.getElementById('f_qty');q.placeholder='доступно '+p.qty;q.focus()}
function mfSec(k){const p=mfPos[k];document.getElementById('f_isin').value=p.isin;document.getElementById('f_sname').value=p.name;const q=document.getElementById('f_qty');q.placeholder='доступно '+p.qty;q.dataset.avail=p.qty;q.dataset.isin=p.isin;q.focus()}
// Больше остатка на торговом разделе ввести нельзя: такому поручению дальше идти некуда.
function mfQtyOK(){const q=document.getElementById('f_qty'),isin=document.getElementById('f_isin');if(!q||!q.dataset.avail||(isin&&isin.value.toUpperCase()!==q.dataset.isin))return true;
const v=parseFloat(String(q.value).replace(',','.'));if(isNaN(v))return true;
if(v>parseFloat(q.dataset.avail)){alert('По бумаге '+q.dataset.isin+' доступно '+q.dataset.avail+' шт., в поручении '+q.value+'. Укажите не больше доступного.');q.focus();return false}return true}
// hbFind — подбор участника справочника НРД по ИНН, названию или коду (без учёта регистра и кавычек).
function hbNorm(s){return String(s||'').toLowerCase().replace(/ё/g,'е').replace(/[«»"'“”.,()]/g,' ').replace(/\s+/g,' ').trim()}
function hbFind(list,q){q=hbNorm(q);