From d1f1e5a4f4d80fa41bea587b06ce562edbe716e8 Mon Sep 17 00:00:00 2001 From: M_Kececi Date: Thu, 18 Jun 2026 15:59:24 +0300 Subject: [PATCH] Merge remote-tracking branch 'origin/master' --- svc/queries/product_pricing_fx_publish.go | 30 ++- svc/routes/product_pricing_save.go | 181 +++++++++++++--- svc/routes/wholesale_campaigns.go | 253 +++++++++++++++++----- 3 files changed, 375 insertions(+), 89 deletions(-) diff --git a/svc/queries/product_pricing_fx_publish.go b/svc/queries/product_pricing_fx_publish.go index 7146425..67be7f2 100644 --- a/svc/queries/product_pricing_fx_publish.go +++ b/svc/queries/product_pricing_fx_publish.go @@ -258,16 +258,40 @@ norm AS ( COALESCE(price, 0) AS price FROM input ), - dims_cache AS ( + -- Prefer PG's authoritative variant dimension table (mmitem_dim). Fall back to cache table if needed. + dims_mmitem_dim AS ( + SELECT + norm.product_code AS product_code, + md.val1::bigint AS dim1, + CASE + WHEN md.val2 IS NULL OR md.val2 = 0 THEN NULL + ELSE md.val2::bigint + END AS dim3 + FROM norm + JOIN mmitem mm + ON mm.code = norm.product_code + JOIN mmitem_dim md + ON md.mmitem_id = mm.id + AND COALESCE(md.is_active, TRUE) = TRUE + WHERE md.val1 IS NOT NULL + AND md.val1 > 0 + GROUP BY norm.product_code, md.val1, md.val2 + ), + dims_cache_table AS ( SELECT NULLIF(BTRIM(c.product_code), '') AS product_code, - c.dim1, - c.dim3 + c.dim1::bigint AS dim1, + c.dim3::bigint AS dim3 FROM mk_mmitem_dim_combo c JOIN norm ON norm.product_code = c.product_code WHERE c.dim1 IS NOT NULL ), + dims_cache AS ( + SELECT product_code, dim1, dim3 FROM dims_mmitem_dim + UNION + SELECT product_code, dim1, dim3 FROM dims_cache_table + ), dims_sdprc AS ( SELECT norm.product_code AS product_code, diff --git a/svc/routes/product_pricing_save.go b/svc/routes/product_pricing_save.go index a29f054..496021a 100644 --- a/svc/routes/product_pricing_save.go +++ b/svc/routes/product_pricing_save.go @@ -591,18 +591,17 @@ DO UPDATE SET dim_id = EXCLUDED.dim_id, updated_at = EXCLUDED.updated_at if err := rows.Scan(&colorCode, &dim1Code, &dim3Code); err != nil { return nil, err } - // Resolve to PG dim ids (e-commerce expects integer ids, e.g. dim1=82, dim3=2182). - // Nebim varies by installation; we try a few fallbacks. In most setups: - // - dim1 is size (ItemDim1Code) - // - dim3 is color (ColorCode) + // Resolve to PG dim ids. For this installation we align with PG's authoritative mmitem_dim model: + // - dim1 corresponds to mmitem_dim.val1 (typically Color). + // - dim3 corresponds to mmitem_dim.val2 (typically Size). d1 := int64(0) - if id, ok := resolveDimvalFromToken(pgTx, "dimval1", dim1Code); ok { + if id, ok := resolveDimvalFromToken(pgTx, "dimval1", colorCode); ok { d1 = id resolvedDim1++ } else if id, ok := resolveDimvalFromToken(pgTx, "dimval1", dim3Code); ok { d1 = id resolvedDim1++ - } else if id, ok := resolveDimvalFromToken(pgTx, "dimval1", colorCode); ok { + } else if id, ok := resolveDimvalFromToken(pgTx, "dimval1", dim1Code); ok { d1 = id resolvedDim1++ } @@ -610,12 +609,11 @@ DO UPDATE SET dim_id = EXCLUDED.dim_id, updated_at = EXCLUDED.updated_at continue } var d3 sql.NullInt64 - // IMPORTANT: In this Nebim setup, both ItemDim1Code and ItemDim3Code are mapped in PG as dimval1 ids. - // We therefore resolve dim3 via dimval1 as well (not dimval3). - if id, ok := resolveDimvalFromToken(pgTx, "dimval1", dim3Code); ok { + // dim3 corresponds to mmitem_dim.val2 (usually ItemDim1Code). + if id, ok := resolveDimvalFromToken(pgTx, "dimval1", dim1Code); ok { d3 = sql.NullInt64{Int64: id, Valid: true} resolvedDim3++ - } else if id, ok := resolveDimvalFromToken(pgTx, "dimval1", colorCode); ok { + } else if id, ok := resolveDimvalFromToken(pgTx, "dimval1", dim3Code); ok { d3 = sql.NullInt64{Int64: id, Valid: true} resolvedDim3++ } @@ -645,6 +643,119 @@ DO UPDATE SET dim_id = EXCLUDED.dim_id, updated_at = EXCLUDED.updated_at return out, nil } + ensureMMItemDimRows := func(mmItemID int64, combos []dimCombo, extraVal3 map[string]int64) { + if mmItemID <= 0 || len(combos) == 0 { + return + } + // Best-effort: don't assume a specific unique constraint exists on mmitem_dim. + for _, c := range combos { + if c.Dim1 <= 0 { + continue + } + v2 := int64(0) + var v2any any = nil + if c.Dim3.Valid && c.Dim3.Int64 > 0 { + v2 = c.Dim3.Int64 + v2any = v2 + } + // If we managed to resolve an "ItemDim3Code" id too, store it in val3 and mark mmdim_id=3. + v3 := int64(0) + if extraVal3 != nil { + if vv, ok := extraVal3[fmt.Sprintf("%d|%d", c.Dim1, v2)]; ok && vv > 0 { + v3 = vv + } + } + mmdimID := int64(2) + var v3any any = nil + if v3 > 0 { + mmdimID = 3 + v3any = v3 + } + + _, _ = pgTx.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 +); +`, mmItemID, mmdimID, c.Dim1, v2any, v3any) + } + } + + loadDimsFromPgMMItemDim := func(mmItemID int64, productCode string) ([]dimCombo, error) { + started := time.Now() + if mmItemID <= 0 { + return nil, fmt.Errorf("invalid mmitem_id") + } + rows, err := pgTx.QueryContext(ctx, ` +SELECT mmdim_id, val1, val2, val3 +FROM mmitem_dim +WHERE mmitem_id = $1 + AND COALESCE(is_active, TRUE) = TRUE +`, mmItemID) + if err != nil { + return nil, err + } + defer rows.Close() + + out := make([]dimCombo, 0, 64) + seen := make(map[string]struct{}, 128) + readRows := 0 + for rows.Next() { + readRows++ + var mmdimID sql.NullInt64 + var v1 sql.NullInt64 + var v2 sql.NullInt64 + var v3 sql.NullInt64 + if err := rows.Scan(&mmdimID, &v1, &v2, &v3); err != nil { + return nil, err + } + if !v1.Valid || v1.Int64 <= 0 { + continue + } + d1 := v1.Int64 + + var d3 sql.NullInt64 + // In production data we've observed the variant key as (val1, val2). val3 may exist but is not + // used as the second axis for pricing/campaign matching in this app. + _ = mmdimID + _ = v3 + if v2.Valid && v2.Int64 > 0 { + d3 = sql.NullInt64{Int64: v2.Int64, Valid: true} + } + + key := fmt.Sprintf("%d|%d", d1, func() int64 { + if d3.Valid { + return d3.Int64 + } + return 0 + }()) + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + out = append(out, dimCombo{Dim1: d1, Dim3: d3}) + } + if err := rows.Err(); err != nil { + return nil, err + } + logger.Info("save:pg:dims:mmitem-dim:loaded", + "product_code", strings.TrimSpace(productCode), + "mmitem_id", mmItemID, + "rows_read", readRows, + "dims", len(out), + "duration_ms", time.Since(started).Milliseconds(), + ) + return out, nil + } + upsertDimCombosCache := func(productCode string, dims []dimCombo) error { productCode = strings.TrimSpace(productCode) if productCode == "" || len(dims) == 0 { @@ -983,35 +1094,45 @@ VALUES ( mmItemID = 0 } dims := []dimCombo{} - // Prefer cached dim combos (fast). If not present, load from Nebim stock query (used by product-stock-query UI). + // Prefer PG's own authoritative dim combo table (mmitem_dim). Cache is only a fast-path fallback. if mmItemID > 0 { - cacheStarted := time.Now() - cached, cacheErr := loadDimCombosFromCache(code) - if cacheErr == nil && len(cached) > 0 { - dims = cached - logger.Info("save:pg:dims:cache:hit", - "product_code", code, - "dims", len(dims), - "duration_ms", time.Since(cacheStarted).Milliseconds(), - ) - } else if cacheErr != nil { - logger.Error("save:pg:dims:cache-load:error", "product_code", code, "err", cacheErr) - } else { - logger.Info("save:pg:dims:cache:miss", - "product_code", code, - "duration_ms", time.Since(cacheStarted).Milliseconds(), - ) + // 1) Authoritative source: mmitem_dim (PG). + if d, err := loadDimsFromPgMMItemDim(mmItemID, code); err == nil && len(d) > 0 { + dims = d + _ = upsertDimCombosCache(code, dims) // best-effort cache fill } + // 2) Cache fallback (fast). + cacheStarted := time.Now() + if len(dims) == 0 { + cached, cacheErr := loadDimCombosFromCache(code) + if cacheErr == nil && len(cached) > 0 { + dims = cached + logger.Info("save:pg:dims:cache:hit", + "product_code", code, + "dims", len(dims), + "duration_ms", time.Since(cacheStarted).Milliseconds(), + ) + } else if cacheErr != nil { + logger.Error("save:pg:dims:cache-load:error", "product_code", code, "err", cacheErr) + } else { + logger.Info("save:pg:dims:cache:miss", + "product_code", code, + "duration_ms", time.Since(cacheStarted).Milliseconds(), + ) + } + } + + // 3) Last resort: MSSQL stock tokens (legacy). if len(dims) == 0 { d, err := loadDimsFromMssqlStock(code) if err != nil { logger.Error("save:pg:dims:mssql:error", "product_code", code, "err", err) } else { dims = d - if err := upsertDimCombosCache(code, dims); err != nil { - logger.Error("save:pg:dims:cache:error", "product_code", code, "err", err) - } + _ = upsertDimCombosCache(code, dims) + // If PG doesn't have mmitem_dim rows for this product yet, try to seed them. + ensureMMItemDimRows(mmItemID, dims, nil) } } } diff --git a/svc/routes/wholesale_campaigns.go b/svc/routes/wholesale_campaigns.go index 9729d52..19670a6 100644 --- a/svc/routes/wholesale_campaigns.go +++ b/svc/routes/wholesale_campaigns.go @@ -642,6 +642,7 @@ func GetWholesaleCampaignVariantRowsHandler(pg *sql.DB, mssql *sql.DB) http.Hand // 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 @@ -663,6 +664,7 @@ WHERE code = ANY($1::text[]) c = strings.TrimSpace(c) if c != "" && id > 0 { codeToItem[c] = id + itemToCode[id] = c } } rows.Close() @@ -727,15 +729,6 @@ DO UPDATE SET dim_id = EXCLUDED.dim_id, updated_at = EXCLUDED.updated_at 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 @@ -744,8 +737,138 @@ DO UPDATE SET dim_id = EXCLUDED.dim_id, updated_at = EXCLUDED.updated_at Dim1 int64 Dim3Key int64 } - // Deduplicate by (mmitem_id, dim1, dim3_key) and aggregate stock qty. + + // 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)) + dimIDs := make([]int64, 0, 8192) + 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 + } + d1 := v1.Int64 + + d3k := int64(0) + // Variant key in this app is (val1, val2). val3 may exist but is not the second axis for matching. + _ = mmdimID + _ = v3 + if v2.Valid && v2.Int64 > 0 { + d3k = v2.Int64 + } + + 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, + } + dimIDs = append(dimIDs, d1) + if d3k > 0 { + dimIDs = append(dimIDs, d3k) + } + } + rows.Close() + } + + // Resolve dim ids -> tokens for a readable VariantCode. + idToToken := map[int64]string{} + if len(dimIDs) > 0 { + // uniq + uniq := make([]int64, 0, len(dimIDs)) + seen := make(map[int64]struct{}, len(dimIDs)) + for _, id := range dimIDs { + if id <= 0 { + continue + } + if _, ok := seen[id]; ok { + continue + } + seen[id] = struct{}{} + uniq = append(uniq, id) + } + if len(uniq) > 0 { + rows, err := pg.QueryContext(ctx, ` +SELECT DISTINCT ON (dim_id) dim_id, token +FROM mk_dim_token_map +WHERE dim_column = 'dimval1' + AND dim_id = ANY($1::bigint[]) +ORDER BY dim_id, updated_at DESC; +`, 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 != "" { + idToToken[id] = tok + } + } + rows.Close() + } + } + } + for k, v := range tmpMap { + t1 := strings.TrimSpace(idToToken[v.Dim1]) + if t1 == "" { + t1 = fmt.Sprintf("%d", v.Dim1) + } + if v.Dim3Key > 0 { + t3 := strings.TrimSpace(idToToken[v.Dim3Key]) + if t3 == "" { + t3 = fmt.Sprintf("%d", v.Dim3Key) + } + v.VariantCode = t1 + "-" + t3 + } else { + v.VariantCode = t1 + } + tmpMap[k] = v + } + + // 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 + } + defer msRows.Close() for msRows.Next() { var itemCode, colorCode, dim1Code, dim3Code string var qty sql.NullFloat64 @@ -762,66 +885,84 @@ DO UPDATE SET dim_id = EXCLUDED.dim_id, updated_at = EXCLUDED.updated_at 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 - } - + // Map Nebim tokens to PG integer ids (dimval1 namespace). + // This app uses key: dim1=, dim3= to match mmitem_dim (val1,val2). d1 := int64(0) - // IMPORTANT: In this Nebim setup, both ItemDim1Code and ItemDim3Code are mapped in PG as dimval1 ids. - // We therefore resolve both axes via dimval1 and store the second axis in dim3 (still a bigint). - if id, ok := resolveDimID("dimval1", dim1Code); ok { - d1 = id - } else if id, ok := resolveDimID("dimval1", dim3Code); ok { - d1 = id - } else if id, ok := resolveDimID("dimval1", colorCode); ok { + if id, ok := resolveDimID("dimval1", colorCode); ok { d1 = id } if d1 <= 0 { continue } d3k := int64(0) - if id, ok := resolveDimID("dimval1", dim3Code); ok { - d3k = id - } else if id, ok := resolveDimID("dimval1", colorCode); ok { + if id, ok := resolveDimID("dimval1", dim1Code); ok { d3k = id } + key := fmt.Sprintf("%d|%d|%d", itemID, d1, d3k) + prev, ok := tmpMap[key] + if !ok { + // If PG does not have mmitem_dim rows for this item yet, seed it from MSSQL and include it. + if !hasMMItemDim[itemID] { + var v2 any = nil + if d3k > 0 { + v2 = d3k + } + v3 := int64(0) + if id, ok := resolveDimID("dimval1", dim3Code); 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. + dimIDs = append(dimIDs, d1) + if d3k > 0 { + dimIDs = append(dimIDs, d3k) + } + prev = tmpMap[key] + ok = true + } + } + if !ok { + continue + } + } 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 + prev.StockQty += q + tmpMap[key] = prev + _ = colorCode // display-only } tmp := make([]tmpRow, 0, len(tmpMap))