Исходящие: удаление черновика (кнопка в карточке и в журнале) — только в статусе draft, с снятием поручения QUIK МНП, если бумаги заблокированы

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014FsEKyJrYF69Cj9ZXzFLRi
This commit is contained in:
zuevav
2026-09-25 15:22:08 +03:00
parent 4b81862a90
commit 16bfedb85f
6 changed files with 140 additions and 3 deletions
+56 -1
View File
@@ -6,7 +6,9 @@ import (
"encoding/json"
"fmt"
"html/template"
"log"
"net/http"
"net/url"
"path"
"strings"
"time"
@@ -392,6 +394,7 @@ type homeData struct {
type claimsData struct {
page
Items []ClaimView
Flash string
}
// claimData — данные карточки.
@@ -442,6 +445,9 @@ func RegisterAdmin(mux *http.ServeMux, svc *Service, rc *RuntimeConfig, getOpts
case strings.HasPrefix(p, "claims/") && strings.HasSuffix(p, "/export.zip"):
id := strings.TrimSuffix(strings.TrimPrefix(p, "claims/"), "/export.zip")
a.exportClaim(w, r, svc, id)
case strings.HasPrefix(p, "claims/") && strings.HasSuffix(p, "/delete-draft"):
id := strings.TrimSuffix(strings.TrimPrefix(p, "claims/"), "/delete-draft")
a.deleteDraft(w, r, svc, id)
case strings.HasPrefix(p, "claims/"):
id := strings.TrimPrefix(p, "claims/")
a.renderClaim(w, r, svc, id)
@@ -527,7 +533,56 @@ func (a *admin) renderClaims(w http.ResponseWriter, r *http.Request, svc *Servic
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
render(w, a.claims, claimsData{page: nowPage("Заявки", "claims"), Items: pageData.Items})
render(w, a.claims, claimsData{page: nowPage("Заявки", "claims"), Items: pageData.Items, Flash: r.URL.Query().Get("flash")})
}
// deleteDraft — POST /admin/claims/{id}/delete-draft: удалить черновик (заявка ещё не уходила в НРД).
// Если по нему бумаги уже заблокированы в QUIK МНП — сначала поручение снимается.
func (a *admin) deleteDraft(w http.ResponseWriter, r *http.Request, svc *Service, id string) {
if r.Method != http.MethodPost {
http.Error(w, "method", http.StatusMethodNotAllowed)
return
}
id = path.Base(id)
back := func(to, msg string) { http.Redirect(w, r, to+"?flash="+url.QueryEscape(msg), http.StatusSeeOther) }
d, err := svc.Repo().GetByID(r.Context(), id)
if err != nil {
back("/admin/claims", "Заявка не найдена")
return
}
if d.State != m2mcore.StateDraft {
back("/admin/claims/"+id, "Удалить можно только черновик: эта заявка уже ушла в НРД")
return
}
dd, ok := svc.Repo().(m2mcore.DraftDeleter)
if !ok {
back("/admin/claims/"+id, "Хранилище заявок не поддерживает удаление")
return
}
var notes []string
for _, l := range globalQuikLinks.ForGUID(string(d.GUID)) {
if l.State == "registered" && globalRC != nil {
if _, err := QuikOutDeny(r.Context(), globalRC, l.GUID, l.ISIN, "черновик удалён оператором"); err != nil {
back("/admin/claims/"+id, "Черновик не удалён: не удалось снять поручение в QUIK МНП по "+l.ISIN+" — "+err.Error())
return
}
notes = append(notes, "поручение QUIK по "+l.ISIN+" снято")
}
}
if err := dd.DeleteDraft(r.Context(), id); err != nil {
back("/admin/claims/"+id, "Черновик не удалён: "+err.Error())
return
}
actor := ""
if u := currentUser(w); u != nil {
actor = u.Login
}
log.Printf("admin: черновик %s (GUID %s) удалён %s; %s", id, d.GUID, actor, strings.Join(notes, "; "))
msg := "Черновик " + id[:8] + " удалён"
if len(notes) > 0 {
msg += ": " + strings.Join(notes, "; ")
}
back("/admin/claims", msg)
}
func (a *admin) renderClaim(w http.ResponseWriter, r *http.Request, svc *Service, id string) {
@@ -30,7 +30,7 @@
<p style="margin:0 0 12px"><a href="/admin/claims" class="btn" onclick="if(history.length>1){history.back();return false}">← Назад</a></p>
{{if .Flash}}<div class="card" style="background:var(--accent-weak,#e7f0fb)">{{.Flash}}</div>{{end}}
<div class="card">
<h2>Заявка <code>{{slice .Claim.ID 0 8}}</code> · <span class="badge {{.Claim.Status}}">{{ruState .Claim.Status}}</span></h2>
<h2 style="display:flex;align-items:center;gap:10px;flex-wrap:wrap">Заявка <code>{{slice .Claim.ID 0 8}}</code> · <span class="badge {{.Claim.Status}}">{{ruState .Claim.Status}}</span>{{if and (eq .Claim.Status "draft") (can "operate")}}<form method="post" action="/admin/claims/{{.Claim.ID}}/delete-draft" style="margin:0 0 0 auto" onsubmit="return confirm('Удалить черновик? Заявка ещё не уходила в НРД. Если по ней бумаги заблокированы в QUIK МНП, блокировка будет снята.');"><button type="submit" class="btn" style="background:var(--card);color:var(--err,#c0392b);border:1px solid var(--err,#c0392b)">Удалить черновик</button></form>{{end}}</h2>
{{$l := liveOut .Claim}}<div class="live live-big live-{{$l.Kind}}" data-live="out:{{.Claim.ID}}"{{if $l.Active}} data-active="1"{{else}} title="Завершено: состояние больше не проверяется"{{end}}>{{$l.Text}}</div>
<table>
<tr><td style="width:200px" class="muted">Создана</td><td>{{.Claim.CreatedAt.Format "02.01.2006 15:04:05"}}</td></tr>
@@ -1,4 +1,5 @@
{{define "content"}}
{{if .Flash}}<div class="card" style="background:var(--accent-weak,#e7f0fb)">{{.Flash}}</div>{{end}}
<div class="card">
<h2>Журнал заявок ({{len .Items}})</h2>
{{if .Items}}
@@ -15,7 +16,7 @@
<td>{{$f := m2mParty .TransferringDepositoryINN}}{{if $f}}<span style="font-size:11px">{{$f}}</span><br>{{end}}<code style="font-size:11px">{{.TransferringDepositoryINN}}</code></td>
<td>{{$t := m2mParty .ReceivingDepositoryINN}}{{if $t}}<span style="font-size:11px">{{$t}}</span><br>{{end}}<code style="font-size:11px">{{.ReceivingDepositoryINN}}</code></td>
<td>{{$l := liveOut .}}<span class="badge {{.Status}}">{{ruState .Status}}</span><div class="live live-{{$l.Kind}}" data-live="out:{{.ID}}"{{if $l.Active}} data-active="1"{{else}} title="Завершено: состояние больше не проверяется"{{end}}>{{$l.Text}}</div></td>
<td><a href="/admin/claims/{{.ID}}">детали →</a></td>
<td><a href="/admin/claims/{{.ID}}">детали →</a>{{if and (eq .Status "draft") (can "operate")}}<form method="post" action="/admin/claims/{{.ID}}/delete-draft" style="margin:4px 0 0" onsubmit="return confirm('Удалить черновик? Заявка ещё не уходила в НРД. Если по ней бумаги заблокированы в QUIK МНП, блокировка будет снята.');"><button type="submit" class="btn" style="padding:2px 10px;font-size:12px;background:var(--card);color:var(--err,#c0392b);border:1px solid var(--err,#c0392b)">Удалить</button></form>{{end}}</td>
</tr>
{{end}}
</tbody>
+32
View File
@@ -0,0 +1,32 @@
package m2mcore
import (
"context"
"errors"
"testing"
)
func TestMemoryDeleteDraft(t *testing.T) {
r := NewMemoryRepository()
ctx := context.Background()
d := &Deal{ID: "d1", GUID: "g1", State: StateDraft}
if _, err := r.Create(ctx, d); err != nil {
t.Fatal(err)
}
s := &Deal{ID: "d2", GUID: "g2", State: StateSubmittedToNSD}
if _, err := r.Create(ctx, s); err != nil {
t.Fatal(err)
}
if err := r.DeleteDraft(ctx, "d2"); !errors.Is(err, ErrNotDraft) {
t.Fatalf("ушедшую в НРД удалять нельзя: %v", err)
}
if err := r.DeleteDraft(ctx, "d1"); err != nil {
t.Fatal(err)
}
if _, err := r.GetByID(ctx, "d1"); err == nil {
t.Fatal("черновик должен быть удалён")
}
if _, err := r.GetByGUID(ctx, "g1"); err == nil {
t.Fatal("черновик должен быть удалён и по GUID")
}
}
+24
View File
@@ -299,3 +299,27 @@ func marshalXMLIfPresent(v any) ([]byte, error) {
var (
_ Repository = (*PostgresRepository)(nil)
)
// DeleteDraft удаляет сделку в состоянии draft вместе с её событиями (одной транзакцией; состояние
// проверяется в самом запросе — заявку, успевшую уйти в НРД, не удалить).
func (r *PostgresRepository) DeleteDraft(ctx context.Context, id string) error {
tx, err := r.pool.Begin(ctx)
if err != nil {
return fmt.Errorf("PostgresRepository.DeleteDraft: %w", err)
}
defer func() { _ = tx.Rollback(ctx) }()
var state string
if err := tx.QueryRow(ctx, `SELECT state FROM m2m_core.deals WHERE id=$1::uuid FOR UPDATE`, id).Scan(&state); err != nil {
return ErrNotFound
}
if state != string(StateDraft) {
return ErrNotDraft
}
if _, err := tx.Exec(ctx, `DELETE FROM m2m_core.deal_events WHERE deal_id=$1::uuid`, id); err != nil {
return fmt.Errorf("PostgresRepository.DeleteDraft events: %w", err)
}
if _, err := tx.Exec(ctx, `DELETE FROM m2m_core.deals WHERE id=$1::uuid AND state=$2`, id, string(StateDraft)); err != nil {
return fmt.Errorf("PostgresRepository.DeleteDraft: %w", err)
}
return tx.Commit(ctx)
}
+25
View File
@@ -153,3 +153,28 @@ func (r *MemoryRepository) EventsOf(dealID string) []Event {
copy(out, src)
return out
}
// ErrNotDraft — удалить можно только черновик (заявку, которая ещё не уходила в НРД).
var ErrNotDraft = errors.New("m2mcore: удалить можно только черновик")
// DraftDeleter — хранилище умеет удалять черновики (сделка в состоянии draft вместе с событиями).
type DraftDeleter interface {
DeleteDraft(ctx context.Context, id string) error
}
// DeleteDraft удаляет сделку, если она в состоянии draft.
func (r *MemoryRepository) DeleteDraft(_ context.Context, id string) error {
r.mu.Lock()
defer r.mu.Unlock()
d, ok := r.byID[id]
if !ok {
return ErrNotFound
}
if d.State != StateDraft {
return ErrNotDraft
}
delete(r.byID, id)
delete(r.byGUID, d.GUID)
delete(r.events, id)
return nil
}