chore(catalog): order the admin catalog list like the public offer
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 26s
CI / ui (pull_request) Successful in 1m11s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m50s

Sales (chip packs) first, ascending by rouble price; then the chip-exchange
values, grouped and price-sorted the same way projectOfferPricing lists them, so
the /catalog console mirrors what a buyer sees. Internal cosmetics only — no
product behaviour change. The value-group order moves to a shared helper
(valueGroup) so the offer and the admin list cannot drift.
This commit is contained in:
Ilia Denisov
2026-07-11 12:53:37 +02:00
parent bb71e7b1c7
commit 2c2316fb5e
4 changed files with 120 additions and 5 deletions
@@ -1,9 +1,12 @@
package payments package payments
import ( import (
"cmp"
"context" "context"
"errors" "errors"
"fmt" "fmt"
"math"
"slices"
"strings" "strings"
"github.com/google/uuid" "github.com/google/uuid"
@@ -147,6 +150,65 @@ func (s *Service) AdminCatalog(ctx context.Context) ([]AdminProduct, error) {
return s.store.adminCatalog(ctx) return s.store.adminCatalog(ctx)
} }
// SortAdminCatalog orders an admin catalog list in place the same way the public offer lists products
// ([projectOfferPricing]): chip packs first, ascending by rouble price; then chip-priced values,
// grouped (hints only, no-ads only, no-ads + hints, tournament) and ascending by chip price within a
// group. It is stable, so equal-keyed products keep their incoming order, and it keys archived
// products the same as active ones (the admin list shows both).
func SortAdminCatalog(products []AdminProduct) {
slices.SortStableFunc(products, func(a, b AdminProduct) int {
if pa, pb := adminIsPack(a), adminIsPack(b); pa != pb {
if pa {
return -1 // packs (sales) before values (chip exchange)
}
return 1
} else if pa {
return cmp.Compare(adminPriceAmount(a, string(SourceDirect), CurrencyRUB), adminPriceAmount(b, string(SourceDirect), CurrencyRUB))
}
if d := cmp.Compare(adminValueGroup(a), adminValueGroup(b)); d != 0 {
return d
}
return cmp.Compare(adminPriceAmount(a, "", CurrencyChip), adminPriceAmount(b, "", CurrencyChip))
})
}
// adminIsPack reports whether the product is a chip pack (it carries the chips atom).
func adminIsPack(p AdminProduct) bool {
for _, a := range p.Atoms {
if a.Atom == atomChips {
return true
}
}
return false
}
// adminValueGroup ranks a chip-priced value into the shared listing groups (see [valueGroup]).
func adminValueGroup(p AdminProduct) int {
hasHints, hasNoAds, hasTournament := false, false, false
for _, a := range p.Atoms {
switch a.Atom {
case "hints":
hasHints = true
case "noads_days":
hasNoAds = true
case "tournament":
hasTournament = true
}
}
return valueGroup(hasHints, hasNoAds, hasTournament)
}
// adminPriceAmount returns the minor-unit amount of the product's price for the method and currency,
// or math.MaxInt64 when it carries no such price (so a misconfigured product sorts last, not first).
func adminPriceAmount(p AdminProduct, method string, cur Currency) int64 {
for _, pr := range p.Prices {
if pr.Method == method && pr.Currency == cur {
return pr.Amount
}
}
return math.MaxInt64
}
// CreateProduct validates and inserts a new product with its atoms and prices, returning its id. A // CreateProduct validates and inserts a new product with its atoms and prices, returning its id. A
// product created active must satisfy the sellable shape. // product created active must satisfy the sellable shape.
func (s *Service) CreateProduct(ctx context.Context, in ProductInput, active bool) (uuid.UUID, error) { func (s *Service) CreateProduct(ctx context.Context, in ProductInput, active bool) (uuid.UUID, error) {
+44
View File
@@ -134,3 +134,47 @@ func TestProjectCatalog_Empty(t *testing.T) {
t.Errorf("empty catalog projected %d products, want 0", len(got.Products)) t.Errorf("empty catalog projected %d products, want 0", len(got.Products))
} }
} }
// TestSortAdminCatalog checks the admin list is ordered like the public offer: chip packs first,
// ascending by rouble price; then values, grouped (hints only -> no-ads only -> no-ads + hints) and
// ascending by chip price within a group. Stable and applied to active + archived alike.
func TestSortAdminCatalog(t *testing.T) {
pack := func(title string, rub int64) AdminProduct {
return AdminProduct{
Title: title,
Atoms: []AtomLine{{Atom: atomChips, Quantity: 1}},
Prices: []PriceLine{{Method: string(SourceDirect), Currency: CurrencyRUB, Amount: rub}},
}
}
value := func(title string, chips int64, atoms ...string) AdminProduct {
p := AdminProduct{Title: title, Prices: []PriceLine{{Currency: CurrencyChip, Amount: chips}}}
for _, a := range atoms {
p.Atoms = append(p.Atoms, AtomLine{Atom: a, Quantity: 1})
}
return p
}
products := []AdminProduct{
pack("packDear", 30000),
value("bundle", 500, "hints", "noads_days"),
pack("packCheap", 10000),
value("adsOnly", 150, "noads_days"),
value("hintsBig", 200, "hints"),
value("hintsSmall", 50, "hints"),
}
SortAdminCatalog(products)
want := []string{"packCheap", "packDear", "hintsSmall", "hintsBig", "adsOnly", "bundle"}
for i, p := range products {
if p.Title != want[i] {
t.Fatalf("order[%d] = %q, want %q (full: %v)", i, p.Title, want[i], titles(products))
}
}
}
// titles extracts the product titles for a failure message.
func titles(products []AdminProduct) []string {
out := make([]string, len(products))
for i, p := range products {
out[i] = p.Title
}
return out
}
+11 -5
View File
@@ -109,11 +109,7 @@ func projectOfferPricing(entries []catalogEntry) string {
return strings.TrimRight(b.String(), "\n") return strings.TrimRight(b.String(), "\n")
} }
// offerValueGroup ranks a chip-priced value into the offer's usage groups, in listing order: hints // offerValueGroup ranks a chip-priced value into the usage groups (see [valueGroup]).
// only (0), no-ads only (1), no-ads + hints (2), then anything carrying the tournament atom (3).
// Tournament products are not sellable yet (validateProduct forbids an active one), so group 3 is
// empty today; the rank reserves their place for when the tournament economy lands. A value with no
// recognised benefit atom sorts after the known groups (defensive — the catalog shape forbids it).
func offerValueGroup(e catalogEntry) int { func offerValueGroup(e catalogEntry) int {
hasHints, hasNoAds, hasTournament := false, false, false hasHints, hasNoAds, hasTournament := false, false, false
for _, a := range e.atoms { for _, a := range e.atoms {
@@ -126,6 +122,16 @@ func offerValueGroup(e catalogEntry) int {
hasTournament = true hasTournament = true
} }
} }
return valueGroup(hasHints, hasNoAds, hasTournament)
}
// valueGroup ranks a chip-priced value into the listing groups shared by the public offer and the
// admin catalog, in listing order: hints only (0), no-ads only (1), no-ads + hints (2), then anything
// carrying the tournament atom (3). Tournament products are not sellable yet (validateProduct forbids
// an active one), so group 3 is empty today; the rank reserves their place for when the tournament
// economy lands. A value with no recognised benefit atom sorts after the known groups (defensive —
// the catalog shape forbids it).
func valueGroup(hasHints, hasNoAds, hasTournament bool) int {
switch { switch {
case hasTournament: case hasTournament:
return 3 return 3
@@ -41,6 +41,9 @@ func (s *Server) consoleCatalog(c *gin.Context) {
s.consoleError(c, err) s.consoleError(c, err)
return return
} }
// Order the list like the public offer: sales (chip packs) first, then the chip-exchange values,
// grouped and price-sorted the same way, so the console mirrors what a buyer sees.
payments.SortAdminCatalog(products)
var view adminconsole.CatalogView var view adminconsole.CatalogView
for _, p := range products { for _, p := range products {
view.Products = append(view.Products, catalogRow(p)) view.Products = append(view.Products, catalogRow(p))