package processing import ( "context" "fmt" "github.com/google/uuid" "github.com/jackc/pgx/v5/pgxpool" ) // CategoryBackfillResult is the outcome of BackfillProcessedCategoriesFromMapped. type CategoryBackfillResult struct { Updated int64 `json:"updated"` } // BackfillProcessedCategoriesFromMapped fills empty processed_products.category from // raw_products.mapped_data when a usable unique_id is present. When the company has // categories rows, only known unique_ids are applied (same rule as processOne filter). // Safe for admin "Fix catalog" and migrator reuse. Does not invent categories via vector. func BackfillProcessedCategoriesFromMapped(ctx context.Context, pool *pgxpool.Pool, companyID uuid.UUID) (CategoryBackfillResult, error) { var out CategoryBackfillResult if pool == nil { return out, fmt.Errorf("category backfill: nil pool") } if companyID == uuid.Nil { return out, fmt.Errorf("category backfill: empty company id") } ct, err := pool.Exec(ctx, ` WITH src AS ( SELECT p.id AS processed_id, BTRIM(COALESCE( CASE WHEN jsonb_typeof(r.mapped_data->'category') IN ('string', 'number') THEN NULLIF(BTRIM(r.mapped_data->>'category'), '') ELSE NULL END, NULLIF(BTRIM(r.mapped_data->>'category_unique_id'), ''), NULLIF(BTRIM(r.mapped_data->'category'->>'unique_id'), ''), NULLIF(BTRIM(r.mapped_data->'category'->>'category_unique_id'), ''), NULLIF(BTRIM(r.mapped_data->'category'->>'#text'), ''), NULLIF(BTRIM(r.mapped_data->'category'->>'id'), ''), NULLIF(BTRIM(r.mapped_data->'category'->>'value'), ''), NULLIF(BTRIM(r.mapped_data->'category'->>'code'), '') )) AS cat_raw FROM processed_products p INNER JOIN raw_products r ON r.id = p.raw_product_id AND r.company_id = p.company_id WHERE p.company_id = $1 AND ( COALESCE(NULLIF(BTRIM(p.category), ''), '') = '' OR LOWER(BTRIM(p.category)) = 'none' ) ), mapped AS ( SELECT processed_id, CASE WHEN cat_raw IS NULL OR cat_raw = '' OR LOWER(cat_raw) = 'none' THEN NULL ELSE cat_raw END AS cat_uid FROM src ), eligible AS ( SELECT m.processed_id, m.cat_uid FROM mapped m WHERE m.cat_uid IS NOT NULL AND ( NOT EXISTS (SELECT 1 FROM categories c WHERE c.company_id = $1) OR EXISTS ( SELECT 1 FROM categories c WHERE c.company_id = $1 AND BTRIM(c.unique_id) = m.cat_uid ) ) ) UPDATE processed_products p SET category = e.cat_uid, field_sources = jsonb_set( COALESCE(p.field_sources, '{}'::jsonb), '{category}', '"mapped_backfill"'::jsonb, true ), updated_at = now() FROM eligible e WHERE p.id = e.processed_id`, companyID) if err != nil { return out, fmt.Errorf("category backfill: %w", err) } out.Updated = ct.RowsAffected() return out, nil } // BackfillMappedCategoriesFromProcessed copies processed_products.category into // raw_products.mapped_data.category when mapped category is empty. Legacy A1 dumps // often store category only on processed rows; feed mappings do not map category, // so Fix Catalog / re-seed must push processed → mapped for Products UI coverage. // Does not invent categories. func BackfillMappedCategoriesFromProcessed(ctx context.Context, pool *pgxpool.Pool, companyID uuid.UUID) (int64, error) { if pool == nil { return 0, fmt.Errorf("mapped category backfill: nil pool") } if companyID == uuid.Nil { return 0, fmt.Errorf("mapped category backfill: empty company id") } ct, err := pool.Exec(ctx, ` UPDATE raw_products r SET mapped_data = jsonb_set( COALESCE(r.mapped_data, '{}'::jsonb), '{category}', to_jsonb(p.category), true ), updated_at = now() FROM processed_products p WHERE p.raw_product_id = r.id AND p.company_id = $1 AND r.company_id = $1 AND COALESCE(NULLIF(trim(p.category), ''), '') <> '' AND lower(trim(p.category)) <> 'none' AND COALESCE(NULLIF(trim(r.mapped_data->>'category'), ''), '') = ''`, companyID) if err != nil { return 0, fmt.Errorf("mapped category backfill: %w", err) } return ct.RowsAffected(), nil } // CountMappedCategoryCoverage returns how many raw_products rows have a non-empty // mapped_data.category vs empty, plus taxonomy row count for the company. func CountMappedCategoryCoverage(ctx context.Context, pool *pgxpool.Pool, companyID uuid.UUID) (withCat, withoutCat, taxonomy int64, err error) { if pool == nil { return 0, 0, 0, fmt.Errorf("mapped category coverage: nil pool") } err = pool.QueryRow(ctx, ` SELECT COUNT(*) FILTER ( WHERE COALESCE(NULLIF(trim(mapped_data->>'category'), ''), '') <> '' ), COUNT(*) FILTER ( WHERE COALESCE(NULLIF(trim(mapped_data->>'category'), ''), '') = '' ) FROM raw_products WHERE company_id = $1`, companyID).Scan(&withCat, &withoutCat) if err != nil { return 0, 0, 0, fmt.Errorf("mapped category coverage: %w", err) } err = pool.QueryRow(ctx, ` SELECT COUNT(*) FROM categories WHERE company_id = $1`, companyID).Scan(&taxonomy) if err != nil { return withCat, withoutCat, 0, fmt.Errorf("taxonomy count: %w", err) } return withCat, withoutCat, taxonomy, nil } // CountProcessedCategoryCoverage returns how many processed rows for companyID have // a non-empty category vs empty/"none". func CountProcessedCategoryCoverage(ctx context.Context, pool *pgxpool.Pool, companyID uuid.UUID) (withCat, withoutCat int64, err error) { if pool == nil { return 0, 0, fmt.Errorf("category coverage: nil pool") } err = pool.QueryRow(ctx, ` SELECT COUNT(*) FILTER ( WHERE COALESCE(NULLIF(BTRIM(category), ''), '') <> '' AND LOWER(BTRIM(category)) <> 'none' ), COUNT(*) FILTER ( WHERE COALESCE(NULLIF(BTRIM(category), ''), '') = '' OR LOWER(BTRIM(category)) = 'none' ) FROM processed_products WHERE company_id = $1`, companyID).Scan(&withCat, &withoutCat) if err != nil { return 0, 0, fmt.Errorf("category coverage: %w", err) } return withCat, withoutCat, nil }