package main import ( "bssapp-backend/db" "bssapp-backend/queries" "context" "crypto/sha1" "database/sql" "encoding/hex" "fmt" "log" "math" "os" "sort" "strconv" "strings" "sync/atomic" "time" "github.com/lib/pq" ) type productSeriesAutoStockRaw 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 productSeriesAutoVariant struct { RowKey string ProductCode string ColorCode string Dim3Code string SizeQty map[string]float64 TotalQty float64 UrunAnaGrubu string UrunAltGrubu string Kategori string GroupKey string StockHash string } type productSeriesAutoRule struct { SeriesID int64 Code string Title string GroupKey string Ratio map[string]int Priority int } type productSeriesQueueItem struct { ID int64 RowKey string ProductCode string ColorCode string Dim3Code string Attempts int } type productSeriesAutoStats struct { Scanned int Changed int Processed int Written int Skipped int } var productSeriesSizeGroups = map[string][]string{ "tak": {"44", "46", "48", "50", "52", "54", "56", "58", "60", "62", "64", "66", "68", "70", "72", "74"}, "ayk": {"39", "40", "41", "42", "43", "44", "45", "46"}, "ayk_garson": {"22", "23", "24", "25", "26", "27", "28", "29", "30", "31", "32", "33", "34", "35", "36", "37"}, "yas": {"2", "4", "6", "8", "10", "12", "14"}, "pan": {"38", "40", "42", "44", "46", "48", "50", "52", "54", "56", "58", "60", "62", "64", "66", "68"}, "gom": {"XS", "S", "M", "L", "XL", "2XL", "3XL", "4XL", "5XL", "6XL", "7XL"}, "aks": {"44", "STD", "110", "115", "120", "125", "130", "135"}, } func startProductSeriesAutoSchedulers(pgDB *sql.DB) { enabled := strings.TrimSpace(strings.ToLower(os.Getenv("PRODUCT_SERIES_AUTO_ENABLED"))) if enabled == "0" || enabled == "false" || enabled == "off" { log.Println("Product series auto scheduler disabled") return } if pgDB == nil || db.MssqlDB == nil { return } apply := envBool("PRODUCT_SERIES_AUTO_APPLY", false) deltaIntervalMin := envIntRange("PRODUCT_SERIES_DELTA_INTERVAL_MIN", 60, 5, 1440) queueBatch := envIntRange("PRODUCT_SERIES_DELTA_BATCH_SIZE", 300, 10, 2000) fullHH, fullMM := envHHMM("PRODUCT_SERIES_FULL_HHMM", 5, 30) runFullOnStartup := envBool("PRODUCT_SERIES_FULL_RUN_ON_STARTUP", false) runDeltaOnStartup := envBool("PRODUCT_SERIES_DELTA_RUN_ON_STARTUP", false) var deltaRunning int32 var fullRunning int32 runDelta := func(reason string) { if !atomic.CompareAndSwapInt32(&deltaRunning, 0, 1) { log.Printf("[ProductSeriesDelta] skip (%s): already running", reason) return } defer atomic.StoreInt32(&deltaRunning, 0) ctx, cancel := context.WithTimeout(context.Background(), 2*time.Hour) defer cancel() stats, err := productSeriesDetectStockChanges(ctx, pgDB, reason) if err != nil { log.Printf("[ProductSeriesDelta] detect_error (%s): %v", reason, err) return } processed, written, skipped, err := productSeriesProcessQueue(ctx, pgDB, queueBatch, apply) stats.Processed += processed stats.Written += written stats.Skipped += skipped if err != nil { log.Printf("[ProductSeriesDelta] process_error (%s): %v", reason, err) return } log.Printf("[ProductSeriesDelta] ok (%s): scanned=%d changed=%d processed=%d written=%d skipped=%d apply=%t interval_min=%d", reason, stats.Scanned, stats.Changed, stats.Processed, stats.Written, stats.Skipped, apply, deltaIntervalMin) } runFull := func(reason string) { if !atomic.CompareAndSwapInt32(&fullRunning, 0, 1) { log.Printf("[ProductSeriesFull] skip (%s): already running", reason) return } defer atomic.StoreInt32(&fullRunning, 0) ctx, cancel := context.WithTimeout(context.Background(), 4*time.Hour) defer cancel() stats, err := productSeriesFullRebuild(ctx, pgDB, apply, reason) if err != nil { log.Printf("[ProductSeriesFull] error (%s): %v", reason, err) return } log.Printf("[ProductSeriesFull] ok (%s): scanned=%d processed=%d written=%d skipped=%d apply=%t hhmm=%02d:%02d", reason, stats.Scanned, stats.Processed, stats.Written, stats.Skipped, apply, fullHH, fullMM) } go func() { time.Sleep(3 * time.Second) if runDeltaOnStartup { runDelta("startup") } ticker := time.NewTicker(time.Duration(deltaIntervalMin) * time.Minute) defer ticker.Stop() for range ticker.C { runDelta("scheduled") } }() go func() { time.Sleep(5 * time.Second) if runFullOnStartup { runFull("startup_manual") } for { next := nextProductSeriesWeeklyRun(time.Now(), fullHH, fullMM) wait := time.Until(next) if wait < 0 { wait = time.Minute } log.Printf("[ProductSeriesFull] scheduled next_at=%s in=%s", next.Format(time.RFC3339), wait.Round(time.Second)) time.Sleep(wait) runFull("weekly") } }() } func productSeriesDetectStockChanges(ctx context.Context, pg *sql.DB, reason string) (productSeriesAutoStats, error) { var stats productSeriesAutoStats variants, scanned, err := productSeriesLoadMSSQLStockVariants(ctx) stats.Scanned = scanned if err != nil { return stats, err } tx, err := pg.BeginTx(ctx, nil) if err != nil { return stats, err } defer tx.Rollback() currentKeys := make([]string, 0, len(variants)) for key, v := range variants { currentKeys = append(currentKeys, key) var oldHash string err := tx.QueryRowContext(ctx, `SELECT stock_hash FROM mk_product_series_stock_state WHERE row_key=$1`, key).Scan(&oldHash) if err != nil && err != sql.ErrNoRows { return stats, err } if err == sql.ErrNoRows || oldHash != v.StockHash { if _, err := productSeriesEnqueueTx(ctx, tx, v.ProductCode, v.ColorCode, v.Dim3Code, reason); err != nil { return stats, err } stats.Changed++ } if _, err := tx.ExecContext(ctx, ` INSERT INTO mk_product_series_stock_state (row_key, product_code, color_code, dim3_code, stock_hash, total_qty, last_seen_at, updated_at) VALUES ($1, $2, $3, $4, $5, $6, now(), now()) ON CONFLICT (row_key) DO UPDATE SET stock_hash=EXCLUDED.stock_hash, total_qty=EXCLUDED.total_qty, last_seen_at=now(), updated_at=now() `, key, v.ProductCode, v.ColorCode, v.Dim3Code, v.StockHash, v.TotalQty); err != nil { return stats, err } } rows, err := tx.QueryContext(ctx, ` SELECT row_key, product_code, color_code, dim3_code FROM mk_product_series_stock_state WHERE NOT (row_key = ANY($1)) `, pq.Array(currentKeys)) if err != nil { return stats, err } missing := []productSeriesQueueItem{} for rows.Next() { var it productSeriesQueueItem if err := rows.Scan(&it.RowKey, &it.ProductCode, &it.ColorCode, &it.Dim3Code); err != nil { _ = rows.Close() return stats, err } missing = append(missing, it) } _ = rows.Close() for _, it := range missing { if _, err := productSeriesEnqueueTx(ctx, tx, it.ProductCode, it.ColorCode, it.Dim3Code, "stock_zero_"+reason); err != nil { return stats, err } stats.Changed++ } retryCount, err := productSeriesEnqueueUnmatchedStateTx(ctx, tx, reason) if err != nil { return stats, err } stats.Changed += retryCount return stats, tx.Commit() } func productSeriesFullRebuild(ctx context.Context, pg *sql.DB, apply bool, reason string) (productSeriesAutoStats, error) { var stats productSeriesAutoStats variants, scanned, err := productSeriesLoadMSSQLStockVariants(ctx) stats.Scanned = scanned if err != nil { return stats, err } rules, err := productSeriesLoadRules(ctx, pg) if err != nil { return stats, err } for _, v := range variants { written, skipped, err := productSeriesApplyVariant(ctx, pg, v, rules, apply) if err != nil { return stats, err } stats.Processed++ stats.Written += written stats.Skipped += skipped } _, _ = pg.ExecContext(ctx, ` INSERT INTO mk_product_series_job_log (job_name, reason, finished_at, status, scanned_rows, processed_rows, written_rows, skipped_rows) VALUES ('product_series_full', $1, now(), 'ok', $2, $3, $4, $5) `, reason, stats.Scanned, stats.Processed, stats.Written, stats.Skipped) return stats, nil } func productSeriesProcessQueue(ctx context.Context, pg *sql.DB, batchSize int, apply bool) (int, int, int, error) { rules, err := productSeriesLoadRules(ctx, pg) if err != nil { return 0, 0, 0, err } variants, _, err := productSeriesLoadMSSQLStockVariants(ctx) if err != nil { return 0, 0, 0, err } totalProcessed, totalWritten, totalSkipped := 0, 0, 0 for { tx, err := pg.BeginTx(ctx, nil) if err != nil { return totalProcessed, totalWritten, totalSkipped, err } items, err := productSeriesClaimQueue(ctx, tx, batchSize) if err != nil { _ = tx.Rollback() return totalProcessed, totalWritten, totalSkipped, err } if err := tx.Commit(); err != nil { return totalProcessed, totalWritten, totalSkipped, err } if len(items) == 0 { break } for _, it := range items { variant, ok := variants[productSeriesAutoKey(it.ProductCode, it.ColorCode, it.Dim3Code)] if !ok { variant = productSeriesAutoVariant{ RowKey: productSeriesAutoKey(it.ProductCode, it.ColorCode, it.Dim3Code), ProductCode: it.ProductCode, ColorCode: it.ColorCode, Dim3Code: it.Dim3Code, SizeQty: map[string]float64{}, } } written, skipped, err := productSeriesApplyVariant(ctx, pg, variant, rules, apply) if err != nil { _ = productSeriesMarkQueueFailed(ctx, pg, it, err.Error()) return totalProcessed, totalWritten, totalSkipped, err } totalProcessed++ totalWritten += written totalSkipped += skipped _ = productSeriesMarkQueueDone(ctx, pg, it.ID) } } return totalProcessed, totalWritten, totalSkipped, nil } func productSeriesApplyVariant(ctx context.Context, pg *sql.DB, v productSeriesAutoVariant, rules []productSeriesAutoRule, apply bool) (int, int, error) { mmitemID, dim1ID, dim3ID, ready, err := productSeriesResolvePGVariant(ctx, pg, v.ProductCode, v.ColorCode, v.Dim3Code) if err != nil { return 0, 1, err } if !ready { return 0, 1, nil } selected := productSeriesSelectRules(v, rules) if !apply { return len(selected), 0, nil } tx, err := pg.BeginTx(ctx, nil) if err != nil { return 0, 0, err } defer tx.Rollback() 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, nullableInt64ForAuto(dim3ID)); err != nil { return 0, 0, err } for _, rule := range selected { if _, err := tx.ExecContext(ctx, ` INSERT INTO zbggseri (mmitem_id, dim1, seri_id, dim3) VALUES ($1, $2, $3, $4) `, mmitemID, dim1ID, rule.SeriesID, nullableInt64ForAuto(dim3ID)); err != nil { return 0, 0, err } } if err := tx.Commit(); err != nil { return 0, 0, err } return len(selected), 0, nil } func productSeriesLoadMSSQLStockVariants(ctx context.Context) (map[string]productSeriesAutoVariant, int, error) { rows, err := db.MssqlDB.QueryContext(ctx, queries.GetProductSeriesStockRowsQuery, "", "", "", "", "", "", "", "", "", "0", "", ) if err != nil { return nil, 0, err } defer rows.Close() out := map[string]productSeriesAutoVariant{} scanned := 0 for rows.Next() { scanned++ var raw productSeriesAutoStockRaw if err := rows.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 { return nil, scanned, err } raw.ProductCode = strings.TrimSpace(raw.ProductCode) raw.ColorCode = strings.TrimSpace(raw.ColorCode) raw.Dim3Code = strings.TrimSpace(raw.Dim3Code) raw.SizeCode = normalizeProductSeriesSize(raw.SizeCode) if raw.ProductCode == "" || raw.ColorCode == "" || raw.SizeCode == "" { continue } key := productSeriesAutoKey(raw.ProductCode, raw.ColorCode, raw.Dim3Code) v := out[key] if v.SizeQty == nil { v = productSeriesAutoVariant{ RowKey: key, ProductCode: raw.ProductCode, ColorCode: raw.ColorCode, Dim3Code: raw.Dim3Code, SizeQty: map[string]float64{}, UrunAnaGrubu: strings.TrimSpace(raw.UrunAnaGrubu), UrunAltGrubu: strings.TrimSpace(raw.UrunAltGrubu), Kategori: strings.TrimSpace(raw.Kategori), } } v.SizeQty[raw.SizeCode] += raw.Qty v.TotalQty += raw.Qty out[key] = v } if err := rows.Err(); err != nil { return nil, scanned, err } for key, v := range out { v.GroupKey = productSeriesDetectGroup(v) v.StockHash = productSeriesStockHash(v.SizeQty) out[key] = v } return out, scanned, nil } func productSeriesLoadRules(ctx context.Context, pg *sql.DB) ([]productSeriesAutoRule, error) { rows, err := pg.QueryContext(ctx, ` SELECT d.id, COALESCE(d.code,''), COALESCE(d.title,''), COALESCE(r.size_group,''), COALESCE(r.size_code,''), COALESCE(r.ratio_qty,1), COALESCE(r.priority,0) FROM dfgrp d LEFT JOIN mk_product_series_rule r ON r.series_id=d.id AND r.is_active=TRUE WHERE d.master='zbggseri' AND COALESCE(d.is_active, TRUE)=TRUE ORDER BY d.id, r.priority DESC, r.id `) if err != nil { return nil, err } defer rows.Close() byID := map[int64]*productSeriesAutoRule{} for rows.Next() { var id int64 var code, title, group, size string var ratio, priority int if err := rows.Scan(&id, &code, &title, &group, &size, &ratio, &priority); err != nil { return nil, err } r := byID[id] if r == nil { r = &productSeriesAutoRule{SeriesID: id, Code: strings.TrimSpace(code), Title: strings.TrimSpace(title), GroupKey: strings.TrimSpace(group), Ratio: map[string]int{}} byID[id] = r } size = normalizeProductSeriesSize(size) if size != "" { if ratio <= 0 { ratio = 1 } r.Ratio[size] = ratio if priority > r.Priority { r.Priority = priority } } } if err := rows.Err(); err != nil { return nil, err } out := make([]productSeriesAutoRule, 0, len(byID)) for _, r := range byID { if len(r.Ratio) == 0 { for _, part := range strings.Split(r.Title, "-") { size := normalizeProductSeriesSize(part) if size != "" { r.Ratio[size] = 1 } } } if len(r.Ratio) > 0 { out = append(out, *r) } } sort.Slice(out, func(i, j int) bool { if out[i].Priority != out[j].Priority { return out[i].Priority > out[j].Priority } if len(out[i].Ratio) != len(out[j].Ratio) { return len(out[i].Ratio) > len(out[j].Ratio) } return out[i].Code < out[j].Code }) return out, nil } func productSeriesSelectRules(v productSeriesAutoVariant, rules []productSeriesAutoRule) []productSeriesAutoRule { candidates := make([]productSeriesAutoRule, 0, len(rules)) for _, rule := range rules { if !productSeriesRuleFitsGroup(v.GroupKey, rule) { continue } if productSeriesCanConsume(v.SizeQty, rule) { candidates = append(candidates, rule) } } if len(candidates) == 0 { return nil } if len(candidates) > 24 { candidates = candidates[:24] } type bestState struct { score int rules []productSeriesAutoRule } best := bestState{score: math.MinInt} stock := copyProductSeriesStock(v.SizeQty) var picked []productSeriesAutoRule var dfs func(int) dfs = func(idx int) { if idx >= len(candidates) { score := productSeriesSelectionScore(stock, picked) if score > best.score { best.score = score best.rules = append([]productSeriesAutoRule(nil), picked...) } return } dfs(idx + 1) rule := candidates[idx] if productSeriesCanConsume(stock, rule) { productSeriesConsume(stock, rule, -1) picked = append(picked, rule) dfs(idx + 1) picked = picked[:len(picked)-1] productSeriesConsume(stock, rule, 1) } } dfs(0) sort.Slice(best.rules, func(i, j int) bool { return best.rules[i].Code < best.rules[j].Code }) return best.rules } func productSeriesRuleFitsGroup(group string, rule productSeriesAutoRule) bool { if rule.GroupKey != "" && rule.GroupKey != group { return false } groupSizes := map[string]struct{}{} for _, size := range productSeriesSizeGroups[group] { groupSizes[normalizeProductSeriesSize(size)] = struct{}{} } if len(groupSizes) == 0 { return true } for size := range rule.Ratio { if _, ok := groupSizes[size]; !ok { return false } } return true } func productSeriesCanConsume(stock map[string]float64, rule productSeriesAutoRule) bool { for size, ratio := range rule.Ratio { if stock[size] < float64(ratio) { return false } } return true } func productSeriesConsume(stock map[string]float64, rule productSeriesAutoRule, sign float64) { for size, ratio := range rule.Ratio { stock[size] += sign * float64(ratio) } } func productSeriesSelectionScore(remaining map[string]float64, picked []productSeriesAutoRule) int { usedQty := 0 covered := map[string]struct{}{} priority := 0 for _, rule := range picked { priority += rule.Priority for size, ratio := range rule.Ratio { usedQty += ratio covered[size] = struct{}{} } } leftQty := 0 for _, qty := range remaining { leftQty += int(math.Round(qty)) } return usedQty*100 + len(covered)*20 + priority - leftQty*8 - len(picked)*5 } func productSeriesResolvePGVariant(ctx context.Context, pg *sql.DB, productCode, colorCode, dim3Code string) (int64, int64, sql.NullInt64, bool, error) { var mmitemID int64 if err := pg.QueryRowContext(ctx, `SELECT id FROM mmitem WHERE code=$1`, strings.TrimSpace(productCode)).Scan(&mmitemID); err != nil { if err == sql.ErrNoRows { return 0, 0, sql.NullInt64{}, false, nil } return 0, 0, sql.NullInt64{}, false, err } dim1ID, ok, err := productSeriesResolveDimTokenID(ctx, pg, "dimval1", colorCode, mmitemID) if err != nil { return 0, 0, sql.NullInt64{}, false, err } if !ok || dim1ID <= 0 { return 0, 0, sql.NullInt64{}, false, nil } var dim3ID sql.NullInt64 if strings.TrimSpace(dim3Code) != "" { id, ok, err := productSeriesResolveDimTokenID(ctx, pg, "dimval3", dim3Code, mmitemID) if err != nil { return 0, 0, sql.NullInt64{}, false, err } if !ok || id <= 0 { return 0, 0, sql.NullInt64{}, false, nil } dim3ID = sql.NullInt64{Int64: id, Valid: true} } return mmitemID, dim1ID, dim3ID, true, nil } func productSeriesResolveDimTokenID(ctx context.Context, pg *sql.DB, column string, token string, mmitemID int64) (int64, bool, error) { tok := strings.ToUpper(strings.TrimSpace(token)) if tok == "" || tok == "0" { return 0, false, nil } // dimval3 tokens like "001" can map to different dim ids per product in this installation. // Prefer per-mmitem inference from dfblob (src_id filter) to avoid global mk_dim_token_map mismatches. if column == "dimval3" && mmitemID > 0 { if inferred, ok := productSeriesInferDimIDFromImages(pg, mmitemID, column, tok); ok { return inferred, true, nil } } var id int64 err := pg.QueryRowContext(ctx, `SELECT dim_id FROM mk_dim_token_map WHERE dim_column=$1 AND token=$2`, column, tok).Scan(&id) if err == nil { return id, id > 0, nil } if err != sql.ErrNoRows { return 0, false, err } // Fallback: infer from dfblob filenames. For dimval3 do not persist globally. if mmitemID > 0 { if inferred, ok := productSeriesInferDimIDFromImages(pg, mmitemID, column, tok); ok { return inferred, true, nil } } v := productSeriesResolveDimvalFromFileNameToken(pg, column, tok, 0) if v == "" { return 0, false, nil } parsed, perr := strconv.ParseInt(v, 10, 64) if perr != nil || parsed <= 0 { return 0, false, nil } if column == "dimval1" { // Persist only for dimval1 where tokens are globally stable. _, _ = 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, parsed) } return parsed, true, nil } func productSeriesBuildNameLikePatterns(token string) []string { t := strings.ToUpper(strings.TrimSpace(token)) if t == "" { return nil } return []string{ "% " + t + " %", "%-" + t + "-%", "%-" + t + "_%", "%_" + t + "_%", "%(" + t + ")%", t + " %", } } func productSeriesResolveDimvalFromFileNameToken(pg *sql.DB, column, token string, mmitemID int64) string { patterns := productSeriesBuildNameLikePatterns(token) if len(patterns) == 0 { return "" } srcFilter := "" args := []any{patterns[0], patterns[1], patterns[2], patterns[3], patterns[4], patterns[5]} if mmitemID > 0 { srcFilter = " AND src_id=$7" args = append(args, mmitemID) } query := fmt.Sprintf(` SELECT x.dimv FROM ( SELECT COALESCE(%s::text, '') AS dimv, COUNT(*) AS cnt FROM dfblob WHERE src_table='mmitem' AND typ='img' AND COALESCE(%s::text, '') <> '' %s 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(%s::text, '') ) x ORDER BY x.cnt DESC, x.dimv LIMIT 1 `, column, column, srcFilter, column) var v string if err := pg.QueryRow(query, args...).Scan(&v); err != nil { return "" } v = strings.TrimSpace(v) if v == "" || v == "0" { return "" } return v } func productSeriesInferDimIDFromImages(pg *sql.DB, mmitemID int64, column, token string) (int64, bool) { v := productSeriesResolveDimvalFromFileNameToken(pg, column, token, mmitemID) if v == "" { return 0, false } id, err := strconv.ParseInt(v, 10, 64) if err != nil || id <= 0 { return 0, false } return id, true } func productSeriesClaimQueue(ctx context.Context, tx *sql.Tx, limit int) ([]productSeriesQueueItem, error) { rows, err := tx.QueryContext(ctx, ` WITH picked AS ( SELECT id FROM mk_product_series_recalc_queue WHERE status='pending' AND available_at <= now() ORDER BY queued_at LIMIT $1 FOR UPDATE SKIP LOCKED ) UPDATE mk_product_series_recalc_queue q SET status='processing', updated_at=now() FROM picked WHERE q.id=picked.id RETURNING q.id, q.row_key, q.product_code, q.color_code, q.dim3_code, q.attempts `, limit) if err != nil { return nil, err } defer rows.Close() out := []productSeriesQueueItem{} for rows.Next() { var it productSeriesQueueItem if err := rows.Scan(&it.ID, &it.RowKey, &it.ProductCode, &it.ColorCode, &it.Dim3Code, &it.Attempts); err != nil { return nil, err } out = append(out, it) } return out, rows.Err() } func productSeriesEnqueueTx(ctx context.Context, tx *sql.Tx, productCode, colorCode, dim3Code, reason string) (int, error) { productCode = strings.TrimSpace(productCode) colorCode = strings.TrimSpace(colorCode) dim3Code = strings.TrimSpace(dim3Code) if productCode == "" || colorCode == "" { return 0, nil } _, err := tx.ExecContext(ctx, ` INSERT INTO mk_product_series_recalc_queue (row_key, product_code, color_code, dim3_code, reason, status, attempts, available_at, queued_at, last_error, created_at, updated_at) VALUES ($1, $2, $3, $4, $5, 'pending', 0, now(), now(), '', now(), now()) ON CONFLICT (row_key) WHERE status IN ('pending','processing') DO NOTHING `, productSeriesAutoKey(productCode, colorCode, dim3Code), productCode, colorCode, dim3Code, strings.TrimSpace(reason)) if err != nil { return 0, err } return 1, nil } func productSeriesEnqueueUnmatchedStateTx(ctx context.Context, tx *sql.Tx, reason string) (int, error) { rows, err := tx.QueryContext(ctx, ` WITH candidates AS ( SELECT s.row_key, s.product_code, s.color_code, s.dim3_code FROM mk_product_series_stock_state s LEFT JOIN mmitem m ON BTRIM(m.code) = BTRIM(s.product_code) LEFT JOIN mk_dim_token_map d1 ON d1.dim_column='dimval1' AND BTRIM(d1.token) = BTRIM(s.color_code) LEFT JOIN mk_dim_token_map d3 ON d3.dim_column='dimval3' AND BTRIM(d3.token) = BTRIM(s.dim3_code) LEFT JOIN zbggseri z ON z.mmitem_id = m.id AND z.dim1 = d1.dim_id AND ( (NULLIF(BTRIM(s.dim3_code), '') IS NULL AND z.dim3 IS NULL) OR z.dim3 = d3.dim_id ) WHERE s.total_qty > 0 AND s.last_seen_at > now() - interval '30 days' AND ( m.id IS NULL OR d1.dim_id IS NULL OR (NULLIF(BTRIM(s.dim3_code), '') IS NOT NULL AND d3.dim_id IS NULL) OR z.id IS NULL ) ORDER BY s.updated_at DESC LIMIT 2000 ), inserted AS ( INSERT INTO mk_product_series_recalc_queue (row_key, product_code, color_code, dim3_code, reason, status, attempts, available_at, queued_at, last_error, created_at, updated_at) SELECT row_key, product_code, color_code, dim3_code, $1, 'pending', 0, now(), now(), '', now(), now() FROM candidates ON CONFLICT (row_key) WHERE status IN ('pending','processing') DO NOTHING RETURNING id ) SELECT COUNT(*) FROM inserted `, "unmatched_retry_"+strings.TrimSpace(reason)) if err != nil { return 0, err } defer rows.Close() var count int if rows.Next() { if err := rows.Scan(&count); err != nil { return 0, err } } return count, rows.Err() } func productSeriesMarkQueueDone(ctx context.Context, pg *sql.DB, id int64) error { _, err := pg.ExecContext(ctx, ` UPDATE mk_product_series_recalc_queue SET status='done', processed_at=now(), updated_at=now(), last_error='' WHERE id=$1 `, id) return err } func productSeriesMarkQueueFailed(ctx context.Context, pg *sql.DB, it productSeriesQueueItem, errText string) error { if len(errText) > 900 { errText = errText[:900] } delay := 5 * time.Minute if it.Attempts >= 1 { delay = 15 * time.Minute } if it.Attempts >= 2 { delay = 60 * time.Minute } _, err := pg.ExecContext(ctx, ` UPDATE mk_product_series_recalc_queue SET status='failed', attempts=attempts+1, processed_at=now(), updated_at=now(), last_error=$2, available_at=now() + $3::interval WHERE id=$1 `, it.ID, strings.TrimSpace(errText), fmt.Sprintf("%d seconds", int(delay.Seconds()))) return err } func productSeriesDetectGroup(v productSeriesAutoVariant) string { text := strings.ToUpper(v.Kategori + " " + v.UrunAnaGrubu + " " + v.UrunAltGrubu) switch { case strings.Contains(text, "AYAKKABI") && strings.Contains(text, "GARSON"): return "ayk_garson" case strings.Contains(text, "AYAKKABI"): return "ayk" case strings.Contains(text, "PANTOLON"): return "pan" case strings.Contains(text, "TAKIM") || strings.Contains(text, "DAMATLIK") || strings.Contains(text, "CEKET") || strings.Contains(text, "KABAN") || strings.Contains(text, "MONT") || strings.Contains(text, "YELEK"): return "tak" case strings.Contains(text, "GOMLEK") || strings.Contains(text, "GÖMLEK"): return "gom" case strings.Contains(text, "YAS") || strings.Contains(text, "YAŞ") || strings.Contains(text, "COCUK") || strings.Contains(text, "ÇOCUK"): return "yas" case strings.Contains(text, "AKSESUAR") || strings.Contains(text, "KRAVAT") || strings.Contains(text, "KEMER") || strings.Contains(text, "PAPYON"): return "aks" default: return productSeriesDetectGroupBySizes(v.SizeQty) } } func productSeriesDetectGroupBySizes(sizeQty map[string]float64) string { bestKey := "tak" bestCount := -1 for _, key := range []string{"tak", "pan", "gom", "ayk", "ayk_garson", "yas", "aks"} { sizes := productSeriesSizeGroups[key] set := map[string]struct{}{} for _, size := range sizes { set[normalizeProductSeriesSize(size)] = struct{}{} } count := 0 miss := 0 for size := range sizeQty { if _, ok := set[size]; ok { count++ } else { miss++ } } if miss == 0 && count > bestCount { bestKey = key bestCount = count } } return bestKey } func productSeriesStockHash(sizeQty map[string]float64) string { keys := make([]string, 0, len(sizeQty)) for k := range sizeQty { keys = append(keys, k) } sort.Strings(keys) parts := make([]string, 0, len(keys)) for _, k := range keys { parts = append(parts, fmt.Sprintf("%s=%.2f", k, sizeQty[k])) } sum := sha1.Sum([]byte(strings.Join(parts, "|"))) return hex.EncodeToString(sum[:]) } func productSeriesAutoKey(productCode, colorCode, dim3Code string) string { return strings.TrimSpace(productCode) + "|" + strings.TrimSpace(colorCode) + "|" + strings.TrimSpace(dim3Code) } func normalizeProductSeriesSize(v string) string { return strings.ToUpper(strings.TrimSpace(v)) } func copyProductSeriesStock(in map[string]float64) map[string]float64 { out := make(map[string]float64, len(in)) for k, v := range in { out[k] = v } return out } func nullableInt64ForAuto(v sql.NullInt64) any { if !v.Valid { return nil } return v.Int64 } func nextProductSeriesWeeklyRun(now time.Time, hh int, mm int) time.Time { loc := now.Location() base := time.Date(now.Year(), now.Month(), now.Day(), hh, mm, 0, 0, loc) daysUntilMon := (int(time.Monday) - int(now.Weekday()) + 7) % 7 candidate := base.AddDate(0, 0, daysUntilMon) if !candidate.After(now) { candidate = candidate.AddDate(0, 0, 7) } return candidate } func envBool(key string, fallback bool) bool { raw := strings.TrimSpace(strings.ToLower(os.Getenv(key))) if raw == "" { return fallback } return raw == "1" || raw == "true" || raw == "on" || raw == "yes" } func envIntRange(key string, fallback int, min int, max int) int { raw := strings.TrimSpace(os.Getenv(key)) if raw == "" { return fallback } n, err := strconv.Atoi(raw) if err != nil || n < min || n > max { return fallback } return n } func envHHMM(key string, fallbackHH int, fallbackMM int) (int, int) { raw := strings.TrimSpace(os.Getenv(key)) if raw == "" { return fallbackHH, fallbackMM } parts := strings.Split(raw, ":") if len(parts) != 2 { return fallbackHH, fallbackMM } hh, errH := strconv.Atoi(strings.TrimSpace(parts[0])) mm, errM := strconv.Atoi(strings.TrimSpace(parts[1])) if errH != nil || errM != nil || hh < 0 || hh > 23 || mm < 0 || mm > 59 { return fallbackHH, fallbackMM } return hh, mm }