Files
descrybe/apps/api/internal/processing/category_backfill.go
T

179 lines
5.9 KiB
Go
Raw Normal View History

2026-08-16 16:57:36 +02:00
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
}
2026-08-16 18:18:39 +02:00
// 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
}
2026-08-16 16:57:36 +02:00
// 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
}