diff --git a/svc/main.go b/svc/main.go index 36025f8..049dc41 100644 --- a/svc/main.go +++ b/svc/main.go @@ -925,6 +925,36 @@ func InitRoutes(pgDB *sql.DB, mssql *sql.DB, ml *mailer.GraphMailer) *mux.Router "pricing", "update", wrapV3(routes.PostProductPricingSaveHandler(pgDB, ml)), ) + bindV3(r, pgDB, + "/api/pricing/product-series/definitions", "GET", + "pricing", "view", + wrapV3(http.HandlerFunc(routes.GetProductSeriesDefinitionsHandler(pgDB))), + ) + bindV3(r, pgDB, + "/api/pricing/product-series/definitions", "POST", + "pricing", "update", + wrapV3(http.HandlerFunc(routes.PostProductSeriesDefinitionHandler(pgDB))), + ) + bindV3(r, pgDB, + "/api/pricing/product-series/definitions/{id}", "PUT", + "pricing", "update", + wrapV3(http.HandlerFunc(routes.PutProductSeriesDefinitionHandler(pgDB))), + ) + bindV3(r, pgDB, + "/api/pricing/product-series/definitions/{id}", "DELETE", + "pricing", "update", + wrapV3(http.HandlerFunc(routes.DeleteProductSeriesDefinitionHandler(pgDB))), + ) + bindV3(r, pgDB, + "/api/pricing/product-series/mappings", "GET", + "pricing", "view", + wrapV3(http.HandlerFunc(routes.GetProductSeriesMappingsHandler(pgDB))), + ) + bindV3(r, pgDB, + "/api/pricing/product-series/mappings/save", "POST", + "pricing", "update", + wrapV3(http.HandlerFunc(routes.PostProductSeriesMappingsSaveHandler(pgDB))), + ) bindV3(r, pgDB, "/api/pricing/wholesale-campaigns", "GET", "pricing", "view", @@ -1330,6 +1360,9 @@ func main() { if err := queries.EnsurePricingCalcInfraTables(pgDB); err != nil { log.Println("pricing calc infra bootstrap failed:", err) } + if err := queries.EnsureProductSeriesAutoInfraTables(pgDB); err != nil { + log.Println("product series auto infra bootstrap failed:", err) + } // ------------------------------------------------------- // ✉️ MAILER INIT @@ -1355,6 +1388,7 @@ func main() { startProductPricingCalcScheduler(pgDB) startProductPricingFxDeltaScheduler(pgDB) startProductPricingFxFullScheduler(pgDB) + startProductSeriesAutoSchedulers(pgDB) handler := enableCORS( middlewares.GlobalAuthMiddleware( diff --git a/svc/product_series_auto_scheduler.go b/svc/product_series_auto_scheduler.go new file mode 100644 index 0000000..6cd5be7 --- /dev/null +++ b/svc/product_series_auto_scheduler.go @@ -0,0 +1,866 @@ +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++ + } + + 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 + } + 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, err := productSeriesLoadSingleVariant(ctx, it.ProductCode, it.ColorCode, it.Dim3Code) + if err != nil { + _ = productSeriesMarkQueueFailed(ctx, pg, it, err.Error()) + return totalProcessed, totalWritten, totalSkipped, err + } + 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 productSeriesLoadSingleVariant(ctx context.Context, productCode, colorCode, dim3Code string) (productSeriesAutoVariant, bool, error) { + variants, _, err := productSeriesLoadMSSQLStockVariants(ctx) + if err != nil { + return productSeriesAutoVariant{}, false, err + } + key := productSeriesAutoKey(productCode, colorCode, dim3Code) + v, ok := variants[key] + return v, ok, 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 + } + var dim1ID int64 + if err := pg.QueryRowContext(ctx, `SELECT dim_id FROM mk_dim_token_map WHERE dim_column='dimval1' AND token=$1`, strings.TrimSpace(colorCode)).Scan(&dim1ID); err != nil { + if err == sql.ErrNoRows { + return 0, 0, sql.NullInt64{}, false, nil + } + return 0, 0, sql.NullInt64{}, false, err + } + var dim3ID sql.NullInt64 + if strings.TrimSpace(dim3Code) != "" { + var id int64 + if err := pg.QueryRowContext(ctx, `SELECT dim_id FROM mk_dim_token_map WHERE dim_column='dimval3' AND token=$1`, strings.TrimSpace(dim3Code)).Scan(&id); err != nil { + if err == sql.ErrNoRows { + return 0, 0, sql.NullInt64{}, false, nil + } + return 0, 0, sql.NullInt64{}, false, err + } + dim3ID = sql.NullInt64{Int64: id, Valid: true} + } + return mmitemID, dim1ID, dim3ID, true, nil +} + +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()) +`, productSeriesAutoKey(productCode, colorCode, dim3Code), productCode, colorCode, dim3Code, strings.TrimSpace(reason)) + if err != nil { + if pe, ok := err.(*pq.Error); ok && pe != nil && string(pe.Code) == "23505" { + return 0, nil + } + return 0, err + } + return 1, nil +} + +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 +} diff --git a/svc/queries/product_series.go b/svc/queries/product_series.go new file mode 100644 index 0000000..13b864b --- /dev/null +++ b/svc/queries/product_series.go @@ -0,0 +1,198 @@ +package queries + +const GetProductSeriesStockRowsQuery = ` +DECLARE @Kategori NVARCHAR(100) = NULLIF(LTRIM(RTRIM(@p1)), ''); +DECLARE @UrunAnaGrubu NVARCHAR(100) = NULLIF(LTRIM(RTRIM(@p2)), ''); +DECLARE @UrunAltGrubuList NVARCHAR(MAX) = NULLIF(LTRIM(RTRIM(@p3)), ''); +DECLARE @RenkList NVARCHAR(MAX) = NULLIF(LTRIM(RTRIM(@p4)), ''); +DECLARE @Renk2List NVARCHAR(MAX) = NULLIF(LTRIM(RTRIM(@p5)), ''); +DECLARE @UrunIcerigiList NVARCHAR(MAX) = NULLIF(LTRIM(RTRIM(@p6)), ''); +DECLARE @FitList NVARCHAR(MAX) = NULLIF(LTRIM(RTRIM(@p7)), ''); +DECLARE @DropList NVARCHAR(MAX) = NULLIF(LTRIM(RTRIM(@p8)), ''); +DECLARE @BedenList NVARCHAR(MAX) = NULLIF(LTRIM(RTRIM(@p9)), ''); +DECLARE @Search NVARCHAR(80) = NULLIF(LTRIM(RTRIM(@p11)), ''); +DECLARE @RowLimit INT; + +IF ISNUMERIC(@p10) = 1 +BEGIN + SET @RowLimit = CONVERT(INT, @p10); +END; + +IF @RowLimit IS NULL OR @RowLimit <= 0 +BEGIN + SET @RowLimit = 2147483647; +END; + +CREATE TABLE #AttrFiltered +( + ProductCode NVARCHAR(50) NOT NULL, + ProductDescription NVARCHAR(255) NULL, + ProductAtt01Desc NVARCHAR(255) NULL, + ProductAtt02Desc NVARCHAR(255) NULL, + ProductAtt10Desc NVARCHAR(255) NULL, + ProductAtt11Desc NVARCHAR(255) NULL, + ProductAtt38Desc NVARCHAR(255) NULL, + ProductAtt41Desc NVARCHAR(255) NULL, + ProductAtt44Desc NVARCHAR(255) NULL +); + +IF OBJECT_ID('dbo.ProductFilterTRCache','U') IS NOT NULL +BEGIN + INSERT INTO #AttrFiltered + SELECT + C.ProductCode, + C.ProductDescription, + C.ProductAtt01Desc, + C.ProductAtt02Desc, + ISNULL(PF.ProductAtt10Desc, '') AS ProductAtt10Desc, + C.ProductAtt11Desc, + C.ProductAtt38Desc, + C.ProductAtt41Desc, + C.ProductAtt44Desc + FROM dbo.ProductFilterTRCache C + LEFT JOIN ProductFilterWithDescription('TR') PF ON PF.ProductCode = C.ProductCode + WHERE LEN(C.ProductCode) = 13 + AND (@Search IS NULL OR C.ProductCode LIKE '%' + @Search + '%' OR C.ProductDescription LIKE '%' + @Search + '%') + AND (@Kategori IS NULL OR C.ProductAtt44Desc = @Kategori) + AND (@UrunAnaGrubu IS NULL OR C.ProductAtt01Desc = @UrunAnaGrubu) + AND (@UrunAltGrubuList IS NULL OR CHARINDEX(NCHAR(31) + ISNULL(C.ProductAtt02Desc,'') + NCHAR(31), NCHAR(31) + @UrunAltGrubuList + NCHAR(31)) > 0) + AND (@UrunIcerigiList IS NULL OR CHARINDEX(NCHAR(31) + ISNULL(C.ProductAtt41Desc,'') + NCHAR(31), NCHAR(31) + @UrunIcerigiList + NCHAR(31)) > 0) + AND (@FitList IS NULL OR CHARINDEX(NCHAR(31) + ISNULL(C.ProductAtt38Desc,'') + NCHAR(31), NCHAR(31) + @FitList + NCHAR(31)) > 0) + AND (@DropList IS NULL OR CHARINDEX(NCHAR(31) + ISNULL(C.ProductAtt11Desc,'') + NCHAR(31), NCHAR(31) + @DropList + NCHAR(31)) > 0); +END +ELSE +BEGIN + INSERT INTO #AttrFiltered + SELECT + ProductCode, + ProductDescription, + ProductAtt01Desc, + ProductAtt02Desc, + ProductAtt10Desc, + ProductAtt11Desc, + ProductAtt38Desc, + ProductAtt41Desc, + ProductAtt44Desc + FROM ProductFilterWithDescription('TR') + WHERE LEN(ProductCode) = 13 + AND (@Search IS NULL OR ProductCode LIKE '%' + @Search + '%' OR ProductDescription LIKE '%' + @Search + '%') + AND (@Kategori IS NULL OR ProductAtt44Desc = @Kategori) + AND (@UrunAnaGrubu IS NULL OR ProductAtt01Desc = @UrunAnaGrubu) + AND (@UrunAltGrubuList IS NULL OR CHARINDEX(NCHAR(31) + ISNULL(ProductAtt02Desc,'') + NCHAR(31), NCHAR(31) + @UrunAltGrubuList + NCHAR(31)) > 0) + AND (@UrunIcerigiList IS NULL OR CHARINDEX(NCHAR(31) + ISNULL(ProductAtt41Desc,'') + NCHAR(31), NCHAR(31) + @UrunIcerigiList + NCHAR(31)) > 0) + AND (@FitList IS NULL OR CHARINDEX(NCHAR(31) + ISNULL(ProductAtt38Desc,'') + NCHAR(31), NCHAR(31) + @FitList + NCHAR(31)) > 0) + AND (@DropList IS NULL OR CHARINDEX(NCHAR(31) + ISNULL(ProductAtt11Desc,'') + NCHAR(31), NCHAR(31) + @DropList + NCHAR(31)) > 0); +END; + +CREATE CLUSTERED INDEX IX_AttrFiltered_ProductCode ON #AttrFiltered(ProductCode); + +;WITH Inv AS +( + SELECT + X.ItemCode, + X.ColorCode, + X.ItemDim1Code, + X.ItemDim2Code, + SUM(X.InventoryQty1) AS InventoryQty1, + SUM(X.PickingQty1) AS PickingQty1, + SUM(X.ReserveQty1) AS ReserveQty1, + SUM(X.DispOrderQty1) AS DispOrderQty1 + FROM + ( + SELECT T.ItemCode, T.ColorCode, T.ItemDim1Code, T.ItemDim2Code, + SUM(T.In_Qty1 - T.Out_Qty1) AS InventoryQty1, 0 AS PickingQty1, 0 AS ReserveQty1, 0 AS DispOrderQty1 + FROM trStock T WITH (NOLOCK) + INNER JOIN #AttrFiltered AF ON AF.ProductCode = T.ItemCode + WHERE T.ItemTypeCode = 1 + AND LEN(T.ItemCode) = 13 + AND T.WarehouseCode IN ( + '1-0-14','1-0-10','1-0-8','1-2-5','1-2-4','1-0-12','100','1-0-28', + '1-0-24','1-2-6','1-1-14','1-0-2','1-0-52','1-1-2','1-0-21','1-1-3', + '1-0-33','101','1-014','1-0-49','1-0-36' + ) + GROUP BY T.ItemCode, T.ColorCode, T.ItemDim1Code, T.ItemDim2Code + + UNION ALL + SELECT P.ItemCode, P.ColorCode, P.ItemDim1Code, P.ItemDim2Code, 0, SUM(P.Qty1), 0, 0 + FROM PickingStates P + INNER JOIN #AttrFiltered AF ON AF.ProductCode = P.ItemCode + WHERE P.ItemTypeCode = 1 AND LEN(P.ItemCode) = 13 + GROUP BY P.ItemCode, P.ColorCode, P.ItemDim1Code, P.ItemDim2Code + + UNION ALL + SELECT R.ItemCode, R.ColorCode, R.ItemDim1Code, R.ItemDim2Code, 0, 0, SUM(R.Qty1), 0 + FROM ReserveStates R + INNER JOIN #AttrFiltered AF ON AF.ProductCode = R.ItemCode + WHERE R.ItemTypeCode = 1 AND LEN(R.ItemCode) = 13 + GROUP BY R.ItemCode, R.ColorCode, R.ItemDim1Code, R.ItemDim2Code + + UNION ALL + SELECT D.ItemCode, D.ColorCode, D.ItemDim1Code, D.ItemDim2Code, 0, 0, 0, SUM(D.Qty1) + FROM DispOrderStates D + INNER JOIN #AttrFiltered AF ON AF.ProductCode = D.ItemCode + WHERE D.ItemTypeCode = 1 AND LEN(D.ItemCode) = 13 + GROUP BY D.ItemCode, D.ColorCode, D.ItemDim1Code, D.ItemDim2Code + ) X + GROUP BY X.ItemCode, X.ColorCode, X.ItemDim1Code, X.ItemDim2Code +), +Avail AS +( + SELECT + I.ItemCode, + I.ColorCode, + I.ItemDim1Code, + I.ItemDim2Code, + CAST(ROUND(I.InventoryQty1 - I.PickingQty1 - I.ReserveQty1 - I.DispOrderQty1, 2) AS DECIMAL(18,2)) AS Qty + FROM Inv I + JOIN cdItem CI WITH (NOLOCK) + ON CI.ItemTypeCode = 1 + AND CI.ItemCode = I.ItemCode + WHERE CI.IsBlocked = 0 + AND (I.InventoryQty1 - I.PickingQty1 - I.ReserveQty1 - I.DispOrderQty1) > 0 + AND NULLIF(LTRIM(RTRIM(ISNULL(I.ColorCode, ''))), '') IS NOT NULL + AND (@RenkList IS NULL OR CHARINDEX(NCHAR(31) + ISNULL(I.ColorCode,'') + NCHAR(31), NCHAR(31) + @RenkList + NCHAR(31)) > 0) + AND (@Renk2List IS NULL OR CHARINDEX(NCHAR(31) + ISNULL(I.ItemDim2Code,'') + NCHAR(31), NCHAR(31) + @Renk2List + NCHAR(31)) > 0) + AND (@BedenList IS NULL OR CHARINDEX(NCHAR(31) + ISNULL(I.ItemDim1Code,'') + NCHAR(31), NCHAR(31) + @BedenList + NCHAR(31)) > 0) +), +Groups AS +( + SELECT ItemCode, ColorCode, ItemDim2Code + FROM + ( + SELECT + A.ItemCode, + A.ColorCode, + A.ItemDim2Code, + ROW_NUMBER() OVER (ORDER BY A.ItemCode, A.ColorCode, A.ItemDim2Code) AS rn + FROM Avail A + GROUP BY A.ItemCode, A.ColorCode, A.ItemDim2Code + ) X + WHERE X.rn <= @RowLimit +) +SELECT + G.ItemCode AS product_code, + AF.ProductDescription AS product_description, + G.ColorCode AS color_code, + ISNULL(C.ColorDescription, '') AS color_title, + ISNULL(G.ItemDim2Code, '') AS dim3_code, + A.ItemDim1Code AS size_code, + A.Qty AS qty, + AF.ProductAtt01Desc AS urun_ana_grubu, + AF.ProductAtt02Desc AS urun_alt_grubu, + AF.ProductAtt10Desc AS marka, + AF.ProductAtt11Desc AS drop_val, + AF.ProductAtt38Desc AS fit, + AF.ProductAtt41Desc AS urun_icerigi, + AF.ProductAtt44Desc AS kategori +FROM Groups G +JOIN Avail A + ON A.ItemCode = G.ItemCode + AND A.ColorCode = G.ColorCode + AND ISNULL(A.ItemDim2Code, '') = ISNULL(G.ItemDim2Code, '') +JOIN #AttrFiltered AF ON AF.ProductCode = G.ItemCode +LEFT JOIN cdColorDesc C WITH (NOLOCK) + ON C.ColorCode = G.ColorCode + AND C.LangCode = 'TR' +ORDER BY G.ItemCode, G.ColorCode, G.ItemDim2Code, A.ItemDim1Code +OPTION (RECOMPILE); +` diff --git a/svc/queries/product_series_auto_infra.go b/svc/queries/product_series_auto_infra.go new file mode 100644 index 0000000..004a90e --- /dev/null +++ b/svc/queries/product_series_auto_infra.go @@ -0,0 +1,89 @@ +package queries + +import "database/sql" + +func EnsureProductSeriesAutoInfraTables(pg *sql.DB) error { + stmts := []string{ + ` +CREATE TABLE IF NOT EXISTS mk_product_series_rule ( + id BIGSERIAL PRIMARY KEY, + series_id BIGINT NOT NULL REFERENCES dfgrp(id) ON DELETE CASCADE, + size_group TEXT NOT NULL DEFAULT '', + size_code TEXT NOT NULL, + ratio_qty INTEGER NOT NULL DEFAULT 1, + priority INTEGER NOT NULL DEFAULT 0, + is_active BOOLEAN NOT NULL DEFAULT TRUE, + source TEXT NOT NULL DEFAULT 'manual', + notes TEXT NOT NULL DEFAULT '', + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + CONSTRAINT ck_mk_product_series_rule_ratio CHECK (ratio_qty > 0), + CONSTRAINT uq_mk_product_series_rule UNIQUE (series_id, size_group, size_code) +)`, + `CREATE INDEX IF NOT EXISTS ix_mk_product_series_rule_series ON mk_product_series_rule (series_id, is_active)`, + `CREATE INDEX IF NOT EXISTS ix_mk_product_series_rule_group ON mk_product_series_rule (size_group, size_code)`, + ` +CREATE TABLE IF NOT EXISTS mk_product_series_stock_state ( + row_key TEXT PRIMARY KEY, + product_code TEXT NOT NULL, + color_code TEXT NOT NULL, + dim3_code TEXT NOT NULL DEFAULT '', + stock_hash TEXT NOT NULL DEFAULT '', + total_qty NUMERIC(18,2) NOT NULL DEFAULT 0, + last_seen_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +)`, + `CREATE INDEX IF NOT EXISTS ix_mk_product_series_stock_state_product ON mk_product_series_stock_state (product_code, updated_at DESC)`, + ` +CREATE TABLE IF NOT EXISTS mk_product_series_recalc_queue ( + id BIGSERIAL PRIMARY KEY, + row_key TEXT NOT NULL, + product_code TEXT NOT NULL, + color_code TEXT NOT NULL, + dim3_code TEXT NOT NULL DEFAULT '', + reason TEXT NOT NULL DEFAULT '', + status TEXT NOT NULL DEFAULT 'pending', + attempts SMALLINT NOT NULL DEFAULT 0, + available_at TIMESTAMPTZ NOT NULL DEFAULT now(), + queued_at TIMESTAMPTZ NOT NULL DEFAULT now(), + processed_at TIMESTAMPTZ, + last_error TEXT NOT NULL DEFAULT '', + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + CONSTRAINT ck_mk_product_series_recalc_queue_status CHECK (status IN ('pending','processing','done','failed')) +)`, + `CREATE INDEX IF NOT EXISTS ix_mk_product_series_recalc_queue_status ON mk_product_series_recalc_queue (status, available_at, queued_at)`, + `CREATE UNIQUE INDEX IF NOT EXISTS uq_mk_product_series_recalc_queue_pending ON mk_product_series_recalc_queue (row_key) WHERE status IN ('pending','processing')`, + ` +CREATE TABLE IF NOT EXISTS mk_product_series_job_log ( + id BIGSERIAL PRIMARY KEY, + job_name TEXT NOT NULL, + reason TEXT NOT NULL DEFAULT '', + started_at TIMESTAMPTZ NOT NULL DEFAULT now(), + finished_at TIMESTAMPTZ, + status TEXT NOT NULL DEFAULT 'running', + scanned_rows INTEGER NOT NULL DEFAULT 0, + changed_rows INTEGER NOT NULL DEFAULT 0, + processed_rows INTEGER NOT NULL DEFAULT 0, + written_rows INTEGER NOT NULL DEFAULT 0, + skipped_rows INTEGER NOT NULL DEFAULT 0, + error_text TEXT NOT NULL DEFAULT '' +)`, + `CREATE INDEX IF NOT EXISTS ix_mk_product_series_job_log_started ON mk_product_series_job_log (started_at DESC)`, + } + for _, stmt := range stmts { + if _, err := pg.Exec(stmt); err != nil { + return err + } + } + _, err := pg.Exec(` +INSERT INTO mk_product_series_rule (series_id, size_group, size_code, ratio_qty, priority, source, notes) +SELECT d.id, '', BTRIM(x.size_code), 1, 0, 'dfgrp_title', 'auto-seeded from dfgrp.title' +FROM dfgrp d +CROSS JOIN LATERAL regexp_split_to_table(COALESCE(d.title, ''), '-') AS x(size_code) +WHERE d.master='zbggseri' + AND BTRIM(x.size_code) <> '' +ON CONFLICT (series_id, size_group, size_code) DO NOTHING +`) + return err +} diff --git a/svc/routes/product_series.go b/svc/routes/product_series.go new file mode 100644 index 0000000..bd2adeb --- /dev/null +++ b/svc/routes/product_series.go @@ -0,0 +1,603 @@ +package routes + +import ( + "bssapp-backend/db" + "bssapp-backend/queries" + "context" + "database/sql" + "encoding/json" + "fmt" + "net/http" + "sort" + "strconv" + "strings" + "time" + + "github.com/gorilla/mux" + "github.com/lib/pq" +) + +type productSeriesDefinition struct { + ID int64 `json:"id"` + Code string `json:"code"` + Title string `json:"title"` + IsActive bool `json:"is_active"` + ParentFilter string `json:"parent_filter"` + SortOrder int `json:"sort_order"` + Notes string `json:"notes"` +} + +type productSeriesStockRawRow 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 productSeriesMappingRow struct { + RowKey string `json:"row_key"` + ProductCode string `json:"product_code"` + ProductDescription string `json:"product_description"` + ColorCode string `json:"color_code"` + ColorTitle string `json:"color_title"` + Dim3Code string `json:"dim3_code"` + UrunAnaGrubu string `json:"urun_ana_grubu"` + UrunAltGrubu string `json:"urun_alt_grubu"` + Marka string `json:"marka"` + DropVal string `json:"drop_val"` + Fit string `json:"fit"` + UrunIcerigi string `json:"urun_icerigi"` + Kategori string `json:"kategori"` + SizeQty map[string]float64 `json:"size_qty"` + TotalQty float64 `json:"total_qty"` + SeriesIDs []int64 `json:"series_ids"` + Series []productSeriesDefinition `json:"series"` + MmitemID int64 `json:"mmitem_id"` + Dim1ID int64 `json:"dim1_id"` + Dim3ID int64 `json:"dim3_id"` + MappingReady bool `json:"mapping_ready"` + MappingWarning string `json:"mapping_warning"` +} + +type productSeriesMappingsResponse struct { + Rows []productSeriesMappingRow `json:"rows"` + SizeColumns []string `json:"size_columns"` + Definitions []productSeriesDefinition `json:"definitions"` +} + +func GetProductSeriesDefinitionsHandler(pg *sql.DB) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second) + defer cancel() + + defs, err := listProductSeriesDefinitions(ctx, pg, false) + if err != nil { + http.Error(w, "Seri tanimlari alinamadi: "+err.Error(), http.StatusInternalServerError) + return + } + writeJSON(w, defs) + } +} + +func PostProductSeriesDefinitionHandler(pg *sql.DB) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req productSeriesDefinition + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "Gecersiz istek", http.StatusBadRequest) + return + } + req.Code = strings.TrimSpace(req.Code) + req.Title = strings.TrimSpace(req.Title) + if req.Code == "" { + http.Error(w, "Seri kodu zorunludur", http.StatusBadRequest) + return + } + + ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second) + defer cancel() + err := pg.QueryRowContext(ctx, ` +INSERT INTO dfgrp (code, title, is_active, typ, master, parent_filter, sort_order, is_required, notes) +VALUES ($1, $2, COALESCE($3, TRUE), 'opt', 'zbggseri', $4, $5, FALSE, $6) +RETURNING id +`, req.Code, req.Title, req.IsActive, req.ParentFilter, req.SortOrder, req.Notes).Scan(&req.ID) + if err != nil { + http.Error(w, "Seri tanimi eklenemedi: "+err.Error(), http.StatusInternalServerError) + return + } + writeJSON(w, req) + } +} + +func PutProductSeriesDefinitionHandler(pg *sql.DB) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + id, _ := strconv.ParseInt(mux.Vars(r)["id"], 10, 64) + if id <= 0 { + http.Error(w, "Gecersiz id", http.StatusBadRequest) + return + } + var req productSeriesDefinition + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "Gecersiz istek", http.StatusBadRequest) + return + } + req.Code = strings.TrimSpace(req.Code) + req.Title = strings.TrimSpace(req.Title) + if req.Code == "" { + http.Error(w, "Seri kodu zorunludur", http.StatusBadRequest) + return + } + + ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second) + defer cancel() + res, err := pg.ExecContext(ctx, ` +UPDATE dfgrp +SET code=$2, + title=$3, + is_active=$4, + parent_filter=$5, + sort_order=$6, + notes=$7 +WHERE id=$1 AND master='zbggseri' +`, id, req.Code, req.Title, req.IsActive, req.ParentFilter, req.SortOrder, req.Notes) + if err != nil { + http.Error(w, "Seri tanimi guncellenemedi: "+err.Error(), http.StatusInternalServerError) + return + } + if n, _ := res.RowsAffected(); n == 0 { + http.Error(w, "Seri tanimi bulunamadi", http.StatusNotFound) + return + } + req.ID = id + writeJSON(w, req) + } +} + +func DeleteProductSeriesDefinitionHandler(pg *sql.DB) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + id, _ := strconv.ParseInt(mux.Vars(r)["id"], 10, 64) + if id <= 0 { + http.Error(w, "Gecersiz id", http.StatusBadRequest) + return + } + ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second) + defer cancel() + if _, err := pg.ExecContext(ctx, `UPDATE dfgrp SET is_active=FALSE WHERE id=$1 AND master='zbggseri'`, id); err != nil { + http.Error(w, "Seri tanimi silinemedi: "+err.Error(), http.StatusInternalServerError) + return + } + writeJSON(w, map[string]any{"ok": true}) + } +} + +func GetProductSeriesMappingsHandler(pg *sql.DB) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + f := readStockAttrFilters(r) + limit := 0 + if raw := strings.TrimSpace(r.URL.Query().Get("limit")); raw != "" { + if n, err := strconv.Atoi(raw); err == nil && n >= 0 { + limit = n + } + } + search := firstNonEmpty(r.URL.Query().Get("q"), r.URL.Query().Get("code"), r.URL.Query().Get("product_code")) + + ctx, cancel := context.WithTimeout(r.Context(), 120*time.Second) + defer cancel() + + msRows, err := db.MssqlDB.QueryContext(ctx, queries.GetProductSeriesStockRowsQuery, + f.kategori, + f.urunAnaGrubu, + joinFilterValues(f.urunAltGrubu), + joinFilterValues(f.renk), + joinFilterValues(f.renk2), + joinFilterValues(f.urunIcerigi), + joinFilterValues(f.fit), + joinFilterValues(f.drop), + joinFilterValues(f.beden), + strconv.Itoa(limit), + search, + ) + if err != nil { + http.Error(w, "Stok seri listesi alinamadi: "+err.Error(), http.StatusInternalServerError) + return + } + defer msRows.Close() + + grouped := map[string]*productSeriesMappingRow{} + codeSet := map[string]struct{}{} + colorSet := map[string]struct{}{} + dim3Set := map[string]struct{}{} + sizeSet := map[string]struct{}{} + for msRows.Next() { + var raw productSeriesStockRawRow + if err := msRows.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 { + http.Error(w, "Stok satiri okunamadi: "+err.Error(), http.StatusInternalServerError) + return + } + raw.ProductCode = strings.TrimSpace(raw.ProductCode) + raw.ColorCode = strings.TrimSpace(raw.ColorCode) + raw.Dim3Code = strings.TrimSpace(raw.Dim3Code) + raw.SizeCode = strings.TrimSpace(raw.SizeCode) + key := productSeriesKey(raw.ProductCode, raw.ColorCode, raw.Dim3Code) + row := grouped[key] + if row == nil { + row = &productSeriesMappingRow{ + RowKey: key, + ProductCode: raw.ProductCode, + ProductDescription: strings.TrimSpace(raw.ProductDescription), + ColorCode: raw.ColorCode, + ColorTitle: strings.TrimSpace(raw.ColorTitle), + Dim3Code: raw.Dim3Code, + UrunAnaGrubu: strings.TrimSpace(raw.UrunAnaGrubu), + UrunAltGrubu: strings.TrimSpace(raw.UrunAltGrubu), + Marka: strings.TrimSpace(raw.Marka), + DropVal: strings.TrimSpace(raw.DropVal), + Fit: strings.TrimSpace(raw.Fit), + UrunIcerigi: strings.TrimSpace(raw.UrunIcerigi), + Kategori: strings.TrimSpace(raw.Kategori), + SizeQty: map[string]float64{}, + SeriesIDs: []int64{}, + Series: []productSeriesDefinition{}, + } + grouped[key] = row + codeSet[raw.ProductCode] = struct{}{} + colorSet[raw.ColorCode] = struct{}{} + if raw.Dim3Code != "" { + dim3Set[raw.Dim3Code] = struct{}{} + } + } + if raw.SizeCode != "" { + row.SizeQty[raw.SizeCode] = raw.Qty + sizeSet[raw.SizeCode] = struct{}{} + } + row.TotalQty += raw.Qty + } + if err := msRows.Err(); err != nil { + http.Error(w, "Stok satirlari okunamadi: "+err.Error(), http.StatusInternalServerError) + return + } + + defs, err := listProductSeriesDefinitions(ctx, pg, true) + if err != nil { + http.Error(w, "Seri tanimlari alinamadi: "+err.Error(), http.StatusInternalServerError) + return + } + defByID := make(map[int64]productSeriesDefinition, len(defs)) + for _, d := range defs { + defByID[d.ID] = d + } + + codes := setToSortedSlice(codeSet) + mmitemByCode, _ := loadMmitemIDs(ctx, pg, codes) + dim1ByToken, _ := loadDimTokenIDs(ctx, pg, "dimval1", setToSortedSlice(colorSet)) + dim3ByToken, _ := loadDimTokenIDs(ctx, pg, "dimval3", setToSortedSlice(dim3Set)) + existing, _ := loadProductSeriesAssignments(ctx, pg, codes) + + out := make([]productSeriesMappingRow, 0, len(grouped)) + for _, row := range grouped { + row.MmitemID = mmitemByCode[row.ProductCode] + row.Dim1ID = dim1ByToken[row.ColorCode] + if row.Dim3Code != "" { + row.Dim3ID = dim3ByToken[row.Dim3Code] + } + row.MappingReady = row.MmitemID > 0 && row.Dim1ID > 0 && (row.Dim3Code == "" || row.Dim3ID > 0) + if !row.MappingReady { + row.MappingWarning = "PG urun veya varyant token eslesmesi bulunamadi" + } + assignKey := assignmentKey(row.ProductCode, row.Dim1ID, row.Dim3ID) + for _, id := range existing[assignKey] { + if d, ok := defByID[id]; ok { + row.SeriesIDs = append(row.SeriesIDs, id) + row.Series = append(row.Series, d) + } + } + sort.Slice(row.Series, func(i, j int) bool { return row.Series[i].Code < row.Series[j].Code }) + sort.Slice(row.SeriesIDs, func(i, j int) bool { return row.SeriesIDs[i] < row.SeriesIDs[j] }) + out = append(out, *row) + } + productTotals := productSeriesTotalQtyByCode(out) + sort.Slice(out, func(i, j int) bool { + totalI := productTotals[out[i].ProductCode] + totalJ := productTotals[out[j].ProductCode] + if totalI != totalJ { + return totalI > totalJ + } + if out[i].ProductCode != out[j].ProductCode { + return out[i].ProductCode < out[j].ProductCode + } + if out[i].ColorCode != out[j].ColorCode { + return out[i].ColorCode < out[j].ColorCode + } + return out[i].Dim3Code < out[j].Dim3Code + }) + + writeJSON(w, productSeriesMappingsResponse{ + Rows: out, + SizeColumns: sortSizeColumns(setToSortedSlice(sizeSet)), + Definitions: defs, + }) + } +} + +func productSeriesTotalQtyByCode(rows []productSeriesMappingRow) map[string]float64 { + out := make(map[string]float64, len(rows)) + for _, row := range rows { + out[row.ProductCode] += row.TotalQty + } + return out +} + +type saveProductSeriesMappingsRequest struct { + Items []struct { + ProductCode string `json:"product_code"` + ColorCode string `json:"color_code"` + Dim3Code string `json:"dim3_code"` + SeriesIDs []int64 `json:"series_ids"` + } `json:"items"` +} + +func PostProductSeriesMappingsSaveHandler(pg *sql.DB) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req saveProductSeriesMappingsRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "Gecersiz istek", 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, "Transaction baslatilamadi", http.StatusInternalServerError) + return + } + defer tx.Rollback() + + saved := 0 + for _, item := range req.Items { + code := strings.TrimSpace(item.ProductCode) + color := strings.TrimSpace(item.ColorCode) + dim3Token := strings.TrimSpace(item.Dim3Code) + if code == "" || color == "" { + continue + } + mmitemID, err := resolveMmitemIDTx(ctx, tx, code) + if err != nil || mmitemID <= 0 { + http.Error(w, "PG urun bulunamadi: "+code, http.StatusBadRequest) + return + } + dim1ID, err := resolveDimTokenIDTx(ctx, tx, "dimval1", color) + if err != nil || dim1ID <= 0 { + http.Error(w, "Renk token eslesmesi bulunamadi: "+color, http.StatusBadRequest) + return + } + var dim3ID sql.NullInt64 + if dim3Token != "" { + id, err := resolveDimTokenIDTx(ctx, tx, "dimval3", dim3Token) + if err != nil || id <= 0 { + http.Error(w, "Dim3 token eslesmesi bulunamadi: "+dim3Token, http.StatusBadRequest) + return + } + dim3ID = sql.NullInt64{Int64: id, Valid: true} + } + + 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, nullableInt64Arg(dim3ID)); err != nil { + http.Error(w, "Seri eslesmesi temizlenemedi: "+err.Error(), http.StatusInternalServerError) + return + } + seen := map[int64]struct{}{} + for _, seriesID := range item.SeriesIDs { + if seriesID <= 0 { + continue + } + if _, ok := seen[seriesID]; ok { + continue + } + seen[seriesID] = struct{}{} + if _, err := tx.ExecContext(ctx, ` +INSERT INTO zbggseri (mmitem_id, dim1, seri_id, dim3) +VALUES ($1, $2, $3, $4) +`, mmitemID, dim1ID, seriesID, nullableInt64Arg(dim3ID)); err != nil { + http.Error(w, "Seri eslesmesi kaydedilemedi: "+err.Error(), http.StatusInternalServerError) + return + } + } + saved++ + } + + if err := tx.Commit(); err != nil { + http.Error(w, "Seri eslesmeleri kaydedilemedi: "+err.Error(), http.StatusInternalServerError) + return + } + writeJSON(w, map[string]any{"saved": saved}) + } +} + +func listProductSeriesDefinitions(ctx context.Context, pg *sql.DB, activeOnly bool) ([]productSeriesDefinition, error) { + whereActive := "" + if activeOnly { + whereActive = " AND is_active=TRUE" + } + rows, err := pg.QueryContext(ctx, ` +SELECT id, COALESCE(code,''), COALESCE(title,''), COALESCE(is_active, TRUE), + COALESCE(parent_filter,''), COALESCE(sort_order, 0), COALESCE(notes,'') +FROM dfgrp +WHERE master='zbggseri'`+whereActive+` +ORDER BY sort_order, + CASE WHEN code ~ '^[0-9]+$' THEN code::int ELSE NULL END NULLS LAST, + code, + id +`) + if err != nil { + return nil, err + } + defer rows.Close() + out := []productSeriesDefinition{} + for rows.Next() { + var item productSeriesDefinition + if err := rows.Scan(&item.ID, &item.Code, &item.Title, &item.IsActive, &item.ParentFilter, &item.SortOrder, &item.Notes); err != nil { + return nil, err + } + out = append(out, item) + } + return out, rows.Err() +} + +func loadMmitemIDs(ctx context.Context, pg *sql.DB, codes []string) (map[string]int64, error) { + out := map[string]int64{} + if len(codes) == 0 { + return out, nil + } + rows, err := pg.QueryContext(ctx, `SELECT code, id FROM mmitem WHERE code = ANY($1)`, pq.Array(codes)) + if err != nil { + return out, err + } + defer rows.Close() + for rows.Next() { + var code string + var id int64 + if err := rows.Scan(&code, &id); err != nil { + return out, err + } + out[strings.TrimSpace(code)] = id + } + return out, rows.Err() +} + +func loadDimTokenIDs(ctx context.Context, pg *sql.DB, column string, tokens []string) (map[string]int64, error) { + out := map[string]int64{} + if len(tokens) == 0 { + return out, nil + } + rows, err := pg.QueryContext(ctx, ` +SELECT token, dim_id +FROM mk_dim_token_map +WHERE dim_column=$1 AND token = ANY($2) +`, column, pq.Array(tokens)) + if err != nil { + return out, err + } + defer rows.Close() + for rows.Next() { + var token string + var id int64 + if err := rows.Scan(&token, &id); err != nil { + return out, err + } + out[strings.TrimSpace(token)] = id + } + return out, rows.Err() +} + +func loadProductSeriesAssignments(ctx context.Context, pg *sql.DB, codes []string) (map[string][]int64, error) { + out := map[string][]int64{} + if len(codes) == 0 { + return out, nil + } + rows, err := pg.QueryContext(ctx, ` +SELECT m.code, z.dim1, COALESCE(z.dim3, 0), z.seri_id +FROM zbggseri z +JOIN mmitem m ON m.id = z.mmitem_id +WHERE m.code = ANY($1) +ORDER BY m.code, z.dim1, z.dim3, z.seri_id +`, pq.Array(codes)) + if err != nil { + return out, err + } + defer rows.Close() + for rows.Next() { + var code string + var dim1, dim3, seriesID int64 + if err := rows.Scan(&code, &dim1, &dim3, &seriesID); err != nil { + return out, err + } + key := assignmentKey(strings.TrimSpace(code), dim1, dim3) + out[key] = append(out[key], seriesID) + } + return out, rows.Err() +} + +func resolveMmitemIDTx(ctx context.Context, tx *sql.Tx, code string) (int64, error) { + var id int64 + err := tx.QueryRowContext(ctx, `SELECT id FROM mmitem WHERE code=$1`, code).Scan(&id) + return id, err +} + +func resolveDimTokenIDTx(ctx context.Context, tx *sql.Tx, column string, token string) (int64, error) { + var id int64 + err := tx.QueryRowContext(ctx, `SELECT dim_id FROM mk_dim_token_map WHERE dim_column=$1 AND token=$2`, column, token).Scan(&id) + return id, err +} + +func nullableInt64Arg(v sql.NullInt64) any { + if !v.Valid { + return nil + } + return v.Int64 +} + +func productSeriesKey(productCode, colorCode, dim3Code string) string { + return strings.TrimSpace(productCode) + "|" + strings.TrimSpace(colorCode) + "|" + strings.TrimSpace(dim3Code) +} + +func assignmentKey(productCode string, dim1ID int64, dim3ID int64) string { + return fmt.Sprintf("%s|%d|%d", strings.TrimSpace(productCode), dim1ID, dim3ID) +} + +func setToSortedSlice(set map[string]struct{}) []string { + out := make([]string, 0, len(set)) + for v := range set { + v = strings.TrimSpace(v) + if v != "" { + out = append(out, v) + } + } + sort.Strings(out) + return out +} + +func sortSizeColumns(values []string) []string { + sort.Slice(values, func(i, j int) bool { + ai, ae := strconv.Atoi(values[i]) + bi, be := strconv.Atoi(values[j]) + if ae == nil && be == nil { + return ai < bi + } + return values[i] < values[j] + }) + return values +} + +func writeJSON(w http.ResponseWriter, payload any) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + _ = json.NewEncoder(w).Encode(payload) +} diff --git a/ui/.quasar/prod-spa/app.js b/ui/.quasar/prod-spa/app.js deleted file mode 100644 index caeaac1..0000000 --- a/ui/.quasar/prod-spa/app.js +++ /dev/null @@ -1,75 +0,0 @@ -/* eslint-disable */ -/** - * THIS FILE IS GENERATED AUTOMATICALLY. - * DO NOT EDIT. - * - * You are probably looking on adding startup/initialization code. - * Use "quasar new boot " and add it there. - * One boot file per concern. Then reference the file(s) in quasar.config file > boot: - * boot: ['file', ...] // do not add ".js" extension to it. - * - * Boot files are your "main.js" - **/ - - - - - -import { Quasar } from 'quasar' -import { markRaw } from 'vue' -import RootComponent from 'app/src/App.vue' - -import createStore from 'app/src/stores/index' -import createRouter from 'app/src/router/index' - - - - - -export default async function (createAppFn, quasarUserOptions) { - - - // Create the app instance. - // Here we inject into it the Quasar UI, the router & possibly the store. - const app = createAppFn(RootComponent) - - - - app.use(Quasar, quasarUserOptions) - - - - - const store = typeof createStore === 'function' - ? await createStore({}) - : createStore - - - app.use(store) - - - - - - const router = markRaw( - typeof createRouter === 'function' - ? await createRouter({store}) - : createRouter - ) - - - // make router instance available in store - - store.use(({ store }) => { store.router = router }) - - - - // Expose the app, the router and the store. - // Note that we are not mounting the app here, since bootstrapping will be - // different depending on whether we are in a browser or on the server. - return { - app, - store, - router - } -} diff --git a/ui/.quasar/prod-spa/client-entry.js b/ui/.quasar/prod-spa/client-entry.js deleted file mode 100644 index 5223e2b..0000000 --- a/ui/.quasar/prod-spa/client-entry.js +++ /dev/null @@ -1,158 +0,0 @@ -/* eslint-disable */ -/** - * THIS FILE IS GENERATED AUTOMATICALLY. - * DO NOT EDIT. - * - * You are probably looking on adding startup/initialization code. - * Use "quasar new boot " and add it there. - * One boot file per concern. Then reference the file(s) in quasar.config file > boot: - * boot: ['file', ...] // do not add ".js" extension to it. - * - * Boot files are your "main.js" - **/ - - -import { createApp } from 'vue' - - - - - - - -import '@quasar/extras/roboto-font/roboto-font.css' - -import '@quasar/extras/material-icons/material-icons.css' - - - - -// We load Quasar stylesheet file -import 'quasar/dist/quasar.sass' - - - - -import 'src/css/app.css' - - -import createQuasarApp from './app.js' -import quasarUserOptions from './quasar-user-options.js' - - - - - - - - -const publicPath = `/` - - -async function start ({ - app, - router - , store -}, bootFiles) { - - let hasRedirected = false - const getRedirectUrl = url => { - try { return router.resolve(url).href } - catch (err) {} - - return Object(url) === url - ? null - : url - } - const redirect = url => { - hasRedirected = true - - if (typeof url === 'string' && /^https?:\/\//.test(url)) { - window.location.href = url - return - } - - const href = getRedirectUrl(url) - - // continue if we didn't fail to resolve the url - if (href !== null) { - window.location.href = href - window.location.reload() - } - } - - const urlPath = window.location.href.replace(window.location.origin, '') - - for (let i = 0; hasRedirected === false && i < bootFiles.length; i++) { - try { - await bootFiles[i]({ - app, - router, - store, - ssrContext: null, - redirect, - urlPath, - publicPath - }) - } - catch (err) { - if (err && err.url) { - redirect(err.url) - return - } - - console.error('[Quasar] boot error:', err) - return - } - } - - if (hasRedirected === true) return - - - app.use(router) - - - - - - - app.mount('#q-app') - - - -} - -createQuasarApp(createApp, quasarUserOptions) - - .then(app => { - // eventually remove this when Cordova/Capacitor/Electron support becomes old - const [ method, mapFn ] = Promise.allSettled !== void 0 - ? [ - 'allSettled', - bootFiles => bootFiles.map(result => { - if (result.status === 'rejected') { - console.error('[Quasar] boot error:', result.reason) - return - } - return result.value.default - }) - ] - : [ - 'all', - bootFiles => bootFiles.map(entry => entry.default) - ] - - return Promise[ method ]([ - - import(/* webpackMode: "eager" */ 'boot/dayjs'), - - import(/* webpackMode: "eager" */ 'boot/locale'), - - import(/* webpackMode: "eager" */ 'boot/resizeObserverGuard') - - ]).then(bootFiles => { - const boot = mapFn(bootFiles).filter(entry => typeof entry === 'function') - start(app, boot) - }) - }) - diff --git a/ui/.quasar/prod-spa/client-prefetch.js b/ui/.quasar/prod-spa/client-prefetch.js deleted file mode 100644 index 9bbe3c5..0000000 --- a/ui/.quasar/prod-spa/client-prefetch.js +++ /dev/null @@ -1,116 +0,0 @@ -/* eslint-disable */ -/** - * THIS FILE IS GENERATED AUTOMATICALLY. - * DO NOT EDIT. - * - * You are probably looking on adding startup/initialization code. - * Use "quasar new boot " and add it there. - * One boot file per concern. Then reference the file(s) in quasar.config file > boot: - * boot: ['file', ...] // do not add ".js" extension to it. - * - * Boot files are your "main.js" - **/ - - - -import App from 'app/src/App.vue' -let appPrefetch = typeof App.preFetch === 'function' - ? App.preFetch - : ( - // Class components return the component options (and the preFetch hook) inside __c property - App.__c !== void 0 && typeof App.__c.preFetch === 'function' - ? App.__c.preFetch - : false - ) - - -function getMatchedComponents (to, router) { - const route = to - ? (to.matched ? to : router.resolve(to).route) - : router.currentRoute.value - - if (!route) { return [] } - - const matched = route.matched.filter(m => m.components !== void 0) - - if (matched.length === 0) { return [] } - - return Array.prototype.concat.apply([], matched.map(m => { - return Object.keys(m.components).map(key => { - const comp = m.components[key] - return { - path: m.path, - c: comp - } - }) - })) -} - -export function addPreFetchHooks ({ router, store, publicPath }) { - // Add router hook for handling preFetch. - // Doing it after initial route is resolved so that we don't double-fetch - // the data that we already have. Using router.beforeResolve() so that all - // async components are resolved. - router.beforeResolve((to, from, next) => { - const - urlPath = window.location.href.replace(window.location.origin, ''), - matched = getMatchedComponents(to, router), - prevMatched = getMatchedComponents(from, router) - - let diffed = false - const preFetchList = matched - .filter((m, i) => { - return diffed || (diffed = ( - !prevMatched[i] || - prevMatched[i].c !== m.c || - m.path.indexOf('/:') > -1 // does it has params? - )) - }) - .filter(m => m.c !== void 0 && ( - typeof m.c.preFetch === 'function' - // Class components return the component options (and the preFetch hook) inside __c property - || (m.c.__c !== void 0 && typeof m.c.__c.preFetch === 'function') - )) - .map(m => m.c.__c !== void 0 ? m.c.__c.preFetch : m.c.preFetch) - - - if (appPrefetch !== false) { - preFetchList.unshift(appPrefetch) - appPrefetch = false - } - - - if (preFetchList.length === 0) { - return next() - } - - let hasRedirected = false - const redirect = url => { - hasRedirected = true - next(url) - } - const proceed = () => { - - if (hasRedirected === false) { next() } - } - - - - preFetchList.reduce( - (promise, preFetch) => promise.then(() => hasRedirected === false && preFetch({ - store, - currentRoute: to, - previousRoute: from, - redirect, - urlPath, - publicPath - })), - Promise.resolve() - ) - .then(proceed) - .catch(e => { - console.error(e) - proceed() - }) - }) -} diff --git a/ui/.quasar/prod-spa/quasar-user-options.js b/ui/.quasar/prod-spa/quasar-user-options.js deleted file mode 100644 index ac1dae3..0000000 --- a/ui/.quasar/prod-spa/quasar-user-options.js +++ /dev/null @@ -1,23 +0,0 @@ -/* eslint-disable */ -/** - * THIS FILE IS GENERATED AUTOMATICALLY. - * DO NOT EDIT. - * - * You are probably looking on adding startup/initialization code. - * Use "quasar new boot " and add it there. - * One boot file per concern. Then reference the file(s) in quasar.config file > boot: - * boot: ['file', ...] // do not add ".js" extension to it. - * - * Boot files are your "main.js" - **/ - -import lang from 'quasar/lang/tr.js' - - - -import {Loading,Dialog,Notify} from 'quasar' - - - -export default { config: {"notify":{"position":"top","timeout":2500}},lang,plugins: {Loading,Dialog,Notify} } - diff --git a/ui/quasar.config.js.temporary.compiled.1781734575091.mjs b/ui/quasar.config.js.temporary.compiled.1782152715292.mjs similarity index 100% rename from ui/quasar.config.js.temporary.compiled.1781734575091.mjs rename to ui/quasar.config.js.temporary.compiled.1782152715292.mjs diff --git a/ui/src/layouts/MainLayout.vue b/ui/src/layouts/MainLayout.vue index e679990..d681b3a 100644 --- a/ui/src/layouts/MainLayout.vue +++ b/ui/src/layouts/MainLayout.vue @@ -368,6 +368,16 @@ const menuItems = [ to: '/app/pricing/wholesale-campaigns', permission: 'pricing:view' }, + { + label: 'Ürün Seri Eşleşmeleri', + to: '/app/pricing/product-series-mappings', + permission: 'pricing:view' + }, + { + label: 'Ürün Seri Tanımlamaları', + to: '/app/pricing/product-series-definitions', + permission: 'pricing:view' + }, { label: 'Marka Sınıflandırma', to: '/app/pricing/brand-classification', diff --git a/ui/src/pages/ProductSeriesDefinitions.vue b/ui/src/pages/ProductSeriesDefinitions.vue new file mode 100644 index 0000000..ba4d388 --- /dev/null +++ b/ui/src/pages/ProductSeriesDefinitions.vue @@ -0,0 +1,195 @@ + + + diff --git a/ui/src/pages/ProductSeriesMappings.vue b/ui/src/pages/ProductSeriesMappings.vue new file mode 100644 index 0000000..8dc8a0b --- /dev/null +++ b/ui/src/pages/ProductSeriesMappings.vue @@ -0,0 +1,939 @@ + + + + + diff --git a/ui/src/router/routes.js b/ui/src/router/routes.js index 88036ed..0e6f3dd 100644 --- a/ui/src/router/routes.js +++ b/ui/src/router/routes.js @@ -419,6 +419,18 @@ const routes = [ component: () => import('pages/WholesaleCampaigns.vue'), meta: { permission: 'pricing:view' } }, + { + path: 'pricing/product-series-mappings', + name: 'product-series-mappings', + component: () => import('pages/ProductSeriesMappings.vue'), + meta: { permission: 'pricing:view' } + }, + { + path: 'pricing/product-series-definitions', + name: 'product-series-definitions', + component: () => import('pages/ProductSeriesDefinitions.vue'), + meta: { permission: 'pricing:view' } + }, { path: 'costing/production-product-costing', name: 'production-product-costing',