From 2c2316fb5e7f23f6521d8ab57058df6c8ca1389d Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Sat, 11 Jul 2026 12:53:37 +0200 Subject: [PATCH] chore(catalog): order the admin catalog list like the public offer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- backend/internal/payments/catalog_admin.go | 62 +++++++++++++++++++ backend/internal/payments/catalog_test.go | 44 +++++++++++++ backend/internal/payments/offer.go | 16 +++-- .../internal/server/handlers_admin_catalog.go | 3 + 4 files changed, 120 insertions(+), 5 deletions(-) diff --git a/backend/internal/payments/catalog_admin.go b/backend/internal/payments/catalog_admin.go index 401ec98..e2d351c 100644 --- a/backend/internal/payments/catalog_admin.go +++ b/backend/internal/payments/catalog_admin.go @@ -1,9 +1,12 @@ package payments import ( + "cmp" "context" "errors" "fmt" + "math" + "slices" "strings" "github.com/google/uuid" @@ -147,6 +150,65 @@ func (s *Service) AdminCatalog(ctx context.Context) ([]AdminProduct, error) { 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 // product created active must satisfy the sellable shape. func (s *Service) CreateProduct(ctx context.Context, in ProductInput, active bool) (uuid.UUID, error) { diff --git a/backend/internal/payments/catalog_test.go b/backend/internal/payments/catalog_test.go index a243a98..132fa9a 100644 --- a/backend/internal/payments/catalog_test.go +++ b/backend/internal/payments/catalog_test.go @@ -134,3 +134,47 @@ func TestProjectCatalog_Empty(t *testing.T) { 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 +} diff --git a/backend/internal/payments/offer.go b/backend/internal/payments/offer.go index 0fc4cc0..f4c6392 100644 --- a/backend/internal/payments/offer.go +++ b/backend/internal/payments/offer.go @@ -109,11 +109,7 @@ func projectOfferPricing(entries []catalogEntry) string { return strings.TrimRight(b.String(), "\n") } -// offerValueGroup ranks a chip-priced value into the offer's usage groups, 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). +// offerValueGroup ranks a chip-priced value into the usage groups (see [valueGroup]). func offerValueGroup(e catalogEntry) int { hasHints, hasNoAds, hasTournament := false, false, false for _, a := range e.atoms { @@ -126,6 +122,16 @@ func offerValueGroup(e catalogEntry) int { 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 { case hasTournament: return 3 diff --git a/backend/internal/server/handlers_admin_catalog.go b/backend/internal/server/handlers_admin_catalog.go index aca135b..9912d08 100644 --- a/backend/internal/server/handlers_admin_catalog.go +++ b/backend/internal/server/handlers_admin_catalog.go @@ -41,6 +41,9 @@ func (s *Server) consoleCatalog(c *gin.Context) { s.consoleError(c, err) 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 for _, p := range products { view.Products = append(view.Products, catalogRow(p))