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
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:
@@ -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) {
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
Reference in New Issue
Block a user