package main import ( "bssapp-backend/db" "bssapp-backend/queries" "context" "crypto/sha1" "database/sql" "encoding/hex" "fmt" "log" "math" "os" "sort" "strconv" "strings" "sync" "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 productSeriesFallbackMu sync.Mutex var productSeriesFallbackCachedCode string var productSeriesFallbackCachedID int64 var productSeriesFallbackCachedAt time.Time func productSeriesFallbackEnabled() bool { // Default on: user explicitly requested "stok var ama seri yoksa 1- ata". raw := strings.TrimSpace(strings.ToLower(os.Getenv("PRODUCT_SERIES_FALLBACK_ON_EMPTY"))) if raw == "" { return true } return raw == "1" || raw == "true" || raw == "on" || raw == "yes" } func productSeriesFallbackAutoCreateEnabled() bool { // Default on: avoid manual intervention by auto-creating the fallback dfgrp row when missing. raw := strings.TrimSpace(strings.ToLower(os.Getenv("PRODUCT_SERIES_FALLBACK_AUTO_CREATE"))) if raw == "" { return true } return raw == "1" || raw == "true" || raw == "on" || raw == "yes" } func productSeriesFallbackLogEnabled() bool { raw := strings.TrimSpace(strings.ToLower(os.Getenv("PRODUCT_SERIES_FALLBACK_LOG"))) if raw == "" { return false } return raw == "1" || raw == "true" || raw == "on" || raw == "yes" } func productSeriesFallbackCode() string { code := strings.TrimSpace(os.Getenv("PRODUCT_SERIES_FALLBACK_SERIES_CODE")) if code == "" { // In this installation, the default series code is "1" (not "1-"). code = "1" } return code } func productSeriesResolveFallbackSeries(ctx context.Context, pg *sql.DB) (int64, string, error) { if !productSeriesFallbackEnabled() { return 0, "", nil } code := productSeriesFallbackCode() if code == "" { return 0, "", nil } productSeriesFallbackMu.Lock() // Cache for a bit to avoid repeated lookups under high throughput. if productSeriesFallbackCachedCode == code && productSeriesFallbackCachedAt.After(time.Now().Add(-10*time.Minute)) { id := productSeriesFallbackCachedID productSeriesFallbackMu.Unlock() return id, code, nil } productSeriesFallbackMu.Unlock() var id int64 var gotCode string err := pg.QueryRowContext(ctx, ` SELECT id, COALESCE(code,'') FROM dfgrp WHERE master='zbggseri' AND COALESCE(is_active, TRUE)=TRUE AND ( code = $1 OR title = $1 OR title LIKE $1 || '%' ) ORDER BY CASE WHEN code = $1 THEN 0 WHEN title = $1 THEN 1 ELSE 2 END, id LIMIT 1 `, code).Scan(&id, &gotCode) if err != nil { if err == sql.ErrNoRows { if !productSeriesFallbackAutoCreateEnabled() { // cache negative for a short period too productSeriesFallbackMu.Lock() productSeriesFallbackCachedCode = code productSeriesFallbackCachedID = 0 productSeriesFallbackCachedAt = time.Now() productSeriesFallbackMu.Unlock() return 0, code, nil } // Auto-create the fallback series definition (best-effort) to avoid manual steps. // We guard with the same mutex to avoid duplicate inserts within this process. productSeriesFallbackMu.Lock() defer productSeriesFallbackMu.Unlock() // Re-check under lock in case another goroutine created it while we were waiting. if productSeriesFallbackCachedCode == code && productSeriesFallbackCachedID > 0 && productSeriesFallbackCachedAt.After(time.Now().Add(-10*time.Minute)) { return productSeriesFallbackCachedID, code, nil } var createdID int64 createErr := pg.QueryRowContext(ctx, ` INSERT INTO dfgrp (code, title, is_active, typ, master, parent_filter, sort_order, is_required, notes) SELECT $1, $1, TRUE, 'opt', 'zbggseri', '', 0, FALSE, 'auto-created fallback series' WHERE NOT EXISTS ( SELECT 1 FROM dfgrp WHERE master='zbggseri' AND code=$1 ) RETURNING id `, code).Scan(&createdID) if createErr != nil { // If RETURNING didn't return because it already exists, select it now. var sid int64 var scode string selErr := pg.QueryRowContext(ctx, ` SELECT id, COALESCE(code,'') FROM dfgrp WHERE master='zbggseri' AND code=$1 ORDER BY id LIMIT 1 `, code).Scan(&sid, &scode) if selErr != nil { // still missing; treat as disabled for now productSeriesFallbackCachedCode = code productSeriesFallbackCachedID = 0 productSeriesFallbackCachedAt = time.Now() return 0, code, nil } createdID = sid } productSeriesFallbackCachedCode = code productSeriesFallbackCachedID = createdID productSeriesFallbackCachedAt = time.Now() return createdID, code, nil } return 0, code, err } if gotCode != "" { code = gotCode } productSeriesFallbackMu.Lock() productSeriesFallbackCachedCode = code productSeriesFallbackCachedID = id productSeriesFallbackCachedAt = time.Now() productSeriesFallbackMu.Unlock() return id, code, nil } 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 { if productSeriesFallbackLogEnabled() { log.Printf("[ProductSeriesFallback] skip_not_ready product=%s color=%s dim3=%s", strings.TrimSpace(v.ProductCode), strings.TrimSpace(v.ColorCode), strings.TrimSpace(v.Dim3Code)) } return 0, 1, nil } selected := productSeriesSelectRules(v, rules) if len(selected) == 0 { fallbackID, fallbackCode, err := productSeriesResolveFallbackSeries(ctx, pg) if err != nil { return 0, 1, err } if fallbackID > 0 { // Only apply fallback when the variant has no assignments yet. var exists int checkErr := pg.QueryRowContext(ctx, ` SELECT 1 FROM zbggseri WHERE mmitem_id=$1 AND dim1=$2 AND (($3::bigint IS NULL AND dim3 IS NULL) OR dim3=$3::bigint) LIMIT 1 `, mmitemID, dim1ID, nullableInt64ForAuto(dim3ID)).Scan(&exists) if checkErr == nil { if productSeriesFallbackLogEnabled() { log.Printf("[ProductSeriesFallback] already_exists product=%s color=%s dim3=%s fallback=%s(%d)", strings.TrimSpace(v.ProductCode), strings.TrimSpace(v.ColorCode), strings.TrimSpace(v.Dim3Code), strings.TrimSpace(fallbackCode), fallbackID) } // keep existing manual/previous assignment; nothing to do return 0, 0, nil } if checkErr != nil && checkErr != sql.ErrNoRows { return 0, 1, checkErr } if productSeriesFallbackLogEnabled() { log.Printf("[ProductSeriesFallback] apply product=%s color=%s dim3=%s fallback=%s(%d)", strings.TrimSpace(v.ProductCode), strings.TrimSpace(v.ColorCode), strings.TrimSpace(v.Dim3Code), strings.TrimSpace(fallbackCode), fallbackID) } // Use the fallback series as the single selected rule. selected = []productSeriesAutoRule{{SeriesID: fallbackID}} } else { if productSeriesFallbackLogEnabled() { log.Printf("[ProductSeriesFallback] missing_fallback product=%s color=%s dim3=%s fallback_code=%s", strings.TrimSpace(v.ProductCode), strings.TrimSpace(v.ColorCode), strings.TrimSpace(v.Dim3Code), strings.TrimSpace(fallbackCode)) } return 0, 1, nil } } 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 } // NOTE: In this installation, Nebim ItemDim1Code (size_code) can be numeric tokens that do not match the // hardcoded group size lists (e.g. "XS/S/M..."). Enforcing those lists causes valid rules to be rejected // and series assignment to stay blank across many products. // // We therefore rely only on explicit rule.GroupKey when provided, and otherwise allow the rule to compete // based on actual stock consumption. 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 }