diff --git a/svc/main.go b/svc/main.go index 049dc41..195cc20 100644 --- a/svc/main.go +++ b/svc/main.go @@ -950,6 +950,11 @@ func InitRoutes(pgDB *sql.DB, mssql *sql.DB, ml *mailer.GraphMailer) *mux.Router "pricing", "view", wrapV3(http.HandlerFunc(routes.GetProductSeriesMappingsHandler(pgDB))), ) + bindV3(r, pgDB, + "/api/pricing/product-series/mappings/orphans", "GET", + "pricing", "view", + wrapV3(http.HandlerFunc(routes.GetProductSeriesOrphanMappingsHandler(pgDB))), + ) bindV3(r, pgDB, "/api/pricing/product-series/mappings/save", "POST", "pricing", "update", diff --git a/svc/product_series_auto_scheduler.go b/svc/product_series_auto_scheduler.go index 7b37060..c4aedba 100644 --- a/svc/product_series_auto_scheduler.go +++ b/svc/product_series_auto_scheduler.go @@ -1167,6 +1167,25 @@ func productSeriesResolvePGVariant(ctx context.Context, pg *sql.DB, productCode, } dim3ID = sql.NullInt64{Int64: id, Valid: true} } + + // Only treat variants as ready if they exist in authoritative item-dim combos. + // This prevents writing series assignments for stock variants that don't exist on the PG side. + dim3Key := int64(0) + if dim3ID.Valid { + dim3Key = dim3ID.Int64 + } + var exists int + if err := pg.QueryRowContext(ctx, ` +SELECT 1 +FROM mk_mmitem_dim_combo +WHERE product_code=$1 AND dim1=$2 AND dim3_key=$3 +LIMIT 1 +`, strings.TrimSpace(productCode), dim1ID, dim3Key).Scan(&exists); err != nil { + if err == sql.ErrNoRows { + return 0, 0, sql.NullInt64{}, false, nil + } + return 0, 0, sql.NullInt64{}, false, err + } return mmitemID, dim1ID, dim3ID, true, nil } diff --git a/svc/queries/pricing_calc_infra.go b/svc/queries/pricing_calc_infra.go index 3a615a4..7e86ee3 100644 --- a/svc/queries/pricing_calc_infra.go +++ b/svc/queries/pricing_calc_infra.go @@ -133,12 +133,6 @@ CREATE TABLE IF NOT EXISTS mk_dim_token_map ( } } - // One-time bootstrap for mk_dim_token_map from authoritative PG data, if empty. - // This avoids heuristics (dfblob filename inference) polluting the token map on first deploy. - if err := seedDimTokenMapFromMmitemDim(pg); err != nil { - return err - } - if err := seedPricingTargetMapRows(pg, "mk_price_target_map_pg", "sdprcgrp_id"); err != nil { return err } @@ -161,81 +155,6 @@ WHERE is_active = TRUE return nil } -func seedDimTokenMapFromMmitemDim(pg *sql.DB) error { - // Seed dimval1 only if it's currently empty. - var hasDimval1 int - _ = pg.QueryRow(`SELECT 1 FROM mk_dim_token_map WHERE dim_column='dimval1' LIMIT 1`).Scan(&hasDimval1) - if hasDimval1 == 0 { - // Prefer the most-used dim_id for a given code if duplicates exist, to keep the mapping stable. - if _, err := pg.Exec(` -WITH usage AS ( - SELECT val1 AS dim_id, COUNT(*) AS cnt - FROM mmitem_dim - WHERE val1 IS NOT NULL - GROUP BY val1 -), -candidates AS ( - SELECT UPPER(BTRIM(d.code)) AS token, d.id AS dim_id, u.cnt - FROM usage u - JOIN dfgrp d ON d.id = u.dim_id - WHERE d.code IS NOT NULL - AND BTRIM(d.code) <> '' -), -picked AS ( - SELECT DISTINCT ON (token) token, dim_id - FROM candidates - ORDER BY token, cnt DESC, dim_id -) -INSERT INTO mk_dim_token_map (dim_column, token, dim_id, updated_at) -SELECT 'dimval1', token, dim_id, now() -FROM picked -ON CONFLICT (dim_column, token) -DO UPDATE SET dim_id = EXCLUDED.dim_id, updated_at = EXCLUDED.updated_at -`); err != nil { - return err - } - } - - // Seed dimval3 only if it's currently empty and there is at least one val3 in mmitem_dim. - var hasDimval3 int - _ = pg.QueryRow(`SELECT 1 FROM mk_dim_token_map WHERE dim_column='dimval3' LIMIT 1`).Scan(&hasDimval3) - if hasDimval3 == 0 { - var anyVal3 int - _ = pg.QueryRow(`SELECT 1 FROM mmitem_dim WHERE val3 IS NOT NULL LIMIT 1`).Scan(&anyVal3) - if anyVal3 != 0 { - if _, err := pg.Exec(` -WITH usage AS ( - SELECT val3 AS dim_id, COUNT(*) AS cnt - FROM mmitem_dim - WHERE val3 IS NOT NULL - GROUP BY val3 -), -candidates AS ( - SELECT UPPER(BTRIM(d.code)) AS token, d.id AS dim_id, u.cnt - FROM usage u - JOIN dfgrp d ON d.id = u.dim_id - WHERE d.code IS NOT NULL - AND BTRIM(d.code) <> '' -), -picked AS ( - SELECT DISTINCT ON (token) token, dim_id - FROM candidates - ORDER BY token, cnt DESC, dim_id -) -INSERT INTO mk_dim_token_map (dim_column, token, dim_id, updated_at) -SELECT 'dimval3', token, dim_id, now() -FROM picked -ON CONFLICT (dim_column, token) -DO UPDATE SET dim_id = EXCLUDED.dim_id, updated_at = EXCLUDED.updated_at -`); err != nil { - return err - } - } - } - - return nil -} - func seedPricingTargetMapRows(pg *sql.DB, tableName string, valueColumn string) error { currencies := []string{"TRY", "USD", "EUR"} for _, currency := range currencies { diff --git a/svc/routes/product_series.go b/svc/routes/product_series.go index 84dd4f6..17e52ab 100644 --- a/svc/routes/product_series.go +++ b/svc/routes/product_series.go @@ -292,86 +292,91 @@ func GetProductSeriesMappingsHandler(pg *sql.DB) http.HandlerFunc { defByID[d.ID] = d } + // Strict token->dim_id resolver (no inference/persistence). UI should not invent dim mappings. + loadDimTokenIDsStrict := func(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 tok string + var id int64 + if err := rows.Scan(&tok, &id); err != nil { + return out, err + } + out[strings.TrimSpace(tok)] = id + } + return out, rows.Err() + } + + // Load all known (product_code, dim1, dim3_key) combos for products in this response. + loadProductDimCombos := func(ctx context.Context, pg *sql.DB, productCodes []string) (map[string]struct{}, error) { + out := map[string]struct{}{} + if len(productCodes) == 0 { + return out, nil + } + rows, err := pg.QueryContext(ctx, ` +SELECT product_code, dim1, dim3_key +FROM mk_mmitem_dim_combo +WHERE product_code = ANY($1) +`, pq.Array(productCodes)) + if err != nil { + return out, err + } + defer rows.Close() + for rows.Next() { + var code string + var dim1, dim3Key int64 + if err := rows.Scan(&code, &dim1, &dim3Key); err != nil { + return out, err + } + key := fmt.Sprintf("%s|%d|%d", strings.TrimSpace(code), dim1, dim3Key) + out[key] = struct{}{} + } + return out, rows.Err() + } + codes := setToSortedSlice(codeSet) mmitemByCode, _ := loadMmitemIDs(ctx, pg, codes) - dim1ByToken, _ := loadDimTokenIDs(ctx, pg, "dimval1", setToSortedSlice(colorSet)) - dim3ByToken, _ := loadDimTokenIDs(ctx, pg, "dimval3", setToSortedSlice(dim3Set)) + dim1ByToken, _ := loadDimTokenIDsStrict(ctx, pg, "dimval1", setToSortedSlice(colorSet)) + dim3ByToken, _ := loadDimTokenIDsStrict(ctx, pg, "dimval3", setToSortedSlice(dim3Set)) + combos, _ := loadProductDimCombos(ctx, pg, codes) existing, _ := loadProductSeriesAssignments(ctx, pg, codes) // Per-request cache for per-mmitem dimval3 inference to avoid repeated dfblob scans. - inferCache := map[string]int64{} - inferDim3ForMmitem := func(mmitemID int64, token string) int64 { - mmitemID = int64(mmitemID) - tok := strings.ToUpper(normalizeDimParam(token)) - if mmitemID <= 0 || tok == "" { - return 0 - } - key := fmt.Sprintf("%d|%s", mmitemID, tok) - if v, ok := inferCache[key]; ok { - return v - } - // Use the same pattern approach as resolveDimvalFromFileNameToken, but scoped to src_id. - patterns := buildNameLikePatterns(tok) - if len(patterns) == 0 { - inferCache[key] = 0 - return 0 - } - var dimv string - err := pg.QueryRowContext(ctx, ` -SELECT x.dimv -FROM ( - SELECT COALESCE(dimval3::text, '') AS dimv, COUNT(*) AS cnt - FROM dfblob - WHERE src_table='mmitem' - AND typ='img' - AND src_id=$7 - AND COALESCE(dimval3::text, '') <> '' - 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(dimval3::text, '') -) x -ORDER BY x.cnt DESC, x.dimv -LIMIT 1 -`, patterns[0], patterns[1], patterns[2], patterns[3], patterns[4], patterns[5], mmitemID).Scan(&dimv) - if err != nil { - inferCache[key] = 0 - return 0 - } - dimv = strings.TrimSpace(dimv) - if dimv == "" || dimv == "0" { - inferCache[key] = 0 - return 0 - } - id, err := strconv.ParseInt(dimv, 10, 64) - if err != nil || id <= 0 { - inferCache[key] = 0 - return 0 - } - inferCache[key] = id - return id - } - out := make([]productSeriesMappingRow, 0, len(grouped)) for _, row := range grouped { row.MmitemID = mmitemByCode[row.ProductCode] row.Dim1ID = dim1ByToken[row.ColorCode] if row.Dim3Code != "" { - // dimval3 can be ambiguous, but if mk_dim_token_map has a row we treat it as source of truth. + // Strict: only accept explicit token map rows for dimval3. if v := dim3ByToken[row.Dim3Code]; v > 0 { row.Dim3ID = v - } else if inferred := inferDim3ForMmitem(row.MmitemID, row.Dim3Code); inferred > 0 { - row.Dim3ID = inferred } } + // Only show variants that are also known/valid on the PG side (product_code + dim1 + dim3_key). + // This prevents UI from listing stock variants that don't exist in PG's authoritative item-dim combos. row.MappingReady = row.MmitemID > 0 && row.Dim1ID > 0 && (row.Dim3Code == "" || row.Dim3ID > 0) + dim3Key := int64(0) + if row.Dim3Code != "" { + dim3Key = row.Dim3ID + } + if row.MappingReady { + _, ok := combos[fmt.Sprintf("%s|%d|%d", strings.TrimSpace(row.ProductCode), row.Dim1ID, dim3Key)] + row.MappingReady = ok + } if !row.MappingReady { - row.MappingWarning = "PG urun veya varyant token eslesmesi bulunamadi" + // Skip: do not surface "orphan" variants in the UI. + continue } assignKey := assignmentKey(row.ProductCode, row.Dim1ID, row.Dim3ID) appendSeries := func(key string) { @@ -383,11 +388,6 @@ LIMIT 1 } } appendSeries(assignKey) - // Fallback: if we couldn't match dim3-specific assignments (dim3 token ambiguity / missing), - // show the default (dim3 NULL) assignments for the same product+color. - if len(row.SeriesIDs) == 0 && row.Dim3Code != "" && row.Dim1ID > 0 { - appendSeries(assignmentKey(row.ProductCode, row.Dim1ID, 0)) - } 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) @@ -416,6 +416,239 @@ LIMIT 1 } } +func GetProductSeriesOrphanMappingsHandler(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 + } + + // Strict token map lookups (no inference). + loadDimTokenIDsStrict := func(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 tok string + var id int64 + if err := rows.Scan(&tok, &id); err != nil { + return out, err + } + out[strings.TrimSpace(tok)] = id + } + return out, rows.Err() + } + + loadProductDimCombos := func(ctx context.Context, pg *sql.DB, productCodes []string) (map[string]struct{}, error) { + out := map[string]struct{}{} + if len(productCodes) == 0 { + return out, nil + } + rows, err := pg.QueryContext(ctx, ` +SELECT product_code, dim1, dim3_key +FROM mk_mmitem_dim_combo +WHERE product_code = ANY($1) +`, pq.Array(productCodes)) + if err != nil { + return out, err + } + defer rows.Close() + for rows.Next() { + var code string + var dim1, dim3Key int64 + if err := rows.Scan(&code, &dim1, &dim3Key); err != nil { + return out, err + } + key := fmt.Sprintf("%s|%d|%d", strings.TrimSpace(code), dim1, dim3Key) + out[key] = struct{}{} + } + return out, rows.Err() + } + + codes := setToSortedSlice(codeSet) + mmitemByCode, _ := loadMmitemIDs(ctx, pg, codes) + dim1ByToken, _ := loadDimTokenIDsStrict(ctx, pg, "dimval1", setToSortedSlice(colorSet)) + dim3ByToken, _ := loadDimTokenIDsStrict(ctx, pg, "dimval3", setToSortedSlice(dim3Set)) + combos, _ := loadProductDimCombos(ctx, pg, codes) + + out := make([]productSeriesMappingRow, 0, len(grouped)) + for _, row := range grouped { + if row.TotalQty <= 0 { + continue + } + row.MmitemID = mmitemByCode[row.ProductCode] + row.Dim1ID = dim1ByToken[row.ColorCode] + if row.Dim3Code != "" { + if v := dim3ByToken[row.Dim3Code]; v > 0 { + row.Dim3ID = v + } + } + + // Determine orphan reason. + baseReady := row.MmitemID > 0 && row.Dim1ID > 0 && (row.Dim3Code == "" || row.Dim3ID > 0) + dim3Key := int64(0) + if row.Dim3Code != "" { + dim3Key = row.Dim3ID + } + comboKey := fmt.Sprintf("%s|%d|%d", strings.TrimSpace(row.ProductCode), row.Dim1ID, dim3Key) + _, comboOK := combos[comboKey] + + row.MappingReady = false + switch { + case row.MmitemID <= 0: + row.MappingWarning = "B2B'de urun yok (mmitem)" + case row.Dim1ID <= 0: + row.MappingWarning = "B2B'de renk token eslesmesi yok (mk_dim_token_map: dimval1)" + case row.Dim3Code != "" && row.Dim3ID <= 0: + row.MappingWarning = "B2B'de dim3 token eslesmesi yok (mk_dim_token_map: dimval3)" + case !comboOK: + row.MappingWarning = "B2B'de varyant kombosu yok (mk_mmitem_dim_combo)" + default: + // Not an orphan; skip. + if baseReady && comboOK { + continue + } + row.MappingWarning = "B2B varyant eslesmesi bulunamadi" + } + + 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 { diff --git a/ui/src/layouts/MainLayout.vue b/ui/src/layouts/MainLayout.vue index d681b3a..4b8c756 100644 --- a/ui/src/layouts/MainLayout.vue +++ b/ui/src/layouts/MainLayout.vue @@ -373,6 +373,11 @@ const menuItems = [ to: '/app/pricing/product-series-mappings', permission: 'pricing:view' }, + { + label: 'B2B Olmayan Stok', + to: '/app/pricing/product-series-mappings-orphans', + permission: 'pricing:view' + }, { label: 'Ürün Seri Tanımlamaları', to: '/app/pricing/product-series-definitions', diff --git a/ui/src/router/routes.js b/ui/src/router/routes.js index 0e6f3dd..ba6df47 100644 --- a/ui/src/router/routes.js +++ b/ui/src/router/routes.js @@ -425,6 +425,12 @@ const routes = [ component: () => import('pages/ProductSeriesMappings.vue'), meta: { permission: 'pricing:view' } }, + { + path: 'pricing/product-series-mappings-orphans', + name: 'product-series-mappings-orphans', + component: () => import('pages/ProductSeriesMappingsOrphans.vue'), + meta: { permission: 'pricing:view' } + }, { path: 'pricing/product-series-definitions', name: 'product-series-definitions',