package routes import ( "bssapp-backend/auth" "bssapp-backend/queries" "context" "database/sql" "encoding/json" "fmt" "log" "net/http" "sort" "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, 'DD.MM.YYYY'), '') AS dtst, COALESCE(to_char(dtfn, 'DD.MM.YYYY'), '') 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"` } func buildNebimVariantDisplayCode(colorCode string, dim3Code string) string { colorCode = strings.TrimSpace(colorCode) dim3Code = strings.TrimSpace(dim3Code) if colorCode != "" && dim3Code != "" { return colorCode + "-" + dim3Code } if colorCode != "" { return colorCode } return dim3Code } func chooseDisplayDimToken(raw string, resolvedID int64, reverse map[int64]string) string { raw = strings.TrimSpace(raw) if raw != "" { return raw } if resolvedID > 0 { if tok := strings.TrimSpace(reverse[resolvedID]); tok != "" { return tok } } return raw } 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), 'DD.MM.YYYY'), '') 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)) itemToCode := make(map[int64]string, 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 itemToCode[id] = c } } 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 } type tmpRow struct { ProductCode string VariantCode string StockQty float64 ItemID int64 Dim1 int64 Dim3Key int64 HasMSSQL bool } // Build base variant keys from PG's authoritative table (mmitem_dim). itemIDs := make([]int64, 0, len(codeToItem)) for _, id := range codeToItem { itemIDs = append(itemIDs, id) } tmpMap := make(map[string]tmpRow, 4096) hasMMItemDim := make(map[int64]bool, len(itemIDs)) dim1IDs := make([]int64, 0, 8192) dim3IDs := make([]int64, 0, 8192) itemDim1Candidates := make(map[int64][]int64, len(itemIDs)) itemDim3Candidates := make(map[int64][]int64, len(itemIDs)) addCandidate := func(dst map[int64][]int64, itemID int64, id int64) { if itemID <= 0 || id <= 0 { return } for _, existing := range dst[itemID] { if existing == id { return } } dst[itemID] = append(dst[itemID], id) } if len(itemIDs) > 0 { rows, err := pg.QueryContext(ctx, ` SELECT mmitem_id, mmdim_id, val1, val2, val3 FROM mmitem_dim WHERE mmitem_id = ANY($1::bigint[]) AND COALESCE(is_active, TRUE) = TRUE `, pq.Array(itemIDs)) if err != nil { http.Error(w, "mmitem_dim lookup error: "+err.Error(), http.StatusInternalServerError) return } for rows.Next() { var itemID int64 var mmdimID sql.NullInt64 var v1 sql.NullInt64 var v2 sql.NullInt64 var v3 sql.NullInt64 if err := rows.Scan(&itemID, &mmdimID, &v1, &v2, &v3); err != nil { rows.Close() http.Error(w, "mmitem_dim scan error", http.StatusInternalServerError) return } hasMMItemDim[itemID] = true if !v1.Valid || v1.Int64 <= 0 { continue } // Variant key in this installation: (val1=color, val3=ItemDim2Code/yaka_if_any). Ignore val2 (size). d1 := v1.Int64 _ = v2 addCandidate(itemDim1Candidates, itemID, d1) d3k := int64(0) if mmdimID.Valid && mmdimID.Int64 == 3 { if !v3.Valid || v3.Int64 <= 1000 { continue } } if v3.Valid && v3.Int64 > 1000 { d3k = v3.Int64 addCandidate(itemDim3Candidates, itemID, d3k) } code := strings.TrimSpace(itemToCode[itemID]) if code == "" { continue } key := fmt.Sprintf("%d|%d|%d", itemID, d1, d3k) if _, ok := tmpMap[key]; ok { continue } tmpMap[key] = tmpRow{ ProductCode: code, VariantCode: "", StockQty: 0, ItemID: itemID, Dim1: d1, Dim3Key: d3k, } dim1IDs = append(dim1IDs, d1) if d3k > 0 { dim3IDs = append(dim3IDs, d3k) } } rows.Close() } // Resolve dim ids -> tokens for a fallback readable VariantCode. // MSSQL/Nebim tokens override this below; PG ids are only storage keys. idToDim1Token := map[int64]string{} idToDim3Token := map[int64]string{} loadReverseTokens := func(column string, ids []int64, out map[int64]string) { if len(ids) == 0 { return } // uniq uniq := make([]int64, 0, len(ids)) seen := make(map[int64]struct{}, len(ids)) for _, id := range ids { if id <= 0 { continue } if _, ok := seen[id]; ok { continue } seen[id] = struct{}{} uniq = append(uniq, id) } if len(uniq) == 0 { return } rows, err := pg.QueryContext(ctx, ` SELECT DISTINCT ON (dim_id) dim_id, token FROM mk_dim_token_map WHERE dim_column = $1 AND dim_id = ANY($2::bigint[]) ORDER BY dim_id, updated_at DESC; `, column, pq.Array(uniq)) if err == nil { for rows.Next() { var id int64 var tok string _ = rows.Scan(&id, &tok) tok = strings.TrimSpace(tok) if tok != "" { out[id] = tok } } rows.Close() } } loadReverseTokens("dimval1", dim1IDs, idToDim1Token) loadReverseTokens("dimval3", dim3IDs, idToDim3Token) for k, v := range tmpMap { t1 := strings.TrimSpace(idToDim1Token[v.Dim1]) if t1 == "" { t1 = fmt.Sprintf("%d", v.Dim1) } if v.Dim3Key > 0 { t3 := strings.TrimSpace(idToDim3Token[v.Dim3Key]) if t3 == "" { t3 = fmt.Sprintf("%d", v.Dim3Key) } v.VariantCode = t1 + "-" + t3 } else { v.VariantCode = t1 } tmpMap[k] = v } canonicalToken := func(column string, id int64) string { if id <= 0 { return "" } target := idToDim1Token if column == "dimval3" { target = idToDim3Token } if tok := strings.TrimSpace(target[id]); tok != "" { return tok } var tok string if err := pg.QueryRowContext(ctx, ` SELECT token FROM mk_dim_token_map WHERE dim_column = $1 AND dim_id = $2 ORDER BY CASE WHEN token ~ '^[0-9]{3}$' THEN 0 ELSE 1 END, length(token), updated_at DESC LIMIT 1 `, column, id).Scan(&tok); err == nil { tok = strings.TrimSpace(tok) if tok != "" { target[id] = tok return tok } } return "" } sortDimIDs := func(ids []int64) []int64 { out := append([]int64(nil), ids...) sort.Slice(out, func(i, j int) bool { return out[i] < out[j] }) return out } sortTokens := func(tokens []string) []string { out := append([]string(nil), tokens...) sort.Slice(out, func(i, j int) bool { li := strings.TrimLeft(out[i], "0") lj := strings.TrimLeft(out[j], "0") if li == "" { li = "0" } if lj == "" { lj = "0" } ni, ei := strconv.ParseInt(li, 10, 64) nj, ej := strconv.ParseInt(lj, 10, 64) if ei == nil && ej == nil && ni != nj { return ni < nj } return out[i] < out[j] }) return out } addToken := func(dst map[int64][]string, itemID int64, token string) { token = strings.ToUpper(normalizeDimParam(token)) if itemID <= 0 || token == "" { return } for _, existing := range dst[itemID] { if existing == token { return } } dst[itemID] = append(dst[itemID], token) } buildInferredMap := func(column string, tokenByItem map[int64][]string, idsByItem map[int64][]int64) map[string]int64 { out := make(map[string]int64, 128) for itemID, tokens := range tokenByItem { sortedTokens := sortTokens(tokens) sortedIDs := sortDimIDs(idsByItem[itemID]) if len(sortedTokens) == 0 || len(sortedIDs) == 0 { continue } limit := len(sortedTokens) if len(sortedIDs) < limit { limit = len(sortedIDs) } for i := 0; i < limit; i++ { token := sortedTokens[i] id := sortedIDs[i] if token == "" || id <= 0 { continue } key := fmt.Sprintf("%s|%d|%s", column, itemID, token) if _, exists := out[key]; !exists { out[key] = id } } } return out } persistDimToken := func(column string, token string, id int64) { token = strings.ToUpper(normalizeDimParam(token)) if column == "" || token == "" || id <= 0 { return } _, _ = 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) } type msVariantRow struct { ItemCode string ColorCode string Dim1Code string Dim3Code string Qty sql.NullFloat64 } // MSSQL: stock list for selected products; map to (mmitem_id, dim1, dim3_key) via token->id mapping. 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 } msVariants := make([]msVariantRow, 0, 1024) colorTokensByItem := make(map[int64][]string, len(itemIDs)) dim3TokensByItem := make(map[int64][]string, len(itemIDs)) for msRows.Next() { var itemCode, colorCode, dim1Code, dim3Code string var qty sql.NullFloat64 if err := msRows.Scan(&itemCode, &colorCode, &dim1Code, &dim3Code, &qty); err != nil { msRows.Close() http.Error(w, "variant stock scan error", http.StatusInternalServerError) return } itemCode = strings.TrimSpace(itemCode) if itemCode == "" { continue } itemID := codeToItem[itemCode] if itemID <= 0 { continue } msVariants = append(msVariants, msVariantRow{ ItemCode: itemCode, ColorCode: colorCode, Dim1Code: dim1Code, Dim3Code: dim3Code, Qty: qty, }) addToken(colorTokensByItem, itemID, colorCode) addToken(dim3TokensByItem, itemID, dim3Code) } if err := msRows.Err(); err != nil { msRows.Close() http.Error(w, "variant stock rows error", http.StatusInternalServerError) return } msRows.Close() inferredDim1 := buildInferredMap("dimval1", colorTokensByItem, itemDim1Candidates) inferredDim3 := buildInferredMap("dimval3", dim3TokensByItem, itemDim3Candidates) resolveProductDimID := func(itemID int64, column string, token string, inferred map[string]int64) (int64, bool) { token = strings.ToUpper(normalizeDimParam(token)) if token == "" { return 0, false } if id := inferred[fmt.Sprintf("%s|%d|%s", column, itemID, token)]; id > 0 { persistDimToken(column, token, id) return id, true } if id, ok := resolveDimID(column, token); ok { return id, true } return 0, false } for _, ms := range msVariants { itemCode := ms.ItemCode colorCode := ms.ColorCode dim1Code := ms.Dim1Code dim3Code := ms.Dim3Code qty := ms.Qty itemID := codeToItem[itemCode] if itemID <= 0 { continue } // Map Nebim tokens to PG integer ids. Color and yaka must use separate token namespaces, // because the same visible token (for example "001") can exist in both dimensions. d1 := int64(0) if id, ok := resolveProductDimID(itemID, "dimval1", colorCode, inferredDim1); ok { d1 = id } if d1 <= 0 { continue } d3k := int64(0) if id, ok := resolveProductDimID(itemID, "dimval3", dim3Code, inferredDim3); ok { d3k = id } key := fmt.Sprintf("%d|%d|%d", itemID, d1, d3k) prev, ok := tmpMap[key] if !ok { // Seed missing MSSQL combos even when the product already has some mmitem_dim rows. // PG remains the storage key source, but MSSQL may reveal new/missing color or dim3 combos. var v2 any = nil if sizeID, ok := resolveDimID("dimval1", dim1Code); ok && sizeID > 0 { v2 = sizeID } v3 := int64(0) if id, ok := resolveProductDimID(itemID, "dimval3", dim3Code, inferredDim3); ok { v3 = id } mmdimID := int64(2) var v3any any = nil if v3 > 0 { mmdimID = 3 v3any = v3 } _, _ = pg.ExecContext(ctx, ` INSERT INTO mmitem_dim (mmitem_id, mmdim_id, val1, val2, val3, is_active, qty) SELECT $1, $2, $3, $4, $5, TRUE, 0 WHERE NOT EXISTS ( SELECT 1 FROM mmitem_dim WHERE mmitem_id = $1 AND mmdim_id = $2 AND val1 = $3 AND COALESCE(val2, 0) = COALESCE($4::bigint, 0) AND COALESCE(val3, 0) = COALESCE($5::bigint, 0) LIMIT 1 ); `, itemID, mmdimID, d1, v2, v3any) hasMMItemDim[itemID] = true code := strings.TrimSpace(itemToCode[itemID]) if code != "" { tmpMap[key] = tmpRow{ ProductCode: code, VariantCode: "", StockQty: 0, ItemID: itemID, Dim1: d1, Dim3Key: d3k, } // Keep dim token cache for VariantCode formatting. dim1IDs = append(dim1IDs, d1) if d3k > 0 { dim3IDs = append(dim3IDs, d3k) } prev = tmpMap[key] ok = true } if !ok { continue } } q := 0.0 if qty.Valid { q = qty.Float64 } prev.StockQty += q displayColor := chooseDisplayDimToken(colorCode, d1, map[int64]string{d1: canonicalToken("dimval1", d1)}) displayDim3 := chooseDisplayDimToken(dim3Code, d3k, map[int64]string{d3k: canonicalToken("dimval3", d3k)}) prev.VariantCode = buildNebimVariantDisplayCode(displayColor, displayDim3) prev.HasMSSQL = true tmpMap[key] = prev _ = colorCode // display-only } tmp := make([]tmpRow, 0, len(tmpMap)) for _, v := range tmpMap { tmp = append(tmp, v) } sort.SliceStable(tmp, func(i, j int) bool { if tmp[i].ProductCode != tmp[j].ProductCode { return tmp[i].ProductCode < tmp[j].ProductCode } if tmp[i].HasMSSQL != tmp[j].HasMSSQL { return tmp[i].HasMSSQL } return strings.TrimSpace(tmp[i].VariantCode) < strings.TrimSpace(tmp[j].VariantCode) }) // 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), 'DD.MM.YYYY'), '') 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), ) } }