package routes import ( "bssapp-backend/auth" "bssapp-backend/queries" "context" "database/sql" "encoding/json" "fmt" "log" "net/http" "strconv" "strings" "time" "bssapp-backend/internal/mailer" "github.com/gorilla/mux" "github.com/lib/pq" ) type wholesaleCampaignRow struct { ID int64 `json:"id"` Code string `json:"code"` Title string `json:"title"` IsActive bool `json:"is_active"` Dtst string `json:"dtst,omitempty"` Dtfn string `json:"dtfn,omitempty"` DiscountRate float64 `json:"discount_rate"` Notes string `json:"notes,omitempty"` } // GET /api/pricing/wholesale-campaigns func GetWholesaleCampaignsHandler(pg *sql.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { traceID := buildPricingTraceID(r) w.Header().Set("X-Trace-ID", traceID) claims, ok := auth.GetClaimsFromContext(r.Context()) if !ok || claims == nil { http.Error(w, "unauthorized", http.StatusUnauthorized) return } ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) defer cancel() rows, err := pg.QueryContext(ctx, ` SELECT id, COALESCE(NULLIF(BTRIM(code),''),'') AS code, COALESCE(NULLIF(BTRIM(title),''),'') AS title, COALESCE(is_active, TRUE) AS is_active, COALESCE(to_char(dtst, 'YYYY-MM-DD HH24:MI:SS'), '') AS dtst, COALESCE(to_char(dtfn, 'YYYY-MM-DD HH24:MI:SS'), '') AS dtfn, COALESCE(discount_rate, 0)::float8 AS discount_rate, COALESCE(NULLIF(BTRIM(notes),''),'') AS notes FROM sdcampaign WHERE COALESCE(is_active, TRUE) = TRUE ORDER BY discount_rate ASC, id ASC; `) if err != nil { http.Error(w, "campaign list error: "+err.Error(), http.StatusInternalServerError) return } defer rows.Close() out := make([]wholesaleCampaignRow, 0, 64) for rows.Next() { var it wholesaleCampaignRow var dtst, dtfn string if err := rows.Scan(&it.ID, &it.Code, &it.Title, &it.IsActive, &dtst, &dtfn, &it.DiscountRate, &it.Notes); err != nil { http.Error(w, "campaign scan error", http.StatusInternalServerError) return } it.Dtst = strings.TrimSpace(dtst) it.Dtfn = strings.TrimSpace(dtfn) out = append(out, it) } w.Header().Set("Content-Type", "application/json; charset=utf-8") _ = json.NewEncoder(w).Encode(out) log.Printf("[WholesaleCampaigns] trace=%s user=%s id=%d count=%d", traceID, claims.Username, claims.ID, len(out)) } } type campaignAssignmentRow struct { ProductCode string `json:"product_code"` CampaignID *int64 `json:"campaign_id"` CampaignCode string `json:"campaign_code"` DiscountRate float64 `json:"discount_rate"` IsMixed bool `json:"is_mixed"` VariantRows int `json:"variant_rows"` AssignedDim1s int `json:"assigned_dim1s"` AssignedDim3s int `json:"assigned_dim3s"` Notes string `json:"notes,omitempty"` } // GET /api/pricing/wholesale-campaigns/assignments?product_code=A,B,C func GetWholesaleCampaignAssignmentsHandler(pg *sql.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { traceID := buildPricingTraceID(r) w.Header().Set("X-Trace-ID", traceID) claims, ok := auth.GetClaimsFromContext(r.Context()) if !ok || claims == nil { http.Error(w, "unauthorized", http.StatusUnauthorized) return } codes := splitCSVParam(r.URL.Query().Get("product_code")) if len(codes) == 0 { w.Header().Set("Content-Type", "application/json; charset=utf-8") _ = json.NewEncoder(w).Encode([]campaignAssignmentRow{}) return } if len(codes) > 500 { http.Error(w, "product_code too many", http.StatusBadRequest) return } ctx, cancel := context.WithTimeout(r.Context(), 12*time.Second) defer cancel() rows, err := pg.QueryContext(ctx, ` WITH inp AS ( SELECT UNNEST($1::text[]) AS code ), mm AS ( SELECT m.id AS mmitem_id, m.code FROM mmitem m JOIN inp ON inp.code = m.code ), latest AS ( -- "Current" assignment = latest row per variant key (mmitem_id, dim1, dim3_key). SELECT DISTINCT ON (z.mmitem_id, z.dim1, COALESCE(z.dim3, 0)) z.mmitem_id, z.dim1, COALESCE(z.dim3, 0) AS dim3_key, z.sdcampaign_id FROM zbggcampaign z JOIN mm ON mm.mmitem_id = z.mmitem_id ORDER BY z.mmitem_id, z.dim1, COALESCE(z.dim3, 0), z.id DESC ), agg AS ( SELECT mm.code AS product_code, COUNT(*)::int AS variant_rows, COUNT(DISTINCT l.dim1) FILTER (WHERE l.sdcampaign_id IS NOT NULL)::int AS assigned_dim1s, COUNT(DISTINCT l.dim3_key) FILTER (WHERE l.sdcampaign_id IS NOT NULL)::int AS assigned_dim3s, COUNT(DISTINCT l.sdcampaign_id) FILTER (WHERE l.sdcampaign_id IS NOT NULL)::int AS distinct_campaigns, MAX(l.sdcampaign_id)::bigint AS any_campaign_id FROM mm LEFT JOIN latest l ON l.mmitem_id = mm.mmitem_id GROUP BY mm.code ), single AS ( SELECT a.product_code, CASE WHEN a.distinct_campaigns = 1 THEN a.any_campaign_id ELSE NULL END AS campaign_id, CASE WHEN a.distinct_campaigns > 1 THEN TRUE ELSE FALSE END AS is_mixed, a.variant_rows, a.assigned_dim1s, a.assigned_dim3s FROM agg a ) SELECT s.product_code, s.campaign_id, COALESCE(sc.code,'') AS campaign_code, COALESCE(sc.discount_rate, 0)::float8 AS discount_rate, s.is_mixed, s.variant_rows, s.assigned_dim1s, s.assigned_dim3s, COALESCE(NULLIF(BTRIM(sc.notes),''),'') AS notes FROM single s LEFT JOIN sdcampaign sc ON sc.id = s.campaign_id ORDER BY s.product_code; `, pq.Array(codes)) if err != nil { http.Error(w, "assignment list error: "+err.Error(), http.StatusInternalServerError) return } defer rows.Close() out := make([]campaignAssignmentRow, 0, len(codes)) for rows.Next() { var it campaignAssignmentRow var cid sql.NullInt64 if err := rows.Scan(&it.ProductCode, &cid, &it.CampaignCode, &it.DiscountRate, &it.IsMixed, &it.VariantRows, &it.AssignedDim1s, &it.AssignedDim3s, &it.Notes); err != nil { http.Error(w, "assignment scan error", http.StatusInternalServerError) return } if cid.Valid { v := cid.Int64 it.CampaignID = &v } out = append(out, it) } w.Header().Set("Content-Type", "application/json; charset=utf-8") _ = json.NewEncoder(w).Encode(out) log.Printf("[WholesaleCampaignAssignments] trace=%s user=%s id=%d products=%d", traceID, claims.Username, claims.ID, len(out)) } } type saveWholesaleCampaignItem struct { ProductCode string `json:"product_code"` Dim1 int64 `json:"dim1"` Dim3 *int64 `json:"dim3"` CampaignID *int64 `json:"campaign_id"` } type saveWholesaleCampaignPayload struct { Items []saveWholesaleCampaignItem `json:"items"` } // POST /api/pricing/wholesale-campaigns/save // Appends a new row to zbggcampaign per variant (dim1+dim3), preserving history. func SaveWholesaleCampaignAssignmentsHandler(pg *sql.DB, ml *mailer.GraphMailer) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { started := time.Now() traceID := buildPricingTraceID(r) w.Header().Set("X-Trace-ID", traceID) claims, ok := auth.GetClaimsFromContext(r.Context()) if !ok || claims == nil { http.Error(w, "unauthorized", http.StatusUnauthorized) return } var payload saveWholesaleCampaignPayload if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { http.Error(w, "invalid payload", http.StatusBadRequest) return } if len(payload.Items) == 0 { w.Header().Set("Content-Type", "application/json; charset=utf-8") _ = json.NewEncoder(w).Encode(map[string]any{"success": true, "saved": 0}) return } if len(payload.Items) > 500 { http.Error(w, "too many items", 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, "tx begin error", http.StatusInternalServerError) return } defer tx.Rollback() // Resolve mmitem ids in bulk. codeList := make([]string, 0, len(payload.Items)) seenCode := make(map[string]struct{}, len(payload.Items)) for _, it := range payload.Items { code := strings.TrimSpace(it.ProductCode) if code == "" { continue } if _, ok := seenCode[code]; ok { continue } seenCode[code] = struct{}{} codeList = append(codeList, code) } codeToItemID := make(map[string]int64, len(codeList)) if len(codeList) > 0 { rows, err := tx.QueryContext(ctx, ` SELECT code, id FROM mmitem WHERE code = ANY($1::text[]) `, pq.Array(codeList)) if err != nil { http.Error(w, "mmitem lookup error: "+err.Error(), http.StatusInternalServerError) return } for rows.Next() { var code string var id int64 if err := rows.Scan(&code, &id); err != nil { rows.Close() http.Error(w, "mmitem scan error", http.StatusInternalServerError) return } code = strings.TrimSpace(code) if code != "" && id > 0 { codeToItemID[code] = id } } rows.Close() } saved := 0 for _, it := range payload.Items { code := strings.TrimSpace(it.ProductCode) if code == "" { continue } mmitemID := codeToItemID[code] if mmitemID <= 0 { continue } if it.Dim1 <= 0 { continue } d3k := int64(0) if it.Dim3 != nil && *it.Dim3 > 0 { d3k = *it.Dim3 } // Normalize requested campaign id (nullable). var requested any = nil if it.CampaignID != nil && *it.CampaignID > 0 { requested = *it.CampaignID } // Skip write if "current" assignment is already the same (latest row). { var cur sql.NullInt64 err := tx.QueryRowContext(ctx, ` SELECT sdcampaign_id FROM zbggcampaign WHERE mmitem_id = $1 AND dim1 = $2 AND COALESCE(dim3, 0) = $3 ORDER BY id DESC LIMIT 1 `, mmitemID, it.Dim1, d3k).Scan(&cur) if err != nil && err != sql.ErrNoRows { http.Error(w, "current campaign lookup error: "+err.Error(), http.StatusInternalServerError) return } if err == sql.ErrNoRows && requested == nil { // Clearing a non-existent assignment: no-op. continue } if err == nil { // requested == nil means "clear" if requested == nil && !cur.Valid { continue } if requested != nil && cur.Valid && cur.Int64 == requested.(int64) { continue } } } if _, err := tx.ExecContext(ctx, ` INSERT INTO zbggcampaign (mmitem_id, dim1, dim3, sdcampaign_id) VALUES ($1,$2,$3,$4) `, mmitemID, it.Dim1, func() any { if d3k > 0 { return d3k } return nil }(), requested); err != nil { http.Error(w, "insert campaign row error: "+err.Error(), http.StatusInternalServerError) return } saved++ } if err := tx.Commit(); err != nil { http.Error(w, "commit error", http.StatusInternalServerError) return } // Send campaign mail (post-commit, best-effort). if ml != nil { codes := make([]string, 0, len(payload.Items)) seen := map[string]struct{}{} for _, it := range payload.Items { c := strings.TrimSpace(it.ProductCode) if c == "" { continue } if _, ok := seen[c]; ok { continue } seen[c] = struct{}{} codes = append(codes, c) } go sendWholesaleCampaignChangeMails(context.Background(), ml, codes, claims.Username) } log.Printf("[WholesaleCampaignSave] trace=%s user=%s id=%d items=%d saved=%d duration_ms=%d", traceID, claims.Username, claims.ID, len(payload.Items), saved, time.Since(started).Milliseconds(), ) w.Header().Set("Content-Type", "application/json; charset=utf-8") _ = json.NewEncoder(w).Encode(map[string]any{ "success": true, "saved": saved, }) } } type wholesaleVariantStockRow struct { ProductCode string `json:"product_code"` VariantCode string `json:"variant_code"` StockQty float64 `json:"stock_qty"` } type wholesaleVariantRow struct { ProductCode string `json:"product_code"` VariantCode string `json:"variant_code"` StockQty float64 `json:"stock_qty"` Dim1 int64 `json:"dim1"` Dim3 *int64 `json:"dim3"` CampaignID *int64 `json:"campaign_id"` CampaignCode string `json:"campaign_code"` CampaignTitle string `json:"campaign_title"` DiscountRate float64 `json:"discount_rate"` CampaignLast string `json:"campaign_last_dttm"` } type wholesaleCampaignHistoryRow struct { ID int64 `json:"id"` CampaignID *int64 `json:"campaign_id"` CampaignCode string `json:"campaign_code"` Title string `json:"campaign_title"` DiscountRate float64 `json:"discount_rate"` At string `json:"at"` } type wholesaleCampaignHistoryResponse struct { Rows []wholesaleCampaignHistoryRow `json:"rows"` } type deleteSelectedIDsPayload struct { IDs []int64 `json:"ids"` } // GET /api/pricing/wholesale-campaigns/{code}/campaign-history?dim1=..&dim3=.. func GetWholesaleCampaignHistoryHandler(pg *sql.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { traceID := buildPricingTraceID(r) w.Header().Set("X-Trace-ID", traceID) claims, ok := auth.GetClaimsFromContext(r.Context()) if !ok || claims == nil { http.Error(w, "unauthorized", http.StatusUnauthorized) return } code := strings.TrimSpace(mux.Vars(r)["code"]) if code == "" { http.Error(w, "missing code", http.StatusBadRequest) return } dim1, _ := strconv.ParseInt(strings.TrimSpace(r.URL.Query().Get("dim1")), 10, 64) dim3, _ := strconv.ParseInt(strings.TrimSpace(r.URL.Query().Get("dim3")), 10, 64) if dim1 <= 0 { http.Error(w, "missing dim1", http.StatusBadRequest) return } d3k := dim3 if d3k < 0 { d3k = 0 } ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) defer cancel() var mmitemID int64 if err := pg.QueryRowContext(ctx, `SELECT id FROM mmitem WHERE code = $1`, code).Scan(&mmitemID); err != nil { if err == sql.ErrNoRows { w.Header().Set("Content-Type", "application/json; charset=utf-8") _ = json.NewEncoder(w).Encode(wholesaleCampaignHistoryResponse{Rows: []wholesaleCampaignHistoryRow{}}) return } http.Error(w, "mmitem lookup error: "+err.Error(), http.StatusInternalServerError) return } rows, err := pg.QueryContext(ctx, ` SELECT z.id, z.sdcampaign_id, COALESCE(sc.code,'') AS code, COALESCE(sc.title,'') AS title, COALESCE(sc.discount_rate, 0)::float8 AS discount_rate, COALESCE(to_char(COALESCE(z.zlupd_dttm, z.zlins_dttm), 'YYYY-MM-DD HH24:MI:SS'), '') AS at FROM zbggcampaign z LEFT JOIN sdcampaign sc ON sc.id = z.sdcampaign_id WHERE z.mmitem_id = $1 AND z.dim1 = $2 AND COALESCE(z.dim3, 0) = $3 ORDER BY z.id DESC LIMIT 200; `, mmitemID, dim1, d3k) if err != nil { http.Error(w, "campaign history query error: "+err.Error(), http.StatusInternalServerError) return } defer rows.Close() out := make([]wholesaleCampaignHistoryRow, 0, 64) for rows.Next() { var it wholesaleCampaignHistoryRow var cid sql.NullInt64 if err := rows.Scan(&it.ID, &cid, &it.CampaignCode, &it.Title, &it.DiscountRate, &it.At); err != nil { http.Error(w, "campaign history scan error", http.StatusInternalServerError) return } if cid.Valid && cid.Int64 > 0 { v := cid.Int64 it.CampaignID = &v } it.CampaignCode = strings.TrimSpace(it.CampaignCode) it.Title = strings.TrimSpace(it.Title) it.At = strings.TrimSpace(it.At) out = append(out, it) } w.Header().Set("Content-Type", "application/json; charset=utf-8") _ = json.NewEncoder(w).Encode(wholesaleCampaignHistoryResponse{Rows: out}) log.Printf("[WholesaleCampaignHistory] trace=%s user=%s id=%d code=%s dim1=%d dim3=%d rows=%d", traceID, claims.Username, claims.ID, code, dim1, d3k, len(out), ) } } // POST /api/pricing/wholesale-campaigns/{code}/campaign-history/delete-selected?dim1=..&dim3=.. func PostDeleteSelectedWholesaleCampaignHistoryHandler(pg *sql.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { traceID := buildPricingTraceID(r) w.Header().Set("X-Trace-ID", traceID) claims, ok := auth.GetClaimsFromContext(r.Context()) if !ok || claims == nil { http.Error(w, "unauthorized", http.StatusUnauthorized) return } code := strings.TrimSpace(mux.Vars(r)["code"]) if code == "" { http.Error(w, "missing code", http.StatusBadRequest) return } dim1, _ := strconv.ParseInt(strings.TrimSpace(r.URL.Query().Get("dim1")), 10, 64) dim3, _ := strconv.ParseInt(strings.TrimSpace(r.URL.Query().Get("dim3")), 10, 64) if dim1 <= 0 { http.Error(w, "missing dim1", http.StatusBadRequest) return } d3k := dim3 if d3k < 0 { d3k = 0 } var payload deleteSelectedIDsPayload if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { http.Error(w, "invalid payload", http.StatusBadRequest) return } if len(payload.IDs) == 0 { w.Header().Set("Content-Type", "application/json; charset=utf-8") _ = json.NewEncoder(w).Encode(map[string]any{"success": true, "deleted": 0}) return } if len(payload.IDs) > 500 { http.Error(w, "too many ids", http.StatusBadRequest) return } ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) defer cancel() var mmitemID int64 if err := pg.QueryRowContext(ctx, `SELECT id FROM mmitem WHERE code = $1`, code).Scan(&mmitemID); err != nil { if err == sql.ErrNoRows { http.Error(w, "unknown code", http.StatusBadRequest) return } http.Error(w, "mmitem lookup error: "+err.Error(), http.StatusInternalServerError) return } res, err := pg.ExecContext(ctx, ` DELETE FROM zbggcampaign WHERE id = ANY($1::bigint[]) AND mmitem_id = $2 AND dim1 = $3 AND COALESCE(dim3, 0) = $4 `, pq.Array(payload.IDs), mmitemID, dim1, d3k) if err != nil { http.Error(w, "delete error: "+err.Error(), http.StatusInternalServerError) return } deleted, _ := res.RowsAffected() w.Header().Set("Content-Type", "application/json; charset=utf-8") _ = json.NewEncoder(w).Encode(map[string]any{"success": true, "deleted": deleted}) log.Printf("[WholesaleCampaignHistoryDelete] trace=%s user=%s id=%d code=%s dim1=%d dim3=%d deleted=%d", traceID, claims.Username, claims.ID, code, dim1, d3k, deleted, ) } } // GET /api/pricing/wholesale-campaigns/variant-rows?product_code=A,B,C // Returns variant-level rows with resolved PG dims and current campaign assignment (if any). func GetWholesaleCampaignVariantRowsHandler(pg *sql.DB, mssql *sql.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { traceID := buildPricingTraceID(r) w.Header().Set("X-Trace-ID", traceID) claims, ok := auth.GetClaimsFromContext(r.Context()) if !ok || claims == nil { http.Error(w, "unauthorized", http.StatusUnauthorized) return } if pg == nil { http.Error(w, "pg not ready", http.StatusServiceUnavailable) return } if mssql == nil { http.Error(w, "mssql not ready", http.StatusServiceUnavailable) return } codes := splitCSVParam(r.URL.Query().Get("product_code")) if len(codes) == 0 { w.Header().Set("Content-Type", "application/json; charset=utf-8") _ = json.NewEncoder(w).Encode([]wholesaleVariantRow{}) return } if len(codes) > 250 { http.Error(w, "product_code too many", http.StatusBadRequest) return } // MSSQL 2008 + stock breakdown over many items can be slow; keep a generous timeout. ctx, cancel := context.WithTimeout(r.Context(), 120*time.Second) defer cancel() // Resolve mmitem ids in bulk. codeToItem := make(map[string]int64, len(codes)) { rows, err := pg.QueryContext(ctx, ` SELECT code, id FROM mmitem WHERE code = ANY($1::text[]) `, pq.Array(codes)) if err != nil { http.Error(w, "mmitem lookup error: "+err.Error(), http.StatusInternalServerError) return } for rows.Next() { var c string var id int64 if err := rows.Scan(&c, &id); err != nil { rows.Close() http.Error(w, "mmitem scan error", http.StatusInternalServerError) return } c = strings.TrimSpace(c) if c != "" && id > 0 { codeToItem[c] = id } } rows.Close() } // Dim token -> id resolver (fast path: mk_dim_token_map; fallback: dfblob file_name token inference). dimCache := make(map[string]int64, 1024) parseDimID := func(s string) (int64, bool) { s = strings.TrimSpace(s) if s == "" { return 0, false } s2 := strings.TrimLeft(s, "0") if s2 == "" { s2 = "0" } n, err := strconv.ParseInt(s2, 10, 64) if err != nil || n <= 0 { return 0, false } return n, true } resolveDimID := func(column, token string) (int64, bool) { token = strings.ToUpper(normalizeDimParam(token)) if token == "" { return 0, false } k := column + "|" + token if v, ok := dimCache[k]; ok { return v, v > 0 } // persistent cache { var id int64 if err := pg.QueryRowContext(ctx, ` SELECT dim_id FROM mk_dim_token_map WHERE dim_column = $1 AND token = $2 `, column, token).Scan(&id); err == nil && id > 0 { dimCache[k] = id return id, true } } // fallback: infer id from dfblob metadata (token -> dimval id) v := resolveDimvalFromFileNameToken(pg, column, token) if v == "" { dimCache[k] = 0 return 0, false } id, ok := parseDimID(v) if !ok { dimCache[k] = 0 return 0, false } _, _ = 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, token, id) dimCache[k] = id return id, true } // MSSQL: variant+stock list for selected products. joined := strings.Join(codes, ",") msRows, err := mssql.QueryContext(ctx, queries.GetWholesaleCampaignVariantStockByProducts, joined) if err != nil { http.Error(w, "variant stock query error: "+err.Error(), http.StatusInternalServerError) return } defer msRows.Close() type tmpRow struct { ProductCode string VariantCode string StockQty float64 ItemID int64 Dim1 int64 Dim3Key int64 } // Deduplicate by (mmitem_id, dim1, dim3_key) and aggregate stock qty. tmpMap := make(map[string]tmpRow, 4096) for msRows.Next() { var itemCode, colorCode, dim1Code, dim3Code string var qty sql.NullFloat64 if err := msRows.Scan(&itemCode, &colorCode, &dim1Code, &dim3Code, &qty); err != nil { http.Error(w, "variant stock scan error", http.StatusInternalServerError) return } itemCode = strings.TrimSpace(itemCode) if itemCode == "" { continue } itemID := codeToItem[itemCode] if itemID <= 0 { continue } // Variant token: prefer ColorCode; ItemDim1Code may represent a different attribute. t1 := strings.TrimSpace(colorCode) if t1 == "" || t1 == "0" { t1 = strings.TrimSpace(dim1Code) } t3 := strings.TrimSpace(dim3Code) varCode := strings.TrimSpace(t1) if varCode != "" && t3 != "" && t3 != "0" { varCode = varCode + "-" + strings.TrimSpace(t3) } if varCode == "" { continue } d1 := int64(0) // Resolve dim1: prefer ColorCode first (matches e-comm expectation: dim1=Color). if id, ok := resolveDimID("dimval1", colorCode); ok { d1 = id } else if id, ok := resolveDimID("dimval1", dim1Code); ok { d1 = id } if d1 <= 0 { continue } d3k := int64(0) if id, ok := resolveDimID("dimval3", t3); ok { d3k = id } q := 0.0 if qty.Valid { q = qty.Float64 } key := fmt.Sprintf("%d|%d|%d", itemID, d1, d3k) if prev, ok := tmpMap[key]; ok { prev.StockQty += q // Keep the first non-empty variant code. if prev.VariantCode == "" { prev.VariantCode = varCode } tmpMap[key] = prev } else { tmpMap[key] = tmpRow{ ProductCode: itemCode, VariantCode: varCode, StockQty: q, ItemID: itemID, Dim1: d1, Dim3Key: d3k, } } } if err := msRows.Err(); err != nil { http.Error(w, "variant stock read error: "+err.Error(), http.StatusInternalServerError) return } tmp := make([]tmpRow, 0, len(tmpMap)) for _, v := range tmpMap { tmp = append(tmp, v) } // Bulk load campaign assignment for each (mmitem_id, dim1, dim3_key) type keyRec struct { ItemID int64 `json:"mmitem_id"` Dim1 int64 `json:"dim1"` Dim3Key int64 `json:"dim3_key"` } keys := make([]keyRec, 0, len(tmp)) seenKey := make(map[string]struct{}, len(tmp)) for _, t := range tmp { k := fmt.Sprintf("%d|%d|%d", t.ItemID, t.Dim1, t.Dim3Key) if _, ok := seenKey[k]; ok { continue } seenKey[k] = struct{}{} keys = append(keys, keyRec{ItemID: t.ItemID, Dim1: t.Dim1, Dim3Key: t.Dim3Key}) } rawKeys, _ := json.Marshal(keys) type campAgg struct { CampaignID sql.NullInt64 CampaignCode string CampaignTitle string DiscountRate float64 CampaignLast string } campMap := make(map[string]campAgg, len(keys)) if len(keys) > 0 { rows, err := pg.QueryContext(ctx, ` WITH input AS ( SELECT * FROM jsonb_to_recordset($1::jsonb) AS x(mmitem_id bigint, dim1 int, dim3_key int) ), latest AS ( SELECT i.mmitem_id, i.dim1, i.dim3_key, MAX(z.id)::bigint AS z_id FROM input i LEFT JOIN zbggcampaign z ON z.mmitem_id = i.mmitem_id AND z.dim1 = i.dim1 AND COALESCE(z.dim3, 0) = i.dim3_key GROUP BY i.mmitem_id, i.dim1, i.dim3_key ) SELECT l.mmitem_id, l.dim1, l.dim3_key, z.sdcampaign_id, COALESCE(sc.code,'') AS code, COALESCE(sc.title,'') AS title, COALESCE(sc.discount_rate, 0)::float8 AS discount_rate, COALESCE(to_char(COALESCE(z.zlupd_dttm, z.zlins_dttm), 'YYYY-MM-DD HH24:MI:SS'), '') AS campaign_last_dttm FROM latest l LEFT JOIN zbggcampaign z ON z.id = l.z_id LEFT JOIN sdcampaign sc ON sc.id = z.sdcampaign_id `, rawKeys) if err != nil { http.Error(w, "campaign lookup error: "+err.Error(), http.StatusInternalServerError) return } for rows.Next() { var itemID, d1, d3k int64 var cid sql.NullInt64 var code, title string var rate float64 var last string if err := rows.Scan(&itemID, &d1, &d3k, &cid, &code, &title, &rate, &last); err != nil { rows.Close() http.Error(w, "campaign scan error", http.StatusInternalServerError) return } campMap[fmt.Sprintf("%d|%d|%d", itemID, d1, d3k)] = campAgg{ CampaignID: cid, CampaignCode: strings.TrimSpace(code), CampaignTitle: strings.TrimSpace(title), DiscountRate: rate, CampaignLast: strings.TrimSpace(last), } } rows.Close() } out := make([]wholesaleVariantRow, 0, len(tmp)) for _, t := range tmp { agg := campMap[fmt.Sprintf("%d|%d|%d", t.ItemID, t.Dim1, t.Dim3Key)] var cidp *int64 if agg.CampaignID.Valid && agg.CampaignID.Int64 > 0 { v := agg.CampaignID.Int64 cidp = &v } var d3p *int64 if t.Dim3Key > 0 { v := t.Dim3Key d3p = &v } out = append(out, wholesaleVariantRow{ ProductCode: t.ProductCode, VariantCode: t.VariantCode, StockQty: t.StockQty, Dim1: t.Dim1, Dim3: d3p, CampaignID: cidp, CampaignCode: agg.CampaignCode, CampaignTitle: agg.CampaignTitle, DiscountRate: agg.DiscountRate, CampaignLast: agg.CampaignLast, }) } w.Header().Set("Content-Type", "application/json; charset=utf-8") _ = json.NewEncoder(w).Encode(out) log.Printf("[WholesaleCampaignVariantRows] trace=%s user=%s id=%d products=%d rows=%d", traceID, claims.Username, claims.ID, len(codes), len(out), ) } } // GET /api/pricing/wholesale-campaigns/variants?product_code=A,B,C func GetWholesaleCampaignVariantStockHandler(mssql *sql.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { traceID := buildPricingTraceID(r) w.Header().Set("X-Trace-ID", traceID) claims, ok := auth.GetClaimsFromContext(r.Context()) if !ok || claims == nil { http.Error(w, "unauthorized", http.StatusUnauthorized) return } if mssql == nil { http.Error(w, "mssql not ready", http.StatusServiceUnavailable) return } codes := splitCSVParam(r.URL.Query().Get("product_code")) if len(codes) == 0 { w.Header().Set("Content-Type", "application/json; charset=utf-8") _ = json.NewEncoder(w).Encode([]wholesaleVariantStockRow{}) return } if len(codes) > 300 { http.Error(w, "product_code too many", http.StatusBadRequest) return } ctx, cancel := context.WithTimeout(r.Context(), 25*time.Second) defer cancel() joined := strings.Join(codes, ",") rows, err := mssql.QueryContext(ctx, queries.GetWholesaleCampaignVariantStockByProducts, joined) if err != nil { http.Error(w, "variant stock query error: "+err.Error(), http.StatusInternalServerError) return } defer rows.Close() out := make([]wholesaleVariantStockRow, 0, 2048) for rows.Next() { var itemCode, colorCode, dim1Code, dim3Code string var qty sql.NullFloat64 if err := rows.Scan(&itemCode, &colorCode, &dim1Code, &dim3Code, &qty); err != nil { http.Error(w, "variant stock scan error", http.StatusInternalServerError) return } itemCode = strings.TrimSpace(itemCode) if itemCode == "" { continue } // Variant token: prefer ColorCode; ItemDim1Code may represent a different attribute. t1 := strings.TrimSpace(colorCode) if t1 == "" || t1 == "0" { t1 = strings.TrimSpace(dim1Code) } t3 := strings.TrimSpace(dim3Code) varCode := t1 if t1 != "" && t3 != "" && t3 != "0" { varCode = t1 + "-" + t3 } if varCode == "" { continue } q := 0.0 if qty.Valid { q = qty.Float64 } out = append(out, wholesaleVariantStockRow{ ProductCode: itemCode, VariantCode: varCode, StockQty: q, }) } w.Header().Set("Content-Type", "application/json; charset=utf-8") _ = json.NewEncoder(w).Encode(out) log.Printf("[WholesaleCampaignVariants] trace=%s user=%s id=%d products=%d rows=%d", traceID, claims.Username, claims.ID, len(codes), len(out), ) } }