diff --git a/internal/lkgateway/admin.go b/internal/lkgateway/admin.go index 5d88e8e..a12f745 100644 --- a/internal/lkgateway/admin.go +++ b/internal/lkgateway/admin.go @@ -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) { diff --git a/internal/lkgateway/web/templates/admin_claim.html b/internal/lkgateway/web/templates/admin_claim.html index e47dbd8..efc92be 100644 --- a/internal/lkgateway/web/templates/admin_claim.html +++ b/internal/lkgateway/web/templates/admin_claim.html @@ -30,7 +30,7 @@
{{if .Flash}}{{slice .Claim.ID 0 8}} · {{ruState .Claim.Status}}{{slice .Claim.ID 0 8}} · {{ruState .Claim.Status}}{{if and (eq .Claim.Status "draft") (can "operate")}}| Создана | {{.Claim.CreatedAt.Format "02.01.2006 15:04:05"}} | {{$f := m2mParty .TransferringDepositoryINN}}{{if $f}}{{$f}} {{end}} {{.TransferringDepositoryINN}} |
{{$t := m2mParty .ReceivingDepositoryINN}}{{if $t}}{{$t}} {{end}} {{.ReceivingDepositoryINN}} |
{{$l := liveOut .}}{{ruState .Status}} {{$l.Text}} |
- детали → | +детали →{{if and (eq .Status "draft") (can "operate")}}{{end}} | {{end}} diff --git a/internal/m2mcore/deletedraft_test.go b/internal/m2mcore/deletedraft_test.go new file mode 100644 index 0000000..5816b24 --- /dev/null +++ b/internal/m2mcore/deletedraft_test.go @@ -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") + } +} diff --git a/internal/m2mcore/pgrepo.go b/internal/m2mcore/pgrepo.go index 4f18544..7828b26 100644 --- a/internal/m2mcore/pgrepo.go +++ b/internal/m2mcore/pgrepo.go @@ -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) +} diff --git a/internal/m2mcore/repo.go b/internal/m2mcore/repo.go index 1a341f3..ef05b09 100644 --- a/internal/m2mcore/repo.go +++ b/internal/m2mcore/repo.go @@ -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 +}