Files
bssapp/svc/routes/product_series.go
2026-06-23 17:37:54 +03:00

714 lines
22 KiB
Go

package routes
import (
"bssapp-backend/db"
"bssapp-backend/queries"
"context"
"database/sql"
"encoding/json"
"fmt"
"net/http"
"sort"
"strconv"
"strings"
"time"
"github.com/gorilla/mux"
"github.com/lib/pq"
)
type productSeriesDefinition struct {
ID int64 `json:"id"`
Code string `json:"code"`
Title string `json:"title"`
IsActive bool `json:"is_active"`
ParentFilter string `json:"parent_filter"`
SortOrder int `json:"sort_order"`
Notes string `json:"notes"`
}
type productSeriesStockRawRow struct {
ProductCode string
ProductDescription string
ColorCode string
ColorTitle string
Dim3Code string
SizeCode string
Qty float64
UrunAnaGrubu string
UrunAltGrubu string
Marka string
DropVal string
Fit string
UrunIcerigi string
Kategori string
}
type productSeriesMappingRow struct {
RowKey string `json:"row_key"`
ProductCode string `json:"product_code"`
ProductDescription string `json:"product_description"`
ColorCode string `json:"color_code"`
ColorTitle string `json:"color_title"`
Dim3Code string `json:"dim3_code"`
UrunAnaGrubu string `json:"urun_ana_grubu"`
UrunAltGrubu string `json:"urun_alt_grubu"`
Marka string `json:"marka"`
DropVal string `json:"drop_val"`
Fit string `json:"fit"`
UrunIcerigi string `json:"urun_icerigi"`
Kategori string `json:"kategori"`
SizeQty map[string]float64 `json:"size_qty"`
TotalQty float64 `json:"total_qty"`
SeriesIDs []int64 `json:"series_ids"`
Series []productSeriesDefinition `json:"series"`
MmitemID int64 `json:"mmitem_id"`
Dim1ID int64 `json:"dim1_id"`
Dim3ID int64 `json:"dim3_id"`
MappingReady bool `json:"mapping_ready"`
MappingWarning string `json:"mapping_warning"`
}
type productSeriesMappingsResponse struct {
Rows []productSeriesMappingRow `json:"rows"`
SizeColumns []string `json:"size_columns"`
Definitions []productSeriesDefinition `json:"definitions"`
}
func GetProductSeriesDefinitionsHandler(pg *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
defer cancel()
defs, err := listProductSeriesDefinitions(ctx, pg, false)
if err != nil {
http.Error(w, "Seri tanimlari alinamadi: "+err.Error(), http.StatusInternalServerError)
return
}
writeJSON(w, defs)
}
}
func PostProductSeriesDefinitionHandler(pg *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req productSeriesDefinition
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Gecersiz istek", http.StatusBadRequest)
return
}
req.Code = strings.TrimSpace(req.Code)
req.Title = strings.TrimSpace(req.Title)
if req.Code == "" {
http.Error(w, "Seri kodu zorunludur", http.StatusBadRequest)
return
}
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
defer cancel()
err := pg.QueryRowContext(ctx, `
INSERT INTO dfgrp (code, title, is_active, typ, master, parent_filter, sort_order, is_required, notes)
VALUES ($1, $2, COALESCE($3, TRUE), 'opt', 'zbggseri', $4, $5, FALSE, $6)
RETURNING id
`, req.Code, req.Title, req.IsActive, req.ParentFilter, req.SortOrder, req.Notes).Scan(&req.ID)
if err != nil {
http.Error(w, "Seri tanimi eklenemedi: "+err.Error(), http.StatusInternalServerError)
return
}
writeJSON(w, req)
}
}
func PutProductSeriesDefinitionHandler(pg *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
id, _ := strconv.ParseInt(mux.Vars(r)["id"], 10, 64)
if id <= 0 {
http.Error(w, "Gecersiz id", http.StatusBadRequest)
return
}
var req productSeriesDefinition
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Gecersiz istek", http.StatusBadRequest)
return
}
req.Code = strings.TrimSpace(req.Code)
req.Title = strings.TrimSpace(req.Title)
if req.Code == "" {
http.Error(w, "Seri kodu zorunludur", http.StatusBadRequest)
return
}
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
defer cancel()
res, err := pg.ExecContext(ctx, `
UPDATE dfgrp
SET code=$2,
title=$3,
is_active=$4,
parent_filter=$5,
sort_order=$6,
notes=$7
WHERE id=$1 AND master='zbggseri'
`, id, req.Code, req.Title, req.IsActive, req.ParentFilter, req.SortOrder, req.Notes)
if err != nil {
http.Error(w, "Seri tanimi guncellenemedi: "+err.Error(), http.StatusInternalServerError)
return
}
if n, _ := res.RowsAffected(); n == 0 {
http.Error(w, "Seri tanimi bulunamadi", http.StatusNotFound)
return
}
req.ID = id
writeJSON(w, req)
}
}
func DeleteProductSeriesDefinitionHandler(pg *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
id, _ := strconv.ParseInt(mux.Vars(r)["id"], 10, 64)
if id <= 0 {
http.Error(w, "Gecersiz id", http.StatusBadRequest)
return
}
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
defer cancel()
if _, err := pg.ExecContext(ctx, `UPDATE dfgrp SET is_active=FALSE WHERE id=$1 AND master='zbggseri'`, id); err != nil {
http.Error(w, "Seri tanimi silinemedi: "+err.Error(), http.StatusInternalServerError)
return
}
writeJSON(w, map[string]any{"ok": true})
}
}
func GetProductSeriesMappingsHandler(pg *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
f := readStockAttrFilters(r)
limit := 0
if raw := strings.TrimSpace(r.URL.Query().Get("limit")); raw != "" {
if n, err := strconv.Atoi(raw); err == nil && n >= 0 {
limit = n
}
}
search := firstNonEmpty(r.URL.Query().Get("q"), r.URL.Query().Get("code"), r.URL.Query().Get("product_code"))
ctx, cancel := context.WithTimeout(r.Context(), 120*time.Second)
defer cancel()
msRows, err := db.MssqlDB.QueryContext(ctx, queries.GetProductSeriesStockRowsQuery,
f.kategori,
f.urunAnaGrubu,
joinFilterValues(f.urunAltGrubu),
joinFilterValues(f.renk),
joinFilterValues(f.renk2),
joinFilterValues(f.urunIcerigi),
joinFilterValues(f.fit),
joinFilterValues(f.drop),
joinFilterValues(f.beden),
strconv.Itoa(limit),
search,
)
if err != nil {
http.Error(w, "Stok seri listesi alinamadi: "+err.Error(), http.StatusInternalServerError)
return
}
defer msRows.Close()
grouped := map[string]*productSeriesMappingRow{}
codeSet := map[string]struct{}{}
colorSet := map[string]struct{}{}
dim3Set := map[string]struct{}{}
sizeSet := map[string]struct{}{}
for msRows.Next() {
var raw productSeriesStockRawRow
if err := msRows.Scan(
&raw.ProductCode,
&raw.ProductDescription,
&raw.ColorCode,
&raw.ColorTitle,
&raw.Dim3Code,
&raw.SizeCode,
&raw.Qty,
&raw.UrunAnaGrubu,
&raw.UrunAltGrubu,
&raw.Marka,
&raw.DropVal,
&raw.Fit,
&raw.UrunIcerigi,
&raw.Kategori,
); err != nil {
http.Error(w, "Stok satiri okunamadi: "+err.Error(), http.StatusInternalServerError)
return
}
raw.ProductCode = strings.TrimSpace(raw.ProductCode)
raw.ColorCode = strings.TrimSpace(raw.ColorCode)
raw.Dim3Code = strings.TrimSpace(raw.Dim3Code)
raw.SizeCode = strings.TrimSpace(raw.SizeCode)
key := productSeriesKey(raw.ProductCode, raw.ColorCode, raw.Dim3Code)
row := grouped[key]
if row == nil {
row = &productSeriesMappingRow{
RowKey: key,
ProductCode: raw.ProductCode,
ProductDescription: strings.TrimSpace(raw.ProductDescription),
ColorCode: raw.ColorCode,
ColorTitle: strings.TrimSpace(raw.ColorTitle),
Dim3Code: raw.Dim3Code,
UrunAnaGrubu: strings.TrimSpace(raw.UrunAnaGrubu),
UrunAltGrubu: strings.TrimSpace(raw.UrunAltGrubu),
Marka: strings.TrimSpace(raw.Marka),
DropVal: strings.TrimSpace(raw.DropVal),
Fit: strings.TrimSpace(raw.Fit),
UrunIcerigi: strings.TrimSpace(raw.UrunIcerigi),
Kategori: strings.TrimSpace(raw.Kategori),
SizeQty: map[string]float64{},
SeriesIDs: []int64{},
Series: []productSeriesDefinition{},
}
grouped[key] = row
codeSet[raw.ProductCode] = struct{}{}
colorSet[raw.ColorCode] = struct{}{}
if raw.Dim3Code != "" {
dim3Set[raw.Dim3Code] = struct{}{}
}
}
if raw.SizeCode != "" {
row.SizeQty[raw.SizeCode] = raw.Qty
sizeSet[raw.SizeCode] = struct{}{}
}
row.TotalQty += raw.Qty
}
if err := msRows.Err(); err != nil {
http.Error(w, "Stok satirlari okunamadi: "+err.Error(), http.StatusInternalServerError)
return
}
defs, err := listProductSeriesDefinitions(ctx, pg, true)
if err != nil {
http.Error(w, "Seri tanimlari alinamadi: "+err.Error(), http.StatusInternalServerError)
return
}
defByID := make(map[int64]productSeriesDefinition, len(defs))
for _, d := range defs {
defByID[d.ID] = d
}
codes := setToSortedSlice(codeSet)
mmitemByCode, _ := loadMmitemIDs(ctx, pg, codes)
dim1ByToken, _ := loadDimTokenIDs(ctx, pg, "dimval1", setToSortedSlice(colorSet))
dim3ByToken, _ := loadDimTokenIDs(ctx, pg, "dimval3", setToSortedSlice(dim3Set))
existing, _ := loadProductSeriesAssignments(ctx, pg, codes)
// Per-request cache for per-mmitem dimval3 inference to avoid repeated dfblob scans.
inferCache := map[string]int64{}
inferDim3ForMmitem := func(mmitemID int64, token string) int64 {
mmitemID = int64(mmitemID)
tok := strings.ToUpper(normalizeDimParam(token))
if mmitemID <= 0 || tok == "" {
return 0
}
key := fmt.Sprintf("%d|%s", mmitemID, tok)
if v, ok := inferCache[key]; ok {
return v
}
// Use the same pattern approach as resolveDimvalFromFileNameToken, but scoped to src_id.
patterns := buildNameLikePatterns(tok)
if len(patterns) == 0 {
inferCache[key] = 0
return 0
}
var dimv string
err := pg.QueryRowContext(ctx, `
SELECT x.dimv
FROM (
SELECT COALESCE(dimval3::text, '') AS dimv, COUNT(*) AS cnt
FROM dfblob
WHERE src_table='mmitem'
AND typ='img'
AND src_id=$7
AND COALESCE(dimval3::text, '') <> ''
AND (
UPPER(COALESCE(file_name,'')) LIKE $1 OR
UPPER(COALESCE(file_name,'')) LIKE $2 OR
UPPER(COALESCE(file_name,'')) LIKE $3 OR
UPPER(COALESCE(file_name,'')) LIKE $4 OR
UPPER(COALESCE(file_name,'')) LIKE $5 OR
UPPER(COALESCE(file_name,'')) LIKE $6
)
GROUP BY COALESCE(dimval3::text, '')
) x
ORDER BY x.cnt DESC, x.dimv
LIMIT 1
`, patterns[0], patterns[1], patterns[2], patterns[3], patterns[4], patterns[5], mmitemID).Scan(&dimv)
if err != nil {
inferCache[key] = 0
return 0
}
dimv = strings.TrimSpace(dimv)
if dimv == "" || dimv == "0" {
inferCache[key] = 0
return 0
}
id, err := strconv.ParseInt(dimv, 10, 64)
if err != nil || id <= 0 {
inferCache[key] = 0
return 0
}
inferCache[key] = id
return id
}
out := make([]productSeriesMappingRow, 0, len(grouped))
for _, row := range grouped {
row.MmitemID = mmitemByCode[row.ProductCode]
row.Dim1ID = dim1ByToken[row.ColorCode]
if row.Dim3Code != "" {
// dimval3 tokens can be ambiguous globally; prefer per-mmitem inference.
if inferred := inferDim3ForMmitem(row.MmitemID, row.Dim3Code); inferred > 0 {
row.Dim3ID = inferred
} else {
row.Dim3ID = dim3ByToken[row.Dim3Code]
}
}
row.MappingReady = row.MmitemID > 0 && row.Dim1ID > 0 && (row.Dim3Code == "" || row.Dim3ID > 0)
if !row.MappingReady {
row.MappingWarning = "PG urun veya varyant token eslesmesi bulunamadi"
}
assignKey := assignmentKey(row.ProductCode, row.Dim1ID, row.Dim3ID)
appendSeries := func(key string) {
for _, id := range existing[key] {
if d, ok := defByID[id]; ok {
row.SeriesIDs = append(row.SeriesIDs, id)
row.Series = append(row.Series, d)
}
}
}
appendSeries(assignKey)
// Fallback: if we couldn't match dim3-specific assignments (dim3 token ambiguity / missing),
// show the default (dim3 NULL) assignments for the same product+color.
if len(row.SeriesIDs) == 0 && row.Dim3Code != "" && row.Dim1ID > 0 {
appendSeries(assignmentKey(row.ProductCode, row.Dim1ID, 0))
}
sort.Slice(row.Series, func(i, j int) bool { return row.Series[i].Code < row.Series[j].Code })
sort.Slice(row.SeriesIDs, func(i, j int) bool { return row.SeriesIDs[i] < row.SeriesIDs[j] })
out = append(out, *row)
}
productTotals := productSeriesTotalQtyByCode(out)
sort.Slice(out, func(i, j int) bool {
totalI := productTotals[out[i].ProductCode]
totalJ := productTotals[out[j].ProductCode]
if totalI != totalJ {
return totalI > totalJ
}
if out[i].ProductCode != out[j].ProductCode {
return out[i].ProductCode < out[j].ProductCode
}
if out[i].ColorCode != out[j].ColorCode {
return out[i].ColorCode < out[j].ColorCode
}
return out[i].Dim3Code < out[j].Dim3Code
})
writeJSON(w, productSeriesMappingsResponse{
Rows: out,
SizeColumns: sortSizeColumns(setToSortedSlice(sizeSet)),
Definitions: defs,
})
}
}
func productSeriesTotalQtyByCode(rows []productSeriesMappingRow) map[string]float64 {
out := make(map[string]float64, len(rows))
for _, row := range rows {
out[row.ProductCode] += row.TotalQty
}
return out
}
type saveProductSeriesMappingsRequest struct {
Items []struct {
ProductCode string `json:"product_code"`
ColorCode string `json:"color_code"`
Dim3Code string `json:"dim3_code"`
SeriesIDs []int64 `json:"series_ids"`
} `json:"items"`
}
func PostProductSeriesMappingsSaveHandler(pg *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req saveProductSeriesMappingsRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Gecersiz istek", http.StatusBadRequest)
return
}
ctx, cancel := context.WithTimeout(r.Context(), 60*time.Second)
defer cancel()
tx, err := pg.BeginTx(ctx, nil)
if err != nil {
http.Error(w, "Transaction baslatilamadi", http.StatusInternalServerError)
return
}
defer tx.Rollback()
saved := 0
for _, item := range req.Items {
code := strings.TrimSpace(item.ProductCode)
color := strings.TrimSpace(item.ColorCode)
dim3Token := strings.TrimSpace(item.Dim3Code)
if code == "" || color == "" {
continue
}
mmitemID, err := resolveMmitemIDTx(ctx, tx, code)
if err != nil || mmitemID <= 0 {
http.Error(w, "PG urun bulunamadi: "+code, http.StatusBadRequest)
return
}
dim1ID, err := resolveDimTokenIDTx(ctx, tx, "dimval1", color)
if err != nil || dim1ID <= 0 {
http.Error(w, "Renk token eslesmesi bulunamadi: "+color, http.StatusBadRequest)
return
}
var dim3ID sql.NullInt64
if dim3Token != "" {
id, err := resolveDimTokenIDTx(ctx, tx, "dimval3", dim3Token)
if err != nil || id <= 0 {
http.Error(w, "Dim3 token eslesmesi bulunamadi: "+dim3Token, http.StatusBadRequest)
return
}
dim3ID = sql.NullInt64{Int64: id, Valid: true}
}
if _, err := tx.ExecContext(ctx, `
DELETE FROM zbggseri
WHERE mmitem_id=$1
AND dim1=$2
AND (($3::bigint IS NULL AND dim3 IS NULL) OR dim3=$3::bigint)
`, mmitemID, dim1ID, nullableInt64Arg(dim3ID)); err != nil {
http.Error(w, "Seri eslesmesi temizlenemedi: "+err.Error(), http.StatusInternalServerError)
return
}
seen := map[int64]struct{}{}
for _, seriesID := range item.SeriesIDs {
if seriesID <= 0 {
continue
}
if _, ok := seen[seriesID]; ok {
continue
}
seen[seriesID] = struct{}{}
if _, err := tx.ExecContext(ctx, `
INSERT INTO zbggseri (mmitem_id, dim1, seri_id, dim3)
VALUES ($1, $2, $3, $4)
`, mmitemID, dim1ID, seriesID, nullableInt64Arg(dim3ID)); err != nil {
http.Error(w, "Seri eslesmesi kaydedilemedi: "+err.Error(), http.StatusInternalServerError)
return
}
}
saved++
}
if err := tx.Commit(); err != nil {
http.Error(w, "Seri eslesmeleri kaydedilemedi: "+err.Error(), http.StatusInternalServerError)
return
}
writeJSON(w, map[string]any{"saved": saved})
}
}
func listProductSeriesDefinitions(ctx context.Context, pg *sql.DB, activeOnly bool) ([]productSeriesDefinition, error) {
whereActive := ""
if activeOnly {
whereActive = " AND is_active=TRUE"
}
rows, err := pg.QueryContext(ctx, `
SELECT id, COALESCE(code,''), COALESCE(title,''), COALESCE(is_active, TRUE),
COALESCE(parent_filter,''), COALESCE(sort_order, 0), COALESCE(notes,'')
FROM dfgrp
WHERE master='zbggseri'`+whereActive+`
ORDER BY sort_order,
CASE WHEN code ~ '^[0-9]+$' THEN code::int ELSE NULL END NULLS LAST,
code,
id
`)
if err != nil {
return nil, err
}
defer rows.Close()
out := []productSeriesDefinition{}
for rows.Next() {
var item productSeriesDefinition
if err := rows.Scan(&item.ID, &item.Code, &item.Title, &item.IsActive, &item.ParentFilter, &item.SortOrder, &item.Notes); err != nil {
return nil, err
}
out = append(out, item)
}
return out, rows.Err()
}
func loadMmitemIDs(ctx context.Context, pg *sql.DB, codes []string) (map[string]int64, error) {
out := map[string]int64{}
if len(codes) == 0 {
return out, nil
}
rows, err := pg.QueryContext(ctx, `SELECT code, id FROM mmitem WHERE code = ANY($1)`, pq.Array(codes))
if err != nil {
return out, err
}
defer rows.Close()
for rows.Next() {
var code string
var id int64
if err := rows.Scan(&code, &id); err != nil {
return out, err
}
out[strings.TrimSpace(code)] = id
}
return out, rows.Err()
}
func loadDimTokenIDs(ctx context.Context, pg *sql.DB, column string, tokens []string) (map[string]int64, error) {
out := map[string]int64{}
if len(tokens) == 0 {
return out, nil
}
rows, err := pg.QueryContext(ctx, `
SELECT token, dim_id
FROM mk_dim_token_map
WHERE dim_column=$1 AND token = ANY($2)
`, column, pq.Array(tokens))
if err != nil {
return out, err
}
defer rows.Close()
for rows.Next() {
var token string
var id int64
if err := rows.Scan(&token, &id); err != nil {
return out, err
}
out[strings.TrimSpace(token)] = id
}
if err := rows.Err(); err != nil {
return out, err
}
// Best-effort fallback: infer missing token->dim_id from dfblob file_name patterns.
// NOTE: For dimval3, the same token can map to different dim ids per product in this
// installation, so we do NOT infer/persist globally here. Per-product inference is
// handled in the row loop (using mmitem_id) to avoid wrong matches.
for _, rawTok := range tokens {
tok := strings.ToUpper(normalizeDimParam(rawTok))
if tok == "" {
continue
}
if _, ok := out[tok]; ok {
continue
}
if column == "dimval3" {
// avoid global inference for dimval3
continue
}
v := resolveDimvalFromFileNameToken(pg, column, tok)
if v == "" {
continue
}
id, err := strconv.ParseInt(v, 10, 64)
if err != nil || id <= 0 {
continue
}
// Persist for future requests (best-effort).
_, _ = pg.ExecContext(ctx, `
INSERT INTO mk_dim_token_map (dim_column, token, dim_id, updated_at)
VALUES ($1,$2,$3,now())
ON CONFLICT (dim_column, token)
DO UPDATE SET dim_id = EXCLUDED.dim_id, updated_at = EXCLUDED.updated_at
`, column, tok, id)
out[tok] = id
}
return out, nil
}
func loadProductSeriesAssignments(ctx context.Context, pg *sql.DB, codes []string) (map[string][]int64, error) {
out := map[string][]int64{}
if len(codes) == 0 {
return out, nil
}
rows, err := pg.QueryContext(ctx, `
SELECT m.code, z.dim1, COALESCE(z.dim3, 0), z.seri_id
FROM zbggseri z
JOIN mmitem m ON m.id = z.mmitem_id
WHERE m.code = ANY($1)
ORDER BY m.code, z.dim1, z.dim3, z.seri_id
`, pq.Array(codes))
if err != nil {
return out, err
}
defer rows.Close()
for rows.Next() {
var code string
var dim1, dim3, seriesID int64
if err := rows.Scan(&code, &dim1, &dim3, &seriesID); err != nil {
return out, err
}
key := assignmentKey(strings.TrimSpace(code), dim1, dim3)
out[key] = append(out[key], seriesID)
}
return out, rows.Err()
}
func resolveMmitemIDTx(ctx context.Context, tx *sql.Tx, code string) (int64, error) {
var id int64
err := tx.QueryRowContext(ctx, `SELECT id FROM mmitem WHERE code=$1`, code).Scan(&id)
return id, err
}
func resolveDimTokenIDTx(ctx context.Context, tx *sql.Tx, column string, token string) (int64, error) {
var id int64
err := tx.QueryRowContext(ctx, `SELECT dim_id FROM mk_dim_token_map WHERE dim_column=$1 AND token=$2`, column, token).Scan(&id)
return id, err
}
func nullableInt64Arg(v sql.NullInt64) any {
if !v.Valid {
return nil
}
return v.Int64
}
func productSeriesKey(productCode, colorCode, dim3Code string) string {
return strings.TrimSpace(productCode) + "|" + strings.TrimSpace(colorCode) + "|" + strings.TrimSpace(dim3Code)
}
func assignmentKey(productCode string, dim1ID int64, dim3ID int64) string {
return fmt.Sprintf("%s|%d|%d", strings.TrimSpace(productCode), dim1ID, dim3ID)
}
func setToSortedSlice(set map[string]struct{}) []string {
out := make([]string, 0, len(set))
for v := range set {
v = strings.TrimSpace(v)
if v != "" {
out = append(out, v)
}
}
sort.Strings(out)
return out
}
func sortSizeColumns(values []string) []string {
sort.Slice(values, func(i, j int) bool {
ai, ae := strconv.Atoi(values[i])
bi, be := strconv.Atoi(values[j])
if ae == nil && be == nil {
return ai < bi
}
return values[i] < values[j]
})
return values
}
func writeJSON(w http.ResponseWriter, payload any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
_ = json.NewEncoder(w).Encode(payload)
}